claude-dev-env 2.20.0 → 2.21.1

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.
@@ -1,9 +1,11 @@
1
1
  import json
2
- from pathlib import Path
2
+ import subprocess
3
3
  import sys
4
- import tomllib
4
+ from pathlib import Path
5
+ from typing import Any
5
6
 
6
7
  import pytest
8
+ import tomllib
7
9
 
8
10
  module_directory = str(Path(__file__).parents[1])
9
11
  if module_directory not in sys.path:
@@ -17,8 +19,8 @@ from codex_compat_materializer import (
17
19
  PlannedFile,
18
20
  atomic_write,
19
21
  build_plan,
20
- convert_agent,
21
22
  content_to_bytes,
23
+ convert_agent,
22
24
  hash_content,
23
25
  load_manifest,
24
26
  parse_frontmatter,
@@ -144,6 +146,20 @@ def test_validation_rejects_overlap_and_unsafe_paths(tmp_path: Path) -> None:
144
146
  validate_target_path(config.target_root, name)
145
147
 
146
148
 
149
+ def test_public_legacy_call_forms_validate_collection_entries(tmp_path: Path) -> None:
150
+ config = MaterializerConfig(tmp_path / "source", tmp_path / "target")
151
+
152
+ with pytest.raises(TypeError, match="ClaudeAgent entries"):
153
+ build_plan(config, all_agents=[object()])
154
+ with pytest.raises(TypeError, match="PlannedFile entries"):
155
+ publish_plan(config, all_planned_files=[object()])
156
+
157
+
158
+ def test_render_codex_failure_blast_radius_requires_the_excerpt_heading() -> None:
159
+ with pytest.raises(MaterializerError, match="requires a Codex excerpt"):
160
+ materializer.render_codex_failure_blast_radius("# No excerpt\n")
161
+
162
+
147
163
  def test_validation_rejects_reparse_point_from_portable_attribute_seam(
148
164
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
149
165
  ) -> None:
@@ -428,6 +444,69 @@ def _apply_source_agent(source_root: Path, target_root: Path, description: str)
428
444
  return target_root / "Luna.toml"
429
445
 
430
446
 
447
+ def _write_codex_hook_source(source_root: Path) -> None:
448
+ package_root = Path(__file__).parents[2]
449
+ source_hooks = source_root / "hooks"
450
+ source_hooks.mkdir(parents=True, exist_ok=True)
451
+ (source_hooks / "hooks.json").write_bytes(
452
+ (package_root / "hooks" / "hooks.json").read_bytes()
453
+ )
454
+ for each_relative_path in materializer.codex_hook_dependency_manifest:
455
+ source_path = source_root / each_relative_path
456
+ source_path.parent.mkdir(parents=True, exist_ok=True)
457
+ source_path.write_bytes((package_root / each_relative_path).read_bytes())
458
+
459
+
460
+ def test_build_plan_rejects_reparse_hook_dependencies(
461
+ tmp_path: Path,
462
+ ) -> None:
463
+ source = tmp_path / "source"
464
+ _write_codex_hook_source(source)
465
+ dependency_path = source / materializer.codex_enforcer_script_relative_path
466
+ outside_path = tmp_path / "outside.py"
467
+ outside_path.write_text("secret", encoding="utf-8")
468
+ try:
469
+ dependency_path.unlink()
470
+ dependency_path.symlink_to(outside_path)
471
+ except OSError as error:
472
+ pytest.skip(f"symlinks unavailable: {error}")
473
+
474
+ with pytest.raises(MaterializerError, match="reparse point"):
475
+ build_plan(MaterializerConfig(source, tmp_path / "target"))
476
+
477
+
478
+ def test_publish_plan_preserves_modified_enforcer_hook_as_conflict(
479
+ tmp_path: Path,
480
+ ) -> None:
481
+ source = tmp_path / "source"
482
+ target = tmp_path / "target"
483
+ _write_codex_hook_source(source)
484
+ target.mkdir()
485
+ modified_command = f'python3 "{target / materializer.codex_enforcer_script_relative_path}" --custom'
486
+ existing_manifest = {
487
+ "hooks": {
488
+ "PreToolUse": [
489
+ {
490
+ "matcher": materializer.codex_hook_matcher,
491
+ "hooks": [
492
+ {"type": "command", "command": modified_command, "timeout": 5}
493
+ ],
494
+ }
495
+ ]
496
+ }
497
+ }
498
+ hooks_path = target / materializer.codex_hook_manifest_target_path
499
+ hooks_path.write_text(json.dumps(existing_manifest), encoding="utf-8")
500
+ config = MaterializerConfig(source, target, should_apply=True)
501
+
502
+ planned, report = build_plan(config)
503
+ publication = publish_plan(config, planned, report)
504
+
505
+ assert publication.conflicted == 1
506
+ assert publication.details["conflicted"] == ["hooks.json"]
507
+ assert json.loads(hooks_path.read_text(encoding="utf-8")) == existing_manifest
508
+
509
+
431
510
  def test_apply_with_a_missing_source_root_keeps_managed_files_and_fails(
432
511
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
433
512
  ) -> None:
@@ -698,3 +777,249 @@ def test_frontmatter_rejects_non_string_name() -> None:
698
777
  "---\nname: 1\ndescription: y\n---\n",
699
778
  "bad.md",
700
779
  )
