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,327 @@
|
|
|
1
|
+
"""Tests for plugin-manifest.schema.json and plugin-manifest.derived.schema.json.
|
|
2
|
+
|
|
3
|
+
Verifies:
|
|
4
|
+
- plugin-manifest.schema.json (source shape) accepts a minimal hand-authored
|
|
5
|
+
.claude-plugin/plugin.json (AC 4).
|
|
6
|
+
- The source schema loads with the expected top-level shape.
|
|
7
|
+
- T2: source schema forbids the hooks property (AC10 gate 1).
|
|
8
|
+
- T2: derived schema accepts the synthesised hooks.SessionStart block (AC10 gate 1).
|
|
9
|
+
- T5: every source-tree packs/*/.claude-plugin/plugin.json carries no hooks
|
|
10
|
+
block (AC10).
|
|
11
|
+
- T5: every source-tree packs/*/.claude-plugin/plugin.json validates against
|
|
12
|
+
the source-shape schema (AC10).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import unittest
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
22
|
+
PLUGIN_MANIFEST_SCHEMA_PATH = (
|
|
23
|
+
REPO_ROOT / "docs" / "contracts" / "plugin-manifest.schema.json"
|
|
24
|
+
)
|
|
25
|
+
PLUGIN_MANIFEST_DERIVED_SCHEMA_PATH = (
|
|
26
|
+
REPO_ROOT / "docs" / "contracts" / "plugin-manifest.derived.schema.json"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_schema() -> dict:
|
|
31
|
+
return json.loads(PLUGIN_MANIFEST_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_derived_schema() -> dict:
|
|
35
|
+
return json.loads(PLUGIN_MANIFEST_DERIVED_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PluginManifestSchemaAcceptsValidExamplesTests(unittest.TestCase):
|
|
39
|
+
"""plugin-manifest.schema.json accepts well-formed plugin.json structures."""
|
|
40
|
+
|
|
41
|
+
def test_accepts_minimal_plugin_manifest(self) -> None:
|
|
42
|
+
"""A minimal hand-authored .claude-plugin/plugin.json is accepted.
|
|
43
|
+
|
|
44
|
+
Verifies AC 4: the schema validates the hand-authored per-pack manifest.
|
|
45
|
+
"""
|
|
46
|
+
from agentbundle.build.validate import validate
|
|
47
|
+
|
|
48
|
+
schema = _load_schema()
|
|
49
|
+
minimal = {
|
|
50
|
+
"name": "agent-ready-core",
|
|
51
|
+
"version": "0.1.0",
|
|
52
|
+
"description": "Core agent skills for the agent-ready-repo template.",
|
|
53
|
+
}
|
|
54
|
+
errors = validate(minimal, schema)
|
|
55
|
+
self.assertEqual(
|
|
56
|
+
errors,
|
|
57
|
+
[],
|
|
58
|
+
f"schema rejected minimal plugin.json:\n" + "\n".join(errors),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def test_accepts_plugin_manifest_with_skills_and_agents(self) -> None:
|
|
62
|
+
"""A plugin.json with optional skills and agents arrays is accepted."""
|
|
63
|
+
from agentbundle.build.validate import validate
|
|
64
|
+
|
|
65
|
+
schema = _load_schema()
|
|
66
|
+
full = {
|
|
67
|
+
"name": "agent-ready-governance-extras",
|
|
68
|
+
"version": "0.1.0",
|
|
69
|
+
"description": "RFC/ADR ceremony skills.",
|
|
70
|
+
"skills": ["new-rfc", "new-adr", "update-conventions"],
|
|
71
|
+
"agents": ["adversarial-reviewer"],
|
|
72
|
+
}
|
|
73
|
+
errors = validate(full, schema)
|
|
74
|
+
self.assertEqual(
|
|
75
|
+
errors,
|
|
76
|
+
[],
|
|
77
|
+
f"schema rejected plugin.json with skills and agents:\n"
|
|
78
|
+
+ "\n".join(errors),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def test_accepts_plugin_manifest_without_optional_fields(self) -> None:
|
|
82
|
+
"""A plugin.json with only required fields (no skills, no agents) is accepted."""
|
|
83
|
+
from agentbundle.build.validate import validate
|
|
84
|
+
|
|
85
|
+
schema = _load_schema()
|
|
86
|
+
minimal = {
|
|
87
|
+
"name": "agent-ready-user-guide-diataxis",
|
|
88
|
+
"version": "0.2.0",
|
|
89
|
+
"description": "Diátaxis user-guide scaffolding.",
|
|
90
|
+
}
|
|
91
|
+
errors = validate(minimal, schema)
|
|
92
|
+
self.assertEqual(
|
|
93
|
+
errors,
|
|
94
|
+
[],
|
|
95
|
+
f"schema rejected plugin.json with only required fields:\n"
|
|
96
|
+
+ "\n".join(errors),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PluginManifestSchemaRejectsInvalidExamplesTests(unittest.TestCase):
|
|
101
|
+
"""plugin-manifest.schema.json rejects malformed plugin.json structures."""
|
|
102
|
+
|
|
103
|
+
def test_rejects_missing_name(self) -> None:
|
|
104
|
+
"""A plugin.json without a name field is rejected."""
|
|
105
|
+
from agentbundle.build.validate import validate
|
|
106
|
+
|
|
107
|
+
schema = _load_schema()
|
|
108
|
+
instance = {
|
|
109
|
+
"version": "0.1.0",
|
|
110
|
+
"description": "Missing name.",
|
|
111
|
+
}
|
|
112
|
+
errors = validate(instance, schema)
|
|
113
|
+
self.assertTrue(errors, "schema accepted plugin.json missing 'name'")
|
|
114
|
+
self.assertTrue(
|
|
115
|
+
any("name" in e for e in errors),
|
|
116
|
+
f"error should mention 'name'; got: {errors}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def test_rejects_missing_version(self) -> None:
|
|
120
|
+
"""A plugin.json without a version field is rejected."""
|
|
121
|
+
from agentbundle.build.validate import validate
|
|
122
|
+
|
|
123
|
+
schema = _load_schema()
|
|
124
|
+
instance = {
|
|
125
|
+
"name": "agent-ready-core",
|
|
126
|
+
"description": "Missing version.",
|
|
127
|
+
}
|
|
128
|
+
errors = validate(instance, schema)
|
|
129
|
+
self.assertTrue(errors, "schema accepted plugin.json missing 'version'")
|
|
130
|
+
|
|
131
|
+
def test_rejects_missing_description(self) -> None:
|
|
132
|
+
"""A plugin.json without a description field is rejected."""
|
|
133
|
+
from agentbundle.build.validate import validate
|
|
134
|
+
|
|
135
|
+
schema = _load_schema()
|
|
136
|
+
instance = {
|
|
137
|
+
"name": "agent-ready-core",
|
|
138
|
+
"version": "0.1.0",
|
|
139
|
+
}
|
|
140
|
+
errors = validate(instance, schema)
|
|
141
|
+
self.assertTrue(errors, "schema accepted plugin.json missing 'description'")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class PluginManifestSchemaLoadsTests(unittest.TestCase):
|
|
145
|
+
"""Smoke test: the schema file loads and has the expected top-level shape."""
|
|
146
|
+
|
|
147
|
+
def test_schema_loads(self) -> None:
|
|
148
|
+
schema = _load_schema()
|
|
149
|
+
self.assertEqual(schema.get("type"), "object")
|
|
150
|
+
|
|
151
|
+
def test_schema_requires_name_version_description(self) -> None:
|
|
152
|
+
schema = _load_schema()
|
|
153
|
+
required = schema.get("required", [])
|
|
154
|
+
self.assertIn("name", required)
|
|
155
|
+
self.assertIn("version", required)
|
|
156
|
+
self.assertIn("description", required)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class PluginManifestSchemaSplitTests(unittest.TestCase):
|
|
160
|
+
"""T2: Source schema forbids hooks; derived schema accepts synthesised hooks (AC10 gate 1).
|
|
161
|
+
|
|
162
|
+
test_source_plugin_manifest_schema_forbids_hooks
|
|
163
|
+
test_derived_plugin_manifest_schema_accepts_synthesised_hooks
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def test_source_plugin_manifest_schema_forbids_hooks(self) -> None:
|
|
167
|
+
"""Source-shape schema rejects any manifest carrying a hooks property.
|
|
168
|
+
|
|
169
|
+
AC10 gate 1 (Blocker-5 rail): a stray hooks block in a source-tree
|
|
170
|
+
plugin.json must fail schema validation. The additionalProperties: false
|
|
171
|
+
+ explicit property list is the mechanism — hooks is not in the list.
|
|
172
|
+
"""
|
|
173
|
+
from agentbundle.build.validate import validate
|
|
174
|
+
|
|
175
|
+
schema = _load_schema()
|
|
176
|
+
|
|
177
|
+
# Minimal manifest (no hooks) must still validate.
|
|
178
|
+
minimal = {
|
|
179
|
+
"name": "agent-ready-core",
|
|
180
|
+
"version": "0.1.0",
|
|
181
|
+
"description": "Core agent skills.",
|
|
182
|
+
}
|
|
183
|
+
errors = validate(minimal, schema)
|
|
184
|
+
self.assertEqual(
|
|
185
|
+
errors,
|
|
186
|
+
[],
|
|
187
|
+
f"source schema rejected a valid manifest with no hooks:\n"
|
|
188
|
+
+ "\n".join(errors),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Manifest with hooks must be rejected — hooks is not in the source
|
|
192
|
+
# schema's properties enumeration and additionalProperties is false.
|
|
193
|
+
with_hooks = {
|
|
194
|
+
"name": "agent-ready-core",
|
|
195
|
+
"version": "0.1.0",
|
|
196
|
+
"description": "Core agent skills.",
|
|
197
|
+
"hooks": {
|
|
198
|
+
"SessionStart": [
|
|
199
|
+
{
|
|
200
|
+
"command": 'python3 "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/scripts/install-marker.py"'
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
errors = validate(with_hooks, schema)
|
|
206
|
+
self.assertTrue(
|
|
207
|
+
errors,
|
|
208
|
+
"source schema must reject a manifest carrying a hooks property "
|
|
209
|
+
"(hooks is not in the source schema's properties list; "
|
|
210
|
+
"additionalProperties: false should block it)",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def test_derived_plugin_manifest_schema_accepts_synthesised_hooks(self) -> None:
|
|
214
|
+
"""Derived-shape schema accepts a manifest with the synthesised hooks.SessionStart block.
|
|
215
|
+
|
|
216
|
+
AC10 gate 1: the build pipeline validates derived-tree manifests against
|
|
217
|
+
the derived schema. The derived schema adds hooks to the properties
|
|
218
|
+
enumeration so additionalProperties: false still holds.
|
|
219
|
+
"""
|
|
220
|
+
from agentbundle.build.validate import validate
|
|
221
|
+
|
|
222
|
+
derived_schema = _load_derived_schema()
|
|
223
|
+
|
|
224
|
+
# Minimal manifest (no hooks) must also be valid under the derived schema.
|
|
225
|
+
minimal = {
|
|
226
|
+
"name": "agent-ready-core",
|
|
227
|
+
"version": "0.1.0",
|
|
228
|
+
"description": "Core agent skills.",
|
|
229
|
+
}
|
|
230
|
+
errors = validate(minimal, derived_schema)
|
|
231
|
+
self.assertEqual(
|
|
232
|
+
errors,
|
|
233
|
+
[],
|
|
234
|
+
f"derived schema rejected a valid manifest with no hooks:\n"
|
|
235
|
+
+ "\n".join(errors),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Manifest with synthesised hooks.SessionStart block must be accepted.
|
|
239
|
+
derived = {
|
|
240
|
+
"name": "agent-ready-core",
|
|
241
|
+
"version": "0.1.0",
|
|
242
|
+
"description": "Core agent skills.",
|
|
243
|
+
"hooks": {
|
|
244
|
+
"SessionStart": [
|
|
245
|
+
{
|
|
246
|
+
"command": 'python3 "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/scripts/install-marker.py"'
|
|
247
|
+
}
|
|
248
|
+
]
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
errors = validate(derived, derived_schema)
|
|
252
|
+
self.assertEqual(
|
|
253
|
+
errors,
|
|
254
|
+
[],
|
|
255
|
+
f"derived schema rejected a manifest with the synthesised hooks block:\n"
|
|
256
|
+
+ "\n".join(errors),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class SourcePluginJsonAuditTests(unittest.TestCase):
|
|
261
|
+
"""T5: Audit every source-tree packs/*/.claude-plugin/plugin.json (AC10).
|
|
262
|
+
|
|
263
|
+
test_no_source_plugin_json_carries_hooks
|
|
264
|
+
test_source_plugin_json_validates_against_schema
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def _source_manifests(self) -> list[Path]:
|
|
268
|
+
"""Return paths for all source-tree per-pack plugin.json files."""
|
|
269
|
+
return sorted((REPO_ROOT / "packs").glob("*/.claude-plugin/plugin.json"))
|
|
270
|
+
|
|
271
|
+
def test_no_source_plugin_json_carries_hooks(self) -> None:
|
|
272
|
+
"""Every source-tree plugin.json must not declare a hooks block.
|
|
273
|
+
|
|
274
|
+
AC10: the hooks block is synthesised by the build pipeline; hand-authored
|
|
275
|
+
source manifests must never pre-declare it. This test pins that invariant
|
|
276
|
+
permanently so a future accidental hooks block is caught immediately.
|
|
277
|
+
"""
|
|
278
|
+
manifests = self._source_manifests()
|
|
279
|
+
self.assertTrue(
|
|
280
|
+
manifests,
|
|
281
|
+
"No packs/*/.claude-plugin/plugin.json found — "
|
|
282
|
+
"check that REPO_ROOT resolves correctly.",
|
|
283
|
+
)
|
|
284
|
+
for manifest_path in manifests:
|
|
285
|
+
with self.subTest(pack=manifest_path.parent.parent.name):
|
|
286
|
+
content = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
287
|
+
self.assertNotIn(
|
|
288
|
+
"hooks",
|
|
289
|
+
content,
|
|
290
|
+
f"{manifest_path.relative_to(REPO_ROOT)}: "
|
|
291
|
+
f"source-tree plugin.json must not carry a 'hooks' block "
|
|
292
|
+
f"(the build pipeline synthesises it). "
|
|
293
|
+
f"Remove the stray hooks block from the source file.",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def test_source_plugin_json_validates_against_schema(self) -> None:
|
|
297
|
+
"""Every source-tree plugin.json must validate against the source-shape schema.
|
|
298
|
+
|
|
299
|
+
AC10: the source schema (plugin-manifest.schema.json) explicitly forbids
|
|
300
|
+
hooks via additionalProperties: false. Validating every source-tree
|
|
301
|
+
manifest against it here provides a second gate that catches both missing
|
|
302
|
+
required fields and any stray additional properties (including hooks).
|
|
303
|
+
"""
|
|
304
|
+
from agentbundle.build.validate import validate
|
|
305
|
+
|
|
306
|
+
schema = _load_schema()
|
|
307
|
+
manifests = self._source_manifests()
|
|
308
|
+
self.assertTrue(
|
|
309
|
+
manifests,
|
|
310
|
+
"No packs/*/.claude-plugin/plugin.json found — "
|
|
311
|
+
"check that REPO_ROOT resolves correctly.",
|
|
312
|
+
)
|
|
313
|
+
for manifest_path in manifests:
|
|
314
|
+
with self.subTest(pack=manifest_path.parent.parent.name):
|
|
315
|
+
content = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
316
|
+
errors = validate(content, schema)
|
|
317
|
+
self.assertEqual(
|
|
318
|
+
errors,
|
|
319
|
+
[],
|
|
320
|
+
f"{manifest_path.relative_to(REPO_ROOT)} failed schema "
|
|
321
|
+
f"validation against plugin-manifest.schema.json:\n"
|
|
322
|
+
+ "\n".join(errors),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
unittest.main()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tests for the lifted `merge-json` projection helper.
|
|
2
|
+
|
|
3
|
+
Originally private to ``adapters/claude_code.py`` as ``_project_merge_json``;
|
|
4
|
+
lifted to ``build/projections/merge_json.py`` by
|
|
5
|
+
docs/specs/dropped-primitives-coverage (T2). The existing claude-code
|
|
6
|
+
merge-json tests at ``test_adapter_claude_code.py`` remain green and
|
|
7
|
+
form the regression safety net for the lift; this module pins the
|
|
8
|
+
helper's contract directly so codex.py (T4) can rely on it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import tempfile
|
|
15
|
+
import unittest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from agentbundle.build.projections.merge_json import project_merge_json
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestProjectMergeJson(unittest.TestCase):
|
|
22
|
+
def setUp(self) -> None:
|
|
23
|
+
self._tmpdir = tempfile.TemporaryDirectory()
|
|
24
|
+
self.source = Path(self._tmpdir.name) / "source"
|
|
25
|
+
self.output = Path(self._tmpdir.name) / "output"
|
|
26
|
+
self.source.mkdir()
|
|
27
|
+
self.output.mkdir()
|
|
28
|
+
|
|
29
|
+
def tearDown(self) -> None:
|
|
30
|
+
self._tmpdir.cleanup()
|
|
31
|
+
|
|
32
|
+
def _rule(
|
|
33
|
+
self,
|
|
34
|
+
target_path: str = ".target/hooks.json",
|
|
35
|
+
managed_key: str = "hooks",
|
|
36
|
+
) -> dict:
|
|
37
|
+
return {"target-path": target_path, "managed-key": managed_key}
|
|
38
|
+
|
|
39
|
+
def test_empty_source_dir_writes_nothing(self) -> None:
|
|
40
|
+
"""No TOML files → no JSON output at the target path."""
|
|
41
|
+
project_merge_json(self.source, self.output, self._rule())
|
|
42
|
+
self.assertFalse((self.output / ".target" / "hooks.json").exists())
|
|
43
|
+
|
|
44
|
+
def test_empty_managed_key_writes_nothing(self) -> None:
|
|
45
|
+
"""TOML file present but managed-key payload is empty → no output."""
|
|
46
|
+
(self.source / "one.toml").write_text("[hooks]\n", encoding="utf-8")
|
|
47
|
+
project_merge_json(self.source, self.output, self._rule())
|
|
48
|
+
self.assertFalse((self.output / ".target" / "hooks.json").exists())
|
|
49
|
+
|
|
50
|
+
def test_single_toml_writes_managed_key(self) -> None:
|
|
51
|
+
"""One TOML's managed-key payload lands at the target JSON."""
|
|
52
|
+
(self.source / "one.toml").write_text(
|
|
53
|
+
'[hooks]\n'
|
|
54
|
+
'"SessionStart" = [{ matcher = "*", hooks = [{ type = "command", command = "echo hi" }] }]\n',
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
project_merge_json(self.source, self.output, self._rule())
|
|
58
|
+
target = self.output / ".target" / "hooks.json"
|
|
59
|
+
self.assertTrue(target.exists())
|
|
60
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
61
|
+
self.assertIn("hooks", data)
|
|
62
|
+
self.assertIn("SessionStart", data["hooks"])
|
|
63
|
+
|
|
64
|
+
def test_merges_into_existing_json(self) -> None:
|
|
65
|
+
"""Existing non-managed keys in the JSON target are preserved."""
|
|
66
|
+
target = self.output / ".target" / "hooks.json"
|
|
67
|
+
target.parent.mkdir(parents=True)
|
|
68
|
+
target.write_text(
|
|
69
|
+
json.dumps({"other-key": {"keep": "this"}, "hooks": {"X": ["old"]}}),
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
)
|
|
72
|
+
(self.source / "one.toml").write_text(
|
|
73
|
+
'[hooks]\n"Y" = ["new"]\n', encoding="utf-8"
|
|
74
|
+
)
|
|
75
|
+
project_merge_json(self.source, self.output, self._rule())
|
|
76
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
77
|
+
self.assertEqual(data["other-key"], {"keep": "this"})
|
|
78
|
+
# Both old and new managed-key entries merged.
|
|
79
|
+
self.assertEqual(data["hooks"]["X"], ["old"])
|
|
80
|
+
self.assertEqual(data["hooks"]["Y"], ["new"])
|
|
81
|
+
|
|
82
|
+
def test_multiple_toml_files_merge_in_sorted_order(self) -> None:
|
|
83
|
+
"""Source files are iterated sorted; later overrides earlier."""
|
|
84
|
+
(self.source / "a.toml").write_text(
|
|
85
|
+
'[hooks]\n"X" = ["from-a"]\n', encoding="utf-8"
|
|
86
|
+
)
|
|
87
|
+
(self.source / "b.toml").write_text(
|
|
88
|
+
'[hooks]\n"X" = ["from-b"]\n', encoding="utf-8"
|
|
89
|
+
)
|
|
90
|
+
project_merge_json(self.source, self.output, self._rule())
|
|
91
|
+
target = self.output / ".target" / "hooks.json"
|
|
92
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
93
|
+
self.assertEqual(data["hooks"]["X"], ["from-b"])
|
|
94
|
+
|
|
95
|
+
def test_output_serialisation_shape(self) -> None:
|
|
96
|
+
"""Output uses indent=2, sort_keys=True, trailing newline (idempotency)."""
|
|
97
|
+
(self.source / "one.toml").write_text(
|
|
98
|
+
'[hooks]\n"Y" = ["y"]\n"X" = ["x"]\n', encoding="utf-8"
|
|
99
|
+
)
|
|
100
|
+
project_merge_json(self.source, self.output, self._rule())
|
|
101
|
+
target = self.output / ".target" / "hooks.json"
|
|
102
|
+
text = target.read_text(encoding="utf-8")
|
|
103
|
+
self.assertTrue(text.endswith("\n"), "expected trailing newline")
|
|
104
|
+
# sort_keys: X before Y in the serialised hooks dict.
|
|
105
|
+
x_pos = text.index('"X"')
|
|
106
|
+
y_pos = text.index('"Y"')
|
|
107
|
+
self.assertLess(x_pos, y_pos, "expected sort_keys=True ordering")
|
|
108
|
+
|
|
109
|
+
def test_non_toml_files_ignored(self) -> None:
|
|
110
|
+
"""Files without .toml suffix are skipped."""
|
|
111
|
+
(self.source / "skip.md").write_text("ignored", encoding="utf-8")
|
|
112
|
+
(self.source / "one.toml").write_text(
|
|
113
|
+
'[hooks]\n"X" = ["x"]\n', encoding="utf-8"
|
|
114
|
+
)
|
|
115
|
+
project_merge_json(self.source, self.output, self._rule())
|
|
116
|
+
target = self.output / ".target" / "hooks.json"
|
|
117
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
118
|
+
self.assertEqual(data["hooks"], {"X": ["x"]})
|
|
119
|
+
|
|
120
|
+
def test_target_path_at_target_root(self) -> None:
|
|
121
|
+
"""Target paths land where the rule says — verified for codex's
|
|
122
|
+
`.codex/hooks.json` shape that T4 will dispatch to."""
|
|
123
|
+
(self.source / "one.toml").write_text(
|
|
124
|
+
'[hooks]\n"SessionStart" = [{matcher="*", hooks=[{type="command", command="x"}]}]\n',
|
|
125
|
+
encoding="utf-8",
|
|
126
|
+
)
|
|
127
|
+
project_merge_json(
|
|
128
|
+
self.source, self.output, self._rule(target_path=".codex/hooks.json")
|
|
129
|
+
)
|
|
130
|
+
self.assertTrue((self.output / ".codex" / "hooks.json").exists())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestClaudeCodeIntegrationStillGreen(unittest.TestCase):
|
|
134
|
+
"""Belt-and-braces: re-import claude-code's project_packs and confirm
|
|
135
|
+
the merge-json branch still dispatches through the lifted helper."""
|
|
136
|
+
|
|
137
|
+
def test_claude_code_imports_lifted_helper(self) -> None:
|
|
138
|
+
from agentbundle.build.adapters import claude_code
|
|
139
|
+
from agentbundle.build.projections import merge_json as projections_merge_json
|
|
140
|
+
|
|
141
|
+
# The lifted symbol is the same one claude_code consumes.
|
|
142
|
+
self.assertIs(
|
|
143
|
+
claude_code.project_merge_json, projections_merge_json.project_merge_json
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
unittest.main()
|