claude-dev-env 2.5.0 → 2.7.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/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/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/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/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/sync_to_cursor/rules.py +0 -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/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.mjs +1 -1
- 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/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",
|
|
@@ -381,7 +381,7 @@ function runCodeEditorTask(task, context) {
|
|
|
381
381
|
`You are the COMMIT step opening the environment-hardening PR (${context.sourceLabel}) for the change staged in ${context.hardeningRepoPath} on branch ${context.hardeningBranch}. The edit step left the hooks/rules edits in the working tree and the verify step passed, so a verifier verdict already binds to this exact working tree. Do NOT touch the PR's own branch.\n\n` +
|
|
382
382
|
`Rules:\n` +
|
|
383
383
|
`- Make NO further file edits of any kind. Any edit changes the surface and invalidates the verdict that unlocks the commit gate, so the push would be blocked. Only commit and push what is already there.\n` +
|
|
384
|
-
`- In ${context.hardeningRepoPath}: make ONE commit of the staged hooks/rules change on branch ${context.hardeningBranch}, push it, then open a DRAFT PR. The PR body references the follow-up issue ${context.issueUrl || '(none)'} and states the PR hardens the environment so the deferred violation classes are blocked at Write/Edit time. Honor the gh-
|
|
384
|
+
`- In ${context.hardeningRepoPath}: make ONE commit of the staged hooks/rules change on branch ${context.hardeningBranch}, push it, then open a DRAFT PR. The PR body references the follow-up issue ${context.issueUrl || '(none)'} and states the PR hardens the environment so the deferred violation classes are blocked at Write/Edit time. Honor the gh-cli-conventions rule: write a BOM-free temp file and pass --body-file.\n` +
|
|
385
385
|
`- Title the PR as a Conventional Commit — a type prefix (feat, fix, chore, docs, refactor, perf, ci, style, test, build, revert), an optional scope in parentheses, then a colon and a short summary, e.g. "feat(hooks): block the deferred violation class". The target repo's CI validates the PR title as a semantic commit and rejects a non-conforming title.\n\n` +
|
|
386
386
|
`Return the full https URL of the DRAFT hardening PR in hardeningPrUrl (empty string when no PR was opened) and a one-line summary.`,
|
|
387
387
|
{ label, phase: 'Converge', schema: HARDENING_COMMIT_SCHEMA, agentType: 'clean-coder', ...TIERS.sonnetMedium },
|
|
@@ -510,4 +510,4 @@ Expected output after the patch lands: a list containing `Line 91: Collection co
|
|
|
510
510
|
- Inventory data sources: live `gh api repos/example-org/example-repo/pulls/{101,102}/comments` filtered to `Copilot`; verbatim bodies preserved in the inventory table above.
|
|
511
511
|
- Original-commit content used to confirm violations: PR #101 head `29117309cf4ec1e83883160d8c819e0843f9c3ac`; PR #102 review-time commit `e4abf52c3a6c724b4e64bfed0d979cd60a2c8bf0`; PR #102 merged head `c9c935a96cc59d39d623dc7eddda3d341007607c`.
|
|
512
512
|
- CODE_RULES.md sections invoked by the patch plan: §⚡ Magic values; §5 Extended naming rules (collections `all_orders`, `all_users`); §6.5 File length guidance; §7 Right-Sized Engineering; §10 No redundant data fetches (used as analogue for wrapper plumb-through).
|
|
513
|
-
- Constraints honored: `gh-
|
|
513
|
+
- Constraints honored: `gh-cli-conventions` (no `gh ... --body` calls in the new code paths), `shell-invocation` (no `$(...)` in the verification commands above; multi-step shell flows are written as separate Bash invocations or `&&`-chained literal strings).
|