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,250 @@
|
|
|
1
|
+
"""Tests for the stdlib JSON-Schema subset validator (T1a)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from agentbundle.build.validate import validate
|
|
12
|
+
|
|
13
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
14
|
+
SCHEMA_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.schema.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TypeKeywordTests(unittest.TestCase):
|
|
18
|
+
def test_object_accepts_dict(self) -> None:
|
|
19
|
+
self.assertEqual(validate({}, {"type": "object"}), [])
|
|
20
|
+
|
|
21
|
+
def test_object_rejects_list(self) -> None:
|
|
22
|
+
errors = validate([], {"type": "object"})
|
|
23
|
+
self.assertTrue(errors)
|
|
24
|
+
self.assertIn("expected object", errors[0])
|
|
25
|
+
|
|
26
|
+
def test_string_accepts_string(self) -> None:
|
|
27
|
+
self.assertEqual(validate("x", {"type": "string"}), [])
|
|
28
|
+
|
|
29
|
+
def test_string_rejects_integer(self) -> None:
|
|
30
|
+
errors = validate(7, {"type": "string"})
|
|
31
|
+
self.assertTrue(errors)
|
|
32
|
+
|
|
33
|
+
def test_integer_rejects_boolean(self) -> None:
|
|
34
|
+
# Python bool is a subclass of int; the validator must reject it.
|
|
35
|
+
errors = validate(True, {"type": "integer"})
|
|
36
|
+
self.assertTrue(errors)
|
|
37
|
+
self.assertIn("boolean", errors[0])
|
|
38
|
+
|
|
39
|
+
def test_boolean_accepts_true(self) -> None:
|
|
40
|
+
self.assertEqual(validate(True, {"type": "boolean"}), [])
|
|
41
|
+
|
|
42
|
+
def test_boolean_rejects_integer(self) -> None:
|
|
43
|
+
errors = validate(1, {"type": "boolean"})
|
|
44
|
+
self.assertTrue(errors)
|
|
45
|
+
|
|
46
|
+
def test_array_accepts_list(self) -> None:
|
|
47
|
+
self.assertEqual(validate([1, 2], {"type": "array"}), [])
|
|
48
|
+
|
|
49
|
+
def test_array_rejects_string(self) -> None:
|
|
50
|
+
errors = validate("nope", {"type": "array"})
|
|
51
|
+
self.assertTrue(errors)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RequiredKeywordTests(unittest.TestCase):
|
|
55
|
+
def test_required_present(self) -> None:
|
|
56
|
+
schema = {"type": "object", "required": ["a"]}
|
|
57
|
+
self.assertEqual(validate({"a": 1}, schema), [])
|
|
58
|
+
|
|
59
|
+
def test_required_missing(self) -> None:
|
|
60
|
+
schema = {"type": "object", "required": ["a"]}
|
|
61
|
+
errors = validate({}, schema)
|
|
62
|
+
self.assertTrue(errors)
|
|
63
|
+
self.assertIn("missing required property", errors[0])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class EnumKeywordTests(unittest.TestCase):
|
|
67
|
+
def test_enum_accepts_member(self) -> None:
|
|
68
|
+
self.assertEqual(validate("a", {"enum": ["a", "b"]}), [])
|
|
69
|
+
|
|
70
|
+
def test_enum_rejects_non_member(self) -> None:
|
|
71
|
+
errors = validate("c", {"enum": ["a", "b"]})
|
|
72
|
+
self.assertTrue(errors)
|
|
73
|
+
self.assertIn("not in enum", errors[0])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PatternKeywordTests(unittest.TestCase):
|
|
77
|
+
def test_pattern_match(self) -> None:
|
|
78
|
+
self.assertEqual(validate("abc123", {"pattern": "^[a-z]+[0-9]+$"}), [])
|
|
79
|
+
|
|
80
|
+
def test_pattern_mismatch(self) -> None:
|
|
81
|
+
errors = validate("nope!", {"pattern": "^[a-z]+$"})
|
|
82
|
+
self.assertTrue(errors)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ItemsKeywordTests(unittest.TestCase):
|
|
86
|
+
def test_items_homogeneous(self) -> None:
|
|
87
|
+
schema = {"type": "array", "items": {"type": "string"}}
|
|
88
|
+
self.assertEqual(validate(["a", "b"], schema), [])
|
|
89
|
+
|
|
90
|
+
def test_items_rejects_wrong_type(self) -> None:
|
|
91
|
+
schema = {"type": "array", "items": {"type": "string"}}
|
|
92
|
+
errors = validate(["a", 1], schema)
|
|
93
|
+
self.assertTrue(errors)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PropertiesAndAdditionalTests(unittest.TestCase):
|
|
97
|
+
def test_additional_properties_false(self) -> None:
|
|
98
|
+
schema = {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"properties": {"a": {"type": "string"}},
|
|
101
|
+
"additionalProperties": False,
|
|
102
|
+
}
|
|
103
|
+
errors = validate({"a": "x", "b": "y"}, schema)
|
|
104
|
+
self.assertTrue(errors)
|
|
105
|
+
self.assertIn("additional property", errors[0])
|
|
106
|
+
|
|
107
|
+
def test_additional_properties_default_allows(self) -> None:
|
|
108
|
+
schema = {"type": "object", "properties": {"a": {"type": "string"}}}
|
|
109
|
+
self.assertEqual(validate({"a": "x", "b": "y"}, schema), [])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SchemaJsonSelfValidationTests(unittest.TestCase):
|
|
113
|
+
"""The shipped adapter.schema.json must load and validate at least one minimal
|
|
114
|
+
contract — a smoke test for T1a's authored schema."""
|
|
115
|
+
|
|
116
|
+
def test_schema_loads(self) -> None:
|
|
117
|
+
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
118
|
+
self.assertEqual(schema.get("type"), "object")
|
|
119
|
+
|
|
120
|
+
def test_schema_accepts_minimal_contract(self) -> None:
|
|
121
|
+
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
122
|
+
minimal = {
|
|
123
|
+
"contract": {"version": "0.1"},
|
|
124
|
+
"primitive": {
|
|
125
|
+
"skill": {"source-path": ".apm/skills/"},
|
|
126
|
+
"agent": {"source-path": ".apm/agents/"},
|
|
127
|
+
"hook-body": {"source-path": ".apm/hooks/"},
|
|
128
|
+
"hook-wiring": {"source-path": ".apm/hook-wiring/"},
|
|
129
|
+
"command": {"source-path": ".apm/commands/"},
|
|
130
|
+
},
|
|
131
|
+
"adapter": {
|
|
132
|
+
"claude-code": {
|
|
133
|
+
"projection": [
|
|
134
|
+
{
|
|
135
|
+
"primitive": "skill",
|
|
136
|
+
"mode": "direct-directory",
|
|
137
|
+
"target-path": ".claude/skills/",
|
|
138
|
+
"on-conflict": "prompt-then-preserve",
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
errors = validate(minimal, schema)
|
|
145
|
+
self.assertEqual(errors, [], f"schema rejected minimal contract: {errors}")
|
|
146
|
+
|
|
147
|
+
def test_schema_rejects_unknown_mode(self) -> None:
|
|
148
|
+
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
149
|
+
bad = {
|
|
150
|
+
"contract": {"version": "0.1"},
|
|
151
|
+
"primitive": {
|
|
152
|
+
"skill": {"source-path": ".apm/skills/"},
|
|
153
|
+
"agent": {"source-path": ".apm/agents/"},
|
|
154
|
+
"hook-body": {"source-path": ".apm/hooks/"},
|
|
155
|
+
"hook-wiring": {"source-path": ".apm/hook-wiring/"},
|
|
156
|
+
"command": {"source-path": ".apm/commands/"},
|
|
157
|
+
},
|
|
158
|
+
"adapter": {
|
|
159
|
+
"claude-code": {
|
|
160
|
+
"projection": [
|
|
161
|
+
{
|
|
162
|
+
"primitive": "skill",
|
|
163
|
+
"mode": "bogus-mode",
|
|
164
|
+
"target-path": ".claude/skills/",
|
|
165
|
+
"on-conflict": "prompt-then-preserve",
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
errors = validate(bad, schema)
|
|
172
|
+
self.assertTrue(errors, "schema accepted an unknown projection mode")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CliValidateSubcommandTests(unittest.TestCase):
|
|
176
|
+
"""`python -m agentbundle.build validate <path>` smoke test.
|
|
177
|
+
|
|
178
|
+
Uses subprocess so the test exercises the actual argparse wiring.
|
|
179
|
+
Does not rely on adapter.toml existing (T1b authors that); writes
|
|
180
|
+
a temp TOML and validates it against the shipped schema.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def test_validate_subcommand_exits_zero_on_minimal_contract(self) -> None:
|
|
184
|
+
import tempfile
|
|
185
|
+
|
|
186
|
+
toml_text = """
|
|
187
|
+
[contract]
|
|
188
|
+
version = "0.1"
|
|
189
|
+
|
|
190
|
+
[primitive.skill]
|
|
191
|
+
source-path = ".apm/skills/"
|
|
192
|
+
|
|
193
|
+
[primitive.agent]
|
|
194
|
+
source-path = ".apm/agents/"
|
|
195
|
+
|
|
196
|
+
[primitive.hook-body]
|
|
197
|
+
source-path = ".apm/hooks/"
|
|
198
|
+
|
|
199
|
+
[primitive.hook-wiring]
|
|
200
|
+
source-path = ".apm/hook-wiring/"
|
|
201
|
+
|
|
202
|
+
[primitive.command]
|
|
203
|
+
source-path = ".apm/commands/"
|
|
204
|
+
|
|
205
|
+
[[adapter.claude-code.projection]]
|
|
206
|
+
primitive = "skill"
|
|
207
|
+
mode = "direct-directory"
|
|
208
|
+
target-path = ".claude/skills/"
|
|
209
|
+
on-conflict = "prompt-then-preserve"
|
|
210
|
+
"""
|
|
211
|
+
with tempfile.NamedTemporaryFile(
|
|
212
|
+
mode="w", suffix=".toml", delete=False
|
|
213
|
+
) as tmp:
|
|
214
|
+
tmp.write(toml_text)
|
|
215
|
+
tmp_path = tmp.name
|
|
216
|
+
|
|
217
|
+
result = subprocess.run(
|
|
218
|
+
[sys.executable, "-m", "agentbundle.build", "validate", tmp_path],
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
cwd=REPO_ROOT,
|
|
222
|
+
)
|
|
223
|
+
self.assertEqual(
|
|
224
|
+
result.returncode,
|
|
225
|
+
0,
|
|
226
|
+
f"validate subcommand failed: stderr={result.stderr}",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def test_help_exits_zero(self) -> None:
|
|
230
|
+
result = subprocess.run(
|
|
231
|
+
[sys.executable, "-m", "agentbundle.build", "--help"],
|
|
232
|
+
capture_output=True,
|
|
233
|
+
text=True,
|
|
234
|
+
cwd=REPO_ROOT,
|
|
235
|
+
)
|
|
236
|
+
self.assertEqual(result.returncode, 0)
|
|
237
|
+
self.assertIn("validate", result.stdout)
|
|
238
|
+
|
|
239
|
+
def test_validate_help_exits_zero(self) -> None:
|
|
240
|
+
result = subprocess.run(
|
|
241
|
+
[sys.executable, "-m", "agentbundle.build", "validate", "--help"],
|
|
242
|
+
capture_output=True,
|
|
243
|
+
text=True,
|
|
244
|
+
cwd=REPO_ROOT,
|
|
245
|
+
)
|
|
246
|
+
self.assertEqual(result.returncode, 0)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
unittest.main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Stdlib-only JSON-Schema subset validator.
|
|
2
|
+
|
|
3
|
+
The build pipeline depends on stdlib only (per spec § Boundaries —
|
|
4
|
+
Never do), so we cannot import a JSON-Schema library. This module
|
|
5
|
+
ships the subset RFC-0001 + spec AC #6 commit to:
|
|
6
|
+
|
|
7
|
+
Supported keywords:
|
|
8
|
+
- `type`: one of "object", "array", "string", "integer", "boolean"
|
|
9
|
+
- `properties`: object → schema map (only meaningful when type=object)
|
|
10
|
+
- `required`: list of property names (only meaningful when type=object)
|
|
11
|
+
- `enum`: list of allowed scalar values
|
|
12
|
+
- `pattern`: regex string applied to strings (via `re`)
|
|
13
|
+
- `items`: schema applied element-wise (only meaningful when type=array)
|
|
14
|
+
- `additionalProperties`: bool or schema (only meaningful when type=object)
|
|
15
|
+
- `minItems`: integer; array must have at least this many items
|
|
16
|
+
- `contains`: subschema; array must have at least one item matching it
|
|
17
|
+
- `if` / `then` / `else`: conditional subschemas (all three applied to
|
|
18
|
+
the same instance as the parent). RFC-0004's pack.schema.json uses
|
|
19
|
+
this trio to express "v0.2 packs require `[pack.install]`" and
|
|
20
|
+
"default-scope ∈ allowed-scopes."
|
|
21
|
+
|
|
22
|
+
Unsupported by design: `$ref`, `$defs`, `oneOf`, `anyOf`, `allOf`,
|
|
23
|
+
`not`, `format`, `minimum`/`maximum`, `minLength`/`maxLength`. If the
|
|
24
|
+
contract grows a need, an RFC amends this subset; the validator does
|
|
25
|
+
not silently expand.
|
|
26
|
+
|
|
27
|
+
`validate(instance, schema)` returns a list of error strings — empty
|
|
28
|
+
list means valid. Callers decide how to surface (the CLI's `validate`
|
|
29
|
+
subcommand prints them to stderr and exits non-zero).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
_TYPE_PYTHON = {
|
|
38
|
+
"object": dict,
|
|
39
|
+
"array": list,
|
|
40
|
+
"string": str,
|
|
41
|
+
"integer": int,
|
|
42
|
+
"boolean": bool,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate(instance: Any, schema: dict, path: str = "$") -> list[str]:
|
|
47
|
+
"""Validate `instance` against `schema`. Return a list of error strings."""
|
|
48
|
+
errors: list[str] = []
|
|
49
|
+
if not isinstance(schema, dict):
|
|
50
|
+
return [f"{path}: schema is not an object"]
|
|
51
|
+
|
|
52
|
+
expected_type = schema.get("type")
|
|
53
|
+
if expected_type is not None:
|
|
54
|
+
if expected_type not in _TYPE_PYTHON:
|
|
55
|
+
return [f"{path}: unsupported type {expected_type!r}"]
|
|
56
|
+
py_type = _TYPE_PYTHON[expected_type]
|
|
57
|
+
# bool is a subclass of int in Python; reject bool when integer is expected
|
|
58
|
+
# and reject int when boolean is expected.
|
|
59
|
+
if expected_type == "integer" and isinstance(instance, bool):
|
|
60
|
+
errors.append(f"{path}: expected integer, got boolean")
|
|
61
|
+
return errors
|
|
62
|
+
if expected_type == "boolean" and not isinstance(instance, bool):
|
|
63
|
+
errors.append(f"{path}: expected boolean, got {type(instance).__name__}")
|
|
64
|
+
return errors
|
|
65
|
+
if not isinstance(instance, py_type):
|
|
66
|
+
errors.append(
|
|
67
|
+
f"{path}: expected {expected_type}, got {type(instance).__name__}"
|
|
68
|
+
)
|
|
69
|
+
return errors
|
|
70
|
+
|
|
71
|
+
if "enum" in schema:
|
|
72
|
+
if instance not in schema["enum"]:
|
|
73
|
+
errors.append(
|
|
74
|
+
f"{path}: value {instance!r} not in enum {schema['enum']!r}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if "pattern" in schema and isinstance(instance, str):
|
|
78
|
+
if re.search(schema["pattern"], instance) is None:
|
|
79
|
+
errors.append(
|
|
80
|
+
f"{path}: value {instance!r} does not match pattern {schema['pattern']!r}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# `required`, `properties`, `additionalProperties` apply whenever the
|
|
84
|
+
# instance is a dict — per JSON-Schema 2020-12, these keywords are
|
|
85
|
+
# vacuously true for non-object instances. Gating on `expected_type ==
|
|
86
|
+
# "object"` (the previous shape) caused subschemas with no `type`
|
|
87
|
+
# declaration (e.g. RFC-0004's `if`/`then` blocks) to silently skip
|
|
88
|
+
# their constraints — surprising callers and breaking the cross-field
|
|
89
|
+
# `default-scope ∈ allowed-scopes` invariant. Stay safe-by-default:
|
|
90
|
+
# apply when the instance matches.
|
|
91
|
+
if isinstance(instance, dict):
|
|
92
|
+
for required_key in schema.get("required", []):
|
|
93
|
+
if required_key not in instance:
|
|
94
|
+
errors.append(f"{path}: missing required property {required_key!r}")
|
|
95
|
+
properties = schema.get("properties", {})
|
|
96
|
+
for key, value in instance.items():
|
|
97
|
+
subpath = f"{path}.{key}"
|
|
98
|
+
if key in properties:
|
|
99
|
+
errors.extend(validate(value, properties[key], subpath))
|
|
100
|
+
else:
|
|
101
|
+
additional = schema.get("additionalProperties", True)
|
|
102
|
+
if additional is False:
|
|
103
|
+
errors.append(f"{path}: additional property {key!r} not allowed")
|
|
104
|
+
elif isinstance(additional, dict):
|
|
105
|
+
errors.extend(validate(value, additional, subpath))
|
|
106
|
+
|
|
107
|
+
if isinstance(instance, list):
|
|
108
|
+
item_schema = schema.get("items")
|
|
109
|
+
if isinstance(item_schema, dict):
|
|
110
|
+
for index, element in enumerate(instance):
|
|
111
|
+
errors.extend(validate(element, item_schema, f"{path}[{index}]"))
|
|
112
|
+
min_items = schema.get("minItems")
|
|
113
|
+
# bool is a subclass of int in Python — accept only true integers.
|
|
114
|
+
if isinstance(min_items, int) and not isinstance(min_items, bool):
|
|
115
|
+
if len(instance) < min_items:
|
|
116
|
+
errors.append(
|
|
117
|
+
f"{path}: array has {len(instance)} item(s), minItems={min_items}"
|
|
118
|
+
)
|
|
119
|
+
contains_schema = schema.get("contains")
|
|
120
|
+
if isinstance(contains_schema, dict):
|
|
121
|
+
if not any(
|
|
122
|
+
not validate(element, contains_schema, f"{path}[{index}]")
|
|
123
|
+
for index, element in enumerate(instance)
|
|
124
|
+
):
|
|
125
|
+
errors.append(
|
|
126
|
+
f"{path}: no item matches the 'contains' subschema"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Conditional subschemas — applied last so type/required/enum errors on
|
|
130
|
+
# the instance surface before any conditional branch fires. The 'if'
|
|
131
|
+
# subschema is evaluated silently (its errors are not surfaced); only
|
|
132
|
+
# the chosen branch's errors propagate. Per JSON-Schema 2020-12,
|
|
133
|
+
# missing 'then' or 'else' is a no-op for that branch.
|
|
134
|
+
if "if" in schema and isinstance(schema["if"], dict):
|
|
135
|
+
if_errors = validate(instance, schema["if"], path)
|
|
136
|
+
branch_key = "then" if not if_errors else "else"
|
|
137
|
+
branch_schema = schema.get(branch_key)
|
|
138
|
+
if isinstance(branch_schema, dict):
|
|
139
|
+
errors.extend(validate(instance, branch_schema, path))
|
|
140
|
+
|
|
141
|
+
return errors
|
agentbundle/catalogue.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Catalogue URI resolver — T5 deliverable, reused by T6/T8/T11/T12.
|
|
2
|
+
|
|
3
|
+
Accepts:
|
|
4
|
+
- Local relative or absolute paths.
|
|
5
|
+
- ``git+https://github.com/<owner>/<repo>[@<ref>]``
|
|
6
|
+
|
|
7
|
+
For ``git+https://`` URIs the resolver:
|
|
8
|
+
1. Parses owner, repo, and optional ref.
|
|
9
|
+
2. Constructs a GitHub archive URL (tag, branch, or SHA — tried in
|
|
10
|
+
that order by a light heuristic: tags contain only ``v`` + semver
|
|
11
|
+
chars or no slash; SHAs are exactly 40 hex chars; everything else
|
|
12
|
+
is a branch).
|
|
13
|
+
3. Fetches with ``urllib.request.urlopen`` — no subprocess, no git.
|
|
14
|
+
4. Extracts with ``tarfile`` into a per-call tempdir and returns the
|
|
15
|
+
inner ``<repo>-<ref>/`` directory.
|
|
16
|
+
|
|
17
|
+
``git+ssh://`` URIs raise ``CatalogueError`` immediately — SSH is
|
|
18
|
+
deferred to v1.1.
|
|
19
|
+
|
|
20
|
+
Unreachable URLs raise ``CatalogueError`` with the tarball URL in the
|
|
21
|
+
message so the caller can report exactly what was attempted.
|
|
22
|
+
|
|
23
|
+
No subprocess calls anywhere in this module.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import atexit
|
|
29
|
+
import re
|
|
30
|
+
import shutil
|
|
31
|
+
import tarfile
|
|
32
|
+
import tempfile
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
_SSH_PREFIX = "git+ssh://"
|
|
38
|
+
_HTTPS_PREFIX = "git+https://"
|
|
39
|
+
|
|
40
|
+
# Match git+https://github.com/<owner>/<repo>[@<ref>]
|
|
41
|
+
# Group 1: owner, Group 2: repo (no .git suffix), Group 3: ref (optional)
|
|
42
|
+
_HTTPS_RE = re.compile(
|
|
43
|
+
r"^git\+https://github\.com/([^/]+)/([^/@]+?)(?:\.git)?(?:@([^@]+))?$"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# A SHA is exactly 40 lowercase hex digits (or 7–40 for abbreviated SHAs;
|
|
47
|
+
# we accept the full pattern only to keep the heuristic simple).
|
|
48
|
+
_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CatalogueError(ValueError):
|
|
52
|
+
"""Raised when a catalogue URI cannot be resolved."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_catalogue(uri: str) -> Path:
|
|
56
|
+
"""Resolve *uri* to a local directory rooted at the catalogue.
|
|
57
|
+
|
|
58
|
+
Returns a ``Path`` to the local directory. For ``git+https://`` URIs
|
|
59
|
+
the path lives inside a per-call tempdir registered with ``atexit``
|
|
60
|
+
so it's removed at process exit — see ``_resolve_https``. Callers
|
|
61
|
+
must not assume the directory survives past process termination.
|
|
62
|
+
"""
|
|
63
|
+
if uri.startswith(_SSH_PREFIX):
|
|
64
|
+
raise CatalogueError(
|
|
65
|
+
"SSH git URLs deferred to v1.1; use https or local path."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if uri.startswith(_HTTPS_PREFIX):
|
|
69
|
+
return _resolve_https(uri)
|
|
70
|
+
|
|
71
|
+
# Local path — relative or absolute.
|
|
72
|
+
return Path(uri)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_https(uri: str) -> Path:
|
|
76
|
+
m = _HTTPS_RE.match(uri)
|
|
77
|
+
if not m:
|
|
78
|
+
raise CatalogueError(
|
|
79
|
+
f"Cannot parse git+https URI: {uri!r}. "
|
|
80
|
+
"Expected format: git+https://github.com/<owner>/<repo>[@<ref>]"
|
|
81
|
+
)
|
|
82
|
+
owner, repo, ref = m.group(1), m.group(2), m.group(3)
|
|
83
|
+
if not ref:
|
|
84
|
+
ref = "main"
|
|
85
|
+
|
|
86
|
+
tarball_url = _github_archive_url(owner, repo, ref)
|
|
87
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="agentbundle-catalogue-"))
|
|
88
|
+
# Best-effort cleanup at process exit — atexit handlers run on normal
|
|
89
|
+
# interpreter shutdown; for crash paths the OS reaps /tmp eventually.
|
|
90
|
+
atexit.register(shutil.rmtree, str(tmpdir), True)
|
|
91
|
+
_fetch_and_extract(tarball_url, tmpdir)
|
|
92
|
+
# The GitHub archive extracts to <repo>-<ref>/ (with '/' → '-' in SHAs).
|
|
93
|
+
inner = _find_inner_dir(tmpdir)
|
|
94
|
+
return inner
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ref_type(ref: str) -> str:
|
|
98
|
+
"""Heuristically classify a ref as 'tag', 'sha', or 'branch'.
|
|
99
|
+
|
|
100
|
+
The plan specifies: try tag, then branch, then SHA order — but we
|
|
101
|
+
need to pick exactly one URL at construction time because the caller
|
|
102
|
+
doesn't retry across URL forms (the tarball fetch either succeeds or
|
|
103
|
+
raises ``CatalogueError``).
|
|
104
|
+
|
|
105
|
+
Heuristic:
|
|
106
|
+
- Exactly 40 lowercase hex chars → SHA.
|
|
107
|
+
- Looks like a version tag (optional 'v' + digits/dots, e.g. v1.0
|
|
108
|
+
or 1.0.0) → tag.
|
|
109
|
+
- Anything else → branch.
|
|
110
|
+
|
|
111
|
+
This matches the plan's examples: ``v1.0`` → tag, ``main`` → branch,
|
|
112
|
+
``deadbeef`` (7 chars) or a full 40-char SHA → sha.
|
|
113
|
+
|
|
114
|
+
Abbreviated SHAs (7–39 chars, all hex) are treated as SHA because
|
|
115
|
+
that's the most likely intent and ``archive/<sha>`` accepts prefixes.
|
|
116
|
+
"""
|
|
117
|
+
if _SHA_RE.match(ref):
|
|
118
|
+
return "sha"
|
|
119
|
+
# Version tag pattern: optional 'v', one or more numeric segments
|
|
120
|
+
if re.match(r"^v?\d+(\.\d+)*$", ref):
|
|
121
|
+
return "tag"
|
|
122
|
+
return "branch"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _github_archive_url(owner: str, repo: str, ref: str) -> str:
|
|
126
|
+
rtype = _ref_type(ref)
|
|
127
|
+
if rtype == "tag":
|
|
128
|
+
return f"https://github.com/{owner}/{repo}/archive/refs/tags/{ref}.tar.gz"
|
|
129
|
+
if rtype == "branch":
|
|
130
|
+
return f"https://github.com/{owner}/{repo}/archive/refs/heads/{ref}.tar.gz"
|
|
131
|
+
# SHA
|
|
132
|
+
return f"https://github.com/{owner}/{repo}/archive/{ref}.tar.gz"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _fetch_and_extract(url: str, dest: Path) -> None:
|
|
136
|
+
try:
|
|
137
|
+
with urllib.request.urlopen(url) as resp: # noqa: S310 — url is assembled from parsed owner/repo/ref
|
|
138
|
+
with tarfile.open(fileobj=resp, mode="r|gz") as tf:
|
|
139
|
+
# filter="data" rejects unsafe members (absolute paths, ..
|
|
140
|
+
# links, devices, setuid bits) — Python 3.12+ default but
|
|
141
|
+
# explicit for 3.11 compatibility and to silence the 3.14
|
|
142
|
+
# DeprecationWarning. Path-jail is belt; this is braces.
|
|
143
|
+
tf.extractall(path=dest, filter="data") # noqa: S202
|
|
144
|
+
except urllib.error.URLError as exc:
|
|
145
|
+
raise CatalogueError(
|
|
146
|
+
f"Failed to fetch catalogue archive: {url} — {exc.reason}"
|
|
147
|
+
) from exc
|
|
148
|
+
except tarfile.TarError as exc:
|
|
149
|
+
raise CatalogueError(
|
|
150
|
+
f"Failed to extract tarball from {url}: {exc}"
|
|
151
|
+
) from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _find_inner_dir(tmpdir: Path) -> Path:
|
|
155
|
+
"""Return the single top-level directory inside *tmpdir*.
|
|
156
|
+
|
|
157
|
+
GitHub archives always produce exactly one top-level directory
|
|
158
|
+
(``<repo>-<ref>/``). If the extraction produced something else,
|
|
159
|
+
return *tmpdir* itself so callers still have something to work with.
|
|
160
|
+
"""
|
|
161
|
+
children = [p for p in tmpdir.iterdir() if p.is_dir()]
|
|
162
|
+
if len(children) == 1:
|
|
163
|
+
return children[0]
|
|
164
|
+
return tmpdir
|