agentbundle 0.2.0__py3-none-any.whl
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.
- agentbundle/__init__.py +14 -0
- agentbundle/__main__.py +5 -0
- agentbundle/_data/adapter.schema.json +270 -0
- agentbundle/_data/adapter.toml +584 -0
- agentbundle/_data/install-marker.py +1099 -0
- agentbundle/_data/pack.schema.json +152 -0
- agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
- agentbundle/_data/plugin-manifest.schema.json +18 -0
- agentbundle/build/__init__.py +206 -0
- agentbundle/build/__main__.py +8 -0
- agentbundle/build/adapter_root_bins.py +336 -0
- agentbundle/build/adapters/__init__.py +46 -0
- agentbundle/build/adapters/claude_code.py +142 -0
- agentbundle/build/adapters/codex.py +227 -0
- agentbundle/build/adapters/copilot.py +149 -0
- agentbundle/build/adapters/kiro.py +608 -0
- agentbundle/build/adapters/kiro_cli.py +53 -0
- agentbundle/build/adapters/kiro_ide.py +275 -0
- agentbundle/build/contract.py +20 -0
- agentbundle/build/lint_packs.py +555 -0
- agentbundle/build/main.py +596 -0
- agentbundle/build/phase_order.py +40 -0
- agentbundle/build/projections/__init__.py +13 -0
- agentbundle/build/projections/codex_agent_toml.py +232 -0
- agentbundle/build/projections/copilot_agent_md.py +206 -0
- agentbundle/build/projections/copilot_hooks_json.py +142 -0
- agentbundle/build/projections/direct_directory.py +41 -0
- agentbundle/build/projections/hook_id.py +27 -0
- agentbundle/build/projections/kiro_ide_hook.py +256 -0
- agentbundle/build/projections/merge_into_agent_json.py +264 -0
- agentbundle/build/projections/merge_json.py +58 -0
- agentbundle/build/projections/user_merge_json.py +324 -0
- agentbundle/build/scope_rails.py +728 -0
- agentbundle/build/self_host.py +1486 -0
- agentbundle/build/shared_libs.py +309 -0
- agentbundle/build/target_resolver.py +85 -0
- agentbundle/build/tests/__init__.py +0 -0
- agentbundle/build/tests/test_adapter_claude_code.py +275 -0
- agentbundle/build/tests/test_adapter_codex.py +699 -0
- agentbundle/build/tests/test_adapter_copilot.py +91 -0
- agentbundle/build/tests/test_adapter_kiro.py +449 -0
- agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
- agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
- agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
- agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
- agentbundle/build/tests/test_build_ships_seeds.py +78 -0
- agentbundle/build/tests/test_contract.py +582 -0
- agentbundle/build/tests/test_contract_scope.py +224 -0
- agentbundle/build/tests/test_contract_v07.py +191 -0
- agentbundle/build/tests/test_contract_v08.py +230 -0
- agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
- agentbundle/build/tests/test_end_to_end_build.py +227 -0
- agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
- agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
- agentbundle/build/tests/test_lint_packs.py +703 -0
- agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
- agentbundle/build/tests/test_pack_schema.py +265 -0
- agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
- agentbundle/build/tests/test_pack_schema_install.py +305 -0
- agentbundle/build/tests/test_pipeline.py +272 -0
- agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
- agentbundle/build/tests/test_projections_merge_json.py +148 -0
- agentbundle/build/tests/test_scope_rails.py +398 -0
- agentbundle/build/tests/test_security.py +97 -0
- agentbundle/build/tests/test_self_host_check.py +2100 -0
- agentbundle/build/tests/test_shared_libs_projection.py +415 -0
- agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
- agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
- agentbundle/build/tests/test_validate.py +250 -0
- agentbundle/build/validate.py +141 -0
- agentbundle/catalogue.py +164 -0
- agentbundle/cli.py +486 -0
- agentbundle/commands/__init__.py +5 -0
- agentbundle/commands/_common.py +174 -0
- agentbundle/commands/_drop_warning.py +329 -0
- agentbundle/commands/adapt.py +343 -0
- agentbundle/commands/config.py +125 -0
- agentbundle/commands/diff.py +211 -0
- agentbundle/commands/init_state.py +279 -0
- agentbundle/commands/install.py +3026 -0
- agentbundle/commands/list_packs.py +170 -0
- agentbundle/commands/list_targets.py +23 -0
- agentbundle/commands/reconcile.py +161 -0
- agentbundle/commands/render.py +165 -0
- agentbundle/commands/scaffold.py +69 -0
- agentbundle/commands/uninstall.py +294 -0
- agentbundle/commands/upgrade.py +699 -0
- agentbundle/commands/validate.py +688 -0
- agentbundle/config.py +747 -0
- agentbundle/render.py +123 -0
- agentbundle/safety.py +633 -0
- agentbundle/scope.py +319 -0
- agentbundle/user_config.py +284 -0
- agentbundle/version.py +49 -0
- agentbundle-0.2.0.dist-info/METADATA +37 -0
- agentbundle-0.2.0.dist-info/RECORD +99 -0
- agentbundle-0.2.0.dist-info/WHEEL +5 -0
- agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
- agentbundle-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2100 @@
|
|
|
1
|
+
"""Tests for `make build --self`, `--self --dry-run`, and `--check` (T7).
|
|
2
|
+
|
|
3
|
+
The dirty-tree fixture is a `tempfile.TemporaryDirectory()` initialised
|
|
4
|
+
as a git repo (`git init`), with a tracked file committed and then
|
|
5
|
+
modified — exercising the real refusal path against `git status
|
|
6
|
+
--porcelain`.
|
|
7
|
+
|
|
8
|
+
Test-only symlink creation: several cases below call `os.symlink` /
|
|
9
|
+
`Path.symlink_to` to fabricate CLAUDE.md symlink fixtures and exercise
|
|
10
|
+
the symlink branch of `_recreate_claude_symlink`. These are runtime
|
|
11
|
+
test fixtures, not release content; the Windows-portability lint
|
|
12
|
+
(`lint_packs.py`) catches symlinks shipped *inside packs*, which is
|
|
13
|
+
a different surface. On native Windows these tests would need a
|
|
14
|
+
`skipIf(sys.platform == 'win32')` decorator, but Windows CI is Phase 5
|
|
15
|
+
of the portability plan and out of scope for this PR.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import tempfile
|
|
25
|
+
import unittest
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from agentbundle.build.contract import load as load_contract
|
|
29
|
+
from agentbundle.build.self_host import (
|
|
30
|
+
_is_equivalent_claude_md_shape,
|
|
31
|
+
_recreate_claude_symlink,
|
|
32
|
+
diff_against_working_tree,
|
|
33
|
+
is_dirty_tree,
|
|
34
|
+
project_to_temp,
|
|
35
|
+
resolve_markers,
|
|
36
|
+
run_self_host,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
40
|
+
CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _seed_pack(root: Path, name: str = "core") -> Path:
|
|
44
|
+
pack = root / name
|
|
45
|
+
(pack / ".apm" / "skills" / "foo").mkdir(parents=True)
|
|
46
|
+
(pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
|
|
47
|
+
"---\ndescription: foo\n---\n# foo\n",
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
)
|
|
50
|
+
(pack / "pack.toml").write_text(
|
|
51
|
+
f'[pack]\nname = "{name}"\nversion = "0.1.0"\n',
|
|
52
|
+
encoding="utf-8",
|
|
53
|
+
)
|
|
54
|
+
return pack
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _seed_pack_with_skill(root: Path, name: str, skill: str, description: str) -> Path:
|
|
58
|
+
pack = root / name
|
|
59
|
+
(pack / ".apm" / "skills" / skill).mkdir(parents=True)
|
|
60
|
+
(pack / ".apm" / "skills" / skill / "SKILL.md").write_text(
|
|
61
|
+
f"---\ndescription: {description}\n---\n# {skill}\n",
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
)
|
|
64
|
+
(pack / "pack.toml").write_text(
|
|
65
|
+
f'[pack]\nname = "{name}"\nversion = "0.1.0"\n',
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
return pack
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _seed_discovery(tree: Path) -> Path:
|
|
72
|
+
"""Drop a minimal `.adapt-discovery.toml` into a test working tree so
|
|
73
|
+
`run_self_host`'s fail-fast (spec AC14) doesn't reject the call.
|
|
74
|
+
Canonical v0.1 shape per adapt-to-project AC9 — no `[markers]`
|
|
75
|
+
table needed for the no-marker case.
|
|
76
|
+
"""
|
|
77
|
+
path = tree / ".adapt-discovery.toml"
|
|
78
|
+
path.write_text('discovery-schema-version = "0.1"\n', encoding="utf-8")
|
|
79
|
+
return path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _git_init(path: Path) -> None:
|
|
83
|
+
env = os.environ.copy()
|
|
84
|
+
env["GIT_AUTHOR_NAME"] = "test"
|
|
85
|
+
env["GIT_AUTHOR_EMAIL"] = "test@example.com"
|
|
86
|
+
env["GIT_COMMITTER_NAME"] = "test"
|
|
87
|
+
env["GIT_COMMITTER_EMAIL"] = "test@example.com"
|
|
88
|
+
subprocess.run(["git", "init", "-q", str(path)], check=True, env=env)
|
|
89
|
+
subprocess.run(["git", "-C", str(path), "checkout", "-q", "-b", "main"], check=False, env=env)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _git_commit_all(path: Path, message: str) -> None:
|
|
93
|
+
env = os.environ.copy()
|
|
94
|
+
env["GIT_AUTHOR_NAME"] = "test"
|
|
95
|
+
env["GIT_AUTHOR_EMAIL"] = "test@example.com"
|
|
96
|
+
env["GIT_COMMITTER_NAME"] = "test"
|
|
97
|
+
env["GIT_COMMITTER_EMAIL"] = "test@example.com"
|
|
98
|
+
subprocess.run(["git", "-C", str(path), "add", "-A"], check=True, env=env)
|
|
99
|
+
subprocess.run(["git", "-C", str(path), "commit", "-q", "-m", message], check=True, env=env)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DryRunCleanTreeTests(unittest.TestCase):
|
|
103
|
+
@classmethod
|
|
104
|
+
def setUpClass(cls) -> None:
|
|
105
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
106
|
+
|
|
107
|
+
def test_dry_run_against_already_projected_tree_returns_zero(self) -> None:
|
|
108
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
109
|
+
tmp_path = Path(tmp)
|
|
110
|
+
packs_dir = tmp_path / "packs"
|
|
111
|
+
packs_dir.mkdir()
|
|
112
|
+
_seed_pack(packs_dir, "core")
|
|
113
|
+
working_tree = tmp_path / "tree"
|
|
114
|
+
working_tree.mkdir()
|
|
115
|
+
_git_init(working_tree)
|
|
116
|
+
_seed_discovery(working_tree)
|
|
117
|
+
|
|
118
|
+
# Pre-seed via real-write self-host so the working tree
|
|
119
|
+
# exactly matches what a subsequent dry-run will produce
|
|
120
|
+
# (including the new seed/marketplace/symlink outputs).
|
|
121
|
+
run_self_host(
|
|
122
|
+
working_tree=working_tree,
|
|
123
|
+
packs_dir=packs_dir,
|
|
124
|
+
dry_run=False,
|
|
125
|
+
force=True,
|
|
126
|
+
contract=self.contract,
|
|
127
|
+
)
|
|
128
|
+
_git_commit_all(working_tree, "seed")
|
|
129
|
+
|
|
130
|
+
exit_code = run_self_host(
|
|
131
|
+
working_tree=working_tree,
|
|
132
|
+
packs_dir=packs_dir,
|
|
133
|
+
dry_run=True,
|
|
134
|
+
force=False,
|
|
135
|
+
contract=self.contract,
|
|
136
|
+
)
|
|
137
|
+
self.assertEqual(exit_code, 0)
|
|
138
|
+
|
|
139
|
+
def test_dry_run_with_drift_returns_non_zero(self) -> None:
|
|
140
|
+
import io
|
|
141
|
+
from contextlib import redirect_stderr
|
|
142
|
+
|
|
143
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
144
|
+
tmp_path = Path(tmp)
|
|
145
|
+
packs_dir = tmp_path / "packs"
|
|
146
|
+
packs_dir.mkdir()
|
|
147
|
+
_seed_pack(packs_dir, "core")
|
|
148
|
+
working_tree = tmp_path / "tree"
|
|
149
|
+
working_tree.mkdir()
|
|
150
|
+
_git_init(working_tree)
|
|
151
|
+
_seed_discovery(working_tree)
|
|
152
|
+
|
|
153
|
+
run_self_host(
|
|
154
|
+
working_tree=working_tree,
|
|
155
|
+
packs_dir=packs_dir,
|
|
156
|
+
dry_run=False,
|
|
157
|
+
force=True,
|
|
158
|
+
contract=self.contract,
|
|
159
|
+
)
|
|
160
|
+
_git_commit_all(working_tree, "seed")
|
|
161
|
+
|
|
162
|
+
target = working_tree / ".claude" / "skills" / "foo" / "SKILL.md"
|
|
163
|
+
target.write_text("drift!\n", encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
buf = io.StringIO()
|
|
166
|
+
with redirect_stderr(buf):
|
|
167
|
+
exit_code = run_self_host(
|
|
168
|
+
working_tree=working_tree,
|
|
169
|
+
packs_dir=packs_dir,
|
|
170
|
+
dry_run=True,
|
|
171
|
+
force=False,
|
|
172
|
+
contract=self.contract,
|
|
173
|
+
)
|
|
174
|
+
self.assertNotEqual(exit_code, 0)
|
|
175
|
+
# AC #10: stderr names the drifted file (per-file drift listing).
|
|
176
|
+
stderr_text = buf.getvalue()
|
|
177
|
+
self.assertIn(".claude/skills/foo/SKILL.md", stderr_text)
|
|
178
|
+
self.assertIn("drift", stderr_text)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class DirtyTreeRefusalTests(unittest.TestCase):
|
|
182
|
+
@classmethod
|
|
183
|
+
def setUpClass(cls) -> None:
|
|
184
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
185
|
+
|
|
186
|
+
def test_refuses_dirty_tree_without_force(self) -> None:
|
|
187
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
188
|
+
tmp_path = Path(tmp)
|
|
189
|
+
packs_dir = tmp_path / "packs"
|
|
190
|
+
packs_dir.mkdir()
|
|
191
|
+
_seed_pack(packs_dir, "core")
|
|
192
|
+
working_tree = tmp_path / "tree"
|
|
193
|
+
working_tree.mkdir()
|
|
194
|
+
_git_init(working_tree)
|
|
195
|
+
_seed_discovery(working_tree)
|
|
196
|
+
(working_tree / "tracked.txt").write_text("a\n", encoding="utf-8")
|
|
197
|
+
_git_commit_all(working_tree, "seed")
|
|
198
|
+
(working_tree / "tracked.txt").write_text("b\n", encoding="utf-8")
|
|
199
|
+
self.assertTrue(is_dirty_tree(working_tree))
|
|
200
|
+
|
|
201
|
+
exit_code = run_self_host(
|
|
202
|
+
working_tree=working_tree,
|
|
203
|
+
packs_dir=packs_dir,
|
|
204
|
+
dry_run=False,
|
|
205
|
+
force=False,
|
|
206
|
+
contract=self.contract,
|
|
207
|
+
)
|
|
208
|
+
self.assertNotEqual(exit_code, 0)
|
|
209
|
+
|
|
210
|
+
def test_force_proceeds_through_dirty_tree(self) -> None:
|
|
211
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
212
|
+
tmp_path = Path(tmp)
|
|
213
|
+
packs_dir = tmp_path / "packs"
|
|
214
|
+
packs_dir.mkdir()
|
|
215
|
+
_seed_pack(packs_dir, "core")
|
|
216
|
+
working_tree = tmp_path / "tree"
|
|
217
|
+
working_tree.mkdir()
|
|
218
|
+
_git_init(working_tree)
|
|
219
|
+
_seed_discovery(working_tree)
|
|
220
|
+
(working_tree / "tracked.txt").write_text("a\n", encoding="utf-8")
|
|
221
|
+
_git_commit_all(working_tree, "seed")
|
|
222
|
+
(working_tree / "tracked.txt").write_text("b\n", encoding="utf-8")
|
|
223
|
+
|
|
224
|
+
exit_code = run_self_host(
|
|
225
|
+
working_tree=working_tree,
|
|
226
|
+
packs_dir=packs_dir,
|
|
227
|
+
dry_run=False,
|
|
228
|
+
force=True,
|
|
229
|
+
contract=self.contract,
|
|
230
|
+
)
|
|
231
|
+
self.assertEqual(exit_code, 0)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class MarkerResolutionTests(unittest.TestCase):
|
|
235
|
+
@classmethod
|
|
236
|
+
def setUpClass(cls) -> None:
|
|
237
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
238
|
+
|
|
239
|
+
def test_self_resolves_markers_against_discovery_file(self) -> None:
|
|
240
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
241
|
+
tmp_path = Path(tmp)
|
|
242
|
+
packs_dir = tmp_path / "packs"
|
|
243
|
+
packs_dir.mkdir()
|
|
244
|
+
pack = _seed_pack(packs_dir, "core")
|
|
245
|
+
# Use a marker in a skill file the adapter projects through.
|
|
246
|
+
(pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
|
|
247
|
+
"---\ndescription: <adapt:project-name>\n---\nHello <adapt:project-name>.\n",
|
|
248
|
+
encoding="utf-8",
|
|
249
|
+
)
|
|
250
|
+
working_tree = tmp_path / "tree"
|
|
251
|
+
working_tree.mkdir()
|
|
252
|
+
_git_init(working_tree)
|
|
253
|
+
_seed_discovery(working_tree)
|
|
254
|
+
(working_tree / ".adapt-discovery.toml").write_text(
|
|
255
|
+
'discovery-schema-version = "0.1"\n[markers]\nproject-name = "demo"\n',
|
|
256
|
+
encoding="utf-8",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
exit_code = run_self_host(
|
|
260
|
+
working_tree=working_tree,
|
|
261
|
+
packs_dir=packs_dir,
|
|
262
|
+
dry_run=False,
|
|
263
|
+
force=True, # tree is dirty (just added discovery file)
|
|
264
|
+
contract=self.contract,
|
|
265
|
+
)
|
|
266
|
+
self.assertEqual(exit_code, 0)
|
|
267
|
+
skill = working_tree / ".claude" / "skills" / "foo" / "SKILL.md"
|
|
268
|
+
self.assertTrue(skill.exists())
|
|
269
|
+
text = skill.read_text(encoding="utf-8")
|
|
270
|
+
self.assertIn("demo", text)
|
|
271
|
+
self.assertNotIn("<adapt:", text)
|
|
272
|
+
|
|
273
|
+
def test_resolve_markers_helper_leaves_unmatched_markers(self) -> None:
|
|
274
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
275
|
+
tmp_path = Path(tmp)
|
|
276
|
+
# resolve_markers restricts its scope to adapter-target paths
|
|
277
|
+
# (TARGET_PATHS) — write the fixture under AGENTS.md so the
|
|
278
|
+
# walk actually visits it.
|
|
279
|
+
(tmp_path / "AGENTS.md").write_text(
|
|
280
|
+
"Hello <adapt:name>, also <adapt:unknown>!\n",
|
|
281
|
+
encoding="utf-8",
|
|
282
|
+
)
|
|
283
|
+
count = resolve_markers(tmp_path, {"name": "World"})
|
|
284
|
+
self.assertEqual(count, 1)
|
|
285
|
+
text = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
|
|
286
|
+
self.assertIn("Hello World", text)
|
|
287
|
+
self.assertIn("<adapt:unknown>", text)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class WorkingTreeOnConflictTests(unittest.TestCase):
|
|
291
|
+
"""`--self` must honour each adapter's on-conflict policy against
|
|
292
|
+
the working tree. The previous render-to-temp pattern broke this
|
|
293
|
+
because the temp dir started empty."""
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def setUpClass(cls) -> None:
|
|
297
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
298
|
+
|
|
299
|
+
def test_merge_json_preserves_unrelated_keys_under_self(self) -> None:
|
|
300
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
301
|
+
tmp_path = Path(tmp)
|
|
302
|
+
packs_dir = tmp_path / "packs"
|
|
303
|
+
packs_dir.mkdir()
|
|
304
|
+
pack = _seed_pack(packs_dir, "core")
|
|
305
|
+
(pack / ".apm" / "hook-wiring").mkdir(parents=True)
|
|
306
|
+
(pack / ".apm" / "hook-wiring" / "baz.toml").write_text(
|
|
307
|
+
'[hooks]\nbaz = "tools/hooks/baz.sh"\n',
|
|
308
|
+
encoding="utf-8",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
working_tree = tmp_path / "tree"
|
|
312
|
+
working_tree.mkdir()
|
|
313
|
+
_git_init(working_tree)
|
|
314
|
+
_seed_discovery(working_tree)
|
|
315
|
+
settings_path = working_tree / ".claude" / "settings.local.json"
|
|
316
|
+
settings_path.parent.mkdir(parents=True)
|
|
317
|
+
settings_path.write_text(
|
|
318
|
+
json.dumps({"otherKey": {"preserved": True}}),
|
|
319
|
+
encoding="utf-8",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
exit_code = run_self_host(
|
|
323
|
+
working_tree=working_tree,
|
|
324
|
+
packs_dir=packs_dir,
|
|
325
|
+
dry_run=False,
|
|
326
|
+
force=True,
|
|
327
|
+
contract=self.contract,
|
|
328
|
+
)
|
|
329
|
+
self.assertEqual(exit_code, 0)
|
|
330
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
331
|
+
# Existing key survives — merge-managed-key-only honoured.
|
|
332
|
+
self.assertEqual(data["otherKey"], {"preserved": True})
|
|
333
|
+
# New hooks-key content landed.
|
|
334
|
+
self.assertIn("baz", data["hooks"])
|
|
335
|
+
|
|
336
|
+
def test_managed_block_preserves_outside_content_under_self(self) -> None:
|
|
337
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
338
|
+
tmp_path = Path(tmp)
|
|
339
|
+
packs_dir = tmp_path / "packs"
|
|
340
|
+
packs_dir.mkdir()
|
|
341
|
+
core = _seed_pack(packs_dir, "core")
|
|
342
|
+
(core / "seeds").mkdir()
|
|
343
|
+
(core / "seeds" / "AGENTS.md").write_text(
|
|
344
|
+
"# Custom AGENTS.md\n\nDo not lose me.\n",
|
|
345
|
+
encoding="utf-8",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
working_tree = tmp_path / "tree"
|
|
349
|
+
working_tree.mkdir()
|
|
350
|
+
_git_init(working_tree)
|
|
351
|
+
_seed_discovery(working_tree)
|
|
352
|
+
|
|
353
|
+
exit_code = run_self_host(
|
|
354
|
+
working_tree=working_tree,
|
|
355
|
+
packs_dir=packs_dir,
|
|
356
|
+
dry_run=False,
|
|
357
|
+
force=True,
|
|
358
|
+
contract=self.contract,
|
|
359
|
+
)
|
|
360
|
+
self.assertEqual(exit_code, 0)
|
|
361
|
+
text = (working_tree / "AGENTS.md").read_text(encoding="utf-8")
|
|
362
|
+
self.assertIn("# Custom AGENTS.md", text)
|
|
363
|
+
self.assertIn("Do not lose me.", text)
|
|
364
|
+
# Post-RFC-0009: Codex no longer writes the managed block.
|
|
365
|
+
# The legacy delimiter must be absent from projected output.
|
|
366
|
+
self.assertNotIn("<!-- agent-skills:start -->", text)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class SelfHostAdapterAllowListTests(unittest.TestCase):
|
|
370
|
+
"""Self-host allow-list (spec § Phased rollout / § Always do).
|
|
371
|
+
|
|
372
|
+
The allow-list is load-bearing: a future contributor adding the
|
|
373
|
+
`kiro` or `copilot` adapter to `ADAPTERS` (the global registry) but
|
|
374
|
+
not to `SELF_HOST_ADAPTERS` would otherwise produce a silent
|
|
375
|
+
no-op surprise. These tests pin the contract.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
def setUpClass(cls) -> None:
|
|
380
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
381
|
+
|
|
382
|
+
def test_non_allow_listed_adapter_is_skipped(self) -> None:
|
|
383
|
+
"""An adapter registered in ADAPTERS and the contract but excluded
|
|
384
|
+
from SELF_HOST_ADAPTERS does not run under run_self_host."""
|
|
385
|
+
from unittest.mock import MagicMock, patch
|
|
386
|
+
|
|
387
|
+
from agentbundle.build import self_host as self_host_module
|
|
388
|
+
|
|
389
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
390
|
+
tmp_path = Path(tmp)
|
|
391
|
+
packs_dir = tmp_path / "packs"
|
|
392
|
+
packs_dir.mkdir()
|
|
393
|
+
_seed_pack(packs_dir, "core")
|
|
394
|
+
working_tree = tmp_path / "tree"
|
|
395
|
+
working_tree.mkdir()
|
|
396
|
+
_git_init(working_tree)
|
|
397
|
+
_seed_discovery(working_tree)
|
|
398
|
+
(working_tree / ".keep").write_text("", encoding="utf-8")
|
|
399
|
+
_git_commit_all(working_tree, "init")
|
|
400
|
+
|
|
401
|
+
# Register a sentinel adapter into ADAPTERS, registry, and the
|
|
402
|
+
# contract; if SELF_HOST_ADAPTERS is honoured, it must not
|
|
403
|
+
# be invoked. Post-T5, `_project_all_adapters` looks up the
|
|
404
|
+
# adapter module via `registry`, so the sentinel needs an
|
|
405
|
+
# entry there with a `project_packs` callable.
|
|
406
|
+
sentinel_module = MagicMock()
|
|
407
|
+
patched_adapters = dict(self_host_module.ADAPTERS)
|
|
408
|
+
patched_adapters["sentinel"] = sentinel_module.project
|
|
409
|
+
patched_registry = dict(self_host_module.registry)
|
|
410
|
+
patched_registry["sentinel"] = sentinel_module
|
|
411
|
+
patched_contract = dict(self.contract)
|
|
412
|
+
patched_contract["adapter"] = {
|
|
413
|
+
**self.contract["adapter"],
|
|
414
|
+
"sentinel": {"projection": []},
|
|
415
|
+
}
|
|
416
|
+
with patch.object(self_host_module, "ADAPTERS", patched_adapters), \
|
|
417
|
+
patch.object(self_host_module, "registry", patched_registry):
|
|
418
|
+
exit_code = self_host_module.run_self_host(
|
|
419
|
+
working_tree=working_tree,
|
|
420
|
+
packs_dir=packs_dir,
|
|
421
|
+
dry_run=False,
|
|
422
|
+
force=True,
|
|
423
|
+
contract=patched_contract,
|
|
424
|
+
)
|
|
425
|
+
self.assertEqual(exit_code, 0)
|
|
426
|
+
sentinel_module.project_packs.assert_not_called()
|
|
427
|
+
|
|
428
|
+
def test_allow_listed_adapter_runs(self) -> None:
|
|
429
|
+
"""An adapter in SELF_HOST_ADAPTERS, registered in ADAPTERS and the
|
|
430
|
+
contract, IS invoked under run_self_host."""
|
|
431
|
+
from unittest.mock import MagicMock, patch
|
|
432
|
+
|
|
433
|
+
from agentbundle.build import self_host as self_host_module
|
|
434
|
+
|
|
435
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
436
|
+
tmp_path = Path(tmp)
|
|
437
|
+
packs_dir = tmp_path / "packs"
|
|
438
|
+
packs_dir.mkdir()
|
|
439
|
+
_seed_pack(packs_dir, "core")
|
|
440
|
+
working_tree = tmp_path / "tree"
|
|
441
|
+
working_tree.mkdir()
|
|
442
|
+
_git_init(working_tree)
|
|
443
|
+
_seed_discovery(working_tree)
|
|
444
|
+
(working_tree / ".keep").write_text("", encoding="utf-8")
|
|
445
|
+
_git_commit_all(working_tree, "init")
|
|
446
|
+
|
|
447
|
+
sentinel_module = MagicMock()
|
|
448
|
+
patched_adapters = dict(self_host_module.ADAPTERS)
|
|
449
|
+
patched_adapters["sentinel"] = sentinel_module.project
|
|
450
|
+
patched_registry = dict(self_host_module.registry)
|
|
451
|
+
patched_registry["sentinel"] = sentinel_module
|
|
452
|
+
patched_contract = dict(self.contract)
|
|
453
|
+
patched_contract["adapter"] = {
|
|
454
|
+
**self.contract["adapter"],
|
|
455
|
+
"sentinel": {"projection": []},
|
|
456
|
+
}
|
|
457
|
+
with patch.object(self_host_module, "ADAPTERS", patched_adapters), \
|
|
458
|
+
patch.object(self_host_module, "registry", patched_registry), \
|
|
459
|
+
patch.object(
|
|
460
|
+
self_host_module,
|
|
461
|
+
"SELF_HOST_ADAPTERS",
|
|
462
|
+
("claude-code", "sentinel"),
|
|
463
|
+
):
|
|
464
|
+
exit_code = self_host_module.run_self_host(
|
|
465
|
+
working_tree=working_tree,
|
|
466
|
+
packs_dir=packs_dir,
|
|
467
|
+
dry_run=False,
|
|
468
|
+
force=True,
|
|
469
|
+
contract=patched_contract,
|
|
470
|
+
)
|
|
471
|
+
self.assertEqual(exit_code, 0)
|
|
472
|
+
# Sentinel `project_packs` called once with the list of all
|
|
473
|
+
# discovered pack paths (T5 routing change — previously one
|
|
474
|
+
# call per pack via single-pack `project`).
|
|
475
|
+
self.assertEqual(sentinel_module.project_packs.call_count, 1)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class AgentsMdCompositionTests(unittest.TestCase):
|
|
479
|
+
@classmethod
|
|
480
|
+
def setUpClass(cls) -> None:
|
|
481
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
482
|
+
|
|
483
|
+
def test_self_host_composes_agents_body_codex_block_and_footer(self) -> None:
|
|
484
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
485
|
+
tmp_path = Path(tmp)
|
|
486
|
+
packs_dir = tmp_path / "packs"
|
|
487
|
+
packs_dir.mkdir()
|
|
488
|
+
core = _seed_pack_with_skill(
|
|
489
|
+
packs_dir, "core", "core-skill", "core skill description"
|
|
490
|
+
)
|
|
491
|
+
_seed_pack_with_skill(
|
|
492
|
+
packs_dir,
|
|
493
|
+
"governance-extras",
|
|
494
|
+
"governance-skill",
|
|
495
|
+
"governance skill description",
|
|
496
|
+
)
|
|
497
|
+
(core / "seeds").mkdir()
|
|
498
|
+
(core / "seeds" / "AGENTS.md").write_text(
|
|
499
|
+
"# Body\n\nBody source.\n", encoding="utf-8"
|
|
500
|
+
)
|
|
501
|
+
(core / "seeds" / "_agents-footer.md").write_text(
|
|
502
|
+
"> Footer source.\n", encoding="utf-8"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
working_tree = tmp_path / "tree"
|
|
506
|
+
working_tree.mkdir()
|
|
507
|
+
_git_init(working_tree)
|
|
508
|
+
_seed_discovery(working_tree)
|
|
509
|
+
|
|
510
|
+
exit_code = run_self_host(
|
|
511
|
+
working_tree=working_tree,
|
|
512
|
+
packs_dir=packs_dir,
|
|
513
|
+
dry_run=False,
|
|
514
|
+
force=True,
|
|
515
|
+
contract=self.contract,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
self.assertEqual(exit_code, 0)
|
|
519
|
+
text = (working_tree / "AGENTS.md").read_text(encoding="utf-8")
|
|
520
|
+
self.assertTrue(text.startswith("# Body\n\nBody source.\n"))
|
|
521
|
+
# Post-RFC-0009: skill descriptions no longer inline into
|
|
522
|
+
# AGENTS.md; Codex is not in `SELF_HOST_ADAPTERS` so the
|
|
523
|
+
# `.agents/skills/` tree is not produced in self-host output.
|
|
524
|
+
# Codex projection is tested against tempdir paths (AC29);
|
|
525
|
+
# Claude Code's `.claude/skills/` is the self-host surface.
|
|
526
|
+
self.assertNotIn("core skill description", text)
|
|
527
|
+
self.assertNotIn("governance skill description", text)
|
|
528
|
+
self.assertFalse((working_tree / ".agents").exists())
|
|
529
|
+
self.assertTrue(
|
|
530
|
+
(working_tree / ".claude" / "skills" / "core-skill" / "SKILL.md").is_file()
|
|
531
|
+
)
|
|
532
|
+
self.assertTrue(
|
|
533
|
+
(working_tree / ".claude" / "skills" / "governance-skill" / "SKILL.md").is_file()
|
|
534
|
+
)
|
|
535
|
+
self.assertTrue(text.endswith("> Footer source.\n"))
|
|
536
|
+
|
|
537
|
+
def test_compose_preserves_existing_agents_md(self) -> None:
|
|
538
|
+
"""When the working tree already carries an AGENTS.md (Manual file
|
|
539
|
+
per EXCLUDED_PATTERNS), `_compose_agents_md` must not clobber it
|
|
540
|
+
with the body+footer composition. Regression guard for the
|
|
541
|
+
2026-05-25 amendment that classified AGENTS.md as Manual."""
|
|
542
|
+
from agentbundle.build.self_host import _compose_agents_md
|
|
543
|
+
|
|
544
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
545
|
+
tmp_path = Path(tmp)
|
|
546
|
+
packs_dir = tmp_path / "packs"
|
|
547
|
+
packs_dir.mkdir()
|
|
548
|
+
core = _seed_pack(packs_dir, "core")
|
|
549
|
+
(core / "seeds").mkdir()
|
|
550
|
+
(core / "seeds" / "AGENTS.md").write_text(
|
|
551
|
+
"# Seed body\n", encoding="utf-8"
|
|
552
|
+
)
|
|
553
|
+
(core / "seeds" / "_agents-footer.md").write_text(
|
|
554
|
+
"> Seed footer.\n", encoding="utf-8"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
output = tmp_path / "out"
|
|
558
|
+
output.mkdir()
|
|
559
|
+
adopter_content = "# Adopter's filled-in AGENTS.md\n\nLive content.\n"
|
|
560
|
+
(output / "AGENTS.md").write_text(adopter_content, encoding="utf-8")
|
|
561
|
+
|
|
562
|
+
result = _compose_agents_md(packs_dir, output, self.contract)
|
|
563
|
+
|
|
564
|
+
self.assertIsNone(result)
|
|
565
|
+
self.assertEqual(
|
|
566
|
+
(output / "AGENTS.md").read_text(encoding="utf-8"),
|
|
567
|
+
adopter_content,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class SelfHostPackFilterTests(unittest.TestCase):
|
|
572
|
+
"""`SELF_HOST_PACKS` narrows which packs contribute to the working-tree
|
|
573
|
+
projection. User-scope-default packs (architect, atlassian, etc.) are
|
|
574
|
+
advertised via marketplace.json but their primitives must not land in
|
|
575
|
+
this repo's `.claude/skills/` tree.
|
|
576
|
+
"""
|
|
577
|
+
|
|
578
|
+
@classmethod
|
|
579
|
+
def setUpClass(cls) -> None:
|
|
580
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
581
|
+
|
|
582
|
+
def test_non_allow_listed_pack_skills_do_not_project(self) -> None:
|
|
583
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
584
|
+
tmp_path = Path(tmp)
|
|
585
|
+
packs_dir = tmp_path / "packs"
|
|
586
|
+
packs_dir.mkdir()
|
|
587
|
+
_seed_pack_with_skill(
|
|
588
|
+
packs_dir, "core", "core-skill", "core skill"
|
|
589
|
+
)
|
|
590
|
+
_seed_pack_with_skill(
|
|
591
|
+
packs_dir, "atlassian", "jira", "user-scope skill"
|
|
592
|
+
)
|
|
593
|
+
working_tree = tmp_path / "tree"
|
|
594
|
+
working_tree.mkdir()
|
|
595
|
+
_git_init(working_tree)
|
|
596
|
+
_seed_discovery(working_tree)
|
|
597
|
+
|
|
598
|
+
exit_code = run_self_host(
|
|
599
|
+
working_tree=working_tree,
|
|
600
|
+
packs_dir=packs_dir,
|
|
601
|
+
dry_run=False,
|
|
602
|
+
force=True,
|
|
603
|
+
contract=self.contract,
|
|
604
|
+
)
|
|
605
|
+
self.assertEqual(exit_code, 0)
|
|
606
|
+
self.assertTrue(
|
|
607
|
+
(working_tree / ".claude" / "skills" / "core-skill" / "SKILL.md").is_file()
|
|
608
|
+
)
|
|
609
|
+
self.assertFalse(
|
|
610
|
+
(working_tree / ".claude" / "skills" / "jira").exists(),
|
|
611
|
+
msg="atlassian/jira skill must not project to .claude/skills/ — "
|
|
612
|
+
"atlassian is not in SELF_HOST_PACKS",
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def test_non_allow_listed_pack_seeds_do_not_project(self) -> None:
|
|
616
|
+
from agentbundle.build.self_host import _project_seeds
|
|
617
|
+
|
|
618
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
619
|
+
tmp_path = Path(tmp)
|
|
620
|
+
packs_dir = tmp_path / "packs"
|
|
621
|
+
packs_dir.mkdir()
|
|
622
|
+
for name in ("core", "atlassian"):
|
|
623
|
+
pack = packs_dir / name
|
|
624
|
+
(pack / "seeds" / "docs").mkdir(parents=True)
|
|
625
|
+
(pack / "seeds" / "docs" / f"{name}.md").write_text(
|
|
626
|
+
f"# {name}\n", encoding="utf-8"
|
|
627
|
+
)
|
|
628
|
+
(pack / "pack.toml").write_text(
|
|
629
|
+
f'[pack]\nname = "{name}"\nversion = "0.1.0"\n',
|
|
630
|
+
encoding="utf-8",
|
|
631
|
+
)
|
|
632
|
+
output = tmp_path / "out"
|
|
633
|
+
output.mkdir()
|
|
634
|
+
|
|
635
|
+
_project_seeds(packs_dir, output)
|
|
636
|
+
|
|
637
|
+
self.assertTrue((output / "docs" / "core.md").exists())
|
|
638
|
+
self.assertFalse(
|
|
639
|
+
(output / "docs" / "atlassian.md").exists(),
|
|
640
|
+
msg="atlassian seed must not project — not in SELF_HOST_PACKS",
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
def _core_pack_with_backlog_seed(self, packs_dir: Path) -> None:
|
|
644
|
+
"""Build a minimal `core` pack whose seed carries a placeholder
|
|
645
|
+
`docs/backlog.md` (RFC-0016 mechanism 5)."""
|
|
646
|
+
pack = packs_dir / "core"
|
|
647
|
+
(pack / "seeds" / "docs").mkdir(parents=True)
|
|
648
|
+
(pack / "seeds" / "docs" / "backlog.md").write_text(
|
|
649
|
+
"# Backlog\n\n<!-- no deferred items yet -->\n", encoding="utf-8"
|
|
650
|
+
)
|
|
651
|
+
(pack / "pack.toml").write_text(
|
|
652
|
+
'[pack]\nname = "core"\nversion = "0.1.0"\n', encoding="utf-8"
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
def test_backlog_path_is_excluded(self) -> None:
|
|
656
|
+
from agentbundle.build.self_host import _is_excluded
|
|
657
|
+
|
|
658
|
+
# docs/backlog.md must be Manual (Excluded) so the preserve gate fires.
|
|
659
|
+
self.assertTrue(_is_excluded(Path("docs/backlog.md")))
|
|
660
|
+
|
|
661
|
+
def test_curated_backlog_preserved_on_reprojection(self) -> None:
|
|
662
|
+
"""`_project_seeds` MUST NOT clobber a curated on-disk
|
|
663
|
+
`docs/backlog.md` — it is Excluded and already exists (AC7)."""
|
|
664
|
+
from agentbundle.build.self_host import _project_seeds
|
|
665
|
+
|
|
666
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
667
|
+
tmp_path = Path(tmp)
|
|
668
|
+
packs_dir = tmp_path / "packs"
|
|
669
|
+
packs_dir.mkdir()
|
|
670
|
+
self._core_pack_with_backlog_seed(packs_dir)
|
|
671
|
+
output = tmp_path / "out"
|
|
672
|
+
(output / "docs").mkdir(parents=True)
|
|
673
|
+
curated = output / "docs" / "backlog.md"
|
|
674
|
+
curated_bytes = b"# Backlog\n\n## real-spec\n- AC1 open\n"
|
|
675
|
+
curated.write_bytes(curated_bytes)
|
|
676
|
+
|
|
677
|
+
_project_seeds(packs_dir, output)
|
|
678
|
+
|
|
679
|
+
self.assertEqual(
|
|
680
|
+
curated.read_bytes(),
|
|
681
|
+
curated_bytes,
|
|
682
|
+
msg="curated docs/backlog.md must survive re-projection byte-identical",
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
def test_backlog_seed_lands_when_absent(self) -> None:
|
|
686
|
+
"""On a tree lacking `docs/backlog.md`, the placeholder seed lands
|
|
687
|
+
(first-install scaffold branch of the preserve gate)."""
|
|
688
|
+
from agentbundle.build.self_host import _project_seeds
|
|
689
|
+
|
|
690
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
691
|
+
tmp_path = Path(tmp)
|
|
692
|
+
packs_dir = tmp_path / "packs"
|
|
693
|
+
packs_dir.mkdir()
|
|
694
|
+
self._core_pack_with_backlog_seed(packs_dir)
|
|
695
|
+
output = tmp_path / "out"
|
|
696
|
+
output.mkdir()
|
|
697
|
+
|
|
698
|
+
_project_seeds(packs_dir, output)
|
|
699
|
+
|
|
700
|
+
landed = output / "docs" / "backlog.md"
|
|
701
|
+
self.assertTrue(landed.is_file())
|
|
702
|
+
self.assertIn("<!-- no deferred items yet -->", landed.read_text(encoding="utf-8"))
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
class ExcludedGlobTests(unittest.TestCase):
|
|
706
|
+
"""Pin the glob corner cases that the second adversarial sweep caught:
|
|
707
|
+
`**` must match arbitrary depth (not literal-prefix-startswith), and
|
|
708
|
+
bare root-only patterns must anchor to the repo root."""
|
|
709
|
+
|
|
710
|
+
def test_double_star_matches_arbitrary_depth(self) -> None:
|
|
711
|
+
from agentbundle.build.self_host import _is_excluded
|
|
712
|
+
|
|
713
|
+
# docs/specs/*/notes/** should match a nested notes file
|
|
714
|
+
self.assertTrue(
|
|
715
|
+
_is_excluded(Path("docs/specs/self-hosting/notes/foo.md"))
|
|
716
|
+
)
|
|
717
|
+
self.assertTrue(
|
|
718
|
+
_is_excluded(Path("docs/specs/feature/notes/sub/dir/bar.md"))
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
def test_root_only_patterns_do_not_match_nested(self) -> None:
|
|
722
|
+
from agentbundle.build.self_host import _is_excluded
|
|
723
|
+
|
|
724
|
+
# README.md is root-only; nested README.md must NOT be excluded
|
|
725
|
+
self.assertTrue(_is_excluded(Path("README.md")))
|
|
726
|
+
self.assertFalse(_is_excluded(Path(".claude/skills/README.md")))
|
|
727
|
+
self.assertFalse(_is_excluded(Path("docs/random/README.md")))
|
|
728
|
+
|
|
729
|
+
# AGENTS.md root-only; nested AGENTS.md must NOT be excluded
|
|
730
|
+
self.assertTrue(_is_excluded(Path("AGENTS.md")))
|
|
731
|
+
self.assertFalse(_is_excluded(Path("packages/foo/AGENTS.md")))
|
|
732
|
+
|
|
733
|
+
# Makefile, .gitignore, .adapt-discovery.toml — same pattern
|
|
734
|
+
self.assertTrue(_is_excluded(Path("Makefile")))
|
|
735
|
+
self.assertFalse(_is_excluded(Path("subdir/Makefile")))
|
|
736
|
+
self.assertTrue(_is_excluded(Path(".adapt-discovery.toml")))
|
|
737
|
+
|
|
738
|
+
def test_directory_double_star_matches_anything_under(self) -> None:
|
|
739
|
+
from agentbundle.build.self_host import _is_excluded
|
|
740
|
+
|
|
741
|
+
# packs/** matches everything under packs/
|
|
742
|
+
self.assertTrue(_is_excluded(Path("packs/core/pack.toml")))
|
|
743
|
+
self.assertTrue(
|
|
744
|
+
_is_excluded(Path("packs/core/.apm/skills/work-loop/SKILL.md"))
|
|
745
|
+
)
|
|
746
|
+
# but packs.md at root is NOT under packs/
|
|
747
|
+
self.assertFalse(_is_excluded(Path("packs.md")))
|
|
748
|
+
|
|
749
|
+
def test_post_2026_05_25_shrink_leaves_only_conventions(self) -> None:
|
|
750
|
+
"""Per RFC-0002 amendment 2026-05-25: PROJECTED_README_OVERRIDES
|
|
751
|
+
shrank from 20 to 1 entry; only `docs/CONVENTIONS.md` remains.
|
|
752
|
+
Every other formerly-overridden path now falls through to
|
|
753
|
+
EXCLUDED_PATTERNS coverage."""
|
|
754
|
+
from agentbundle.build.self_host import _is_excluded
|
|
755
|
+
|
|
756
|
+
# docs/CONVENTIONS.md stays in the override → not excluded.
|
|
757
|
+
self.assertFalse(_is_excluded(Path("docs/CONVENTIONS.md")))
|
|
758
|
+
|
|
759
|
+
# All 19 reclassified paths are now Excluded (either via
|
|
760
|
+
# existing `docs/<area>/*.md` patterns, the `docs/guides/**/*.md`
|
|
761
|
+
# pattern, or one of the 8 explicit additions made by the
|
|
762
|
+
# amendment).
|
|
763
|
+
for path in (
|
|
764
|
+
# Covered by `docs/architecture/*.md`:
|
|
765
|
+
"docs/architecture/README.md",
|
|
766
|
+
"docs/architecture/overview.md",
|
|
767
|
+
# Covered by `docs/knowledge/*.md`:
|
|
768
|
+
"docs/knowledge/README.md",
|
|
769
|
+
# Covered by `docs/product/*.md`:
|
|
770
|
+
"docs/product/README.md",
|
|
771
|
+
"docs/product/roadmap.md",
|
|
772
|
+
"docs/product/changelog.md",
|
|
773
|
+
# Covered by `docs/guides/**/*.md`:
|
|
774
|
+
"docs/guides/README.md",
|
|
775
|
+
"docs/guides/tutorials/README.md",
|
|
776
|
+
"docs/guides/how-to/README.md",
|
|
777
|
+
"docs/guides/reference/README.md",
|
|
778
|
+
"docs/guides/explanation/README.md",
|
|
779
|
+
# Explicit literal additions:
|
|
780
|
+
"docs/CHARTER.md",
|
|
781
|
+
"docs/knowledge/patterns.jsonl",
|
|
782
|
+
"docs/rfc/README.md",
|
|
783
|
+
"docs/adr/README.md",
|
|
784
|
+
"docs/specs/README.md",
|
|
785
|
+
"packages/README.md",
|
|
786
|
+
"packages/_example/README.md",
|
|
787
|
+
"packages/_example/AGENTS.md",
|
|
788
|
+
):
|
|
789
|
+
self.assertTrue(
|
|
790
|
+
_is_excluded(Path(path)),
|
|
791
|
+
msg=f"{path} should be Excluded post-2026-05-25 shrink",
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
# Regression guard: a hypothetical contributor-added subsystem
|
|
795
|
+
# doc under `docs/architecture/` stays Excluded — proves the
|
|
796
|
+
# shrink didn't accidentally widen the override.
|
|
797
|
+
self.assertTrue(_is_excluded(Path("docs/architecture/data-pipeline.md")))
|
|
798
|
+
|
|
799
|
+
# The literal additions are anchored: `packages/_example/README.md`
|
|
800
|
+
# matches; a hypothetical `packages/foo/_example/README.md` does
|
|
801
|
+
# not. (Other patterns like `packages/agentbundle/**` cover
|
|
802
|
+
# nested package directories; the literal additions guard the
|
|
803
|
+
# specific `_example/` scaffold only.)
|
|
804
|
+
self.assertTrue(_is_excluded(Path("packages/_example/README.md")))
|
|
805
|
+
self.assertFalse(_is_excluded(Path("packages/foo/_example/README.md")))
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
class SeedProjectionTests(unittest.TestCase):
|
|
809
|
+
"""Unit tests for `_project_seeds` (spec § Always do, AC7, AC9)."""
|
|
810
|
+
|
|
811
|
+
def test_basic_seed_projection_copies_to_root(self) -> None:
|
|
812
|
+
from agentbundle.build.self_host import _project_seeds
|
|
813
|
+
|
|
814
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
815
|
+
tmp_path = Path(tmp)
|
|
816
|
+
packs_dir = tmp_path / "packs"
|
|
817
|
+
packs_dir.mkdir()
|
|
818
|
+
pack = packs_dir / "core"
|
|
819
|
+
(pack / "seeds" / "docs").mkdir(parents=True)
|
|
820
|
+
(pack / "seeds" / "docs" / "CHARTER.md").write_text(
|
|
821
|
+
"# Charter\n", encoding="utf-8"
|
|
822
|
+
)
|
|
823
|
+
(pack / "pack.toml").write_text(
|
|
824
|
+
'[pack]\nname = "core"\nversion = "0.1.0"\n', encoding="utf-8"
|
|
825
|
+
)
|
|
826
|
+
output = tmp_path / "out"
|
|
827
|
+
output.mkdir()
|
|
828
|
+
|
|
829
|
+
_project_seeds(packs_dir, output)
|
|
830
|
+
|
|
831
|
+
self.assertTrue((output / "docs" / "CHARTER.md").exists())
|
|
832
|
+
self.assertEqual(
|
|
833
|
+
(output / "docs" / "CHARTER.md").read_text(encoding="utf-8"),
|
|
834
|
+
"# Charter\n",
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
def test_excluded_path_with_on_disk_content_preserved(self) -> None:
|
|
838
|
+
"""RFC-0002 § Amendments § 2026-05-25 invariant: seed projection
|
|
839
|
+
MUST NOT overwrite Manual paths whose on-disk content is this
|
|
840
|
+
repo's filled-in instance.
|
|
841
|
+
|
|
842
|
+
Pre-amendment, `_project_seeds` blind-wrote every seed,
|
|
843
|
+
clobbering living docs (`docs/architecture/overview.md`,
|
|
844
|
+
`docs/specs/README.md`, `docs/knowledge/patterns.jsonl`, etc.)
|
|
845
|
+
whenever `make build-self FORCE=1` was invoked. The fix gates
|
|
846
|
+
writes on `_is_excluded(relative) AND target exists`.
|
|
847
|
+
|
|
848
|
+
Regression guard: if the predicate is removed or inverted,
|
|
849
|
+
this test re-introduces the clobber.
|
|
850
|
+
"""
|
|
851
|
+
from agentbundle.build.self_host import _project_seeds
|
|
852
|
+
|
|
853
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
854
|
+
tmp_path = Path(tmp)
|
|
855
|
+
packs_dir = tmp_path / "packs"
|
|
856
|
+
packs_dir.mkdir()
|
|
857
|
+
pack = packs_dir / "core"
|
|
858
|
+
(pack / "seeds" / "docs" / "specs").mkdir(parents=True)
|
|
859
|
+
# Placeholder seed (what ships to adopters).
|
|
860
|
+
(pack / "seeds" / "docs" / "specs" / "README.md").write_text(
|
|
861
|
+
"# Specs\n\n<!-- no specs yet -->\n", encoding="utf-8"
|
|
862
|
+
)
|
|
863
|
+
(pack / "pack.toml").write_text(
|
|
864
|
+
'[pack]\nname = "core"\nversion = "0.1.0"\n', encoding="utf-8"
|
|
865
|
+
)
|
|
866
|
+
output = tmp_path / "out"
|
|
867
|
+
(output / "docs" / "specs").mkdir(parents=True)
|
|
868
|
+
# Living instance on disk (what this repo or an adopter
|
|
869
|
+
# already filled in).
|
|
870
|
+
(output / "docs" / "specs" / "README.md").write_text(
|
|
871
|
+
"# Specs\n\n| Spec | Status |\n| --- | --- |\n| foo | Draft |\n",
|
|
872
|
+
encoding="utf-8",
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
_project_seeds(packs_dir, output)
|
|
876
|
+
|
|
877
|
+
# The on-disk filled content survives; the placeholder
|
|
878
|
+
# seed did NOT clobber it.
|
|
879
|
+
on_disk = (output / "docs" / "specs" / "README.md").read_text(
|
|
880
|
+
encoding="utf-8"
|
|
881
|
+
)
|
|
882
|
+
self.assertIn("| foo | Draft |", on_disk)
|
|
883
|
+
self.assertNotIn("<!-- no specs yet -->", on_disk)
|
|
884
|
+
|
|
885
|
+
def test_excluded_path_missing_on_disk_gets_seed(self) -> None:
|
|
886
|
+
"""First-install case: when an Excluded path does NOT exist on
|
|
887
|
+
disk, the placeholder seed IS projected (so adopters get the
|
|
888
|
+
scaffold on a clean install)."""
|
|
889
|
+
from agentbundle.build.self_host import _project_seeds
|
|
890
|
+
|
|
891
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
892
|
+
tmp_path = Path(tmp)
|
|
893
|
+
packs_dir = tmp_path / "packs"
|
|
894
|
+
packs_dir.mkdir()
|
|
895
|
+
pack = packs_dir / "core"
|
|
896
|
+
(pack / "seeds" / "docs" / "specs").mkdir(parents=True)
|
|
897
|
+
(pack / "seeds" / "docs" / "specs" / "README.md").write_text(
|
|
898
|
+
"# Specs\n\n<!-- no specs yet -->\n", encoding="utf-8"
|
|
899
|
+
)
|
|
900
|
+
(pack / "pack.toml").write_text(
|
|
901
|
+
'[pack]\nname = "core"\nversion = "0.1.0"\n', encoding="utf-8"
|
|
902
|
+
)
|
|
903
|
+
output = tmp_path / "out"
|
|
904
|
+
output.mkdir() # No pre-existing docs/specs/README.md
|
|
905
|
+
|
|
906
|
+
_project_seeds(packs_dir, output)
|
|
907
|
+
|
|
908
|
+
on_disk = (output / "docs" / "specs" / "README.md").read_text(
|
|
909
|
+
encoding="utf-8"
|
|
910
|
+
)
|
|
911
|
+
self.assertIn("<!-- no specs yet -->", on_disk)
|
|
912
|
+
|
|
913
|
+
def test_two_packs_contribute_to_same_dir_without_collision(self) -> None:
|
|
914
|
+
from agentbundle.build.self_host import _project_seeds
|
|
915
|
+
|
|
916
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
917
|
+
tmp_path = Path(tmp)
|
|
918
|
+
packs_dir = tmp_path / "packs"
|
|
919
|
+
packs_dir.mkdir()
|
|
920
|
+
for name, fname in [("core", "spec.md"), ("governance-extras", "rfc.md")]:
|
|
921
|
+
pack = packs_dir / name
|
|
922
|
+
(pack / "seeds" / "docs" / "_templates").mkdir(parents=True)
|
|
923
|
+
(pack / "seeds" / "docs" / "_templates" / fname).write_text(
|
|
924
|
+
f"# {fname}\n", encoding="utf-8"
|
|
925
|
+
)
|
|
926
|
+
(pack / "pack.toml").write_text(
|
|
927
|
+
f'[pack]\nname = "{name}"\nversion = "0.1.0"\n',
|
|
928
|
+
encoding="utf-8",
|
|
929
|
+
)
|
|
930
|
+
output = tmp_path / "out"
|
|
931
|
+
output.mkdir()
|
|
932
|
+
|
|
933
|
+
_project_seeds(packs_dir, output)
|
|
934
|
+
|
|
935
|
+
self.assertTrue((output / "docs" / "_templates" / "spec.md").exists())
|
|
936
|
+
self.assertTrue((output / "docs" / "_templates" / "rfc.md").exists())
|
|
937
|
+
|
|
938
|
+
def test_collision_with_different_content_raises(self) -> None:
|
|
939
|
+
from agentbundle.build.self_host import _project_seeds
|
|
940
|
+
|
|
941
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
942
|
+
tmp_path = Path(tmp)
|
|
943
|
+
packs_dir = tmp_path / "packs"
|
|
944
|
+
packs_dir.mkdir()
|
|
945
|
+
for name, content in [("core", "v1\n"), ("governance-extras", "v2\n")]:
|
|
946
|
+
pack = packs_dir / name
|
|
947
|
+
(pack / "seeds").mkdir(parents=True)
|
|
948
|
+
(pack / "seeds" / "AGENTS.md").write_text(content, encoding="utf-8")
|
|
949
|
+
(pack / "pack.toml").write_text(
|
|
950
|
+
f'[pack]\nname = "{name}"\nversion = "0.1.0"\n',
|
|
951
|
+
encoding="utf-8",
|
|
952
|
+
)
|
|
953
|
+
output = tmp_path / "out"
|
|
954
|
+
output.mkdir()
|
|
955
|
+
|
|
956
|
+
with self.assertRaises(ValueError) as ctx:
|
|
957
|
+
_project_seeds(packs_dir, output)
|
|
958
|
+
self.assertIn("seed collision", str(ctx.exception))
|
|
959
|
+
self.assertIn("AGENTS.md", str(ctx.exception))
|
|
960
|
+
|
|
961
|
+
def test_reference_md_two_producer_collision_raises(self) -> None:
|
|
962
|
+
"""Living documentation of the stack-pack reference-architecture
|
|
963
|
+
contract: two packs that each ship a filled
|
|
964
|
+
`docs/architecture/reference.md` with differing content collide,
|
|
965
|
+
so no bundler override field is needed — the two-producer case is
|
|
966
|
+
caught by `_project_seeds` and routes through `.upstream` + merge.
|
|
967
|
+
|
|
968
|
+
The *generic* collision mechanism is already proven by
|
|
969
|
+
`test_collision_with_different_content_raises` (staged on
|
|
970
|
+
`AGENTS.md`); the collision branch is path-agnostic. This
|
|
971
|
+
path-named test is a contract-labelled regression guard: if a
|
|
972
|
+
future change ever special-cases `docs/architecture/reference.md`
|
|
973
|
+
in `_project_seeds` (e.g. the pack-override the contract forbids),
|
|
974
|
+
this test — not a generically-named one — goes red.
|
|
975
|
+
"""
|
|
976
|
+
from agentbundle.build.self_host import _project_seeds
|
|
977
|
+
|
|
978
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
979
|
+
tmp_path = Path(tmp)
|
|
980
|
+
packs_dir = tmp_path / "packs"
|
|
981
|
+
packs_dir.mkdir()
|
|
982
|
+
for name, content in [
|
|
983
|
+
("core", "# golden path A\n"),
|
|
984
|
+
("governance-extras", "# golden path B\n"),
|
|
985
|
+
]:
|
|
986
|
+
pack = packs_dir / name
|
|
987
|
+
(pack / "seeds" / "docs" / "architecture").mkdir(parents=True)
|
|
988
|
+
(pack / "seeds" / "docs" / "architecture" / "reference.md").write_text(
|
|
989
|
+
content, encoding="utf-8"
|
|
990
|
+
)
|
|
991
|
+
(pack / "pack.toml").write_text(
|
|
992
|
+
f'[pack]\nname = "{name}"\nversion = "0.1.0"\n',
|
|
993
|
+
encoding="utf-8",
|
|
994
|
+
)
|
|
995
|
+
output = tmp_path / "out"
|
|
996
|
+
output.mkdir()
|
|
997
|
+
|
|
998
|
+
with self.assertRaises(ValueError) as ctx:
|
|
999
|
+
_project_seeds(packs_dir, output)
|
|
1000
|
+
# Message shape captured from a real invocation:
|
|
1001
|
+
# "seed collision at docs/architecture/reference.md: <a> and
|
|
1002
|
+
# <b> differ — rename or consolidate one of them."
|
|
1003
|
+
self.assertIn(
|
|
1004
|
+
"seed collision at docs/architecture/reference.md",
|
|
1005
|
+
str(ctx.exception),
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
def test_no_pre_placed_reference_md_core_seed(self) -> None:
|
|
1009
|
+
"""Precondition that makes the *sole*-producer case collision-free:
|
|
1010
|
+
`core` does NOT ship `docs/architecture/reference.md` as a seed.
|
|
1011
|
+
The arc42 template is a skill asset instantiated on demand, never a
|
|
1012
|
+
pre-placed seed — so a single stack pack shipping `reference.md`
|
|
1013
|
+
has nothing in core to collide against.
|
|
1014
|
+
"""
|
|
1015
|
+
core_seed = (
|
|
1016
|
+
REPO_ROOT
|
|
1017
|
+
/ "packs"
|
|
1018
|
+
/ "core"
|
|
1019
|
+
/ "seeds"
|
|
1020
|
+
/ "docs"
|
|
1021
|
+
/ "architecture"
|
|
1022
|
+
/ "reference.md"
|
|
1023
|
+
)
|
|
1024
|
+
self.assertFalse(
|
|
1025
|
+
core_seed.exists(),
|
|
1026
|
+
f"{core_seed} must not exist — reference.md is a template-on-demand "
|
|
1027
|
+
"skill asset, not a core seed (a core seed would collide with every "
|
|
1028
|
+
"stack pack that ships its own).",
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
def test_underscore_prefixed_files_are_composition_fragments_not_projected(
|
|
1032
|
+
self,
|
|
1033
|
+
) -> None:
|
|
1034
|
+
"""Files like `_agents-footer.md` live in seeds for composition;
|
|
1035
|
+
they aren't standalone projection targets."""
|
|
1036
|
+
from agentbundle.build.self_host import _project_seeds
|
|
1037
|
+
|
|
1038
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1039
|
+
tmp_path = Path(tmp)
|
|
1040
|
+
packs_dir = tmp_path / "packs"
|
|
1041
|
+
packs_dir.mkdir()
|
|
1042
|
+
pack = packs_dir / "core"
|
|
1043
|
+
(pack / "seeds").mkdir(parents=True)
|
|
1044
|
+
(pack / "seeds" / "_agents-footer.md").write_text(
|
|
1045
|
+
"> footer\n", encoding="utf-8"
|
|
1046
|
+
)
|
|
1047
|
+
(pack / "seeds" / "AGENTS.md").write_text(
|
|
1048
|
+
"# AGENTS\n", encoding="utf-8"
|
|
1049
|
+
)
|
|
1050
|
+
(pack / "pack.toml").write_text(
|
|
1051
|
+
'[pack]\nname = "core"\nversion = "0.1.0"\n', encoding="utf-8"
|
|
1052
|
+
)
|
|
1053
|
+
output = tmp_path / "out"
|
|
1054
|
+
output.mkdir()
|
|
1055
|
+
|
|
1056
|
+
_project_seeds(packs_dir, output)
|
|
1057
|
+
|
|
1058
|
+
self.assertTrue((output / "AGENTS.md").exists())
|
|
1059
|
+
self.assertFalse((output / "_agents-footer.md").exists())
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
class MarketplaceAggregationTests(unittest.TestCase):
|
|
1063
|
+
"""Unit tests for `_aggregate_marketplace`."""
|
|
1064
|
+
|
|
1065
|
+
def test_aggregates_all_plugin_jsons(self) -> None:
|
|
1066
|
+
from agentbundle.build.self_host import _aggregate_marketplace
|
|
1067
|
+
|
|
1068
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1069
|
+
tmp_path = Path(tmp)
|
|
1070
|
+
packs_dir = tmp_path / "packs"
|
|
1071
|
+
packs_dir.mkdir()
|
|
1072
|
+
for name in ("core", "governance-extras"):
|
|
1073
|
+
pack = packs_dir / name
|
|
1074
|
+
(pack / ".claude-plugin").mkdir(parents=True)
|
|
1075
|
+
(pack / ".claude-plugin" / "plugin.json").write_text(
|
|
1076
|
+
json.dumps({"name": name, "version": "0.1.0"}),
|
|
1077
|
+
encoding="utf-8",
|
|
1078
|
+
)
|
|
1079
|
+
(pack / "pack.toml").write_text(
|
|
1080
|
+
f'[pack]\nname = "{name}"\nversion = "0.1.0"\n',
|
|
1081
|
+
encoding="utf-8",
|
|
1082
|
+
)
|
|
1083
|
+
output = tmp_path / "out"
|
|
1084
|
+
output.mkdir()
|
|
1085
|
+
|
|
1086
|
+
_aggregate_marketplace(packs_dir, output)
|
|
1087
|
+
|
|
1088
|
+
mp = output / ".claude-plugin" / "marketplace.json"
|
|
1089
|
+
self.assertTrue(mp.exists())
|
|
1090
|
+
payload = json.loads(mp.read_text(encoding="utf-8"))
|
|
1091
|
+
names = {entry["name"] for entry in payload["plugins"]}
|
|
1092
|
+
self.assertEqual(names, {"core", "governance-extras"})
|
|
1093
|
+
self.assertEqual(payload["owner"], {"name": "eugenelim"})
|
|
1094
|
+
|
|
1095
|
+
def test_aggregation_is_deterministic(self) -> None:
|
|
1096
|
+
from agentbundle.build.self_host import _aggregate_marketplace
|
|
1097
|
+
|
|
1098
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1099
|
+
tmp_path = Path(tmp)
|
|
1100
|
+
packs_dir = tmp_path / "packs"
|
|
1101
|
+
packs_dir.mkdir()
|
|
1102
|
+
for name in ("zeta", "alpha"):
|
|
1103
|
+
pack = packs_dir / name
|
|
1104
|
+
(pack / ".claude-plugin").mkdir(parents=True)
|
|
1105
|
+
(pack / ".claude-plugin" / "plugin.json").write_text(
|
|
1106
|
+
json.dumps({"name": name, "version": "0.1.0"}),
|
|
1107
|
+
encoding="utf-8",
|
|
1108
|
+
)
|
|
1109
|
+
(pack / "pack.toml").write_text(
|
|
1110
|
+
f'[pack]\nname = "{name}"\nversion = "0.1.0"\n',
|
|
1111
|
+
encoding="utf-8",
|
|
1112
|
+
)
|
|
1113
|
+
output_a = tmp_path / "out_a"
|
|
1114
|
+
output_a.mkdir()
|
|
1115
|
+
output_b = tmp_path / "out_b"
|
|
1116
|
+
output_b.mkdir()
|
|
1117
|
+
_aggregate_marketplace(packs_dir, output_a)
|
|
1118
|
+
_aggregate_marketplace(packs_dir, output_b)
|
|
1119
|
+
self.assertEqual(
|
|
1120
|
+
(output_a / ".claude-plugin" / "marketplace.json").read_bytes(),
|
|
1121
|
+
(output_b / ".claude-plugin" / "marketplace.json").read_bytes(),
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
class ClaudeSymlinkTests(unittest.TestCase):
|
|
1126
|
+
"""Unit tests for `_recreate_claude_symlink`."""
|
|
1127
|
+
|
|
1128
|
+
def test_creates_symlink_when_missing(self) -> None:
|
|
1129
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1130
|
+
|
|
1131
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1132
|
+
tree = Path(tmp)
|
|
1133
|
+
(tree / "AGENTS.md").write_text("agents\n", encoding="utf-8")
|
|
1134
|
+
_recreate_claude_symlink(tree)
|
|
1135
|
+
link = tree / "CLAUDE.md"
|
|
1136
|
+
self.assertTrue(link.is_symlink())
|
|
1137
|
+
self.assertEqual(os.readlink(link), "AGENTS.md")
|
|
1138
|
+
|
|
1139
|
+
def test_idempotent_on_correct_symlink(self) -> None:
|
|
1140
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1141
|
+
|
|
1142
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1143
|
+
tree = Path(tmp)
|
|
1144
|
+
(tree / "AGENTS.md").write_text("agents\n", encoding="utf-8")
|
|
1145
|
+
(tree / "CLAUDE.md").symlink_to("AGENTS.md")
|
|
1146
|
+
_recreate_claude_symlink(tree) # should not raise
|
|
1147
|
+
self.assertEqual(os.readlink(tree / "CLAUDE.md"), "AGENTS.md")
|
|
1148
|
+
|
|
1149
|
+
def test_replaces_wrong_symlink(self) -> None:
|
|
1150
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1151
|
+
|
|
1152
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1153
|
+
tree = Path(tmp)
|
|
1154
|
+
(tree / "AGENTS.md").write_text("agents\n", encoding="utf-8")
|
|
1155
|
+
(tree / "CLAUDE.md").symlink_to("other.md")
|
|
1156
|
+
_recreate_claude_symlink(tree)
|
|
1157
|
+
self.assertEqual(os.readlink(tree / "CLAUDE.md"), "AGENTS.md")
|
|
1158
|
+
|
|
1159
|
+
def test_creates_dangling_symlink_when_agents_md_missing_on_posix(self) -> None:
|
|
1160
|
+
"""Historic POSIX semantic preserved: when AGENTS.md is absent
|
|
1161
|
+
the symlink branch creates a dangling link rather than raising.
|
|
1162
|
+
Test fixtures throughout this suite rely on it. The copy
|
|
1163
|
+
branch, exercised on Windows, takes the documented skip-with-
|
|
1164
|
+
warning path instead — see `ClaudeSymlinkFallbackTests`."""
|
|
1165
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1166
|
+
|
|
1167
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1168
|
+
tree = Path(tmp)
|
|
1169
|
+
link = _recreate_claude_symlink(tree)
|
|
1170
|
+
self.assertTrue(link.is_symlink())
|
|
1171
|
+
self.assertFalse((tree / "AGENTS.md").exists())
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
class ClaudeSymlinkFallbackTests(unittest.TestCase):
|
|
1175
|
+
"""Windows-portability: copy fallback on Windows / under --no-symlink.
|
|
1176
|
+
|
|
1177
|
+
The host OS is faked via monkeypatching `sys.platform` so these
|
|
1178
|
+
tests run identically on macOS, Linux, and Windows CI."""
|
|
1179
|
+
|
|
1180
|
+
def test_force_copy_skips_with_warning_when_source_missing(self) -> None:
|
|
1181
|
+
"""No AGENTS.md to copy → emit a one-line warning, return
|
|
1182
|
+
without writing CLAUDE.md. Mirror of the POSIX dangling-symlink
|
|
1183
|
+
semantic, adapted to the copy mode."""
|
|
1184
|
+
import io
|
|
1185
|
+
from contextlib import redirect_stderr
|
|
1186
|
+
|
|
1187
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1188
|
+
|
|
1189
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1190
|
+
tree = Path(tmp)
|
|
1191
|
+
buf = io.StringIO()
|
|
1192
|
+
with redirect_stderr(buf):
|
|
1193
|
+
_recreate_claude_symlink(tree, force_copy=True)
|
|
1194
|
+
self.assertFalse((tree / "CLAUDE.md").exists())
|
|
1195
|
+
self.assertIn("missing", buf.getvalue())
|
|
1196
|
+
|
|
1197
|
+
def test_force_copy_writes_regular_file_with_agents_md_contents(self) -> None:
|
|
1198
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1199
|
+
|
|
1200
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1201
|
+
tree = Path(tmp)
|
|
1202
|
+
(tree / "AGENTS.md").write_text("# agents canonical\n", encoding="utf-8")
|
|
1203
|
+
claude = _recreate_claude_symlink(tree, force_copy=True)
|
|
1204
|
+
self.assertFalse(claude.is_symlink())
|
|
1205
|
+
self.assertTrue(claude.is_file())
|
|
1206
|
+
self.assertEqual(
|
|
1207
|
+
claude.read_text(encoding="utf-8"), "# agents canonical\n"
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
def test_force_copy_replaces_existing_symlink(self) -> None:
|
|
1211
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1212
|
+
|
|
1213
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1214
|
+
tree = Path(tmp)
|
|
1215
|
+
(tree / "AGENTS.md").write_text("content\n", encoding="utf-8")
|
|
1216
|
+
(tree / "CLAUDE.md").symlink_to("AGENTS.md")
|
|
1217
|
+
_recreate_claude_symlink(tree, force_copy=True)
|
|
1218
|
+
claude = tree / "CLAUDE.md"
|
|
1219
|
+
self.assertFalse(claude.is_symlink())
|
|
1220
|
+
self.assertEqual(claude.read_text(encoding="utf-8"), "content\n")
|
|
1221
|
+
|
|
1222
|
+
def test_force_copy_idempotent_when_contents_match(self) -> None:
|
|
1223
|
+
"""Idempotency: the on-disk file isn't rewritten when CLAUDE.md
|
|
1224
|
+
already matches AGENTS.md, and the warning only fires on the
|
|
1225
|
+
actual write (one occurrence across two calls). Pin both
|
|
1226
|
+
contracts here so a future refactor cannot silently flip
|
|
1227
|
+
either behaviour."""
|
|
1228
|
+
import io
|
|
1229
|
+
from contextlib import redirect_stderr
|
|
1230
|
+
|
|
1231
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1232
|
+
|
|
1233
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1234
|
+
tree = Path(tmp)
|
|
1235
|
+
(tree / "AGENTS.md").write_text("hello\n", encoding="utf-8")
|
|
1236
|
+
buf = io.StringIO()
|
|
1237
|
+
with redirect_stderr(buf):
|
|
1238
|
+
_recreate_claude_symlink(tree, force_copy=True)
|
|
1239
|
+
mtime_first = (tree / "CLAUDE.md").stat().st_mtime_ns
|
|
1240
|
+
_recreate_claude_symlink(tree, force_copy=True)
|
|
1241
|
+
mtime_second = (tree / "CLAUDE.md").stat().st_mtime_ns
|
|
1242
|
+
self.assertEqual(mtime_first, mtime_second)
|
|
1243
|
+
# Warning fires only on the actual write — the idempotent
|
|
1244
|
+
# short-circuit returns early before emitting it.
|
|
1245
|
+
self.assertEqual(buf.getvalue().count("--no-symlink"), 1)
|
|
1246
|
+
|
|
1247
|
+
def test_windows_platform_takes_copy_path(self) -> None:
|
|
1248
|
+
import io
|
|
1249
|
+
from contextlib import redirect_stderr
|
|
1250
|
+
from unittest.mock import patch
|
|
1251
|
+
|
|
1252
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1253
|
+
|
|
1254
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1255
|
+
tree = Path(tmp)
|
|
1256
|
+
(tree / "AGENTS.md").write_text("agents\n", encoding="utf-8")
|
|
1257
|
+
buf = io.StringIO()
|
|
1258
|
+
with patch("agentbundle.build.self_host.sys.platform", "win32"):
|
|
1259
|
+
with redirect_stderr(buf):
|
|
1260
|
+
_recreate_claude_symlink(tree)
|
|
1261
|
+
claude = tree / "CLAUDE.md"
|
|
1262
|
+
self.assertFalse(claude.is_symlink())
|
|
1263
|
+
self.assertTrue(claude.is_file())
|
|
1264
|
+
self.assertEqual(claude.read_text(encoding="utf-8"), "agents\n")
|
|
1265
|
+
self.assertIn("CLAUDE.md", buf.getvalue())
|
|
1266
|
+
self.assertIn("copy", buf.getvalue().lower())
|
|
1267
|
+
|
|
1268
|
+
def test_default_path_unchanged_on_posix(self) -> None:
|
|
1269
|
+
"""Sanity: with no force_copy and sys.platform unmonkeypatched,
|
|
1270
|
+
the existing symlink behaviour is unchanged."""
|
|
1271
|
+
from agentbundle.build.self_host import _recreate_claude_symlink
|
|
1272
|
+
|
|
1273
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1274
|
+
tree = Path(tmp)
|
|
1275
|
+
(tree / "AGENTS.md").write_text("agents\n", encoding="utf-8")
|
|
1276
|
+
_recreate_claude_symlink(tree)
|
|
1277
|
+
link = tree / "CLAUDE.md"
|
|
1278
|
+
self.assertTrue(link.is_symlink())
|
|
1279
|
+
self.assertEqual(os.readlink(link), "AGENTS.md")
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
class MissingDiscoveryFailFastTests(unittest.TestCase):
|
|
1283
|
+
"""AC14: missing `.adapt-discovery.toml` causes fail-fast with named message."""
|
|
1284
|
+
|
|
1285
|
+
@classmethod
|
|
1286
|
+
def setUpClass(cls) -> None:
|
|
1287
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
1288
|
+
|
|
1289
|
+
def test_missing_discovery_returns_non_zero_with_named_message(self) -> None:
|
|
1290
|
+
import io
|
|
1291
|
+
from contextlib import redirect_stderr
|
|
1292
|
+
|
|
1293
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1294
|
+
tmp_path = Path(tmp)
|
|
1295
|
+
packs_dir = tmp_path / "packs"
|
|
1296
|
+
packs_dir.mkdir()
|
|
1297
|
+
_seed_pack(packs_dir, "core")
|
|
1298
|
+
working_tree = tmp_path / "tree"
|
|
1299
|
+
working_tree.mkdir()
|
|
1300
|
+
_git_init(working_tree)
|
|
1301
|
+
# Deliberately do NOT seed .adapt-discovery.toml.
|
|
1302
|
+
buf = io.StringIO()
|
|
1303
|
+
with redirect_stderr(buf):
|
|
1304
|
+
exit_code = run_self_host(
|
|
1305
|
+
working_tree=working_tree,
|
|
1306
|
+
packs_dir=packs_dir,
|
|
1307
|
+
dry_run=False,
|
|
1308
|
+
force=True,
|
|
1309
|
+
contract=self.contract,
|
|
1310
|
+
)
|
|
1311
|
+
self.assertNotEqual(exit_code, 0)
|
|
1312
|
+
self.assertIn(
|
|
1313
|
+
"missing .adapt-discovery.toml required by --self",
|
|
1314
|
+
buf.getvalue(),
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
class DriftSourceNamingTests(unittest.TestCase):
|
|
1319
|
+
"""AC: drift messages name source path + regeneration command."""
|
|
1320
|
+
|
|
1321
|
+
@classmethod
|
|
1322
|
+
def setUpClass(cls) -> None:
|
|
1323
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
1324
|
+
|
|
1325
|
+
def test_drift_message_includes_source_and_regen_command(self) -> None:
|
|
1326
|
+
import io
|
|
1327
|
+
from contextlib import redirect_stderr
|
|
1328
|
+
|
|
1329
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1330
|
+
tmp_path = Path(tmp)
|
|
1331
|
+
packs_dir = tmp_path / "packs"
|
|
1332
|
+
packs_dir.mkdir()
|
|
1333
|
+
_seed_pack(packs_dir, "core")
|
|
1334
|
+
working_tree = tmp_path / "tree"
|
|
1335
|
+
working_tree.mkdir()
|
|
1336
|
+
_git_init(working_tree)
|
|
1337
|
+
_seed_discovery(working_tree)
|
|
1338
|
+
|
|
1339
|
+
run_self_host(
|
|
1340
|
+
working_tree=working_tree,
|
|
1341
|
+
packs_dir=packs_dir,
|
|
1342
|
+
dry_run=False,
|
|
1343
|
+
force=True,
|
|
1344
|
+
contract=self.contract,
|
|
1345
|
+
)
|
|
1346
|
+
_git_commit_all(working_tree, "seed")
|
|
1347
|
+
|
|
1348
|
+
# Introduce drift on a projected path.
|
|
1349
|
+
target = working_tree / ".claude" / "skills" / "foo" / "SKILL.md"
|
|
1350
|
+
target.write_text("drift!\n", encoding="utf-8")
|
|
1351
|
+
|
|
1352
|
+
buf = io.StringIO()
|
|
1353
|
+
with redirect_stderr(buf):
|
|
1354
|
+
exit_code = run_self_host(
|
|
1355
|
+
working_tree=working_tree,
|
|
1356
|
+
packs_dir=packs_dir,
|
|
1357
|
+
dry_run=True,
|
|
1358
|
+
force=False,
|
|
1359
|
+
contract=self.contract,
|
|
1360
|
+
)
|
|
1361
|
+
self.assertEqual(exit_code, 1)
|
|
1362
|
+
stderr_text = buf.getvalue()
|
|
1363
|
+
self.assertIn("[drift]", stderr_text)
|
|
1364
|
+
self.assertIn(".claude/skills/foo/SKILL.md", stderr_text)
|
|
1365
|
+
# Source path named
|
|
1366
|
+
self.assertIn("packs/core/.apm/skills/foo/SKILL.md", stderr_text)
|
|
1367
|
+
# Regen command named
|
|
1368
|
+
self.assertIn("run: make build-self", stderr_text)
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
class InfoLineUnclassifiedTests(unittest.TestCase):
|
|
1372
|
+
"""AC6: paths not in Projected and not in Excluded surface as `[info]`."""
|
|
1373
|
+
|
|
1374
|
+
@classmethod
|
|
1375
|
+
def setUpClass(cls) -> None:
|
|
1376
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
1377
|
+
|
|
1378
|
+
def test_unclassified_path_surfaces_as_info_without_failing(self) -> None:
|
|
1379
|
+
import io
|
|
1380
|
+
from contextlib import redirect_stderr
|
|
1381
|
+
|
|
1382
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1383
|
+
tmp_path = Path(tmp)
|
|
1384
|
+
packs_dir = tmp_path / "packs"
|
|
1385
|
+
packs_dir.mkdir()
|
|
1386
|
+
_seed_pack(packs_dir, "core")
|
|
1387
|
+
working_tree = tmp_path / "tree"
|
|
1388
|
+
working_tree.mkdir()
|
|
1389
|
+
_git_init(working_tree)
|
|
1390
|
+
_seed_discovery(working_tree)
|
|
1391
|
+
|
|
1392
|
+
run_self_host(
|
|
1393
|
+
working_tree=working_tree,
|
|
1394
|
+
packs_dir=packs_dir,
|
|
1395
|
+
dry_run=False,
|
|
1396
|
+
force=True,
|
|
1397
|
+
contract=self.contract,
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
# Introduce an unclassified path: not under any Excluded pattern,
|
|
1401
|
+
# not in Projected set.
|
|
1402
|
+
(working_tree / "stray-note.md").write_text("note\n", encoding="utf-8")
|
|
1403
|
+
_git_commit_all(working_tree, "seed + stray")
|
|
1404
|
+
|
|
1405
|
+
buf = io.StringIO()
|
|
1406
|
+
with redirect_stderr(buf):
|
|
1407
|
+
exit_code = run_self_host(
|
|
1408
|
+
working_tree=working_tree,
|
|
1409
|
+
packs_dir=packs_dir,
|
|
1410
|
+
dry_run=True,
|
|
1411
|
+
force=False,
|
|
1412
|
+
contract=self.contract,
|
|
1413
|
+
)
|
|
1414
|
+
self.assertEqual(exit_code, 0) # info lines don't fail the build
|
|
1415
|
+
self.assertIn("[info] unclassified: stray-note.md", buf.getvalue())
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
class ForwardFlowIntegrationTests(unittest.TestCase):
|
|
1419
|
+
"""End-to-end forward-flow (plan T7): mutate a pack-side source,
|
|
1420
|
+
re-project, and assert the projection updated AND the gate is clean
|
|
1421
|
+
against the new content."""
|
|
1422
|
+
|
|
1423
|
+
@classmethod
|
|
1424
|
+
def setUpClass(cls) -> None:
|
|
1425
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
1426
|
+
|
|
1427
|
+
def test_forward_flow_pack_edit_re_projects_and_gate_passes(self) -> None:
|
|
1428
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1429
|
+
tmp_path = Path(tmp)
|
|
1430
|
+
packs_dir = tmp_path / "packs"
|
|
1431
|
+
packs_dir.mkdir()
|
|
1432
|
+
pack = _seed_pack(packs_dir, "core")
|
|
1433
|
+
(pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
|
|
1434
|
+
"---\ndescription: foo\n---\n# foo v1\n",
|
|
1435
|
+
encoding="utf-8",
|
|
1436
|
+
)
|
|
1437
|
+
working_tree = tmp_path / "tree"
|
|
1438
|
+
working_tree.mkdir()
|
|
1439
|
+
_git_init(working_tree)
|
|
1440
|
+
_seed_discovery(working_tree)
|
|
1441
|
+
|
|
1442
|
+
# Initial real-write seeds the projection.
|
|
1443
|
+
exit_code = run_self_host(
|
|
1444
|
+
working_tree=working_tree,
|
|
1445
|
+
packs_dir=packs_dir,
|
|
1446
|
+
dry_run=False,
|
|
1447
|
+
force=True,
|
|
1448
|
+
contract=self.contract,
|
|
1449
|
+
)
|
|
1450
|
+
self.assertEqual(exit_code, 0)
|
|
1451
|
+
projected = working_tree / ".claude" / "skills" / "foo" / "SKILL.md"
|
|
1452
|
+
self.assertIn("foo v1", projected.read_text(encoding="utf-8"))
|
|
1453
|
+
_git_commit_all(working_tree, "initial projection")
|
|
1454
|
+
|
|
1455
|
+
# Mutate the pack-side source.
|
|
1456
|
+
(pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
|
|
1457
|
+
"---\ndescription: foo\n---\n# foo v2\n",
|
|
1458
|
+
encoding="utf-8",
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
# Re-projection picks up the new content.
|
|
1462
|
+
exit_code = run_self_host(
|
|
1463
|
+
working_tree=working_tree,
|
|
1464
|
+
packs_dir=packs_dir,
|
|
1465
|
+
dry_run=False,
|
|
1466
|
+
force=True,
|
|
1467
|
+
contract=self.contract,
|
|
1468
|
+
)
|
|
1469
|
+
self.assertEqual(exit_code, 0)
|
|
1470
|
+
self.assertIn("foo v2", projected.read_text(encoding="utf-8"))
|
|
1471
|
+
|
|
1472
|
+
# Gate is now clean against the freshly-projected content.
|
|
1473
|
+
_git_commit_all(working_tree, "re-projection")
|
|
1474
|
+
exit_code = run_self_host(
|
|
1475
|
+
working_tree=working_tree,
|
|
1476
|
+
packs_dir=packs_dir,
|
|
1477
|
+
dry_run=True,
|
|
1478
|
+
force=False,
|
|
1479
|
+
contract=self.contract,
|
|
1480
|
+
)
|
|
1481
|
+
self.assertEqual(exit_code, 0)
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
class DirtyTreeStderrMessageTests(unittest.TestCase):
|
|
1485
|
+
@classmethod
|
|
1486
|
+
def setUpClass(cls) -> None:
|
|
1487
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
1488
|
+
|
|
1489
|
+
def test_refusal_message_names_dirty_tree(self) -> None:
|
|
1490
|
+
import io
|
|
1491
|
+
from contextlib import redirect_stderr
|
|
1492
|
+
|
|
1493
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1494
|
+
tmp_path = Path(tmp)
|
|
1495
|
+
packs_dir = tmp_path / "packs"
|
|
1496
|
+
packs_dir.mkdir()
|
|
1497
|
+
_seed_pack(packs_dir, "core")
|
|
1498
|
+
working_tree = tmp_path / "tree"
|
|
1499
|
+
working_tree.mkdir()
|
|
1500
|
+
_git_init(working_tree)
|
|
1501
|
+
_seed_discovery(working_tree)
|
|
1502
|
+
(working_tree / "tracked.txt").write_text("a\n", encoding="utf-8")
|
|
1503
|
+
_git_commit_all(working_tree, "seed")
|
|
1504
|
+
(working_tree / "tracked.txt").write_text("b\n", encoding="utf-8")
|
|
1505
|
+
|
|
1506
|
+
buf = io.StringIO()
|
|
1507
|
+
with redirect_stderr(buf):
|
|
1508
|
+
exit_code = run_self_host(
|
|
1509
|
+
working_tree=working_tree,
|
|
1510
|
+
packs_dir=packs_dir,
|
|
1511
|
+
dry_run=False,
|
|
1512
|
+
force=False,
|
|
1513
|
+
contract=self.contract,
|
|
1514
|
+
)
|
|
1515
|
+
self.assertNotEqual(exit_code, 0)
|
|
1516
|
+
self.assertIn("dirty", buf.getvalue())
|
|
1517
|
+
self.assertIn("refusing", buf.getvalue())
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
class PlainBuildCopiesMarkerThroughTests(unittest.TestCase):
|
|
1521
|
+
"""Spec § Boundaries: only --self resolves markers. Plain `make build`
|
|
1522
|
+
must copy `<adapt:NAME>` markers through unchanged."""
|
|
1523
|
+
|
|
1524
|
+
@classmethod
|
|
1525
|
+
def setUpClass(cls) -> None:
|
|
1526
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
1527
|
+
|
|
1528
|
+
def test_plain_build_preserves_marker(self) -> None:
|
|
1529
|
+
from agentbundle.build.main import discover_packs, load_recipe, run_recipe
|
|
1530
|
+
|
|
1531
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1532
|
+
tmp_path = Path(tmp)
|
|
1533
|
+
packs_dir = tmp_path / "packs"
|
|
1534
|
+
packs_dir.mkdir()
|
|
1535
|
+
pack = _seed_pack(packs_dir, "core")
|
|
1536
|
+
(pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
|
|
1537
|
+
"---\ndescription: foo\n---\nHello <adapt:project-name>.\n",
|
|
1538
|
+
encoding="utf-8",
|
|
1539
|
+
)
|
|
1540
|
+
(pack / ".claude-plugin").mkdir()
|
|
1541
|
+
(pack / ".claude-plugin" / "plugin.json").write_text(
|
|
1542
|
+
json.dumps({"name": "core", "version": "0.1.0", "description": "x"}),
|
|
1543
|
+
encoding="utf-8",
|
|
1544
|
+
)
|
|
1545
|
+
output_dir = tmp_path / "dist"
|
|
1546
|
+
run_recipe(
|
|
1547
|
+
load_recipe("per-pack-claude-plugin"),
|
|
1548
|
+
discover_packs(packs_dir),
|
|
1549
|
+
output_dir,
|
|
1550
|
+
self.contract,
|
|
1551
|
+
)
|
|
1552
|
+
text = (
|
|
1553
|
+
output_dir
|
|
1554
|
+
/ "claude-plugins"
|
|
1555
|
+
/ "core"
|
|
1556
|
+
/ ".claude"
|
|
1557
|
+
/ "skills"
|
|
1558
|
+
/ "foo"
|
|
1559
|
+
/ "SKILL.md"
|
|
1560
|
+
).read_text(encoding="utf-8")
|
|
1561
|
+
self.assertIn("<adapt:project-name>", text)
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
class CrlfNormalisationTests(unittest.TestCase):
|
|
1565
|
+
"""Phase-2 comparison rule (a): text-like files compare equal after
|
|
1566
|
+
CRLF→LF normalisation. Pins the spec's CRLF + `core.autocrlf` case."""
|
|
1567
|
+
|
|
1568
|
+
def test_crlf_on_disk_lf_in_shadow_is_not_drift(self) -> None:
|
|
1569
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1570
|
+
shadow = Path(tmp) / "shadow"
|
|
1571
|
+
tree = Path(tmp) / "tree"
|
|
1572
|
+
shadow.mkdir()
|
|
1573
|
+
tree.mkdir()
|
|
1574
|
+
(shadow / "doc.md").write_bytes(b"hello\nworld\n")
|
|
1575
|
+
(tree / "doc.md").write_bytes(b"hello\r\nworld\r\n")
|
|
1576
|
+
|
|
1577
|
+
self.assertEqual(diff_against_working_tree(shadow, tree), [])
|
|
1578
|
+
|
|
1579
|
+
def test_trailing_space_after_lf_normalisation_drifts(self) -> None:
|
|
1580
|
+
"""LF norm doesn't whitewash genuine content differences."""
|
|
1581
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1582
|
+
shadow = Path(tmp) / "shadow"
|
|
1583
|
+
tree = Path(tmp) / "tree"
|
|
1584
|
+
shadow.mkdir()
|
|
1585
|
+
tree.mkdir()
|
|
1586
|
+
(shadow / "doc.md").write_bytes(b"hello\nworld\n")
|
|
1587
|
+
(tree / "doc.md").write_bytes(b"hello \nworld\n") # extra space
|
|
1588
|
+
|
|
1589
|
+
drifts = diff_against_working_tree(shadow, tree)
|
|
1590
|
+
self.assertEqual(len(drifts), 1)
|
|
1591
|
+
self.assertIn("doc.md", drifts[0])
|
|
1592
|
+
self.assertIn("content differs", drifts[0])
|
|
1593
|
+
|
|
1594
|
+
def test_binary_files_not_normalised(self) -> None:
|
|
1595
|
+
"""A non-UTF-8 binary that happens to contain 0x0D 0x0A must not
|
|
1596
|
+
be normalised — the bytes carry value beyond line termination."""
|
|
1597
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1598
|
+
shadow = Path(tmp) / "shadow"
|
|
1599
|
+
tree = Path(tmp) / "tree"
|
|
1600
|
+
shadow.mkdir()
|
|
1601
|
+
tree.mkdir()
|
|
1602
|
+
# Two binary blobs that would be equal under LF normalisation
|
|
1603
|
+
# but differ byte-for-byte. The leading 0xFF makes them
|
|
1604
|
+
# un-decodable as UTF-8.
|
|
1605
|
+
(shadow / "icon.bin").write_bytes(b"\xff\x00\r\n\x01")
|
|
1606
|
+
(tree / "icon.bin").write_bytes(b"\xff\x00\n\x01")
|
|
1607
|
+
|
|
1608
|
+
drifts = diff_against_working_tree(shadow, tree)
|
|
1609
|
+
self.assertEqual(len(drifts), 1)
|
|
1610
|
+
self.assertIn("icon.bin", drifts[0])
|
|
1611
|
+
self.assertIn("content differs", drifts[0])
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
class FileModeBitsTests(unittest.TestCase):
|
|
1615
|
+
"""Phase-2 comparison rule (b): mode bits drift for regular files."""
|
|
1616
|
+
|
|
1617
|
+
def test_mode_bits_drift_for_regular_files(self) -> None:
|
|
1618
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1619
|
+
shadow = Path(tmp) / "shadow"
|
|
1620
|
+
tree = Path(tmp) / "tree"
|
|
1621
|
+
shadow.mkdir()
|
|
1622
|
+
tree.mkdir()
|
|
1623
|
+
(shadow / "hook.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
1624
|
+
(tree / "hook.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
1625
|
+
os.chmod(shadow / "hook.sh", 0o755)
|
|
1626
|
+
os.chmod(tree / "hook.sh", 0o644)
|
|
1627
|
+
|
|
1628
|
+
drifts = diff_against_working_tree(shadow, tree)
|
|
1629
|
+
self.assertEqual(len(drifts), 1)
|
|
1630
|
+
self.assertIn("hook.sh", drifts[0])
|
|
1631
|
+
self.assertIn("mode 0o644 vs 0o755", drifts[0])
|
|
1632
|
+
|
|
1633
|
+
def test_matching_mode_no_drift(self) -> None:
|
|
1634
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1635
|
+
shadow = Path(tmp) / "shadow"
|
|
1636
|
+
tree = Path(tmp) / "tree"
|
|
1637
|
+
shadow.mkdir()
|
|
1638
|
+
tree.mkdir()
|
|
1639
|
+
(shadow / "hook.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
1640
|
+
(tree / "hook.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
1641
|
+
os.chmod(shadow / "hook.sh", 0o755)
|
|
1642
|
+
os.chmod(tree / "hook.sh", 0o755)
|
|
1643
|
+
|
|
1644
|
+
self.assertEqual(diff_against_working_tree(shadow, tree), [])
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
class SymlinkTargetTests(unittest.TestCase):
|
|
1648
|
+
"""Phase-2 comparison rule (c): symlink targets compared via lstat,
|
|
1649
|
+
never followed. The repo-root `CLAUDE.md` alias is exempted from the
|
|
1650
|
+
strict target-equality rule by AC15b (see `ClaudeMdEquivalenceTests`),
|
|
1651
|
+
so these tests deliberately use non-CLAUDE.md filenames where they
|
|
1652
|
+
need to exercise the Phase-2 path without short-circuiting through
|
|
1653
|
+
the equivalence helper."""
|
|
1654
|
+
|
|
1655
|
+
def test_symlink_target_mismatch_drifts(self) -> None:
|
|
1656
|
+
"""CLAUDE.md is fine here: the disk-side target is `README.md`,
|
|
1657
|
+
not `AGENTS.md`, so the equivalence helper returns False (clause
|
|
1658
|
+
1 fails) and the comparison falls through to the strict
|
|
1659
|
+
target-equality path AC15b leaves unchanged."""
|
|
1660
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1661
|
+
shadow = Path(tmp) / "shadow"
|
|
1662
|
+
tree = Path(tmp) / "tree"
|
|
1663
|
+
shadow.mkdir()
|
|
1664
|
+
tree.mkdir()
|
|
1665
|
+
os.symlink("AGENTS.md", shadow / "CLAUDE.md")
|
|
1666
|
+
os.symlink("README.md", tree / "CLAUDE.md")
|
|
1667
|
+
|
|
1668
|
+
drifts = diff_against_working_tree(shadow, tree)
|
|
1669
|
+
self.assertEqual(len(drifts), 1)
|
|
1670
|
+
self.assertIn("CLAUDE.md", drifts[0])
|
|
1671
|
+
self.assertIn("symlink target differs", drifts[0])
|
|
1672
|
+
self.assertIn("AGENTS.md", drifts[0])
|
|
1673
|
+
self.assertIn("README.md", drifts[0])
|
|
1674
|
+
|
|
1675
|
+
def test_matching_symlinks_no_drift(self) -> None:
|
|
1676
|
+
"""Non-CLAUDE.md filename — exercises the Phase-2 matching-target
|
|
1677
|
+
path proper. Using `CLAUDE.md` here would pass for the wrong
|
|
1678
|
+
reason (the AC15b short-circuit would fire before the
|
|
1679
|
+
target-equality check), masking a future regression in the
|
|
1680
|
+
strict path. AGENTS.md is created on both sides so the symlink
|
|
1681
|
+
target resolves and the rglob iteration over AGENTS.md doesn't
|
|
1682
|
+
drift; the assertion this test owns is about `alias.md`."""
|
|
1683
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1684
|
+
shadow = Path(tmp) / "shadow"
|
|
1685
|
+
tree = Path(tmp) / "tree"
|
|
1686
|
+
shadow.mkdir()
|
|
1687
|
+
tree.mkdir()
|
|
1688
|
+
(shadow / "AGENTS.md").write_text("body", encoding="utf-8")
|
|
1689
|
+
(tree / "AGENTS.md").write_text("body", encoding="utf-8")
|
|
1690
|
+
os.symlink("AGENTS.md", shadow / "alias.md")
|
|
1691
|
+
os.symlink("AGENTS.md", tree / "alias.md")
|
|
1692
|
+
|
|
1693
|
+
self.assertEqual(diff_against_working_tree(shadow, tree), [])
|
|
1694
|
+
|
|
1695
|
+
def test_symlink_in_shadow_regular_on_disk_drifts(self) -> None:
|
|
1696
|
+
"""The general type-mismatch rule fires for any projected file
|
|
1697
|
+
whose shadow shape disagrees with its on-disk shape. The
|
|
1698
|
+
repo-root CLAUDE.md alias is exempted by AC15b
|
|
1699
|
+
(`ClaudeMdEquivalenceTests`); every other file keeps the
|
|
1700
|
+
strict rule, so this test uses an arbitrary non-CLAUDE.md
|
|
1701
|
+
filename to exercise it."""
|
|
1702
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1703
|
+
shadow = Path(tmp) / "shadow"
|
|
1704
|
+
tree = Path(tmp) / "tree"
|
|
1705
|
+
shadow.mkdir()
|
|
1706
|
+
tree.mkdir()
|
|
1707
|
+
# Create a target so shadow's symlink "looks" valid in isolation.
|
|
1708
|
+
(shadow / "target.md").write_text("body", encoding="utf-8")
|
|
1709
|
+
os.symlink("target.md", shadow / "alias.md")
|
|
1710
|
+
# On-disk: a regular file with identical content.
|
|
1711
|
+
(tree / "target.md").write_text("body", encoding="utf-8")
|
|
1712
|
+
(tree / "alias.md").write_text("body", encoding="utf-8")
|
|
1713
|
+
|
|
1714
|
+
drifts = diff_against_working_tree(shadow, tree)
|
|
1715
|
+
type_mismatch = [d for d in drifts if "alias.md" in d and "expected symlink" in d]
|
|
1716
|
+
self.assertEqual(len(type_mismatch), 1)
|
|
1717
|
+
|
|
1718
|
+
def test_symlink_target_never_followed(self) -> None:
|
|
1719
|
+
"""A dangling symlink does not crash the gate — the target is
|
|
1720
|
+
compared as a string, no read-through happens."""
|
|
1721
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1722
|
+
shadow = Path(tmp) / "shadow"
|
|
1723
|
+
tree = Path(tmp) / "tree"
|
|
1724
|
+
shadow.mkdir()
|
|
1725
|
+
tree.mkdir()
|
|
1726
|
+
os.symlink("/nonexistent/target", shadow / "ptr")
|
|
1727
|
+
os.symlink("/nonexistent/target", tree / "ptr")
|
|
1728
|
+
|
|
1729
|
+
# Equal targets → no drift, even though neither target exists.
|
|
1730
|
+
self.assertEqual(diff_against_working_tree(shadow, tree), [])
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
class StrengthenedDiffRegressionIntegrationTests(unittest.TestCase):
|
|
1734
|
+
"""Integration: one fixture exercising all three Phase-2 rules.
|
|
1735
|
+
|
|
1736
|
+
Each rule is paired with the regression it was added to catch:
|
|
1737
|
+
CRLF accidentally drifting against LF source; an executable bit
|
|
1738
|
+
silently dropped during projection; a CLAUDE.md → AGENTS.md
|
|
1739
|
+
symlink replaced by a regular file or pointed at the wrong target.
|
|
1740
|
+
"""
|
|
1741
|
+
|
|
1742
|
+
@classmethod
|
|
1743
|
+
def setUpClass(cls) -> None:
|
|
1744
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
1745
|
+
|
|
1746
|
+
def test_each_rule_catches_its_regression(self) -> None:
|
|
1747
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1748
|
+
shadow = Path(tmp) / "shadow"
|
|
1749
|
+
tree = Path(tmp) / "tree"
|
|
1750
|
+
shadow.mkdir()
|
|
1751
|
+
tree.mkdir()
|
|
1752
|
+
|
|
1753
|
+
# Rule (a) regression: same content, LF in shadow, CRLF on
|
|
1754
|
+
# disk. The pre-Phase-2 gate would have drifted; the
|
|
1755
|
+
# strengthened gate must NOT.
|
|
1756
|
+
(shadow / "doc.md").write_bytes(b"hello\nworld\n")
|
|
1757
|
+
(tree / "doc.md").write_bytes(b"hello\r\nworld\r\n")
|
|
1758
|
+
|
|
1759
|
+
# Rule (b) regression: an executable hook script whose
|
|
1760
|
+
# +x bit gets dropped on disk.
|
|
1761
|
+
(shadow / "hook.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
1762
|
+
(tree / "hook.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
1763
|
+
os.chmod(shadow / "hook.sh", 0o755)
|
|
1764
|
+
os.chmod(tree / "hook.sh", 0o644)
|
|
1765
|
+
|
|
1766
|
+
# Rule (c) regression: CLAUDE.md → AGENTS.md projected as a
|
|
1767
|
+
# symlink, but on disk it points at the wrong target. The
|
|
1768
|
+
# gate must not follow the symlinks — read_bytes would
|
|
1769
|
+
# accidentally compare AGENTS.md vs README.md content and
|
|
1770
|
+
# might have hidden the regression.
|
|
1771
|
+
(shadow / "AGENTS.md").write_text("agents-body", encoding="utf-8")
|
|
1772
|
+
(shadow / "README.md").write_text("readme-body", encoding="utf-8")
|
|
1773
|
+
(tree / "AGENTS.md").write_text("agents-body", encoding="utf-8")
|
|
1774
|
+
(tree / "README.md").write_text("readme-body", encoding="utf-8")
|
|
1775
|
+
os.symlink("AGENTS.md", shadow / "CLAUDE.md")
|
|
1776
|
+
os.symlink("README.md", tree / "CLAUDE.md")
|
|
1777
|
+
|
|
1778
|
+
drifts = diff_against_working_tree(shadow, tree)
|
|
1779
|
+
|
|
1780
|
+
# Rule (a): no drift on the CRLF-vs-LF file.
|
|
1781
|
+
self.assertFalse(
|
|
1782
|
+
any("doc.md" in d for d in drifts),
|
|
1783
|
+
f"doc.md drifted despite CRLF→LF normalisation: {drifts}",
|
|
1784
|
+
)
|
|
1785
|
+
# Rule (b): mode drift surfaced.
|
|
1786
|
+
mode_drifts = [d for d in drifts if "hook.sh" in d]
|
|
1787
|
+
self.assertEqual(len(mode_drifts), 1)
|
|
1788
|
+
self.assertIn("mode 0o644 vs 0o755", mode_drifts[0])
|
|
1789
|
+
# Rule (c): symlink-target drift surfaced; the gate did
|
|
1790
|
+
# NOT follow through to compare AGENTS.md content.
|
|
1791
|
+
symlink_drifts = [d for d in drifts if "CLAUDE.md" in d]
|
|
1792
|
+
self.assertEqual(len(symlink_drifts), 1)
|
|
1793
|
+
self.assertIn("symlink target differs", symlink_drifts[0])
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
class ClaudeMdEquivalenceTests(unittest.TestCase):
|
|
1797
|
+
"""The repo-root CLAUDE.md alias has three on-disk shapes that are
|
|
1798
|
+
presentational and must not count as drift: a symlink to AGENTS.md,
|
|
1799
|
+
a regular-file copy of AGENTS.md content, and a regular file whose
|
|
1800
|
+
content is the literal string "AGENTS.md" (Windows-materialised
|
|
1801
|
+
symlink). See spec AC15b."""
|
|
1802
|
+
|
|
1803
|
+
def _shadow_with_symlink_claude(self, tree: Path) -> Path:
|
|
1804
|
+
"""Build a tiny shadow tree where the shadow's CLAUDE.md is a
|
|
1805
|
+
symlink to AGENTS.md (the POSIX shadow shape)."""
|
|
1806
|
+
shadow = tree / "shadow"
|
|
1807
|
+
shadow.mkdir()
|
|
1808
|
+
(shadow / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1809
|
+
(shadow / "CLAUDE.md").symlink_to("AGENTS.md")
|
|
1810
|
+
return shadow
|
|
1811
|
+
|
|
1812
|
+
def _shadow_with_copy_claude(self, tree: Path) -> Path:
|
|
1813
|
+
"""Build a tiny shadow tree where the shadow's CLAUDE.md is a
|
|
1814
|
+
regular-file copy of AGENTS.md (the Windows shadow shape)."""
|
|
1815
|
+
shadow = tree / "shadow"
|
|
1816
|
+
shadow.mkdir()
|
|
1817
|
+
(shadow / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1818
|
+
(shadow / "CLAUDE.md").write_text("body\n", encoding="utf-8")
|
|
1819
|
+
return shadow
|
|
1820
|
+
|
|
1821
|
+
def test_symlink_shadow_against_symlink_disk_no_drift(self) -> None:
|
|
1822
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1823
|
+
tree = Path(tmp)
|
|
1824
|
+
shadow = self._shadow_with_symlink_claude(tree)
|
|
1825
|
+
disk = tree / "disk"
|
|
1826
|
+
disk.mkdir()
|
|
1827
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1828
|
+
(disk / "CLAUDE.md").symlink_to("AGENTS.md")
|
|
1829
|
+
self.assertEqual(diff_against_working_tree(shadow, disk), [])
|
|
1830
|
+
|
|
1831
|
+
def test_symlink_shadow_against_copy_disk_no_drift(self) -> None:
|
|
1832
|
+
"""Windows-side regenerated copy on disk; macOS-side symlink in
|
|
1833
|
+
shadow. Equivalence rule applies — no drift."""
|
|
1834
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1835
|
+
tree = Path(tmp)
|
|
1836
|
+
shadow = self._shadow_with_symlink_claude(tree)
|
|
1837
|
+
disk = tree / "disk"
|
|
1838
|
+
disk.mkdir()
|
|
1839
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1840
|
+
(disk / "CLAUDE.md").write_text("body\n", encoding="utf-8")
|
|
1841
|
+
self.assertEqual(diff_against_working_tree(shadow, disk), [])
|
|
1842
|
+
|
|
1843
|
+
def test_symlink_shadow_against_materialised_disk_no_drift(self) -> None:
|
|
1844
|
+
"""Windows checkout without symlink support — `CLAUDE.md` is a
|
|
1845
|
+
regular file containing the literal string `AGENTS.md`."""
|
|
1846
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1847
|
+
tree = Path(tmp)
|
|
1848
|
+
shadow = self._shadow_with_symlink_claude(tree)
|
|
1849
|
+
disk = tree / "disk"
|
|
1850
|
+
disk.mkdir()
|
|
1851
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1852
|
+
(disk / "CLAUDE.md").write_text("AGENTS.md", encoding="utf-8")
|
|
1853
|
+
self.assertEqual(diff_against_working_tree(shadow, disk), [])
|
|
1854
|
+
|
|
1855
|
+
def test_copy_shadow_against_symlink_disk_no_drift(self) -> None:
|
|
1856
|
+
"""Windows runner produces a copy in shadow; disk has the
|
|
1857
|
+
symlink that Git for Windows materialised. Inverse of the case
|
|
1858
|
+
the Windows CI job hit on PR #77."""
|
|
1859
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1860
|
+
tree = Path(tmp)
|
|
1861
|
+
shadow = self._shadow_with_copy_claude(tree)
|
|
1862
|
+
disk = tree / "disk"
|
|
1863
|
+
disk.mkdir()
|
|
1864
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1865
|
+
(disk / "CLAUDE.md").symlink_to("AGENTS.md")
|
|
1866
|
+
self.assertEqual(diff_against_working_tree(shadow, disk), [])
|
|
1867
|
+
|
|
1868
|
+
def test_copy_shadow_against_copy_disk_no_drift(self) -> None:
|
|
1869
|
+
"""The all-copy path: Windows runner generates a copy shadow,
|
|
1870
|
+
disk has a real content-copy too. Production path on a Windows
|
|
1871
|
+
host with `core.symlinks=false` and an adopter who has already
|
|
1872
|
+
run `make build-self` once locally."""
|
|
1873
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1874
|
+
tree = Path(tmp)
|
|
1875
|
+
shadow = self._shadow_with_copy_claude(tree)
|
|
1876
|
+
disk = tree / "disk"
|
|
1877
|
+
disk.mkdir()
|
|
1878
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1879
|
+
(disk / "CLAUDE.md").write_text("body\n", encoding="utf-8")
|
|
1880
|
+
self.assertEqual(diff_against_working_tree(shadow, disk), [])
|
|
1881
|
+
|
|
1882
|
+
def test_copy_shadow_against_materialised_disk_no_drift(self) -> None:
|
|
1883
|
+
"""Copy in shadow, Git-for-Windows-materialised stub on disk
|
|
1884
|
+
(regular file whose content is the literal `AGENTS.md`). The
|
|
1885
|
+
out-of-the-box Windows checkout shape on a host where the
|
|
1886
|
+
adopter has not yet regenerated."""
|
|
1887
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1888
|
+
tree = Path(tmp)
|
|
1889
|
+
shadow = self._shadow_with_copy_claude(tree)
|
|
1890
|
+
disk = tree / "disk"
|
|
1891
|
+
disk.mkdir()
|
|
1892
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1893
|
+
(disk / "CLAUDE.md").write_text("AGENTS.md", encoding="utf-8")
|
|
1894
|
+
self.assertEqual(diff_against_working_tree(shadow, disk), [])
|
|
1895
|
+
|
|
1896
|
+
def test_clause_b_routes_through_lf_normalisation(self) -> None:
|
|
1897
|
+
"""Clause (b) regression — disk-side CLAUDE.md with CRLF line
|
|
1898
|
+
endings against an LF AGENTS.md must not drift. Pins the
|
|
1899
|
+
`_normalise_lf` call on both sides; a future refactor that
|
|
1900
|
+
drops normalisation would fail this test rather than slipping
|
|
1901
|
+
through silently on a Windows runner where `core.autocrlf=true`
|
|
1902
|
+
is the default. Adjacent to `CrlfNormalisationTests`, which
|
|
1903
|
+
covers normalisation as a Phase-2 rule in its own right; this
|
|
1904
|
+
test pins that the equivalence helper routes clause (b) through
|
|
1905
|
+
the same normaliser rather than re-implementing byte equality."""
|
|
1906
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1907
|
+
tree = Path(tmp)
|
|
1908
|
+
shadow = self._shadow_with_symlink_claude(tree)
|
|
1909
|
+
# Overwrite AGENTS.md with multi-line LF content so the
|
|
1910
|
+
# CRLF difference on disk is non-trivial (a one-line file
|
|
1911
|
+
# ending in \n vs \r\n is indistinguishable after strip).
|
|
1912
|
+
(shadow / "AGENTS.md").write_text(
|
|
1913
|
+
"line one\nline two\n", encoding="utf-8"
|
|
1914
|
+
)
|
|
1915
|
+
disk = tree / "disk"
|
|
1916
|
+
disk.mkdir()
|
|
1917
|
+
(disk / "AGENTS.md").write_text(
|
|
1918
|
+
"line one\nline two\n", encoding="utf-8"
|
|
1919
|
+
)
|
|
1920
|
+
(disk / "CLAUDE.md").write_bytes(b"line one\r\nline two\r\n")
|
|
1921
|
+
self.assertEqual(diff_against_working_tree(shadow, disk), [])
|
|
1922
|
+
|
|
1923
|
+
def test_materialised_disk_with_crlf_newline_equivalent(self) -> None:
|
|
1924
|
+
"""Clause (c) regression — Git for Windows under
|
|
1925
|
+
`core.autocrlf=true` writes the materialised-symlink stub as
|
|
1926
|
+
`AGENTS.md\\r\\n`. The helper accepts it via the `strip()`
|
|
1927
|
+
path, matching `lint-agents-md.py` check #2's tolerance."""
|
|
1928
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1929
|
+
tree = Path(tmp)
|
|
1930
|
+
shadow = self._shadow_with_symlink_claude(tree)
|
|
1931
|
+
disk = tree / "disk"
|
|
1932
|
+
disk.mkdir()
|
|
1933
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1934
|
+
(disk / "CLAUDE.md").write_bytes(b"AGENTS.md\r\n")
|
|
1935
|
+
self.assertEqual(diff_against_working_tree(shadow, disk), [])
|
|
1936
|
+
|
|
1937
|
+
def test_clause_c_rejects_agents_md_with_extra_text(self) -> None:
|
|
1938
|
+
"""Tamper-boundary pin — clause (c)'s `strip() == "AGENTS.md"`
|
|
1939
|
+
must not loosen to `startswith` or `in`. A `CLAUDE.md` that
|
|
1940
|
+
opens with `AGENTS.md` but carries extra content below is the
|
|
1941
|
+
realistic accidental-tamper shape (operator opens the file,
|
|
1942
|
+
sees the existing target, types under it). Drifts."""
|
|
1943
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1944
|
+
tree = Path(tmp)
|
|
1945
|
+
shadow = self._shadow_with_symlink_claude(tree)
|
|
1946
|
+
disk = tree / "disk"
|
|
1947
|
+
disk.mkdir()
|
|
1948
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1949
|
+
(disk / "CLAUDE.md").write_text(
|
|
1950
|
+
"AGENTS.md\nmore words\n", encoding="utf-8"
|
|
1951
|
+
)
|
|
1952
|
+
drifts = diff_against_working_tree(shadow, disk)
|
|
1953
|
+
claude_drifts = [d for d in drifts if "CLAUDE.md" in d]
|
|
1954
|
+
self.assertEqual(len(claude_drifts), 1, drifts)
|
|
1955
|
+
|
|
1956
|
+
def test_drift_message_names_the_three_accepted_shapes(self) -> None:
|
|
1957
|
+
"""Diagnosability — when the equivalence helper rejects, the
|
|
1958
|
+
drift entry includes an operator-facing hint naming the three
|
|
1959
|
+
accepted shapes (mirroring `lint-agents-md.py` check #2's
|
|
1960
|
+
narration). Without the hint, an operator who hand-edited
|
|
1961
|
+
CLAUDE.md sees only `content differs` and has to read the spec
|
|
1962
|
+
to figure out the fix."""
|
|
1963
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1964
|
+
tree = Path(tmp)
|
|
1965
|
+
shadow = self._shadow_with_symlink_claude(tree)
|
|
1966
|
+
disk = tree / "disk"
|
|
1967
|
+
disk.mkdir()
|
|
1968
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1969
|
+
(disk / "CLAUDE.md").write_text("tampered\n", encoding="utf-8")
|
|
1970
|
+
drifts = diff_against_working_tree(shadow, disk)
|
|
1971
|
+
claude_drifts = [d for d in drifts if "CLAUDE.md" in d]
|
|
1972
|
+
self.assertEqual(len(claude_drifts), 1, drifts)
|
|
1973
|
+
self.assertIn("symlink", claude_drifts[0])
|
|
1974
|
+
self.assertIn("content-copy", claude_drifts[0])
|
|
1975
|
+
self.assertIn("one-line file", claude_drifts[0])
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def test_tampered_claude_md_still_drifts(self) -> None:
|
|
1979
|
+
"""The equivalence rule is narrow — a regular file whose
|
|
1980
|
+
content is neither AGENTS.md nor the literal string
|
|
1981
|
+
"AGENTS.md" still drifts. Tampering coverage is preserved."""
|
|
1982
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1983
|
+
tree = Path(tmp)
|
|
1984
|
+
shadow = self._shadow_with_symlink_claude(tree)
|
|
1985
|
+
disk = tree / "disk"
|
|
1986
|
+
disk.mkdir()
|
|
1987
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
1988
|
+
(disk / "CLAUDE.md").write_text("evil\n", encoding="utf-8")
|
|
1989
|
+
drifts = diff_against_working_tree(shadow, disk)
|
|
1990
|
+
claude_drifts = [d for d in drifts if "CLAUDE.md" in d]
|
|
1991
|
+
self.assertEqual(len(claude_drifts), 1, drifts)
|
|
1992
|
+
|
|
1993
|
+
def test_missing_claude_md_still_drifts(self) -> None:
|
|
1994
|
+
"""Equivalence does not paper over a missing CLAUDE.md."""
|
|
1995
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1996
|
+
tree = Path(tmp)
|
|
1997
|
+
shadow = self._shadow_with_symlink_claude(tree)
|
|
1998
|
+
disk = tree / "disk"
|
|
1999
|
+
disk.mkdir()
|
|
2000
|
+
(disk / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
2001
|
+
drifts = diff_against_working_tree(shadow, disk)
|
|
2002
|
+
claude_drifts = [d for d in drifts if "CLAUDE.md" in d]
|
|
2003
|
+
self.assertEqual(len(claude_drifts), 1, drifts)
|
|
2004
|
+
self.assertIn("missing on disk", claude_drifts[0])
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
class RecreateClaudeBridgeInvariantTests(unittest.TestCase):
|
|
2008
|
+
"""Bridge invariant — every shape `_recreate_claude_symlink`
|
|
2009
|
+
produces must be accepted by `_is_equivalent_claude_md_shape`.
|
|
2010
|
+
The drift detector's short-circuit trusts the shadow side by
|
|
2011
|
+
construction; this test pins that trust as a contract so a future
|
|
2012
|
+
refactor of either function fails noisily rather than silently
|
|
2013
|
+
weakening the drift gate. Called out as the defence-in-depth the
|
|
2014
|
+
helper docstring promises (`self_host.py:_is_equivalent_claude_md_shape`,
|
|
2015
|
+
"The shadow side is trusted by construction ...")."""
|
|
2016
|
+
|
|
2017
|
+
def _seed(self, tmp: Path, force_copy: bool) -> Path:
|
|
2018
|
+
(tmp / "AGENTS.md").write_text("body\n", encoding="utf-8")
|
|
2019
|
+
_recreate_claude_symlink(tmp, force_copy=force_copy)
|
|
2020
|
+
return tmp
|
|
2021
|
+
|
|
2022
|
+
def test_posix_shadow_passes_equivalence(self) -> None:
|
|
2023
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2024
|
+
root = self._seed(Path(tmp), force_copy=False)
|
|
2025
|
+
self.assertTrue(
|
|
2026
|
+
_is_equivalent_claude_md_shape(
|
|
2027
|
+
root / "CLAUDE.md", root / "AGENTS.md"
|
|
2028
|
+
)
|
|
2029
|
+
)
|
|
2030
|
+
|
|
2031
|
+
def test_force_copy_shadow_passes_equivalence(self) -> None:
|
|
2032
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2033
|
+
root = self._seed(Path(tmp), force_copy=True)
|
|
2034
|
+
self.assertTrue(
|
|
2035
|
+
_is_equivalent_claude_md_shape(
|
|
2036
|
+
root / "CLAUDE.md", root / "AGENTS.md"
|
|
2037
|
+
)
|
|
2038
|
+
)
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
class SelfHostAdapterRoutingTests(unittest.TestCase):
|
|
2042
|
+
"""Mock-based check that `_project_all_adapters` routes through
|
|
2043
|
+
`project_packs` (not the legacy per-pack `project()` loop).
|
|
2044
|
+
|
|
2045
|
+
Source-text grep was rejected during T5 PLAN: a refactor that
|
|
2046
|
+
preserves the contract but changes the call shape would break a
|
|
2047
|
+
grep without breaking behaviour. The mock-based shape pins
|
|
2048
|
+
behaviour.
|
|
2049
|
+
"""
|
|
2050
|
+
|
|
2051
|
+
def test_claude_code_routes_through_project_packs(self) -> None:
|
|
2052
|
+
from unittest import mock
|
|
2053
|
+
|
|
2054
|
+
from agentbundle.build import self_host as self_host_module
|
|
2055
|
+
from agentbundle.build.adapters import claude_code, codex
|
|
2056
|
+
|
|
2057
|
+
contract = load_contract(CONTRACT_PATH)
|
|
2058
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2059
|
+
tmp_path = Path(tmp)
|
|
2060
|
+
packs_dir = tmp_path / "packs"
|
|
2061
|
+
(packs_dir / "core").mkdir(parents=True)
|
|
2062
|
+
(packs_dir / "core" / "pack.toml").write_text(
|
|
2063
|
+
"[pack]\n"
|
|
2064
|
+
'name = "core"\n'
|
|
2065
|
+
'version = "0.0.0"\n'
|
|
2066
|
+
"[pack.adapter-contract]\n"
|
|
2067
|
+
'version = "0.2"\n'
|
|
2068
|
+
"[pack.install]\n"
|
|
2069
|
+
'default-scope = "repo"\n'
|
|
2070
|
+
'allowed-scopes = ["repo"]\n',
|
|
2071
|
+
encoding="utf-8",
|
|
2072
|
+
)
|
|
2073
|
+
(packs_dir / "core" / ".apm").mkdir()
|
|
2074
|
+
out = tmp_path / "out"
|
|
2075
|
+
out.mkdir()
|
|
2076
|
+
|
|
2077
|
+
with mock.patch.object(claude_code, "project_packs") as cc_pp, \
|
|
2078
|
+
mock.patch.object(codex, "project_packs") as cx_pp:
|
|
2079
|
+
self_host_module._project_all_adapters(out, packs_dir, contract)
|
|
2080
|
+
|
|
2081
|
+
# Post-RFC-0009: `SELF_HOST_ADAPTERS = ("claude-code",)` —
|
|
2082
|
+
# codex is NOT routed from self-host. Codex correctness is
|
|
2083
|
+
# gated by adapter unit tests + the AC29 tempdir test, not
|
|
2084
|
+
# by self-host's working-tree drift gate. Keeping codex out
|
|
2085
|
+
# avoids carrying a duplicate `.agents/skills/` tree in the
|
|
2086
|
+
# working tree (the maintainer-overload concern that RFC-0009
|
|
2087
|
+
# exposed once skills became full bodies rather than
|
|
2088
|
+
# one-line teasers).
|
|
2089
|
+
self.assertEqual(cc_pp.call_count, 1)
|
|
2090
|
+
self.assertEqual(cx_pp.call_count, 0)
|
|
2091
|
+
args, _kwargs = cc_pp.call_args
|
|
2092
|
+
pack_paths_arg = args[0]
|
|
2093
|
+
self.assertEqual(
|
|
2094
|
+
pack_paths_arg,
|
|
2095
|
+
[packs_dir / "core"],
|
|
2096
|
+
)
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
if __name__ == "__main__":
|
|
2100
|
+
unittest.main()
|