780
+
781
+
782
+ def test_failure_blast_radius_projection_uses_the_canonical_excerpt() -> None:
783
+ canonical_rule_path = Path(__file__).parents[2] / "rules" / "failure-blast-radius.md"
784
+ canonical_rule = canonical_rule_path.read_text(encoding="utf-8")
785
+
786
+ projected_instruction = materializer.render_codex_failure_blast_radius(canonical_rule)
787
+
788
+ assert projected_instruction.startswith("Failure handling for this run")
789
+ assert "Three real attempts, then park." in projected_instruction
790
+ assert "Report as: N of M complete" in projected_instruction
791
+
792
+
793
+ def test_build_plan_publishes_owned_agents_projection_and_tracks_drift(
794
+ tmp_path: Path,
795
+ ) -> None:
796
+ canonical_rule_path = Path(__file__).parents[2] / "rules" / "failure-blast-radius.md"
797
+ source = tmp_path / "source"
798
+ target = tmp_path / "target"
799
+ rule_path = source / "rules" / "failure-blast-radius.md"
800
+ rule_path.parent.mkdir(parents=True)
801
+ rule_path.write_text(canonical_rule_path.read_text(encoding="utf-8"), encoding="utf-8")
802
+ config = MaterializerConfig(source, target, should_apply=True)
803
+
804
+ planned, report = build_plan(config, all_agents=[])
805
+ publish_plan(config, planned, report)
806
+
807
+ projected_path = target / "AGENTS.md"
808
+ first_projection = projected_path.read_text(encoding="utf-8")
809
+ manifest_record = load_manifest(config.manifest_path)["files"]["AGENTS.md"]
810
+ assert manifest_record["ownership"] == "codex-compat"
811
+ assert manifest_record["source"] == "rules/failure-blast-radius.md"
812
+
813
+ rule_path.write_text(
814
+ rule_path.read_text(encoding="utf-8").replace("Three real attempts, then park.", "Three tested attempts, then park."),
815
+ encoding="utf-8",
816
+ )
817
+ drifted_plan, _ = build_plan(config, all_agents=[])
818
+
819
+ drifted_projection = next(
820
+ each_file.content
821
+ for each_file in drifted_plan
822
+ if each_file.target_relative_path == "AGENTS.md"
823
+ )
824
+ assert drifted_projection != first_projection
825
+
826
+
827
+ def test_codex_hook_manifest_keeps_dispatcher_order_and_adds_enforcer() -> None:
828
+ hooks_path = Path(__file__).parents[2] / "hooks" / "hooks.json"
829
+ hooks_configuration = json.loads(hooks_path.read_text(encoding="utf-8"))
830
+ pre_tool_use_entries = hooks_configuration["hooks"]["PreToolUse"]
831
+ matcher_names = [each_entry.get("matcher") for each_entry in pre_tool_use_entries]
832
+
833
+ dispatcher_index = matcher_names.index("Write|Edit|MultiEdit")
834
+ enforcer_entries = [
835
+ each_entry
836
+ for each_entry in pre_tool_use_entries
837
+ if each_entry.get("matcher") == "apply_patch"
838
+ ]
839
+ assert len(enforcer_entries) == 1
840
+ assert matcher_names.index("apply_patch") > dispatcher_index
841
+ assert enforcer_entries[0]["hooks"][0]["command"].endswith(
842
+ "/hooks/blocking/code_rules_enforcer.py"
843
+ )
844
+
845
+
846
+ def _prepare_projection_fixture(
847
+ tmp_path: Path,
848
+ ) -> tuple[Path, Path, dict[str, Any], Path]:
849
+ source = tmp_path / "source"
850
+ target = tmp_path.parent / "codex-prod-target"
851
+ source_rule = source / "rules" / "failure-blast-radius.md"
852
+ source_hooks = source / "hooks" / "hooks.json"
853
+ source_rule.parent.mkdir(parents=True)
854
+ source_hooks.parent.mkdir(parents=True)
855
+ source_rule.write_text(
856
+ (Path(__file__).parents[2] / "rules" / "failure-blast-radius.md").read_text(
857
+ encoding="utf-8"
858
+ ),
859
+ encoding="utf-8",
860
+ )
861
+ source_hooks.write_text(
862
+ (Path(__file__).parents[2] / "hooks" / "hooks.json").read_text(encoding="utf-8"),
863
+ encoding="utf-8",
864
+ )
865
+ package_root = Path(__file__).parents[2]
866
+ for each_relative_path in materializer.codex_hook_dependency_manifest:
867
+ target_dependency = source / each_relative_path
868
+ target_dependency.parent.mkdir(parents=True, exist_ok=True)
869
+ target_dependency.write_bytes((package_root / each_relative_path).read_bytes())
870
+ target.mkdir()
871
+ existing_hooks = {
872
+ "hooks": {
873
+ "PreToolUse": [
874
+ {
875
+ "matcher": "Write|Edit",
876
+ "hooks": [{"type": "command", "command": "python existing.py"}],
877
+ },
878
+ {
879
+ "matcher": "apply_patch",
880
+ "hooks": [
881
+ {
882
+ "type": "command",
883
+ "command": (
884
+ f'python3 "{target / "hooks" / "blocking" / "code_rules_enforcer.py"}"'
885
+ ),
886
+ "timeout": 60,
887
+ }
888
+ ],
889
+ },
890
+ {
891
+ "matcher": "apply_patch",
892
+ "hooks": [
893
+ {"type": "command", "command": "python not_code_rules_enforcer.py"}
894
+ ],
895
+ },
896
+ ],
897
+ "Bash": [{"matcher": "", "hooks": []}],
898
+ }
899
+ }
900
+ (target / "hooks.json").write_text(json.dumps(existing_hooks), encoding="utf-8")
901
+ retired_owned_path = target / "hooks" / "obsolete.py"
902
+ retired_owned_path.parent.mkdir(parents=True)
903
+ retired_owned_path.write_text("retired owned hook\n", encoding="utf-8")
904
+ retired_manifest = {
905
+ "version": 1,
906
+ "files": {
907
+ "hooks/obsolete.py": {
908
+ "source": "hooks/obsolete.py",
909
+ "hash": materializer.hash_content(retired_owned_path.read_bytes()),
910
+ "ownership": "codex-compat",
911
+ }
912
+ },
913
+ }
914
+ (target / ".codex-compat-manifest.json").write_text(
915
+ json.dumps(retired_manifest), encoding="utf-8"
916
+ )
917
+ return source, target, existing_hooks, retired_owned_path
918
+
919
+
920
+ def _run_materializer_and_read_report(
921
+ source: Path,
922
+ target: Path,
923
+ capsys: pytest.CaptureFixture[str],
924
+ ) -> dict[str, Any]:
925
+ exit_code = materializer.main([str(source), str(target), "--apply"])
926
+ report = json.loads(capsys.readouterr().out)
927
+ assert exit_code == 0, report
928
+ assert report["errors"] == 0, report
929
+ return report
930
+
931
+
932
+ def _assert_projection_and_manifest(
933
+ target: Path,
934
+ existing_hooks: dict[str, Any],
935
+ retired_owned_path: Path,
936
+ ) -> None:
937
+ assert not retired_owned_path.exists()
938
+ projected_instruction = (target / "AGENTS.md").read_text(encoding="utf-8")
939
+ assert "Three real attempts, then park." in projected_instruction
940
+ manifest = json.loads((target / "hooks.json").read_text(encoding="utf-8"))
941
+ assert manifest["hooks"]["PreToolUse"][0] == existing_hooks["hooks"]["PreToolUse"][0]
942
+ apply_patch_entries = [
943
+ each_entry
944
+ for each_entry in manifest["hooks"]["PreToolUse"]
945
+ if each_entry["matcher"] == "apply_patch"
946
+ ]
947
+ assert len(apply_patch_entries) == 1
948
+ apply_patch_commands = [
949
+ each_hook["command"] for each_hook in apply_patch_entries[0]["hooks"]
950
+ ]
951
+ assert apply_patch_commands[0] == "python not_code_rules_enforcer.py"
952
+ managed_command = (
953
+ f'python3 "{target / "hooks" / "blocking" / "code_rules_enforcer.py"}"'
954
+ )
955
+ assert managed_command in apply_patch_commands
956
+ manifest_record = load_manifest(target / ".codex-compat-manifest.json")["files"]
957
+ assert manifest_record["AGENTS.md"]["ownership"] == "codex-compat"
958
+ assert manifest_record["hooks.json"]["source"] == "hooks/hooks.json"
959
+
960
+
961
+ def _assert_detached_enforcer_behavior(target: Path) -> None:
962
+ target_entrypoint = target / materializer.codex_enforcer_script_relative_path
963
+ allow_payload = {
964
+ "tool_name": "apply_patch",
965
+ "cwd": str(target),
966
+ "tool_input": {
967
+ "command": (
968
+ "*** Begin Patch\n"
969
+ "*** Add File: module.py\n"
970
+ "+for each_member in all_members:\n"
971
+ "+ raise AssetItemBlocked()\n"
972
+ "*** End Patch"
973
+ )
974
+ },
975
+ }
976
+ allow_run = subprocess.run(
977
+ [sys.executable, str(target_entrypoint)],
978
+ cwd=target,
979
+ input=json.dumps(allow_payload),
980
+ capture_output=True,
981
+ text=True,
982
+ check=False,
983
+ )
984
+ assert allow_run.returncode == 0
985
+ assert allow_run.stdout == ""
986
+
987
+ deny_payload = {
988
+ **allow_payload,
989
+ "tool_input": {
990
+ "command": allow_payload["tool_input"]["command"].replace(
991
+ "AssetItemBlocked", "RuntimeError"
992
+ )
993
+ },
994
+ }
995
+ deny_run = subprocess.run(
996
+ [sys.executable, str(target_entrypoint)],
997
+ cwd=target,
998
+ input=json.dumps(deny_payload),
999
+ capture_output=True,
1000
+ text=True,
1001
+ check=False,
1002
+ )
1003
+ assert deny_run.returncode == 0
1004
+ assert json.loads(deny_run.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
1005
+ assert target_entrypoint.is_file()
1006
+
1007
+
1008
+ def test_exact_cli_publishes_instruction_and_additive_hook_projection(
1009
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
1010
+ ) -> None:
1011
+ source, target, existing_hooks, retired_owned_path = _prepare_projection_fixture(
1012
+ tmp_path
1013
+ )
1014
+
1015
+ first_report = _run_materializer_and_read_report(source, target, capsys)
1016
+ assert first_report["written"] > 0
1017
+ _assert_projection_and_manifest(target, existing_hooks, retired_owned_path)
1018
+
1019
+ hooks_before_second_run = (target / "hooks.json").read_bytes()
1020
+ second_report = _run_materializer_and_read_report(source, target, capsys)
1021
+ assert second_report["written"] == 0
1022
+ assert (target / "hooks.json").read_bytes() == hooks_before_second_run
1023
+
1024
+ source.rename(tmp_path / "source-detached")
1025
+ _assert_detached_enforcer_behavior(target)