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.
- package/CLAUDE.md +20 -57
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
- package/_shared/pr-loop/worker-spawn.md +3 -1
- package/agents/CLAUDE.md +1 -1
- package/agents/code-verifier.md +36 -7
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- package/codex-capability-map.json +13 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/code-review-enforcement.md +31 -6
- package/hooks/blocking/CLAUDE.md +3 -0
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
- package/hooks/blocking/unscoped_search_blocker.py +391 -0
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +236 -27
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +0 -12
- package/hooks/hooks_constants/CLAUDE.md +5 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- package/package.json +4 -2
- package/rules/CLAUDE.md +17 -23
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +10 -2
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/CLAUDE.md +3 -3
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +79 -13
- package/scripts/grok_headless_runner.py +213 -16
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/resolve_worker_spawn.py +56 -10
- package/scripts/spawn_grok_batch.py +43 -22
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_grok_headless_runner.py +592 -10
- package/scripts/test_invoke_code_review.py +143 -0
- package/scripts/test_invoke_code_review_chain.py +1 -1
- package/scripts/test_invoke_code_review_contract.py +1 -1
- package/scripts/test_resolve_worker_spawn.py +179 -15
- package/scripts/test_spawn_grok_batch.py +225 -22
- package/scripts/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +28 -6
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +73 -0
- package/skills/autoconverge/workflow/converge.mjs +64 -15
- package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
- package/skills/condensing-instructions/SKILL.md +42 -51
- package/skills/fresh-branch/CLAUDE.md +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- package/skills/grok-spawn/SKILL.md +5 -3
- package/skills/grok-spawn/reference/flag-profiles.md +3 -1
- package/skills/orchestrator/SKILL.md +23 -9
- package/skills/orchestrator-refresh/SKILL.md +20 -1
- package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/session-log/SKILL.md +1 -1
- package/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/state-what-is.md +0 -25
- package/rules/tdd.md +0 -7
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from threading import Event, Lock, Thread
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from codex_compat_watcher import (
|
|
7
|
+
DebouncedReconciler,
|
|
8
|
+
LinkCapabilities,
|
|
9
|
+
LinkDecision,
|
|
10
|
+
LinkSpec,
|
|
11
|
+
LinkPlan,
|
|
12
|
+
LinkReport,
|
|
13
|
+
WatchEvent,
|
|
14
|
+
build_link_plan,
|
|
15
|
+
publish_link_plan,
|
|
16
|
+
probe_link_capabilities,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_junction_capability_without_creator_fails_truthfully(tmp_path: Path) -> None:
|
|
21
|
+
source_root = tmp_path / "source"
|
|
22
|
+
source_root.mkdir()
|
|
23
|
+
(source_root / "source").write_text("source", encoding="utf-8")
|
|
24
|
+
plan = build_link_plan(
|
|
25
|
+
[LinkSpec("source", "target", requested_kind="junction")],
|
|
26
|
+
source_root,
|
|
27
|
+
tmp_path / "target",
|
|
28
|
+
LinkCapabilities(False, True),
|
|
29
|
+
should_apply=True,
|
|
30
|
+
should_allow_links=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
report = publish_link_plan(plan, source_root, tmp_path / "target")
|
|
34
|
+
|
|
35
|
+
assert report.applied == 0
|
|
36
|
+
assert report.is_reconcile_required
|
|
37
|
+
assert "unsupported" in report.errors[0]
|
|
38
|
+
assert not (tmp_path / "target").exists()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_link_report_preserves_reconcile_required_read_write_api() -> None:
|
|
42
|
+
report = LinkReport()
|
|
43
|
+
|
|
44
|
+
assert report.reconcile_required is False
|
|
45
|
+
report.reconcile_required = True
|
|
46
|
+
assert report.is_reconcile_required is True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_unavailable_junction_capability_falls_back_to_copy(tmp_path: Path) -> None:
|
|
50
|
+
plan = build_link_plan(
|
|
51
|
+
[LinkSpec("source", "target", requested_kind="junction")],
|
|
52
|
+
tmp_path / "source",
|
|
53
|
+
tmp_path / "target",
|
|
54
|
+
LinkCapabilities(False, False),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
assert [(decision.kind, decision.reason) for decision in plan.decisions] == [
|
|
58
|
+
("copy", "link capability unavailable")
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_default_copier_publishes_file_and_directory(tmp_path: Path) -> None:
|
|
63
|
+
source_root = tmp_path / "source"
|
|
64
|
+
target_root = tmp_path / "target"
|
|
65
|
+
source_root.mkdir()
|
|
66
|
+
(source_root / "file.txt").write_text("file", encoding="utf-8")
|
|
67
|
+
(source_root / "tree").mkdir()
|
|
68
|
+
(source_root / "tree" / "nested.txt").write_text("nested", encoding="utf-8")
|
|
69
|
+
plan = build_link_plan(
|
|
70
|
+
[LinkSpec("file.txt", "file.txt"), LinkSpec("tree", "tree")],
|
|
71
|
+
source_root,
|
|
72
|
+
target_root,
|
|
73
|
+
LinkCapabilities(False, False),
|
|
74
|
+
should_apply=True,
|
|
75
|
+
should_allow_links=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
report = publish_link_plan(plan, source_root, target_root)
|
|
79
|
+
|
|
80
|
+
assert report.applied == 2
|
|
81
|
+
assert (target_root / "file.txt").read_text(encoding="utf-8") == "file"
|
|
82
|
+
assert (target_root / "tree" / "nested.txt").read_text(encoding="utf-8") == "nested"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_default_copier_rejects_reparse_content(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
86
|
+
source_root = tmp_path / "source"
|
|
87
|
+
source_root.mkdir()
|
|
88
|
+
(source_root / "file.txt").write_text("file", encoding="utf-8")
|
|
89
|
+
plan = build_link_plan(
|
|
90
|
+
[LinkSpec("file.txt", "file.txt")],
|
|
91
|
+
source_root,
|
|
92
|
+
tmp_path / "target",
|
|
93
|
+
LinkCapabilities(False, False),
|
|
94
|
+
should_apply=True,
|
|
95
|
+
should_allow_links=True,
|
|
96
|
+
)
|
|
97
|
+
monkeypatch.setattr("codex_compat_watcher._is_reparse_point", lambda source_path: True)
|
|
98
|
+
|
|
99
|
+
report = publish_link_plan(plan, source_root, tmp_path / "target")
|
|
100
|
+
|
|
101
|
+
assert report.applied == 0
|
|
102
|
+
assert report.is_reconcile_required
|
|
103
|
+
assert not (tmp_path / "target" / "file.txt").exists()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_link_publication_rejects_source_symlink_without_callback(
|
|
107
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
108
|
+
) -> None:
|
|
109
|
+
source_root = tmp_path / "source"
|
|
110
|
+
source_root.mkdir()
|
|
111
|
+
source_path = source_root / "linked.txt"
|
|
112
|
+
source_path.write_text("source", encoding="utf-8")
|
|
113
|
+
original_is_symlink = Path.is_symlink
|
|
114
|
+
monkeypatch.setattr(
|
|
115
|
+
Path,
|
|
116
|
+
"is_symlink",
|
|
117
|
+
lambda each_path: each_path == source_path or original_is_symlink(each_path),
|
|
118
|
+
)
|
|
119
|
+
publication_calls: list[tuple[Path, Path]] = []
|
|
120
|
+
plan = LinkPlan([LinkDecision("linked.txt", "linked.txt", "symlink", "apply", "test")], is_dry_run=False)
|
|
121
|
+
|
|
122
|
+
report = publish_link_plan(
|
|
123
|
+
plan,
|
|
124
|
+
source_root,
|
|
125
|
+
tmp_path / "target",
|
|
126
|
+
symlink_creator=lambda source, target, is_directory: publication_calls.append((source, target)),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
assert report.applied == 0
|
|
130
|
+
assert report.is_reconcile_required
|
|
131
|
+
assert publication_calls == []
|
|
132
|
+
assert not (tmp_path / "target").exists()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_link_publication_rejects_source_tree_reparse_without_callback(
|
|
136
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
137
|
+
) -> None:
|
|
138
|
+
source_root = tmp_path / "source"
|
|
139
|
+
source_root.mkdir()
|
|
140
|
+
(source_root / "tree").mkdir()
|
|
141
|
+
nested_path = source_root / "tree" / "nested.txt"
|
|
142
|
+
nested_path.write_text("source", encoding="utf-8")
|
|
143
|
+
monkeypatch.setattr(
|
|
144
|
+
"codex_compat_watcher._is_reparse_point",
|
|
145
|
+
lambda each_path: each_path == nested_path,
|
|
146
|
+
)
|
|
147
|
+
publication_calls: list[tuple[Path, Path]] = []
|
|
148
|
+
plan = LinkPlan([LinkDecision("tree", "tree", "junction", "apply", "test")], is_dry_run=False)
|
|
149
|
+
|
|
150
|
+
report = publish_link_plan(
|
|
151
|
+
plan,
|
|
152
|
+
source_root,
|
|
153
|
+
tmp_path / "target",
|
|
154
|
+
junction_creator=lambda source, target: publication_calls.append((source, target)),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert report.applied == 0
|
|
158
|
+
assert report.is_reconcile_required
|
|
159
|
+
assert publication_calls == []
|
|
160
|
+
assert not (tmp_path / "target").exists()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_link_publication_rejects_symlink_source_root_without_callback(tmp_path: Path) -> None:
|
|
164
|
+
real_source_root = tmp_path / "real-source"
|
|
165
|
+
real_source_root.mkdir()
|
|
166
|
+
(real_source_root / "source.txt").write_text("source", encoding="utf-8")
|
|
167
|
+
linked_source_root = tmp_path / "source"
|
|
168
|
+
try:
|
|
169
|
+
linked_source_root.symlink_to(real_source_root, target_is_directory=True)
|
|
170
|
+
except OSError as error:
|
|
171
|
+
pytest.skip(f"directory symlinks unavailable: {error}")
|
|
172
|
+
publication_calls: list[str] = []
|
|
173
|
+
plan = build_link_plan(
|
|
174
|
+
[LinkSpec("source.txt", "target.txt")],
|
|
175
|
+
linked_source_root,
|
|
176
|
+
tmp_path / "target",
|
|
177
|
+
LinkCapabilities(False, False),
|
|
178
|
+
should_apply=True,
|
|
179
|
+
should_allow_links=True,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
report = publish_link_plan(
|
|
183
|
+
plan,
|
|
184
|
+
linked_source_root,
|
|
185
|
+
tmp_path / "target",
|
|
186
|
+
copier=lambda source_path, target_path: publication_calls.append("copy"),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
assert report.applied == 0
|
|
190
|
+
assert report.is_reconcile_required
|
|
191
|
+
assert "source_root_path cannot be a symlink or reparse point" in report.errors
|
|
192
|
+
assert publication_calls == []
|
|
193
|
+
assert not (tmp_path / "target").exists()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@pytest.mark.parametrize("publication_kind", ["copy", "symlink", "junction", "materialize"])
|
|
197
|
+
def test_link_publication_rejects_missing_source_without_callback(
|
|
198
|
+
tmp_path: Path, publication_kind: str
|
|
199
|
+
) -> None:
|
|
200
|
+
publication_calls: list[str] = []
|
|
201
|
+
plan = LinkPlan(
|
|
202
|
+
[LinkDecision("missing", "target", publication_kind, "apply", "test")],
|
|
203
|
+
is_dry_run=False,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
report = publish_link_plan(
|
|
207
|
+
plan,
|
|
208
|
+
tmp_path / "source",
|
|
209
|
+
tmp_path / "target",
|
|
210
|
+
copier=lambda source_path, target_path: publication_calls.append("copy"),
|
|
211
|
+
symlink_creator=lambda source_path, target_path, is_directory: publication_calls.append(
|
|
212
|
+
"symlink"
|
|
213
|
+
),
|
|
214
|
+
junction_creator=lambda source_path, target_path: publication_calls.append("junction"),
|
|
215
|
+
materializer=lambda: publication_calls.append("materialize"),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
assert report.applied == 0
|
|
219
|
+
assert report.is_reconcile_required
|
|
220
|
+
assert "publication source does not exist" in report.errors
|
|
221
|
+
assert publication_calls == []
|
|
222
|
+
assert not (tmp_path / "target").exists()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_default_copier_reports_copy_failure_without_application(tmp_path: Path) -> None:
|
|
226
|
+
source_root = tmp_path / "source"
|
|
227
|
+
source_root.mkdir()
|
|
228
|
+
(source_root / "file.txt").write_text("file", encoding="utf-8")
|
|
229
|
+
plan = build_link_plan(
|
|
230
|
+
[LinkSpec("file.txt", "file.txt")],
|
|
231
|
+
source_root,
|
|
232
|
+
tmp_path / "target",
|
|
233
|
+
LinkCapabilities(False, False),
|
|
234
|
+
should_apply=True,
|
|
235
|
+
should_allow_links=True,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def fail_copy(source_path: Path, target_path: Path) -> None:
|
|
239
|
+
raise OSError("copy failed")
|
|
240
|
+
|
|
241
|
+
report = publish_link_plan(plan, source_root, tmp_path / "target", copier=fail_copy)
|
|
242
|
+
|
|
243
|
+
assert report.applied == 0
|
|
244
|
+
assert report.reconcile_required
|
|
245
|
+
assert "copy failed" in report.errors
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_default_copier_never_overwrites_unmanaged_target(tmp_path: Path) -> None:
|
|
249
|
+
source_root = tmp_path / "source"
|
|
250
|
+
target_root = tmp_path / "target"
|
|
251
|
+
source_root.mkdir()
|
|
252
|
+
target_root.mkdir()
|
|
253
|
+
(source_root / "file.txt").write_text("new", encoding="utf-8")
|
|
254
|
+
(target_root / "file.txt").write_text("existing", encoding="utf-8")
|
|
255
|
+
plan = LinkPlan([LinkDecision("file.txt", "file.txt", "copy", "apply", "test")], is_dry_run=False)
|
|
256
|
+
|
|
257
|
+
report = publish_link_plan(plan, source_root, target_root)
|
|
258
|
+
|
|
259
|
+
assert report.applied == 0
|
|
260
|
+
assert (target_root / "file.txt").read_text(encoding="utf-8") == "existing"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_explicit_materialize_invokes_materializer_once(tmp_path: Path) -> None:
|
|
264
|
+
calls: list[str] = []
|
|
265
|
+
source_root = tmp_path / "source"
|
|
266
|
+
source_root.mkdir()
|
|
267
|
+
(source_root / "converted").write_text("source", encoding="utf-8")
|
|
268
|
+
plan = LinkPlan(
|
|
269
|
+
decisions=[
|
|
270
|
+
LinkDecision(
|
|
271
|
+
"converted", "target", "materialize", "apply", "explicit materialization"
|
|
272
|
+
)
|
|
273
|
+
],
|
|
274
|
+
is_dry_run=False,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
report = publish_link_plan(
|
|
278
|
+
plan,
|
|
279
|
+
source_root,
|
|
280
|
+
tmp_path / "target",
|
|
281
|
+
materializer=lambda: calls.append("materialize"),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
assert report.applied == 1
|
|
285
|
+
assert calls == ["materialize"]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_source_only_and_unsupported_never_invoke_materializer(tmp_path: Path) -> None:
|
|
289
|
+
calls: list[str] = []
|
|
290
|
+
plan = build_link_plan(
|
|
291
|
+
[
|
|
292
|
+
LinkSpec("source", "source", classification="source_only"),
|
|
293
|
+
LinkSpec("unsupported", "unsupported", classification="unsupported"),
|
|
294
|
+
],
|
|
295
|
+
tmp_path / "source",
|
|
296
|
+
tmp_path / "target",
|
|
297
|
+
LinkCapabilities(True, True),
|
|
298
|
+
should_apply=True,
|
|
299
|
+
should_allow_links=True,
|
|
300
|
+
should_allow_activation=True,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
report = publish_link_plan(
|
|
304
|
+
plan,
|
|
305
|
+
tmp_path / "source",
|
|
306
|
+
tmp_path / "target",
|
|
307
|
+
materializer=lambda: calls.append("materialize"),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
assert report.applied == 0
|
|
311
|
+
assert report.skipped == 2
|
|
312
|
+
assert calls == []
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_capability_fallback_and_classification(tmp_path: Path) -> None:
|
|
316
|
+
plan = build_link_plan(
|
|
317
|
+
[
|
|
318
|
+
LinkSpec("source.txt", "target.txt"),
|
|
319
|
+
LinkSpec("converted.md", "converted.toml", is_byte_compatible=False),
|
|
320
|
+
],
|
|
321
|
+
tmp_path / "source",
|
|
322
|
+
tmp_path / "target",
|
|
323
|
+
LinkCapabilities(False, False),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
assert [(decision.kind, decision.reason) for decision in plan.decisions] == [
|
|
327
|
+
("copy", "link capability unavailable"),
|
|
328
|
+
("materialize", "converted asset requires materializer"),
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_source_only_and_unsupported_assets_never_activate_or_link(tmp_path: Path) -> None:
|
|
333
|
+
plan = build_link_plan(
|
|
334
|
+
[
|
|
335
|
+
LinkSpec("source.md", "source.md", classification="source_only"),
|
|
336
|
+
LinkSpec("unsupported.md", "unsupported.md", classification="unsupported"),
|
|
337
|
+
],
|
|
338
|
+
tmp_path / "source",
|
|
339
|
+
tmp_path / "target",
|
|
340
|
+
LinkCapabilities(True, True),
|
|
341
|
+
should_apply=True,
|
|
342
|
+
should_allow_links=True,
|
|
343
|
+
should_allow_activation=True,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
assert [decision.action for decision in plan.decisions] == ["report", "report"]
|
|
347
|
+
assert publish_link_plan(plan, tmp_path / "source", tmp_path / "target").applied == 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_default_capability_probe_uses_copy_fallback() -> None:
|
|
351
|
+
assert probe_link_capabilities() == LinkCapabilities(False, False)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_capability_probe_uses_injected_results_only_when_opted_in() -> None:
|
|
355
|
+
assert probe_link_capabilities(lambda: True, lambda: True) == LinkCapabilities(False, False)
|
|
356
|
+
assert probe_link_capabilities(lambda: True, lambda: True, should_allow_temporary_probe=True) == LinkCapabilities(True, True)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_build_link_plan_forwards_optional_capability_probes(tmp_path: Path) -> None:
|
|
360
|
+
source_root = tmp_path / "source"
|
|
361
|
+
source_root.mkdir()
|
|
362
|
+
|
|
363
|
+
plan = build_link_plan(
|
|
364
|
+
[LinkSpec("source.txt", "target.txt")],
|
|
365
|
+
source_root,
|
|
366
|
+
tmp_path / "target",
|
|
367
|
+
should_allow_temporary_probe=True,
|
|
368
|
+
symlink_probe=lambda: True,
|
|
369
|
+
junction_probe=lambda: True,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
assert plan.decisions[0].kind == "symlink"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_dry_run_never_creates_links_or_activates(tmp_path: Path) -> None:
|
|
376
|
+
plan = build_link_plan(
|
|
377
|
+
[LinkSpec("hook.py", "hook.py", should_activate_hook=True)],
|
|
378
|
+
tmp_path / "source",
|
|
379
|
+
tmp_path / "target",
|
|
380
|
+
LinkCapabilities(True, True),
|
|
381
|
+
should_apply=True,
|
|
382
|
+
should_allow_links=False,
|
|
383
|
+
should_allow_activation=False,
|
|
384
|
+
)
|
|
385
|
+
report = publish_link_plan(plan, tmp_path / "source", tmp_path / "target")
|
|
386
|
+
|
|
387
|
+
assert report.applied == 0
|
|
388
|
+
assert report.skipped == 1
|
|
389
|
+
assert not (tmp_path / "target" / "hook.py").exists()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_link_publication_requires_apply_and_both_permissions(tmp_path: Path) -> None:
|
|
393
|
+
publication_calls: list[tuple[Path, Path]] = []
|
|
394
|
+
|
|
395
|
+
def record_publication(source_path: Path, target_path: Path) -> None:
|
|
396
|
+
publication_calls.append((source_path, target_path))
|
|
397
|
+
|
|
398
|
+
for should_apply, should_allow_links, should_allow_activation in ((False, True, True), (True, False, True)):
|
|
399
|
+
plan = build_link_plan(
|
|
400
|
+
[LinkSpec("hook.py", "hook.py", should_activate_hook=True)],
|
|
401
|
+
tmp_path / "source",
|
|
402
|
+
tmp_path / "target",
|
|
403
|
+
LinkCapabilities(True, True),
|
|
404
|
+
should_apply=should_apply,
|
|
405
|
+
should_allow_links=should_allow_links,
|
|
406
|
+
should_allow_activation=should_allow_activation,
|
|
407
|
+
)
|
|
408
|
+
publish_link_plan(plan, tmp_path / "source", tmp_path / "target", copier=record_publication)
|
|
409
|
+
|
|
410
|
+
assert publication_calls == []
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_authorized_activation_reaches_injected_link_operation(tmp_path: Path) -> None:
|
|
414
|
+
publication_calls: list[tuple[Path, Path]] = []
|
|
415
|
+
source_root = tmp_path / "source"
|
|
416
|
+
source_root.mkdir()
|
|
417
|
+
(source_root / "hook.py").write_text("hook", encoding="utf-8")
|
|
418
|
+
|
|
419
|
+
plan = build_link_plan(
|
|
420
|
+
[LinkSpec("hook.py", "hook.py", should_activate_hook=True)],
|
|
421
|
+
source_root,
|
|
422
|
+
tmp_path / "target",
|
|
423
|
+
LinkCapabilities(True, True),
|
|
424
|
+
should_apply=True,
|
|
425
|
+
should_allow_links=True,
|
|
426
|
+
should_allow_activation=True,
|
|
427
|
+
)
|
|
428
|
+
report = publish_link_plan(
|
|
429
|
+
plan,
|
|
430
|
+
source_root,
|
|
431
|
+
tmp_path / "target",
|
|
432
|
+
copier=lambda source_path, target_path: publication_calls.append((source_path, target_path)),
|
|
433
|
+
symlink_creator=lambda source_path, target_path, is_directory: publication_calls.append((source_path, target_path)),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
assert report.applied == 1
|
|
437
|
+
assert len(publication_calls) == 1
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_path_safety_and_unmanaged_collision(tmp_path: Path) -> None:
|
|
441
|
+
target_root = tmp_path / "target"
|
|
442
|
+
target_root.mkdir()
|
|
443
|
+
(target_root / "existing.txt").write_text("owned by user", encoding="utf-8")
|
|
444
|
+
plan = build_link_plan(
|
|
445
|
+
[LinkSpec("../escape", "escape"), LinkSpec("source", "existing.txt")],
|
|
446
|
+
tmp_path / "source",
|
|
447
|
+
target_root,
|
|
448
|
+
LinkCapabilities(True, False),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
assert len(plan.errors) == 2
|
|
452
|
+
assert all("\\" not in error for error in plan.errors)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_debounced_reconciler_coalesces_and_suppresses_self_events() -> None:
|
|
456
|
+
calls: list[str] = []
|
|
457
|
+
reconciler = DebouncedReconciler(lambda: calls.append("run"), debounce_seconds=60.0)
|
|
458
|
+
|
|
459
|
+
reconciler.notify(WatchEvent("agents", is_self_event=True))
|
|
460
|
+
assert reconciler.flush(should_force=True) is None
|
|
461
|
+
reconciler.notify(WatchEvent("agents"))
|
|
462
|
+
reconciler.notify(WatchEvent("skills"))
|
|
463
|
+
assert reconciler.flush(should_force=True) is None
|
|
464
|
+
assert calls == ["run"]
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def test_each_configured_root_triggers_reconciliation() -> None:
|
|
468
|
+
calls: list[str] = []
|
|
469
|
+
reconciler = DebouncedReconciler(lambda: calls.append("run") or "run")
|
|
470
|
+
|
|
471
|
+
for each_source_root in ("agents", "commands", "hooks", "rules", "skills"):
|
|
472
|
+
reconciler.notify(WatchEvent(each_source_root))
|
|
473
|
+
|
|
474
|
+
assert reconciler.flush(should_force=True) == "run"
|
|
475
|
+
assert calls == ["run"]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def test_self_event_is_ignored_before_unsafe_event_handling() -> None:
|
|
479
|
+
reconciler = DebouncedReconciler(lambda: "run")
|
|
480
|
+
|
|
481
|
+
reconciler.notify(WatchEvent("hooks", kind="overflow", is_self_event=True, path="hooks/events"))
|
|
482
|
+
|
|
483
|
+
assert reconciler.flush(should_force=True) is None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_flush_serializes_reconciliation_callbacks() -> None:
|
|
487
|
+
callback_started = Event()
|
|
488
|
+
release_callback = Event()
|
|
489
|
+
callback_lock = Lock()
|
|
490
|
+
active_callbacks = 0
|
|
491
|
+
maximum_active_callbacks = 0
|
|
492
|
+
|
|
493
|
+
def reconcile() -> str:
|
|
494
|
+
nonlocal active_callbacks, maximum_active_callbacks
|
|
495
|
+
with callback_lock:
|
|
496
|
+
active_callbacks += 1
|
|
497
|
+
maximum_active_callbacks = max(maximum_active_callbacks, active_callbacks)
|
|
498
|
+
callback_started.set()
|
|
499
|
+
release_callback.wait()
|
|
500
|
+
with callback_lock:
|
|
501
|
+
active_callbacks -= 1
|
|
502
|
+
return "run"
|
|
503
|
+
|
|
504
|
+
reconciler = DebouncedReconciler(reconcile)
|
|
505
|
+
reconciler.notify(WatchEvent("hooks"))
|
|
506
|
+
first_flush = Thread(target=reconciler.flush, kwargs={"should_force": True})
|
|
507
|
+
first_flush.start()
|
|
508
|
+
assert callback_started.wait()
|
|
509
|
+
|
|
510
|
+
reconciler.notify(WatchEvent("rules"))
|
|
511
|
+
assert reconciler.flush(should_force=True) is None
|
|
512
|
+
release_callback.set()
|
|
513
|
+
first_flush.join()
|
|
514
|
+
|
|
515
|
+
assert reconciler.flush(should_force=True) == "run"
|
|
516
|
+
assert maximum_active_callbacks == 1
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def test_reconciler_failure_requests_reconciliation() -> None:
|
|
520
|
+
def fail() -> None:
|
|
521
|
+
raise OSError("inaccessible")
|
|
522
|
+
|
|
523
|
+
reconciler = DebouncedReconciler(fail)
|
|
524
|
+
reconciler.notify(WatchEvent("hooks"))
|
|
525
|
+
|
|
526
|
+
assert reconciler.flush(should_force=True) == {
|
|
527
|
+
"reconcile_required": True,
|
|
528
|
+
"errors": ["inaccessible"],
|
|
529
|
+
"reconcile_reasons": [],
|
|
530
|
+
"reconcile_paths": [],
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def test_unsafe_event_is_reconciled_with_reason_and_path() -> None:
|
|
535
|
+
reconciler = DebouncedReconciler(lambda: {"written": 1})
|
|
536
|
+
|
|
537
|
+
reconciler.notify(WatchEvent("hooks", kind="overflow", path="hooks/events"))
|
|
538
|
+
|
|
539
|
+
assert reconciler.flush(should_force=True) == {
|
|
540
|
+
"written": 1,
|
|
541
|
+
"reconcile_required": True,
|
|
542
|
+
"reconcile_reasons": ["overflow"],
|
|
543
|
+
"reconcile_paths": ["hooks/events"],
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def test_out_of_scope_unsafe_events_are_ignored() -> None:
|
|
548
|
+
reconciler = DebouncedReconciler(lambda: {"written": 1})
|
|
549
|
+
|
|
550
|
+
reconciler.notify(WatchEvent("private", kind="overflow", path="private/events"))
|
|
551
|
+
|
|
552
|
+
assert reconciler.flush(should_force=True) is None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_unsafe_event_details_are_sorted() -> None:
|
|
556
|
+
reconciler = DebouncedReconciler(lambda: {"written": 1})
|
|
557
|
+
reconciler.notify(WatchEvent("hooks", kind="unknown", path="z"))
|
|
558
|
+
reconciler.notify(WatchEvent("agents", kind="overflow", path="a"))
|
|
559
|
+
|
|
560
|
+
assert reconciler.flush(should_force=True) == {
|
|
561
|
+
"written": 1,
|
|
562
|
+
"reconcile_required": True,
|
|
563
|
+
"reconcile_reasons": ["overflow", "unknown"],
|
|
564
|
+
"reconcile_paths": ["a", "z"],
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def test_materialized_reconciliation_delegates_to_materializer(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
569
|
+
calls: list[str] = []
|
|
570
|
+
|
|
571
|
+
def fake_build_plan(config: object) -> tuple[list[object], object]:
|
|
572
|
+
calls.append("build")
|
|
573
|
+
return [], "report"
|
|
574
|
+
|
|
575
|
+
def fake_publish_plan(config: object, planned_files: list[object], report: object) -> object:
|
|
576
|
+
calls.append("publish")
|
|
577
|
+
return report
|
|
578
|
+
|
|
579
|
+
monkeypatch.setattr("codex_compat_watcher.build_plan", fake_build_plan)
|
|
580
|
+
monkeypatch.setattr("codex_compat_watcher.publish_plan", fake_publish_plan)
|
|
581
|
+
|
|
582
|
+
assert __import__("codex_compat_watcher").reconcile_materialized_agents(object()) == "report"
|
|
583
|
+
assert calls == ["build", "publish"]
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def test_private_paths_are_rejected(tmp_path: Path) -> None:
|
|
587
|
+
safe_plan = build_link_plan(
|
|
588
|
+
[LinkSpec("safe/source", "safe")],
|
|
589
|
+
tmp_path / "source",
|
|
590
|
+
tmp_path / "target",
|
|
591
|
+
)
|
|
592
|
+
assert len(safe_plan.decisions) == 1
|
|
593
|
+
|
|
594
|
+
with pytest.raises(ValueError):
|
|
595
|
+
build_link_plan(
|
|
596
|
+
[LinkSpec(str(tmp_path / "private" / "secret"), "safe")],
|
|
597
|
+
tmp_path / "source",
|
|
598
|
+
tmp_path / "target",
|
|
599
|
+
)
|
|
@@ -38,7 +38,6 @@ def _minimal_rule_files(claude_rules: Path) -> None:
|
|
|
38
38
|
"---\npaths:\n - \"Y:/x/**\"\n---\n\n# Tasklings\n",
|
|
39
39
|
encoding="utf-8",
|
|
40
40
|
)
|
|
41
|
-
(claude_rules / "right-sized-engineering.md").write_text("# RSE\n", encoding="utf-8")
|
|
42
41
|
(claude_rules / "bdd.md").write_text("# BDD\n", encoding="utf-8")
|
|
43
42
|
(claude_rules / "testing.md").write_text(
|
|
44
43
|
"---\npaths:\n - \"**/test_*.py\"\n---\n\n# Testing\n",
|
|
@@ -434,7 +434,7 @@ test('the verdict-fence binding does not self-resolve a cwd via git rev-parse fo
|
|
|
434
434
|
);
|
|
435
435
|
});
|
|
436
436
|
|
|
437
|
-
test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and forbids
|
|
437
|
+
test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and forbids editing the tree under verification', () => {
|
|
438
438
|
for (const verifyFunctionName of ['runVerifierTask']) {
|
|
439
439
|
const verifyBody = lensPromptBody(verifyFunctionName);
|
|
440
440
|
assert.match(
|
|
@@ -454,8 +454,8 @@ test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and fo
|
|
|
454
454
|
);
|
|
455
455
|
assert.match(
|
|
456
456
|
verifyBody,
|
|
457
|
-
/
|
|
458
|
-
`expected ${verifyFunctionName} to
|
|
457
|
+
/(?:no|not|never)[^.\n]{0,30}edit[^.\n]{0,40}tree[^.\n]{0,30}(?:under|being)\s+verif/i,
|
|
458
|
+
`expected ${verifyFunctionName} to forbid editing the tree under verification (a deliberate break off that tree stays allowed)`,
|
|
459
459
|
);
|
|
460
460
|
}
|
|
461
461
|
});
|
|
@@ -467,7 +467,7 @@ test('runFixerTask never verifies — verification belongs to the separate verif
|
|
|
467
467
|
assert.match(fixerBody, /agentType:\s*'clean-coder'/, 'expected the fixer to use clean-coder for its commit and recovery edits');
|
|
468
468
|
});
|
|
469
469
|
|
|
470
|
-
test('runVerifierTask uses --manifest-hash-for-branch with the hardening branch and forbids
|
|
470
|
+
test('runVerifierTask uses --manifest-hash-for-branch with the hardening branch and forbids editing the tree under verification', () => {
|
|
471
471
|
const verifyBody = lensPromptBody('runVerifierTask');
|
|
472
472
|
assert.match(
|
|
473
473
|
verifyBody,
|
|
@@ -476,8 +476,8 @@ test('runVerifierTask uses --manifest-hash-for-branch with the hardening branch
|
|
|
476
476
|
);
|
|
477
477
|
assert.match(
|
|
478
478
|
verifyBody,
|
|
479
|
-
/
|
|
480
|
-
'expected the verifier to
|
|
479
|
+
/(?:no|not|never)[^.\n]{0,30}edit[^.\n]{0,40}tree[^.\n]{0,30}(?:under|being)\s+verif/i,
|
|
480
|
+
'expected the verifier to forbid editing the tree under verification (a deliberate break off that tree stays allowed)',
|
|
481
481
|
);
|
|
482
482
|
});
|
|
483
483
|
|
|
@@ -901,6 +901,28 @@ test('convergeReadOnlyAgent prepends HEADLESS_READONLY_PREAMBLE and the worktree
|
|
|
901
901
|
);
|
|
902
902
|
});
|
|
903
903
|
|
|
904
|
+
test('the read-only destructive pointer scopes its no-edit clause to the tree it reads', () => {
|
|
905
|
+
const destructivePointer = convergeSource
|
|
906
|
+
.split('\n')
|
|
907
|
+
.find((eachLine) => eachLine.includes('Never run a destructive command'));
|
|
908
|
+
assert.ok(destructivePointer, 'expected the read-only destructive pointer to be declared');
|
|
909
|
+
assert.match(
|
|
910
|
+
destructivePointer,
|
|
911
|
+
/Never run a destructive command/,
|
|
912
|
+
'expected the destructive-command prohibition to stay absolute',
|
|
913
|
+
);
|
|
914
|
+
assert.match(
|
|
915
|
+
destructivePointer,
|
|
916
|
+
/no edit to the tree it reads/,
|
|
917
|
+
'expected the no-edit clause to be scoped to the tree under verification',
|
|
918
|
+
);
|
|
919
|
+
assert.doesNotMatch(
|
|
920
|
+
destructivePointer,
|
|
921
|
+
/edits nothing/,
|
|
922
|
+
'expected no blanket edits-nothing wording, which forbids the deliberate break off that tree',
|
|
923
|
+
);
|
|
924
|
+
});
|
|
925
|
+
|
|
904
926
|
const taskDispatchers = [
|
|
905
927
|
{ name: 'runGitTask', isAsync: false },
|
|
906
928
|
{ name: 'runFixerTask', isAsync: false },
|