claude-dev-env 2.5.0 → 2.7.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.
Files changed (153) hide show
  1. package/CLAUDE.md +20 -57
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -1
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  12. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  13. package/_shared/pr-loop/worker-spawn.md +3 -1
  14. package/agents/CLAUDE.md +1 -1
  15. package/agents/code-verifier.md +36 -7
  16. package/bin/codex-compat.mjs +104 -0
  17. package/bin/codex-compat.test.mjs +51 -0
  18. package/codex-capability-map.json +13 -0
  19. package/docs/CODE_RULES.md +2 -0
  20. package/docs/codex-compatibility.md +25 -0
  21. package/docs/nas-ssh-invocation.md +96 -12
  22. package/docs/references/code-review-enforcement.md +31 -6
  23. package/hooks/blocking/CLAUDE.md +3 -0
  24. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
  25. package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
  26. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  27. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  28. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  29. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  30. package/hooks/blocking/state_description_blocker.py +1 -1
  31. package/hooks/blocking/stop_dispatcher.py +1 -1
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  33. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  34. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  35. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  36. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  37. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
  38. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  39. package/hooks/git-hooks/CLAUDE.md +3 -0
  40. package/hooks/git-hooks/conftest.py +30 -0
  41. package/hooks/git-hooks/gate_utils.py +2 -2
  42. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  43. package/hooks/git-hooks/pre_push.py +75 -4
  44. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  45. package/hooks/git-hooks/test_config.py +0 -15
  46. package/hooks/git-hooks/test_gate_utils.py +3 -15
  47. package/hooks/git-hooks/test_pre_commit.py +1 -15
  48. package/hooks/git-hooks/test_pre_push.py +236 -27
  49. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  50. package/hooks/hooks.json +0 -12
  51. package/hooks/hooks_constants/CLAUDE.md +5 -1
  52. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  53. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  54. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  55. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  56. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  57. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  58. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  59. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  60. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  61. package/package.json +4 -2
  62. package/rules/CLAUDE.md +17 -23
  63. package/rules/agent-spawn-protocol.md +6 -6
  64. package/rules/anti-corollary-tests.md +1 -1
  65. package/rules/bdd.md +1 -1
  66. package/rules/cleanup-temp-files.md +10 -4
  67. package/rules/code-standards.md +7 -0
  68. package/rules/conservative-action.md +1 -5
  69. package/rules/context7.md +0 -4
  70. package/rules/destructive-commands.md +47 -0
  71. package/rules/doc-inventory-integrity.md +48 -0
  72. package/rules/doc-prose-cuts.md +58 -0
  73. package/rules/docstring-prose-matches-implementation.md +10 -2
  74. package/rules/durable-post-artifacts.md +0 -4
  75. package/rules/eli11-replies.md +31 -0
  76. package/rules/explore-thoroughly.md +4 -4
  77. package/rules/falsify-before-green.md +68 -0
  78. package/rules/file-global-constants.md +1 -1
  79. package/rules/filesystem-search.md +51 -0
  80. package/rules/gh-cli-conventions.md +27 -0
  81. package/rules/git-workflow.md +26 -0
  82. package/rules/hedging-claims.md +9 -0
  83. package/rules/long-horizon-autonomy.md +0 -4
  84. package/rules/measurement-denominators.md +48 -0
  85. package/rules/nas-ssh-invocation.md +23 -5
  86. package/rules/parallel-tools.md +2 -2
  87. package/rules/plain-illustrative-docstrings.md +3 -7
  88. package/rules/plain-language.md +2 -0
  89. package/rules/proof-of-work-pr-comments.md +0 -4
  90. package/rules/re-stage-before-commit.md +2 -0
  91. package/rules/research-mode.md +10 -0
  92. package/rules/shell-invocation.md +21 -0
  93. package/rules/testing.md +4 -0
  94. package/rules/verified-commit-gate-skip.md +3 -27
  95. package/rules/verify-before-asking.md +5 -0
  96. package/rules/windows-filesystem-safe.md +1 -1
  97. package/rules/workers-done-before-complete.md +4 -0
  98. package/scripts/CLAUDE.md +3 -3
  99. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  100. package/scripts/codex_capability_bridge.py +171 -0
  101. package/scripts/codex_compat_materializer.py +1087 -0
  102. package/scripts/codex_compat_watcher.py +502 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  104. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  105. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +79 -13
  106. package/scripts/grok_headless_runner.py +213 -16
  107. package/scripts/invoke_code_review.py +11 -4
  108. package/scripts/resolve_worker_spawn.py +56 -10
  109. package/scripts/spawn_grok_batch.py +43 -22
  110. package/scripts/sync_to_cursor/rules.py +0 -10
  111. package/scripts/test_grok_headless_runner.py +592 -10
  112. package/scripts/test_invoke_code_review.py +143 -0
  113. package/scripts/test_invoke_code_review_chain.py +1 -1
  114. package/scripts/test_invoke_code_review_contract.py +1 -1
  115. package/scripts/test_resolve_worker_spawn.py +179 -15
  116. package/scripts/test_spawn_grok_batch.py +225 -22
  117. package/scripts/tests/test_code_review_constants.py +80 -0
  118. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  119. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  120. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  121. package/scripts/tests/test_sync_to_cursor.py +0 -1
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +28 -6
  123. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +73 -0
  124. package/skills/autoconverge/workflow/converge.mjs +64 -15
  125. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  126. package/skills/condensing-instructions/SKILL.md +42 -51
  127. package/skills/fresh-branch/CLAUDE.md +1 -1
  128. package/skills/fresh-branch/SKILL.md +5 -6
  129. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  131. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  132. package/skills/grok-spawn/SKILL.md +5 -3
  133. package/skills/grok-spawn/reference/flag-profiles.md +3 -1
  134. package/skills/orchestrator/SKILL.md +23 -9
  135. package/skills/orchestrator-refresh/SKILL.md +20 -1
  136. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  137. package/skills/session-log/SKILL.md +1 -1
  138. package/rules/claude-md-orphan-file.md +0 -28
  139. package/rules/cleanup-command-forms.md +0 -23
  140. package/rules/code-reviews.md +0 -11
  141. package/rules/env-var-table-code-drift.md +0 -10
  142. package/rules/gh-body-file.md +0 -5
  143. package/rules/gh-paginate.md +0 -3
  144. package/rules/hook-prose-matches-detector.md +0 -15
  145. package/rules/no-historical-clutter.md +0 -26
  146. package/rules/no-inline-destructive-literals.md +0 -9
  147. package/rules/no-justification-noise.md +0 -61
  148. package/rules/package-inventory-stale-entry.md +0 -25
  149. package/rules/right-sized-engineering.md +0 -28
  150. package/rules/self-contained-docs.md +0 -17
  151. package/rules/shell-invocation-policy.md +0 -5
  152. package/rules/state-what-is.md +0 -25
  153. package/rules/tdd.md +0 -7
