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,224 @@
|
|
|
1
|
+
"""Tests for the RFC-0004 v0.2 `[scope]` table on the adapter contract.
|
|
2
|
+
|
|
3
|
+
Verifies AC #14 (RFC-0004) for the distribution-adapters spec:
|
|
4
|
+
- adapter.schema.json accepts a well-formed [adapter.<name>.scope] block.
|
|
5
|
+
- adapter.schema.json rejects each malformed `allowed-prefixes.user` shape
|
|
6
|
+
enumerated in the spec: ["/"], [""], ["../"], [".."],
|
|
7
|
+
["no-trailing-slash"], ["/begins-with-slash/"], and [].
|
|
8
|
+
- adapter.toml validates against the v0.2 schema with [contract] version =
|
|
9
|
+
"0.2" and the two-prefix [adapter."claude-code".scope] block.
|
|
10
|
+
- The other three reference adapters (kiro, copilot, codex) omit the
|
|
11
|
+
optional [scope] block and remain valid.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import json
|
|
18
|
+
import tomllib
|
|
19
|
+
import unittest
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
23
|
+
CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
|
|
24
|
+
SCHEMA_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.schema.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_schema() -> dict:
|
|
28
|
+
return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_contract() -> dict:
|
|
32
|
+
return tomllib.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ContractVersionTests(unittest.TestCase):
|
|
36
|
+
"""Contract version: bumped to 0.2 by RFC-0004, then to 0.3 by RFC-0005,
|
|
37
|
+
then to 0.4 by RFC-0008 (T2 / spec claude-plugins-install-route), then to
|
|
38
|
+
0.5 by RFC-0010 (T2 / spec apm-install-route-parity), then to 0.6 by
|
|
39
|
+
RFC-0011 / pack-allowed-adapters (codex user-scope table), then to 0.7
|
|
40
|
+
by RFC-0012 / repo-scope-per-adapter-projection (every adapter declares
|
|
41
|
+
`allowed-prefixes.repo`; copilot gains a scope table) and RFC-0013 /
|
|
42
|
+
credential-broker-contract (governance bump) co-residing at v0.7, then
|
|
43
|
+
to 0.8 by docs/specs/dropped-primitives-coverage (codex agent +
|
|
44
|
+
hook-wiring move from `dropped` to first-class projections)."""
|
|
45
|
+
|
|
46
|
+
def test_contract_version_is_0_5(self) -> None:
|
|
47
|
+
# Class/method name preserved; exact version lives in test_contract.py.
|
|
48
|
+
# Assert >= 0.8 so v0.8 scope features survive future bumps. Compare as
|
|
49
|
+
# (major, minor) tuples — a string compare breaks at v0.10 ("0.10" < "0.8"
|
|
50
|
+
# lexically).
|
|
51
|
+
contract = _load_contract()
|
|
52
|
+
version = tuple(int(part) for part in contract["contract"]["version"].split("."))
|
|
53
|
+
self.assertGreaterEqual(version, (0, 8))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ClaudeCodeScopeBlockTests(unittest.TestCase):
|
|
57
|
+
"""The Claude Code adapter declares the v0.2 [scope] block."""
|
|
58
|
+
|
|
59
|
+
def test_claude_code_scope_present(self) -> None:
|
|
60
|
+
contract = _load_contract()
|
|
61
|
+
scope = contract["adapter"]["claude-code"].get("scope")
|
|
62
|
+
self.assertIsNotNone(scope, "Claude Code [scope] block missing")
|
|
63
|
+
self.assertEqual(scope["repo"], ".")
|
|
64
|
+
self.assertEqual(scope["user"], "~")
|
|
65
|
+
|
|
66
|
+
def test_claude_code_allowed_prefixes_user_two_entries(self) -> None:
|
|
67
|
+
"""Two prefixes ship: projected primitives + CLI infrastructure."""
|
|
68
|
+
contract = _load_contract()
|
|
69
|
+
prefixes = (
|
|
70
|
+
contract["adapter"]["claude-code"]["scope"]["allowed-prefixes"]["user"]
|
|
71
|
+
)
|
|
72
|
+
self.assertEqual(prefixes, [".claude/", ".agentbundle/"])
|
|
73
|
+
|
|
74
|
+
def test_contract_validates_against_schema(self) -> None:
|
|
75
|
+
from agentbundle.build.validate import validate
|
|
76
|
+
|
|
77
|
+
errors = validate(_load_contract(), _load_schema())
|
|
78
|
+
self.assertEqual(errors, [], f"v0.2 contract did not validate: {errors}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OtherAdaptersOmitScopeTests(unittest.TestCase):
|
|
82
|
+
"""v0.3 (RFC-0005) adds a `[scope]` table to Kiro alongside Claude
|
|
83
|
+
Code's existing one; v0.6 (RFC-0011) adds one to Codex; v0.7
|
|
84
|
+
(RFC-0012) adds one to Copilot — every shipped adapter now carries
|
|
85
|
+
a `[scope]` table at v0.7."""
|
|
86
|
+
|
|
87
|
+
def test_copilot_has_scope_per_rfc_0012(self) -> None:
|
|
88
|
+
contract = _load_contract()
|
|
89
|
+
scope = contract["adapter"]["copilot"].get("scope")
|
|
90
|
+
self.assertIsNotNone(scope, "copilot [scope] block missing")
|
|
91
|
+
self.assertEqual(scope["repo"], ".")
|
|
92
|
+
# v0.10 (RFC-0024 / copilot-full-parity): copilot is now a full-parity,
|
|
93
|
+
# user-scope-capable adapter. Repo prefixes cover the three projected
|
|
94
|
+
# primitive homes under `.github/`; the legacy `tools/hooks/` prefix is
|
|
95
|
+
# gone (hook-body retargeted to `.github/hooks/`).
|
|
96
|
+
self.assertEqual(
|
|
97
|
+
scope["allowed-prefixes"]["repo"],
|
|
98
|
+
[".github/instructions/", ".github/agents/", ".github/hooks/"],
|
|
99
|
+
)
|
|
100
|
+
# User scope: `~/.copilot/{agents,instructions,hooks}/` + `.agentbundle/`
|
|
101
|
+
# (the install state-file home, same as every other user-capable adapter).
|
|
102
|
+
self.assertEqual(scope["user"], "~")
|
|
103
|
+
self.assertEqual(
|
|
104
|
+
scope["allowed-prefixes"]["user"],
|
|
105
|
+
[
|
|
106
|
+
".copilot/agents/",
|
|
107
|
+
".copilot/instructions/",
|
|
108
|
+
".copilot/hooks/",
|
|
109
|
+
".agentbundle/",
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def test_codex_has_scope_per_rfc_0011(self) -> None:
|
|
114
|
+
contract = _load_contract()
|
|
115
|
+
scope = contract["adapter"]["codex"].get("scope")
|
|
116
|
+
self.assertIsNotNone(scope, "Codex [scope] block missing (RFC-0011)")
|
|
117
|
+
self.assertEqual(scope["repo"], ".")
|
|
118
|
+
self.assertEqual(scope["user"], "~")
|
|
119
|
+
# v0.8 (dropped-primitives-coverage) adds `.codex/` to allow
|
|
120
|
+
# codex agent + hook-wiring projection.
|
|
121
|
+
self.assertEqual(
|
|
122
|
+
scope["allowed-prefixes"]["user"],
|
|
123
|
+
[".agents/skills/", ".codex/", ".agentbundle/"],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def test_kiro_has_scope_per_rfc_0005(self) -> None:
|
|
127
|
+
contract = _load_contract()
|
|
128
|
+
scope = contract["adapter"]["kiro"].get("scope")
|
|
129
|
+
self.assertIsNotNone(scope, "Kiro [scope] block missing")
|
|
130
|
+
self.assertEqual(scope["repo"], ".")
|
|
131
|
+
self.assertEqual(scope["user"], "~")
|
|
132
|
+
|
|
133
|
+
def test_contract_minus_claude_code_scope_still_valid(self) -> None:
|
|
134
|
+
from agentbundle.build.validate import validate
|
|
135
|
+
|
|
136
|
+
contract = _load_contract()
|
|
137
|
+
contract["adapter"]["claude-code"].pop("scope", None)
|
|
138
|
+
errors = validate(contract, _load_schema())
|
|
139
|
+
self.assertEqual(errors, [], f"validate rejected scope-less contract: {errors}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class AllowedPrefixesRejectionTests(unittest.TestCase):
|
|
143
|
+
"""`allowed-prefixes.<scope>` constraints — every bad shape is rejected."""
|
|
144
|
+
|
|
145
|
+
def _validate_with_prefixes(self, prefixes: list[str]) -> list[str]:
|
|
146
|
+
from agentbundle.build.validate import validate
|
|
147
|
+
|
|
148
|
+
schema = _load_schema()
|
|
149
|
+
contract = _load_contract()
|
|
150
|
+
contract["adapter"]["claude-code"]["scope"]["allowed-prefixes"]["user"] = list(prefixes)
|
|
151
|
+
return validate(contract, schema)
|
|
152
|
+
|
|
153
|
+
def test_rejects_root_only(self) -> None:
|
|
154
|
+
errors = self._validate_with_prefixes(["/"])
|
|
155
|
+
self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['/']")
|
|
156
|
+
|
|
157
|
+
def test_rejects_empty_string(self) -> None:
|
|
158
|
+
errors = self._validate_with_prefixes([""])
|
|
159
|
+
self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['']")
|
|
160
|
+
|
|
161
|
+
def test_rejects_dotdot_slash(self) -> None:
|
|
162
|
+
errors = self._validate_with_prefixes(["../"])
|
|
163
|
+
self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['../']")
|
|
164
|
+
|
|
165
|
+
def test_rejects_bare_dotdot(self) -> None:
|
|
166
|
+
errors = self._validate_with_prefixes([".."])
|
|
167
|
+
self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['..']")
|
|
168
|
+
|
|
169
|
+
def test_rejects_no_trailing_slash(self) -> None:
|
|
170
|
+
errors = self._validate_with_prefixes(["no-trailing-slash"])
|
|
171
|
+
self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['no-trailing-slash']")
|
|
172
|
+
|
|
173
|
+
def test_rejects_leading_slash(self) -> None:
|
|
174
|
+
errors = self._validate_with_prefixes(["/begins-with-slash/"])
|
|
175
|
+
self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['/begins-with-slash/']")
|
|
176
|
+
|
|
177
|
+
def test_rejects_empty_array(self) -> None:
|
|
178
|
+
errors = self._validate_with_prefixes([])
|
|
179
|
+
self.assertTrue(errors, "schema accepted allowed-prefixes.user = []")
|
|
180
|
+
|
|
181
|
+
def test_rejects_dotdot_in_middle(self) -> None:
|
|
182
|
+
"""Defence in depth: `..` as an interior segment is also rejected."""
|
|
183
|
+
errors = self._validate_with_prefixes([".claude/../etc/"])
|
|
184
|
+
self.assertTrue(errors, "schema accepted allowed-prefixes.user = ['.claude/../etc/']")
|
|
185
|
+
|
|
186
|
+
def test_accepts_nested_path(self) -> None:
|
|
187
|
+
"""A nested path like `.claude/skills/` is legal — non-empty, trailing /."""
|
|
188
|
+
errors = self._validate_with_prefixes([".claude/skills/"])
|
|
189
|
+
self.assertEqual(errors, [], f"schema rejected nested path: {errors}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class StdlibValidatorExtensionsTests(unittest.TestCase):
|
|
193
|
+
"""Cross-keyword tests for the validator extensions T10 needs."""
|
|
194
|
+
|
|
195
|
+
def test_min_items_rejects_short_array(self) -> None:
|
|
196
|
+
from agentbundle.build.validate import validate
|
|
197
|
+
|
|
198
|
+
schema = {"type": "array", "minItems": 1}
|
|
199
|
+
self.assertTrue(validate([], schema))
|
|
200
|
+
self.assertEqual(validate(["a"], schema), [])
|
|
201
|
+
|
|
202
|
+
def test_pattern_rejects_dotdot_segment(self) -> None:
|
|
203
|
+
from agentbundle.build.validate import validate
|
|
204
|
+
|
|
205
|
+
# The exact pattern shipped on allowed-prefixes.<scope>.items.
|
|
206
|
+
pattern = r"^((?!\.\.(\/|$))[^/]+/)+$"
|
|
207
|
+
schema = {"type": "string", "pattern": pattern}
|
|
208
|
+
for bad in ("../", "..", "/", "", "no-slash", "/abs/"):
|
|
209
|
+
with self.subTest(value=bad):
|
|
210
|
+
self.assertTrue(
|
|
211
|
+
validate(bad, schema),
|
|
212
|
+
f"pattern accepted forbidden value: {bad!r}",
|
|
213
|
+
)
|
|
214
|
+
for good in (".claude/", ".agentbundle/", "deep/nested/path/"):
|
|
215
|
+
with self.subTest(value=good):
|
|
216
|
+
self.assertEqual(
|
|
217
|
+
validate(good, schema),
|
|
218
|
+
[],
|
|
219
|
+
f"pattern rejected legal value: {good!r}",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
unittest.main()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Tests for adapter-contract v0.7 (RFC-0012 / repo-scope-per-adapter-projection
|
|
2
|
+
and RFC-0013 / credential-broker-contract co-residing at v0.7).
|
|
3
|
+
|
|
4
|
+
Verifies the T1 edits landed:
|
|
5
|
+
|
|
6
|
+
- ``[contract] version == "0.7"`` in both the runtime data file
|
|
7
|
+
(`_data/adapter.toml`) and the docs mirror
|
|
8
|
+
(`docs/contracts/adapter.toml`). The two files must stay byte-
|
|
9
|
+
aligned per the v0.3-schema sync test; this module pins the
|
|
10
|
+
version on both as belt-and-braces (AC1 for both RFCs).
|
|
11
|
+
- **RFC-0012 surface:**
|
|
12
|
+
* ``[adapter.copilot.scope]`` exists with ``repo = "."``,
|
|
13
|
+
``allowed-prefixes.repo`` enumerating the per-IDE skill /
|
|
14
|
+
hook-body targets, and NO ``user`` key (Copilot is admissible
|
|
15
|
+
at repo scope only).
|
|
16
|
+
* Every shipped adapter declares ``allowed-prefixes.repo`` as a
|
|
17
|
+
non-empty list of trailing-slash strings.
|
|
18
|
+
* Schema validator refuses fixtures that omit the ``repo`` key
|
|
19
|
+
or ``allowed-prefixes.repo`` from any adapter's scope table.
|
|
20
|
+
- **RFC-0013 surface:**
|
|
21
|
+
* Each user-scope-capable adapter (`claude-code`, `kiro`, `codex`)
|
|
22
|
+
still carries `.agentbundle/` in `allowed-prefixes.user`
|
|
23
|
+
(non-regression — the prefix is what `metadata.auth: creds`
|
|
24
|
+
writes its credential cache under).
|
|
25
|
+
- Property-based ``allowed-prefixes.user`` invariants for every
|
|
26
|
+
user-scope-capable adapter (header-comment edits and
|
|
27
|
+
list-order changes don't trip the assertion).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import copy
|
|
33
|
+
import json
|
|
34
|
+
import tomllib
|
|
35
|
+
import unittest
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
39
|
+
DATA_CONTRACT_PATH = (
|
|
40
|
+
REPO_ROOT / "packages" / "agentbundle" / "agentbundle" / "_data" / "adapter.toml"
|
|
41
|
+
)
|
|
42
|
+
DOCS_CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
|
|
43
|
+
DATA_SCHEMA_PATH = (
|
|
44
|
+
REPO_ROOT
|
|
45
|
+
/ "packages"
|
|
46
|
+
/ "agentbundle"
|
|
47
|
+
/ "agentbundle"
|
|
48
|
+
/ "_data"
|
|
49
|
+
/ "adapter.schema.json"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _version_tuple(contract: dict) -> tuple[int, ...]:
|
|
54
|
+
"""(major, minor) tuple — a string compare breaks at v0.10 ("0.10"<"0.8")."""
|
|
55
|
+
return tuple(int(part) for part in contract["contract"]["version"].split("."))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestContractV07(unittest.TestCase):
|
|
59
|
+
def setUp(self) -> None:
|
|
60
|
+
self.contract = tomllib.loads(DATA_CONTRACT_PATH.read_text(encoding="utf-8"))
|
|
61
|
+
self.docs_contract = tomllib.loads(
|
|
62
|
+
DOCS_CONTRACT_PATH.read_text(encoding="utf-8")
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def test_contract_version_is_07(self) -> None:
|
|
66
|
+
"""RFC-0012 + RFC-0013 + dropped-primitives-coverage co-residing at v0.8.
|
|
67
|
+
|
|
68
|
+
Name preserved to keep the diff small; the v0.7 invariants below
|
|
69
|
+
remain load-bearing post-v0.8 bump because dropped-primitives-coverage
|
|
70
|
+
only widens codex's projection table (it does not regress the v0.7
|
|
71
|
+
``allowed-prefixes`` / scope-table contracts pinned in this module).
|
|
72
|
+
"""
|
|
73
|
+
# Exact version check lives in test_contract.py; assert >= 0.8 (v0.7/v0.8
|
|
74
|
+
# features must survive future bumps — validated by invariant tests below).
|
|
75
|
+
# Compare as (major, minor) tuples — a string compare breaks at v0.10.
|
|
76
|
+
self.assertGreaterEqual(_version_tuple(self.contract), (0, 8))
|
|
77
|
+
|
|
78
|
+
def test_docs_contract_version_is_07(self) -> None:
|
|
79
|
+
"""docs mirror stays in sync (byte-identical to _data/ adapter.toml)."""
|
|
80
|
+
self.assertGreaterEqual(_version_tuple(self.docs_contract), (0, 8))
|
|
81
|
+
|
|
82
|
+
def test_copilot_scope_table_shape(self) -> None:
|
|
83
|
+
copilot_scope = self.contract["adapter"]["copilot"].get("scope")
|
|
84
|
+
self.assertIsNotNone(copilot_scope, "copilot scope table missing")
|
|
85
|
+
self.assertEqual(copilot_scope["repo"], ".")
|
|
86
|
+
# v0.10 (RFC-0024 / copilot-full-parity) supersedes-in-part the v0.7
|
|
87
|
+
# repo-only copilot scope: copilot is now user-scope-capable with three
|
|
88
|
+
# `.github/` repo prefixes (the legacy `tools/hooks/` prefix is gone).
|
|
89
|
+
self.assertEqual(
|
|
90
|
+
copilot_scope["allowed-prefixes"]["repo"],
|
|
91
|
+
[".github/instructions/", ".github/agents/", ".github/hooks/"],
|
|
92
|
+
)
|
|
93
|
+
self.assertEqual(copilot_scope["user"], "~")
|
|
94
|
+
self.assertEqual(
|
|
95
|
+
copilot_scope["allowed-prefixes"]["user"],
|
|
96
|
+
[
|
|
97
|
+
".copilot/agents/",
|
|
98
|
+
".copilot/instructions/",
|
|
99
|
+
".copilot/hooks/",
|
|
100
|
+
".agentbundle/",
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def test_every_adapter_has_allowed_prefixes_repo(self) -> None:
|
|
105
|
+
for name, block in self.contract["adapter"].items():
|
|
106
|
+
with self.subTest(adapter=name):
|
|
107
|
+
scope = block.get("scope")
|
|
108
|
+
self.assertIsNotNone(
|
|
109
|
+
scope, f"adapter {name!r} has no scope table at v0.7"
|
|
110
|
+
)
|
|
111
|
+
repo_prefixes = scope.get("allowed-prefixes", {}).get("repo")
|
|
112
|
+
self.assertIsInstance(
|
|
113
|
+
repo_prefixes,
|
|
114
|
+
list,
|
|
115
|
+
f"adapter {name!r} missing allowed-prefixes.repo",
|
|
116
|
+
)
|
|
117
|
+
self.assertTrue(
|
|
118
|
+
repo_prefixes,
|
|
119
|
+
f"adapter {name!r} allowed-prefixes.repo is empty",
|
|
120
|
+
)
|
|
121
|
+
for entry in repo_prefixes:
|
|
122
|
+
self.assertTrue(
|
|
123
|
+
entry.endswith("/"),
|
|
124
|
+
f"adapter {name!r} prefix {entry!r} must end with '/'",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def test_existing_user_prefixes_invariants(self) -> None:
|
|
128
|
+
"""Property-based assertion — every user-scope-capable adapter's
|
|
129
|
+
prefix list still carries its load-bearing entries. RFC-0013's
|
|
130
|
+
`.agentbundle/` non-regression rolls into this same shape."""
|
|
131
|
+
cc = self.contract["adapter"]["claude-code"]["scope"]
|
|
132
|
+
cc_user = cc["allowed-prefixes"]["user"]
|
|
133
|
+
self.assertIn(".claude/", cc_user)
|
|
134
|
+
self.assertIn(".agentbundle/", cc_user)
|
|
135
|
+
|
|
136
|
+
kiro = self.contract["adapter"]["kiro"]["scope"]
|
|
137
|
+
kiro_user = kiro["allowed-prefixes"]["user"]
|
|
138
|
+
self.assertIn(".kiro/", kiro_user)
|
|
139
|
+
self.assertIn(".agentbundle/", kiro_user)
|
|
140
|
+
|
|
141
|
+
codex = self.contract["adapter"]["codex"]["scope"]
|
|
142
|
+
codex_user = codex["allowed-prefixes"]["user"]
|
|
143
|
+
self.assertIn(".agents/skills/", codex_user)
|
|
144
|
+
self.assertIn(".agentbundle/", codex_user)
|
|
145
|
+
|
|
146
|
+
def test_all_user_scope_adapters_carry_agentbundle_prefix(self) -> None:
|
|
147
|
+
"""RFC-0013 AC2 — `.agentbundle/` is the credential-cache root
|
|
148
|
+
every user-scope-capable adapter must admit."""
|
|
149
|
+
for adapter in ("claude-code", "kiro", "codex"):
|
|
150
|
+
with self.subTest(adapter=adapter):
|
|
151
|
+
prefixes = (
|
|
152
|
+
self.contract["adapter"][adapter]["scope"]
|
|
153
|
+
["allowed-prefixes"]["user"]
|
|
154
|
+
)
|
|
155
|
+
self.assertIn(
|
|
156
|
+
".agentbundle/", prefixes,
|
|
157
|
+
f"{adapter} user-scope allowed-prefixes must include "
|
|
158
|
+
f"'.agentbundle/' (got {prefixes!r})",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def test_schema_refuses_repo_omission(self) -> None:
|
|
162
|
+
"""RFC-0012 AC4 — fixture contract with the ``repo`` key removed
|
|
163
|
+
from any adapter's scope table fails validation."""
|
|
164
|
+
from agentbundle.build.validate import validate
|
|
165
|
+
|
|
166
|
+
schema = json.loads(DATA_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
167
|
+
broken = copy.deepcopy(self.contract)
|
|
168
|
+
del broken["adapter"]["claude-code"]["scope"]["repo"]
|
|
169
|
+
errors = validate(broken, schema)
|
|
170
|
+
self.assertTrue(
|
|
171
|
+
errors,
|
|
172
|
+
"schema accepted a scope table missing the 'repo' key",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def test_schema_refuses_allowed_prefixes_repo_omission(self) -> None:
|
|
176
|
+
"""RFC-0012 AC4 — fixture contract with ``allowed-prefixes.repo``
|
|
177
|
+
removed from any adapter's scope table fails validation."""
|
|
178
|
+
from agentbundle.build.validate import validate
|
|
179
|
+
|
|
180
|
+
schema = json.loads(DATA_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
181
|
+
broken = copy.deepcopy(self.contract)
|
|
182
|
+
del broken["adapter"]["kiro"]["scope"]["allowed-prefixes"]["repo"]
|
|
183
|
+
errors = validate(broken, schema)
|
|
184
|
+
self.assertTrue(
|
|
185
|
+
errors,
|
|
186
|
+
"schema accepted a scope table missing allowed-prefixes.repo",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
unittest.main()
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Tests for adapter-contract v0.8 (docs/specs/dropped-primitives-coverage).
|
|
2
|
+
|
|
3
|
+
Verifies the T1 contract edits:
|
|
4
|
+
|
|
5
|
+
- ``[contract] version == "0.8"`` in both the runtime data file
|
|
6
|
+
(`_data/adapter.toml`) and the docs mirror.
|
|
7
|
+
- Codex `agent` projection: ``mode == "codex-agent-toml"``,
|
|
8
|
+
``target-path == ".codex/agents/"``,
|
|
9
|
+
``frontmatter-mapping == "codex-agent-frontmatter-v0.8"``.
|
|
10
|
+
- Codex `hook-wiring` projection: ``mode == "merge-json"``,
|
|
11
|
+
``target-path == ".codex/hooks.json"``, ``managed-key == "hooks"``.
|
|
12
|
+
- Codex `command` projection: stays `dropped` (no upstream target).
|
|
13
|
+
- ``[adapter.codex.scope].allowed-prefixes.repo`` and ``.user`` each
|
|
14
|
+
include ``".codex/"``.
|
|
15
|
+
- ``[frontmatter-mapping."codex-agent-frontmatter-v0.8"]`` declares
|
|
16
|
+
per-key sub-tables for ``name`` and ``description``; no ``body``
|
|
17
|
+
sub-table (body-to-``developer_instructions`` is a mode-level
|
|
18
|
+
convention, not a rename rule).
|
|
19
|
+
- Schema admits ``"codex-agent-toml"`` at every site that currently
|
|
20
|
+
enumerates ``"dropped"``.
|
|
21
|
+
- Schema validates the v0.8 contract end-to-end.
|
|
22
|
+
- Property invariants for claude-code (all 5 primitives projected) and
|
|
23
|
+
kiro (4 of 5 with `command: dropped`) survive the codex changes
|
|
24
|
+
untouched. Copilot's 3 dropped entries unchanged.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import tomllib
|
|
31
|
+
import unittest
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
35
|
+
DATA_CONTRACT_PATH = (
|
|
36
|
+
REPO_ROOT / "packages" / "agentbundle" / "agentbundle" / "_data" / "adapter.toml"
|
|
37
|
+
)
|
|
38
|
+
DOCS_CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
|
|
39
|
+
DATA_SCHEMA_PATH = (
|
|
40
|
+
REPO_ROOT
|
|
41
|
+
/ "packages"
|
|
42
|
+
/ "agentbundle"
|
|
43
|
+
/ "agentbundle"
|
|
44
|
+
/ "_data"
|
|
45
|
+
/ "adapter.schema.json"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _codex_projection(contract: dict, primitive: str) -> dict:
|
|
50
|
+
"""Find the [[adapter.codex.projection]] entry for ``primitive``."""
|
|
51
|
+
for entry in contract["adapter"]["codex"]["projection"]:
|
|
52
|
+
if entry["primitive"] == primitive:
|
|
53
|
+
return entry
|
|
54
|
+
raise AssertionError(f"codex projection for primitive {primitive!r} not found")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _version_tuple(contract: dict) -> tuple[int, ...]:
|
|
58
|
+
"""(major, minor) tuple — a string compare breaks at v0.10 ("0.10"<"0.8")."""
|
|
59
|
+
return tuple(int(part) for part in contract["contract"]["version"].split("."))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestContractV08(unittest.TestCase):
|
|
63
|
+
def setUp(self) -> None:
|
|
64
|
+
self.contract = tomllib.loads(DATA_CONTRACT_PATH.read_text(encoding="utf-8"))
|
|
65
|
+
self.docs_contract = tomllib.loads(
|
|
66
|
+
DOCS_CONTRACT_PATH.read_text(encoding="utf-8")
|
|
67
|
+
)
|
|
68
|
+
self.schema = json.loads(DATA_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
69
|
+
|
|
70
|
+
def test_contract_version_is_08(self) -> None:
|
|
71
|
+
# v0.8 features are present; exact version check lives in test_contract.py.
|
|
72
|
+
# The v0.8 features (codex-agent-toml, codex-agent-frontmatter-v0.8) must
|
|
73
|
+
# survive future bumps — verify via the codex-specific tests below.
|
|
74
|
+
# Compare as (major, minor) tuples — a string compare breaks at v0.10.
|
|
75
|
+
version = _version_tuple(self.contract)
|
|
76
|
+
self.assertGreaterEqual(version, (0, 8), "contract version must be >= 0.8")
|
|
77
|
+
|
|
78
|
+
def test_docs_contract_version_is_08(self) -> None:
|
|
79
|
+
version = _version_tuple(self.docs_contract)
|
|
80
|
+
self.assertGreaterEqual(version, (0, 8), "docs contract version must be >= 0.8")
|
|
81
|
+
|
|
82
|
+
def test_codex_agent_projection(self) -> None:
|
|
83
|
+
entry = _codex_projection(self.contract, "agent")
|
|
84
|
+
self.assertEqual(entry["mode"], "codex-agent-toml")
|
|
85
|
+
self.assertEqual(entry["target-path"], ".codex/agents/")
|
|
86
|
+
self.assertEqual(
|
|
87
|
+
entry["frontmatter-mapping"], "codex-agent-frontmatter-v0.8"
|
|
88
|
+
)
|
|
89
|
+
self.assertEqual(entry["on-conflict"], "prompt-then-preserve")
|
|
90
|
+
|
|
91
|
+
def test_codex_hook_wiring_projection(self) -> None:
|
|
92
|
+
entry = _codex_projection(self.contract, "hook-wiring")
|
|
93
|
+
self.assertEqual(entry["mode"], "merge-json")
|
|
94
|
+
self.assertEqual(entry["target-path"], ".codex/hooks.json")
|
|
95
|
+
self.assertEqual(entry["managed-key"], "hooks")
|
|
96
|
+
self.assertEqual(entry["on-conflict"], "merge-managed-key-only")
|
|
97
|
+
|
|
98
|
+
def test_codex_command_still_dropped(self) -> None:
|
|
99
|
+
entry = _codex_projection(self.contract, "command")
|
|
100
|
+
self.assertEqual(entry["mode"], "dropped")
|
|
101
|
+
|
|
102
|
+
def test_codex_skill_projection_unchanged(self) -> None:
|
|
103
|
+
"""Non-regression — codex skill projection at `.agents/skills/` unchanged."""
|
|
104
|
+
entry = _codex_projection(self.contract, "skill")
|
|
105
|
+
self.assertEqual(entry["mode"], "direct-directory")
|
|
106
|
+
self.assertEqual(entry["target-path"], ".agents/skills/")
|
|
107
|
+
|
|
108
|
+
def test_codex_hook_body_projection_unchanged(self) -> None:
|
|
109
|
+
"""Non-regression — codex hook-body projection at `tools/hooks/` unchanged."""
|
|
110
|
+
entry = _codex_projection(self.contract, "hook-body")
|
|
111
|
+
self.assertEqual(entry["mode"], "direct-file")
|
|
112
|
+
self.assertEqual(entry["target-path"], "tools/hooks/")
|
|
113
|
+
|
|
114
|
+
def test_codex_allowed_prefixes_includes_codex_dir(self) -> None:
|
|
115
|
+
scope = self.contract["adapter"]["codex"]["scope"]
|
|
116
|
+
self.assertIn(".codex/", scope["allowed-prefixes"]["repo"])
|
|
117
|
+
self.assertIn(".codex/", scope["allowed-prefixes"]["user"])
|
|
118
|
+
|
|
119
|
+
def test_codex_allowed_prefixes_preserves_existing_entries(self) -> None:
|
|
120
|
+
"""Non-regression — `.agents/skills/`, `.agentbundle/`, `tools/hooks/` still present."""
|
|
121
|
+
repo = self.contract["adapter"]["codex"]["scope"]["allowed-prefixes"]["repo"]
|
|
122
|
+
user = self.contract["adapter"]["codex"]["scope"]["allowed-prefixes"]["user"]
|
|
123
|
+
for entry in (".agents/skills/", ".agentbundle/"):
|
|
124
|
+
self.assertIn(entry, repo)
|
|
125
|
+
self.assertIn(entry, user)
|
|
126
|
+
self.assertIn("tools/hooks/", repo)
|
|
127
|
+
|
|
128
|
+
def test_codex_frontmatter_mapping_table(self) -> None:
|
|
129
|
+
mapping = self.contract["frontmatter-mapping"]["codex-agent-frontmatter-v0.8"]
|
|
130
|
+
# Required per-key sub-tables.
|
|
131
|
+
self.assertIn("name", mapping)
|
|
132
|
+
self.assertEqual(mapping["name"]["rename"], "name")
|
|
133
|
+
self.assertIn("description", mapping)
|
|
134
|
+
self.assertEqual(mapping["description"]["rename"], "description")
|
|
135
|
+
# No `body` sub-table — body-to-`developer_instructions` is a
|
|
136
|
+
# mode-level convention per spec AC4, not a frontmatter rename.
|
|
137
|
+
self.assertNotIn("body", mapping)
|
|
138
|
+
self.assertNotIn("developer_instructions", mapping)
|
|
139
|
+
|
|
140
|
+
def test_schema_admits_codex_agent_toml_mode_at_every_dropped_site(self) -> None:
|
|
141
|
+
"""Walk the schema; every enum array containing "dropped" must also
|
|
142
|
+
contain "codex-agent-toml". Discovered dynamically so a future schema
|
|
143
|
+
edit that adds a fifth enum site doesn't silently drift this AC."""
|
|
144
|
+
|
|
145
|
+
def walk(node, path):
|
|
146
|
+
if isinstance(node, dict):
|
|
147
|
+
for k, v in node.items():
|
|
148
|
+
if k == "enum" and isinstance(v, list) and "dropped" in v:
|
|
149
|
+
self.assertIn(
|
|
150
|
+
"codex-agent-toml",
|
|
151
|
+
v,
|
|
152
|
+
f"schema enum at {path} admits 'dropped' but not "
|
|
153
|
+
f"'codex-agent-toml': {v!r}",
|
|
154
|
+
)
|
|
155
|
+
walk(v, f"{path}.{k}")
|
|
156
|
+
elif isinstance(node, list):
|
|
157
|
+
for i, v in enumerate(node):
|
|
158
|
+
walk(v, f"{path}[{i}]")
|
|
159
|
+
|
|
160
|
+
walk(self.schema, "$")
|
|
161
|
+
|
|
162
|
+
def test_schema_loads_v08_contract(self) -> None:
|
|
163
|
+
"""End-to-end: load schema, validate v0.8 contract; no errors. This
|
|
164
|
+
is load-bearing — it pins that codex's new `codex-agent-toml` mode
|
|
165
|
+
and the codex `merge-json` reuse both validate at the codex array
|
|
166
|
+
site (the projection-mode enum is global across adapters, so
|
|
167
|
+
`merge-json` already validates without per-site enum edits)."""
|
|
168
|
+
from agentbundle.build.validate import validate
|
|
169
|
+
|
|
170
|
+
errors = validate(self.contract, self.schema)
|
|
171
|
+
self.assertEqual(
|
|
172
|
+
errors,
|
|
173
|
+
[],
|
|
174
|
+
"v0.8 adapter.toml failed schema validation:\n" + "\n".join(errors),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def test_claude_code_unchanged_post_v08(self) -> None:
|
|
178
|
+
"""Property invariant — claude-code projects all 5 primitives."""
|
|
179
|
+
primitives = {
|
|
180
|
+
entry["primitive"]: entry
|
|
181
|
+
for entry in self.contract["adapter"]["claude-code"]["projection"]
|
|
182
|
+
}
|
|
183
|
+
self.assertEqual(
|
|
184
|
+
set(primitives), {"skill", "agent", "hook-body", "hook-wiring", "command"}
|
|
185
|
+
)
|
|
186
|
+
for primitive, entry in primitives.items():
|
|
187
|
+
self.assertNotEqual(
|
|
188
|
+
entry["mode"],
|
|
189
|
+
"dropped",
|
|
190
|
+
f"claude-code {primitive} unexpectedly dropped at v0.8",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def test_kiro_unchanged_post_v08(self) -> None:
|
|
194
|
+
"""Property invariant — kiro projects 4 of 5 primitives; command dropped."""
|
|
195
|
+
primitives = {
|
|
196
|
+
entry["primitive"]: entry
|
|
197
|
+
for entry in self.contract["adapter"]["kiro"]["projection"]
|
|
198
|
+
}
|
|
199
|
+
self.assertEqual(primitives["command"]["mode"], "dropped")
|
|
200
|
+
for primitive in ("skill", "agent", "hook-body"):
|
|
201
|
+
self.assertNotEqual(
|
|
202
|
+
primitives[primitive]["mode"],
|
|
203
|
+
"dropped",
|
|
204
|
+
f"kiro {primitive} unexpectedly dropped at v0.8",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def test_copilot_unchanged_post_v08(self) -> None:
|
|
208
|
+
"""Property invariant — at v0.10 (RFC-0024 / copilot-full-parity) copilot
|
|
209
|
+
projects 4 of 5 primitives; only `command` stays dropped (the v0.8
|
|
210
|
+
three-dropped state is superseded — agent + hook-wiring flipped to
|
|
211
|
+
native modes)."""
|
|
212
|
+
primitives = {
|
|
213
|
+
entry["primitive"]: entry
|
|
214
|
+
for entry in self.contract["adapter"]["copilot"]["projection"]
|
|
215
|
+
}
|
|
216
|
+
self.assertEqual(
|
|
217
|
+
primitives["command"]["mode"],
|
|
218
|
+
"dropped",
|
|
219
|
+
"copilot command should still be dropped (copilot-cli#618/#1113)",
|
|
220
|
+
)
|
|
221
|
+
for primitive in ("skill", "agent", "hook-body", "hook-wiring"):
|
|
222
|
+
self.assertNotEqual(
|
|
223
|
+
primitives[primitive]["mode"],
|
|
224
|
+
"dropped",
|
|
225
|
+
f"copilot {primitive} should project natively at v0.10",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
unittest.main()
|