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.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. 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()