@@ -0,0 +1,632 @@
1
+ import json
2
+ from pathlib import Path
3
+ import sys
4
+ import tomllib
5
+
6
+ import pytest
7
+
8
+ module_directory = str(Path(__file__).parents[1])
9
+ if module_directory not in sys.path:
10
+ sys.path.insert(0, module_directory)
11
+
12
+ import codex_compat_materializer as materializer
13
+ from codex_compat_materializer import (
14
+ MaterializationReport,
15
+ MaterializerConfig,
16
+ MaterializerError,
17
+ PlannedFile,
18
+ atomic_write,
19
+ build_plan,
20
+ convert_agent,
21
+ content_to_bytes,
22
+ hash_content,
23
+ load_manifest,
24
+ parse_frontmatter,
25
+ publish_plan,
26
+ save_manifest,
27
+ validate_target_path,
28
+ )
29
+
30
+
31
+ def test_conversion_escapes_toml_content() -> None:
32
+ agent = parse_frontmatter(Path("agent.md"), '---\nname: sample\ndescription: "say \\"hi\\""\ntools: ["Read", "Write"]\n---\n', "agent.md")
33
+ converted = convert_agent(agent)
34
+ assert 'description = "say \\"hi\\""' in converted
35
+ assert tomllib.loads(converted)["name"] == "sample"
36
+
37
+
38
+ def test_malformed_and_unsupported_frontmatter() -> None:
39
+ with pytest.raises(MaterializerError):
40
+ parse_frontmatter(Path("bad.md"), "---\nname: x\n", "bad.md")
41
+ agent = parse_frontmatter(Path("ok.md"), "---\nname: x\ndescription: y\nmodel: sonnet\ncolor: blue\n---\n", "ok.md")
42
+ assert agent.unsupported == ("color", "model")
43
+
44
+
45
+ @pytest.mark.parametrize(
46
+ "source_text",
47
+ [
48
+ "---\nname: x\ndescription: y\nunknown: z\n---\n",
49
+ "---\nname: x\ndescription: 'unterminated\n---\n",
50
+ "---\nname: x\ndescription: y\ntools: [Read, 'Write]\n---\n",
51
+ "---\nname: x\nname: y\ndescription: z\n---\n",
52
+ "---\nname: x\ndescription: y\n---\nbody\n---\n",
53
+ ],
54
+ )
55
+ def test_frontmatter_rejects_invalid_syntax(source_text: str) -> None:
56
+ with pytest.raises(MaterializerError):
57
+ parse_frontmatter(Path("bad.md"), source_text, "bad.md")
58
+
59
+
60
+ def test_frontmatter_preserves_commas_inside_quoted_list_entries() -> None:
61
+ agent = parse_frontmatter(
62
+ Path("agent.md"),
63
+ '---\nname: x\ndescription: y\ntools: ["Read,Only", Write]\n---\n',
64
+ "agent.md",
65
+ )
66
+ assert agent.tools == ("Read,Only", "Write")
67
+
68
+
69
+ def test_discover_agents_returns_sorted_parsed_agents(tmp_path: Path) -> None:
70
+ source = tmp_path / "source"
71
+ source.mkdir()
72
+ (source / "zulu.md").write_text("---\nname: Zulu\ndescription: z\n---\n", encoding="utf-8")
73
+ (source / "alpha.md").write_text("---\nname: Alpha\ndescription: a\n---\n", encoding="utf-8")
74
+
75
+ agents = materializer.discover_agents(MaterializerConfig(source, tmp_path / "target"))
76
+
77
+ assert [agent.name for agent in agents] == ["Alpha", "Zulu"]
78
+
79
+
80
+ def test_content_to_bytes_preserves_bytes_and_encodes_text() -> None:
81
+ assert content_to_bytes(b"payload") == b"payload"
82
+ assert content_to_bytes("payload") == b"payload"
83
+
84
+
85
+ def test_save_manifest_round_trips_manifest_content(tmp_path: Path) -> None:
86
+ manifest_path = tmp_path / "target" / "manifest.json"
87
+ manifest = {"version": 1, "files": {"agent.toml": {"hash": "abc"}}}
88
+
89
+ save_manifest(manifest_path, manifest)
90
+
91
+ assert load_manifest(manifest_path) == manifest
92
+
93
+
94
+ def test_create_argument_parser_reads_roots_and_apply_flag() -> None:
95
+ arguments = materializer.create_argument_parser().parse_args(["source", "target", "--apply"])
96
+
97
+ assert arguments.source_root == Path("source")
98
+ assert arguments.target_root == Path("target")
99
+ assert arguments.should_apply is True
100
+
101
+
102
+ def test_validation_rejects_overlap_and_unsafe_paths(tmp_path: Path) -> None:
103
+ with pytest.raises(MaterializerError):
104
+ MaterializerConfig(tmp_path, tmp_path / "target")
105
+ config = MaterializerConfig(tmp_path / "source", tmp_path / "target")
106
+ for name in ("../escape", "C:/escape", "\\\\server\\share", "/rooted"):
107
+ with pytest.raises(MaterializerError):
108
+ validate_target_path(config.target_root, name)
109
+
110
+
111
+ def test_validation_rejects_reparse_point_from_portable_attribute_seam(
112
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
113
+ ) -> None:
114
+ target_root = tmp_path / "target"
115
+ target_root.mkdir()
116
+ monkeypatch.setattr(materializer, "_is_reparse_point", lambda path: path == target_root)
117
+
118
+ with pytest.raises(MaterializerError, match="reparse"):
119
+ validate_target_path(target_root, "agent.toml")
120
+
121
+
122
+ def test_private_source_identity_is_rejected_before_manifest_serialization(
123
+ tmp_path: Path,
124
+ ) -> None:
125
+ config = MaterializerConfig(tmp_path / "source", tmp_path / "target")
126
+ with pytest.raises(MaterializerError):
127
+ build_plan(config, [
128
+ parse_frontmatter(Path("agent.md"), "---\nname: x\ndescription: y\n---\n", "C:/private.md")
129
+ ])
130
+
131
+
132
+ def test_case_folded_target_collisions_include_existing_and_manifest(tmp_path: Path) -> None:
133
+ source = tmp_path / "source"
134
+ target = tmp_path / "target"
135
+ source.mkdir()
136
+ target.mkdir()
137
+ (target / "LUNA.toml").write_text("unmanaged", encoding="utf-8")
138
+ with pytest.raises(MaterializerError):
139
+ build_plan(MaterializerConfig(source, target), [
140
+ parse_frontmatter(Path("a.md"), "---\nname: Luna\ndescription: y\n---\n", "a.md")
141
+ ])
142
+
143
+
144
+ def test_manifest_target_collision_is_rejected(tmp_path: Path) -> None:
145
+ config = MaterializerConfig(tmp_path / "source", tmp_path / "target", should_apply=True)
146
+ planned = [PlannedFile("a.md", "Luna.toml", "managed", "hash")]
147
+ atomic_write(config.manifest_path, '{"files": {"luna.TOML": {}}, "version": 1}\n')
148
+ with pytest.raises(MaterializerError):
149
+ publish_plan(config, planned)
150
+
151
+
152
+ def test_first_run_manifest_target_collision_is_rejected(tmp_path: Path) -> None:
153
+ source = tmp_path / "source"
154
+ target = tmp_path / "target"
155
+ source.mkdir()
156
+ config = MaterializerConfig(source, target, should_apply=True)
157
+ planned = [PlannedFile("a.md", config.manifest_path.name, "managed", "hash")]
158
+
159
+ with pytest.raises(MaterializerError, match="compatibility manifest"):
160
+ publish_plan(config, planned)
161
+
162
+ assert not config.manifest_path.exists()
163
+ assert not config.target_root.exists()
164
+
165
+
166
+ def test_publish_rejects_planned_case_fold_collision(tmp_path: Path) -> None:
167
+ config = MaterializerConfig(tmp_path / "source", tmp_path / "target", should_apply=True)
168
+ planned = [
169
+ PlannedFile("a.md", "Luna.toml", "first", hash_content("first")),
170
+ PlannedFile("b.md", "luna.toml", "second", hash_content("second")),
171
+ ]
172
+ with pytest.raises(MaterializerError, match="case-fold collision"):
173
+ publish_plan(config, planned)
174
+
175
+
176
+ def test_dry_run_is_non_mutating_and_writes_are_idempotent(tmp_path: Path) -> None:
177
+ source = tmp_path / "source"
178
+ target = tmp_path / "target"
179
+ source.mkdir()
180
+ (source / "agent.md").write_text("---\nname: Luna\ndescription: Light\n---\n", encoding="utf-8")
181
+ dry_config = MaterializerConfig(source, target)
182
+ planned, report = build_plan(dry_config)
183
+ publish_plan(dry_config, planned, report)
184
+ assert not target.exists()
185
+ apply_config = MaterializerConfig(source, target, should_apply=True)
186
+ planned, report = build_plan(apply_config)
187
+ publish_plan(apply_config, planned, report)
188
+ first = (target / "Luna.toml").read_text(encoding="utf-8")
189
+ first_manifest = apply_config.manifest_path.read_bytes()
190
+ planned, report = build_plan(apply_config)
191
+ second_report = publish_plan(apply_config, planned, report)
192
+ assert (target / "Luna.toml").read_text(encoding="utf-8") == first
193
+ assert apply_config.manifest_path.read_bytes() == first_manifest
194
+ assert second_report.written == 0
195
+ assert second_report.deleted == 0
196
+ assert second_report.errors == 0
197
+ assert second_report.details["unchanged"] == ["Luna.toml"]
198
+
199
+
200
+ def test_atomic_write_and_unmanaged_collision_are_safe(tmp_path: Path) -> None:
201
+ target = tmp_path / "target"
202
+ target.mkdir()
203
+ existing = target / "Luna.toml"
204
+ existing.write_text("unmanaged", encoding="utf-8")
205
+ planned = PlannedFile("agent.md", "Luna.toml", "managed", "hash")
206
+ report = MaterializationReport()
207
+ publish_plan(MaterializerConfig(tmp_path / "source", target, should_apply=True), [planned], report)
208
+ assert existing.read_text(encoding="utf-8") == "unmanaged"
209
+ atomic_write(existing, "updated")
210
+ assert existing.read_text(encoding="utf-8") == "updated"
211
+
212
+
213
+ def test_generic_publication_accepts_non_toml_managed_bytes(tmp_path: Path) -> None:
214
+ config = MaterializerConfig(tmp_path / "source", tmp_path / "target", should_apply=True)
215
+ managed_content = b"managed binary payload\x00"
216
+ planned = [PlannedFile("watcher", "watcher.bin", managed_content, hash_content(managed_content))]
217
+
218
+ report = publish_plan(config, planned)
219
+
220
+ assert report.written == 1
221
+ assert (config.target_root / "watcher.bin").read_bytes() == managed_content
222
+
223
+
224
+ def test_atomic_write_failure_preserves_previous_target_bytes(tmp_path: Path) -> None:
225
+ target = tmp_path / "Luna.toml"
226
+ target.write_bytes(b"previous bytes")
227
+
228
+ def fail_before_replace(target_name: str) -> None:
229
+ assert target_name == str(target)
230
+ raise RuntimeError("injected atomic write failure")
231
+
232
+ with pytest.raises(RuntimeError, match="injected"):
233
+ atomic_write(target, "replacement", fail_before_replace)
234
+
235
+ assert target.read_bytes() == b"previous bytes"
236
+
237
+
238
+ def test_manifest_is_published_last_and_previous_manifest_survives_failure(tmp_path: Path) -> None:
239
+ source = tmp_path / "source"
240
+ target = tmp_path / "target"
241
+ source.mkdir()
242
+ config = MaterializerConfig(source, target, should_apply=True)
243
+ manifest = config.manifest_path
244
+ manifest.parent.mkdir()
245
+ atomic_write(manifest, '{"files": {}, "version": 1}\n')
246
+ planned = [PlannedFile("agent.md", "Luna.toml", "managed", "hash")]
247
+ with pytest.raises(RuntimeError):
248
+ publish_plan(config, planned, failure_injector=lambda _: (_ for _ in ()).throw(RuntimeError("stop")))
249
+ assert load_manifest(manifest)["files"] == {}
250
+ assert not (target / "Luna.toml").exists()
251
+
252
+
253
+ def test_partial_publication_rolls_back_all_files_and_reports_incomplete_generation(
254
+ tmp_path: Path,
255
+ ) -> None:
256
+ config = MaterializerConfig(tmp_path / "source", tmp_path / "target", should_apply=True)
257
+ planned = [
258
+ PlannedFile("a.md", "Luna.toml", "luna", hash_content("luna")),
259
+ PlannedFile("b.md", "Nova.toml", "nova", hash_content("nova")),
260
+ ]
261
+ call_count = 0
262
+
263
+ def fail_on_second_write(target_name: str) -> None:
264
+ nonlocal call_count
265
+ call_count += 1
266
+ if call_count == 2:
267
+ raise RuntimeError("second write failed")
268
+
269
+ with pytest.raises(RuntimeError, match="second write failed"):
270
+ publish_plan(config, planned, failure_injector=fail_on_second_write)
271
+
272
+ assert not (config.target_root / "Luna.toml").exists()
273
+ assert not (config.target_root / "Nova.toml").exists()
274
+
275
+
276
+ def test_rollback_failure_requires_reconcile_and_has_deterministic_details(
277
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
278
+ ) -> None:
279
+ config = MaterializerConfig(tmp_path / "source", tmp_path / "target", should_apply=True)
280
+ report = MaterializationReport()
281
+ planned = [
282
+ PlannedFile("a.md", "Luna.toml", "luna", hash_content("luna")),
283
+ PlannedFile("b.md", "Nova.toml", "nova", hash_content("nova")),
284
+ ]
285
+
286
+ def fail_later_publication(target_name: str) -> None:
287
+ if target_name.endswith("Nova.toml"):
288
+ raise RuntimeError("later publication failed")
289
+
290
+ original_unlink = Path.unlink
291
+
292
+ def fail_rollback(target_path: Path, missing_ok: bool = False) -> None:
293
+ if target_path == config.target_root / "Luna.toml":
294
+ raise OSError("rollback")
295
+ original_unlink(target_path, missing_ok=missing_ok)
296
+
297
+ monkeypatch.setattr(Path, "unlink", fail_rollback)
298
+ with pytest.raises(RuntimeError, match="later publication failed"):
299
+ publish_plan(config, planned, report, failure_injector=fail_later_publication)
300
+
301
+ assert report.incomplete_generation is True
302
+ assert report.reconcile_required is True
303
+ assert report.details["errors"] == [
304
+ "rollback failed: " + str(config.target_root / "Luna.toml"),
305
+ "incomplete_generation/reconcile_required",
306
+ ]
307
+
308
+
309
+ def test_modified_and_deleted_managed_outputs_are_preserved_or_deleted(
310
+ tmp_path: Path,
311
+ ) -> None:
312
+ source = tmp_path / "source"
313
+ target = tmp_path / "target"
314
+ source.mkdir()
315
+ config = MaterializerConfig(source, target, should_apply=True)
316
+ first = [PlannedFile("a.md", "Luna.toml", "managed", hash_content("managed"))]
317
+ publish_plan(config, first)
318
+ (target / "Luna.toml").write_text("changed", encoding="utf-8")
319
+ second = [PlannedFile("a.md", "Nova.toml", "new", hash_content("new"))]
320
+ report = publish_plan(config, second)
321
+ assert (target / "Luna.toml").read_text(encoding="utf-8") == "changed"
322
+ assert report.preserved == 1
323
+ assert report.modified_managed == 1
324
+ assert report.details["modified_managed"] == ["Luna.toml"]
325
+ assert report.details["stale_managed"] == []
326
+
327
+
328
+ def test_report_contains_deterministic_category_details_and_error_count(
329
+ tmp_path: Path,
330
+ ) -> None:
331
+ target = tmp_path / "target"
332
+ target.mkdir()
333
+ config = MaterializerConfig(tmp_path / "source", target, should_apply=True)
334
+ pruning_config = MaterializerConfig(
335
+ tmp_path / "source", target, should_apply=True, should_allow_full_prune=True
336
+ )
337
+ managed_content = "managed"
338
+ initial = [PlannedFile("a.md", "Luna.toml", managed_content, hash_content(managed_content))]
339
+ publish_plan(config, initial)
340
+ refreshed_report = publish_plan(
341
+ config, [PlannedFile("a.md", "Luna.toml", "new", hash_content("new"))]
342
+ )
343
+ assert refreshed_report.details["written"] == ["Luna.toml"]
344
+ assert refreshed_report.details["modified_managed"] == []
345
+ assert refreshed_report.conflicted == 0
346
+ assert refreshed_report.details["conflicted"] == []
347
+ assert (target / "Luna.toml").read_text(encoding="utf-8") == "new"
348
+
349
+ (target / "Luna.toml").write_text("changed", encoding="utf-8")
350
+ atomic_write(config.manifest_path, json.dumps({"version": 1, "files": {
351
+ "Luna.toml": {"hash": hash_content(managed_content)}
352
+ }}))
353
+ stale_report = publish_plan(pruning_config, [])
354
+ assert stale_report.details["modified_managed"] == ["Luna.toml"]
355
+ assert stale_report.details["stale_managed"] == []
356
+
357
+ (target / "Luna.toml").write_text(managed_content, encoding="utf-8")
358
+ atomic_write(config.manifest_path, json.dumps({"version": 1, "files": {
359
+ "Luna.toml": {"hash": hash_content(managed_content)}
360
+ }}))
361
+ deleted_report = publish_plan(pruning_config, [])
362
+ assert deleted_report.details["deleted"] == ["Luna.toml"]
363
+
364
+ collision_target = target / "Nova.toml"
365
+ collision_target.write_text("unmanaged", encoding="utf-8")
366
+ collision_report = publish_plan(
367
+ config, [PlannedFile("b.md", "Nova.toml", "new", hash_content("new"))]
368
+ )
369
+ assert collision_report.details["unmanaged_collision"] == ["Nova.toml"]
370
+ assert collision_report.conflicted == 1
371
+ assert collision_report.details["conflicted"] == ["Nova.toml"]
372
+
373
+ manifest = {"version": 1, "files": {"Missing.toml": {"hash": hash_content("missing")}}}
374
+ atomic_write(config.manifest_path, json.dumps(manifest))
375
+ error_report = publish_plan(pruning_config, [])
376
+ assert error_report.errors == 1
377
+ assert error_report.error_details == ["missing managed path: Missing.toml"]
378
+
379
+
380
+ def _write_source_agent(source_root: Path, description: str) -> None:
381
+ source_root.mkdir(parents=True, exist_ok=True)
382
+ (source_root / "luna.md").write_text(
383
+ f"---\nname: Luna\ndescription: {description}\n---\n", encoding="utf-8"
384
+ )
385
+
386
+
387
+ def _apply_source_agent(source_root: Path, target_root: Path, description: str) -> Path:
388
+ _write_source_agent(source_root, description)
389
+ config = MaterializerConfig(source_root, target_root, should_apply=True)
390
+ planned, report = build_plan(config)
391
+ publish_plan(config, planned, report)
392
+ return target_root / "Luna.toml"
393
+
394
+
395
+ def test_apply_with_a_missing_source_root_keeps_managed_files_and_fails(
396
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
397
+ ) -> None:
398
+ source = tmp_path / "source"
399
+ target = tmp_path / "target"
400
+ published_path = _apply_source_agent(source, target, "Light")
401
+ published_bytes = published_path.read_bytes()
402
+ manifest_bytes = (target / ".codex-compat-manifest.json").read_bytes()
403
+ source.rename(tmp_path / "moved-source")
404
+ monkeypatch.setattr(
405
+ materializer,
406
+ "_remove_stale_files",
407
+ lambda *arguments: pytest.fail("the destructive path ran for a missing source root"),
408
+ )
409
+
410
+ exit_code = materializer.main([str(source), str(target), "--apply"])
411
+
412
+ report = json.loads(capsys.readouterr().out)
413
+ assert exit_code != 0
414
+ assert report["deleted"] == 0
415
+ assert report["errors"] == 1
416
+ assert published_path.read_bytes() == published_bytes
417
+ assert (target / ".codex-compat-manifest.json").read_bytes() == manifest_bytes
418
+
419
+
420
+ def test_apply_with_an_empty_source_root_keeps_managed_files_until_prune_is_requested(
421
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
422
+ ) -> None:
423
+ source = tmp_path / "source"
424
+ target = tmp_path / "target"
425
+ published_path = _apply_source_agent(source, target, "Light")
426
+ published_bytes = published_path.read_bytes()
427
+ (source / "luna.md").unlink()
428
+ monkeypatch.setattr(
429
+ materializer,
430
+ "_remove_stale_files",
431
+ lambda *arguments: pytest.fail("the destructive path ran without the prune opt-in"),
432
+ )
433
+
434
+ refused_exit_code = materializer.main([str(source), str(target), "--apply"])
435
+
436
+ refusal_report = json.loads(capsys.readouterr().out)
437
+ assert refused_exit_code != 0
438
+ assert refusal_report["deleted"] == 0
439
+ assert refusal_report["error_details"][0].endswith("--allow-prune-all to remove them")
440
+ assert published_path.read_bytes() == published_bytes
441
+
442
+ monkeypatch.undo()
443
+ pruned_exit_code = materializer.main(
444
+ [str(source), str(target), "--apply", "--allow-prune-all"]
445
+ )
446
+
447
+ assert pruned_exit_code == 0
448
+ assert json.loads(capsys.readouterr().out)["deleted"] == 1
449
+ assert not published_path.exists()
450
+
451
+
452
+ def test_reapply_rewrites_a_managed_file_whose_source_description_changed(
453
+ tmp_path: Path,
454
+ ) -> None:
455
+ source = tmp_path / "source"
456
+ target = tmp_path / "target"
457
+ published_path = _apply_source_agent(source, target, "Light")
458
+ assert 'description = "Light"' in published_path.read_text(encoding="utf-8")
459
+
460
+ _write_source_agent(source, "Dark")
461
+ config = MaterializerConfig(source, target, should_apply=True)
462
+ planned, report = build_plan(config)
463
+ refreshed_report = publish_plan(config, planned, report)
464
+
465
+ published_text = published_path.read_text(encoding="utf-8")
466
+ assert 'description = "Dark"' in published_text
467
+ assert 'description = "Light"' not in published_text
468
+ assert refreshed_report.written == 1
469
+ assert refreshed_report.conflicted == 0
470
+ assert load_manifest(config.manifest_path)["files"]["Luna.toml"]["hash"] == hash_content(
471
+ published_text
472
+ )
473
+
474
+
475
+ def test_reapply_preserves_a_hand_edited_managed_file_as_a_conflict(tmp_path: Path) -> None:
476
+ source = tmp_path / "source"
477
+ target = tmp_path / "target"
478
+ published_path = _apply_source_agent(source, target, "Light")
479
+ published_path.write_text("hand edited\n", encoding="utf-8")
480
+
481
+ _write_source_agent(source, "Dark")
482
+ config = MaterializerConfig(source, target, should_apply=True)
483
+ planned, report = build_plan(config)
484
+ conflict_report = publish_plan(config, planned, report)
485
+
486
+ assert published_path.read_text(encoding="utf-8") == "hand edited\n"
487
+ assert conflict_report.written == 0
488
+ assert conflict_report.details["modified_managed"] == ["Luna.toml"]
489
+ assert conflict_report.details["conflicted"] == ["Luna.toml"]
490
+
491
+
492
+ def test_crash_orphan_matching_the_plan_is_adopted_into_the_manifest(tmp_path: Path) -> None:
493
+ source = tmp_path / "source"
494
+ target = tmp_path / "target"
495
+ _apply_source_agent(source, target, "Light")
496
+ (source / "nova.md").write_text(
497
+ "---\nname: Nova\ndescription: Nightfall\n---\n", encoding="utf-8"
498
+ )
499
+ config = MaterializerConfig(source, target, should_apply=True)
500
+ orphan_agent = next(
501
+ each_agent for each_agent in materializer.discover_agents(config) if each_agent.name == "Nova"
502
+ )
503
+ orphan_content = convert_agent(orphan_agent)
504
+ atomic_write(target / "Nova.toml", orphan_content)
505
+
506
+ planned, report = build_plan(config)
507
+ adoption_report = publish_plan(config, planned, report)
508
+
509
+ assert adoption_report.errors == 0
510
+ assert adoption_report.details["adopted"] == ["Nova.toml"]
511
+ assert (target / "Nova.toml").read_text(encoding="utf-8") == orphan_content
512
+ assert load_manifest(config.manifest_path)["files"]["Nova.toml"]["hash"] == hash_content(
513
+ orphan_content
514
+ )
515
+
516
+
517
+ def test_unmanaged_file_at_a_planned_target_is_kept_and_the_error_names_the_remedy(
518
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
519
+ ) -> None:
520
+ source = tmp_path / "source"
521
+ target = tmp_path / "target"
522
+ _write_source_agent(source, "Light")
523
+ target.mkdir()
524
+ user_bytes = b"a file the user wrote\n"
525
+ (target / "Luna.toml").write_bytes(user_bytes)
526
+
527
+ exit_code = materializer.main([str(source), str(target), "--apply"])
528
+
529
+ report = json.loads(capsys.readouterr().out)
530
+ assert exit_code != 0
531
+ assert (target / "Luna.toml").read_bytes() == user_bytes
532
+ assert report["written"] == 0
533
+ assert "move or delete Luna.toml" in report["error_details"][0]
534
+
535
+
536
+ def test_cli_reports_unmanaged_collision_as_json_without_private_paths(
537
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
538
+ ) -> None:
539
+ source = tmp_path / "source"
540
+ target = tmp_path / "target"
541
+ source.mkdir()
542
+ target.mkdir()
543
+ (target / "Luna.toml").write_text("unmanaged", encoding="utf-8")
544
+ planned = [PlannedFile("a.md", "Luna.toml", "new", hash_content("new"))]
545
+ monkeypatch.setattr(materializer, "build_plan", lambda _, all_agents=None: (planned, MaterializationReport()))
546
+
547
+ exit_code = materializer.main([str(source), str(target), "--apply"])
548
+
549
+ printed_report = capsys.readouterr().out
550
+ report = json.loads(printed_report)
551
+ assert exit_code != 0
552
+ assert report["written"] == 0
553
+ assert report["unmanaged_collision"] == 1
554
+ assert report["errors"] == 0
555
+ assert str(tmp_path) not in printed_report
556
+
557
+
558
+ def test_cli_reports_overlapping_roots_as_complete_redacted_json(
559
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
560
+ ) -> None:
561
+ source_root = tmp_path / "private-source"
562
+ target_root = source_root / "private-target"
563
+
564
+ exit_code = materializer.main([str(source_root), str(target_root)])
565
+
566
+ printed_lines = capsys.readouterr().out.splitlines()
567
+ assert len(printed_lines) == 1
568
+ report = json.loads(printed_lines[0])
569
+ expected_fields = {
570
+ *materializer.REPORT_CATEGORIES,
571
+ "details",
572
+ "dry_run",
573
+ "error_details",
574
+ "incomplete_generation",
575
+ "reconcile_required",
576
+ }
577
+ assert set(report) == expected_fields
578
+ assert exit_code != 0
579
+ assert report["errors"] == 1
580
+ assert report["dry_run"] is True
581
+ assert str(tmp_path) not in printed_lines[0]
582
+
583
+
584
+ def test_cli_reports_argument_errors_as_complete_json(
585
+ capsys: pytest.CaptureFixture[str],
586
+ ) -> None:
587
+ exit_code = materializer.main(["only-source"])
588
+
589
+ report = json.loads(capsys.readouterr().out)
590
+
591
+ assert exit_code != 0
592
+ assert report["errors"] == 1
593
+ assert report["dry_run"] is True
594
+ assert len(report["error_details"]) == 1
595
+ assert set(report) == {
596
+ *materializer.REPORT_CATEGORIES,
597
+ "details",
598
+ "dry_run",
599
+ "error_details",
600
+ "incomplete_generation",
601
+ "reconcile_required",
602
+ }
603
+
604
+
605
+ def test_cli_reports_publication_and_rollback_failure_as_json(
606
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
607
+ ) -> None:
608
+ source = tmp_path / "source"
609
+ target = tmp_path / "target"
610
+ source.mkdir()
611
+ planned = [PlannedFile("a.md", "Luna.toml", "luna", hash_content("luna"))]
612
+
613
+ def fail_publication(*args: object, **kwargs: object) -> MaterializationReport:
614
+ report = args[2] if len(args) > 2 else kwargs.get("report")
615
+ assert isinstance(report, MaterializationReport)
616
+ report.reconcile_required = True
617
+ report.incomplete_generation = True
618
+ report.add_error(f"rollback failed: {target / 'Luna.toml'}")
619
+ raise RuntimeError("publication failed")
620
+
621
+ monkeypatch.setattr(materializer, "build_plan", lambda _, all_agents=None: (planned, MaterializationReport()))
622
+ monkeypatch.setattr(materializer, "publish_plan", fail_publication)
623
+
624
+ exit_code = materializer.main([str(source), str(target), "--apply"])
625
+
626
+ report = json.loads(capsys.readouterr().out)
627
+ assert exit_code != 0
628
+ assert report["written"] == 0
629
+ assert report["incomplete_generation"] is True
630
+ assert report["reconcile_required"] is True
631
+ assert report["errors"] == 2
632
+ assert all(str(tmp_path) not in message for message in report["error_details"])