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,699 @@
|
|
|
1
|
+
"""Tests for the Codex adapter (T5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from agentbundle.build.adapters import codex
|
|
11
|
+
from agentbundle.build.adapters.codex import (
|
|
12
|
+
_LEGACY_SKILL_BLOCK_END,
|
|
13
|
+
_LEGACY_SKILL_BLOCK_START,
|
|
14
|
+
_splice_managed_block,
|
|
15
|
+
_strip_legacy_skill_block,
|
|
16
|
+
project,
|
|
17
|
+
project_packs,
|
|
18
|
+
)
|
|
19
|
+
from agentbundle.build.contract import load as load_contract
|
|
20
|
+
|
|
21
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
22
|
+
CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _seed_pack(root: Path, name: str = "pack", skill_prefix: str = "") -> Path:
|
|
26
|
+
pack = root / name
|
|
27
|
+
(pack / ".apm" / "skills" / f"{skill_prefix}foo").mkdir(parents=True)
|
|
28
|
+
(pack / ".apm" / "skills" / f"{skill_prefix}foo" / "SKILL.md").write_text(
|
|
29
|
+
f"---\ndescription: {skill_prefix}foo skill description\n---\n# foo\n",
|
|
30
|
+
encoding="utf-8",
|
|
31
|
+
)
|
|
32
|
+
(pack / ".apm" / "skills" / f"{skill_prefix}alpha").mkdir(parents=True)
|
|
33
|
+
(pack / ".apm" / "skills" / f"{skill_prefix}alpha" / "SKILL.md").write_text(
|
|
34
|
+
f"---\ndescription: {skill_prefix}alpha skill description\n---\n# alpha\n",
|
|
35
|
+
encoding="utf-8",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
(pack / ".apm" / "agents").mkdir(parents=True)
|
|
39
|
+
(pack / ".apm" / "agents" / "bar.md").write_text("agent body\n", encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
(pack / ".apm" / "hooks").mkdir(parents=True)
|
|
42
|
+
(pack / ".apm" / "hooks" / "baz.sh").write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
|
|
43
|
+
(pack / ".apm" / "hooks" / "baz.py").write_text("print('hi')\n", encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
(pack / ".apm" / "hook-wiring").mkdir(parents=True)
|
|
46
|
+
(pack / ".apm" / "hook-wiring" / "baz.toml").write_text("[hooks]\n", encoding="utf-8")
|
|
47
|
+
|
|
48
|
+
(pack / ".apm" / "commands").mkdir(parents=True)
|
|
49
|
+
(pack / ".apm" / "commands" / "qux.md").write_text("# qux\n", encoding="utf-8")
|
|
50
|
+
return pack
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CodexAdapterTests(unittest.TestCase):
|
|
54
|
+
@classmethod
|
|
55
|
+
def setUpClass(cls) -> None:
|
|
56
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
57
|
+
|
|
58
|
+
def test_only_command_dropped_post_v08(self) -> None:
|
|
59
|
+
"""v0.8 inverts the pre-bump assertion: codex now projects `agent`
|
|
60
|
+
(via codex-agent-toml) and `hook-wiring` (via merge-json) natively.
|
|
61
|
+
Only `command` stays dropped — codex custom-prompts are deprecated
|
|
62
|
+
upstream in favour of skills (RFC pointer in spec § Assumptions).
|
|
63
|
+
|
|
64
|
+
Renamed from ``test_agent_hook_wiring_command_dropped`` — the
|
|
65
|
+
v0.7 assertion was the inverse of what the v0.8 contract claims
|
|
66
|
+
(AC2). Deliberate spec-driven inversion, not a regression hiding
|
|
67
|
+
behind a test deletion.
|
|
68
|
+
"""
|
|
69
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
70
|
+
tmp_path = Path(tmp)
|
|
71
|
+
pack = _seed_pack(tmp_path)
|
|
72
|
+
out = tmp_path / "out"
|
|
73
|
+
project(pack, self.contract, out)
|
|
74
|
+
# Agent .md no longer appears anywhere (projected as .toml).
|
|
75
|
+
self.assertFalse(any(out.rglob("bar.md")))
|
|
76
|
+
# Command .md still nowhere (codex command stays dropped).
|
|
77
|
+
self.assertFalse(any(out.rglob("qux.md")))
|
|
78
|
+
# Agent IS projected as TOML.
|
|
79
|
+
self.assertTrue((out / ".codex" / "agents" / "bar.toml").exists())
|
|
80
|
+
|
|
81
|
+
def test_codex_agent_projects_via_codex_agent_toml_mode(self) -> None:
|
|
82
|
+
"""The pack ships ``.apm/agents/<name>.md``; codex projects each
|
|
83
|
+
as ``.codex/agents/<name>.toml`` with the three expected keys."""
|
|
84
|
+
import tomllib
|
|
85
|
+
|
|
86
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
87
|
+
tmp_path = Path(tmp)
|
|
88
|
+
pack = tmp_path / "pack"
|
|
89
|
+
(pack / ".apm" / "agents").mkdir(parents=True)
|
|
90
|
+
(pack / ".apm" / "agents" / "bar.md").write_text(
|
|
91
|
+
"---\nname: bar\ndescription: a bar agent\n---\nAgent body.\n",
|
|
92
|
+
encoding="utf-8",
|
|
93
|
+
)
|
|
94
|
+
out = tmp_path / "out"
|
|
95
|
+
project(pack, self.contract, out)
|
|
96
|
+
target = out / ".codex" / "agents" / "bar.toml"
|
|
97
|
+
self.assertTrue(target.exists(), f"expected {target}")
|
|
98
|
+
data = tomllib.loads(target.read_text(encoding="utf-8"))
|
|
99
|
+
self.assertEqual(data["name"], "bar")
|
|
100
|
+
self.assertEqual(data["description"], "a bar agent")
|
|
101
|
+
self.assertIn("developer_instructions", data)
|
|
102
|
+
|
|
103
|
+
def test_codex_hook_wiring_projects_via_merge_json(self) -> None:
|
|
104
|
+
"""Pack ships ``.apm/hook-wiring/<name>.toml``; codex projects the
|
|
105
|
+
merged result at ``.codex/hooks.json`` with the ``hooks`` key."""
|
|
106
|
+
import json
|
|
107
|
+
|
|
108
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
109
|
+
tmp_path = Path(tmp)
|
|
110
|
+
pack = tmp_path / "pack"
|
|
111
|
+
(pack / ".apm" / "hook-wiring").mkdir(parents=True)
|
|
112
|
+
(pack / ".apm" / "hook-wiring" / "wire.toml").write_text(
|
|
113
|
+
'[hooks]\n'
|
|
114
|
+
'"SessionStart" = [{matcher = "*", hooks = [{type = "command", command = "echo hi"}]}]\n',
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
)
|
|
117
|
+
out = tmp_path / "out"
|
|
118
|
+
project(pack, self.contract, out)
|
|
119
|
+
target = out / ".codex" / "hooks.json"
|
|
120
|
+
self.assertTrue(target.exists(), f"expected {target}")
|
|
121
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
122
|
+
self.assertIn("hooks", data)
|
|
123
|
+
self.assertIn("SessionStart", data["hooks"])
|
|
124
|
+
|
|
125
|
+
def test_codex_command_still_dropped_at_build_time(self) -> None:
|
|
126
|
+
"""Fixture pack with one command; assert NO command-shaped output
|
|
127
|
+
anywhere under ``<output>/.codex/`` (mode is `dropped`,
|
|
128
|
+
``_iter_primitives`` skips it)."""
|
|
129
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
130
|
+
tmp_path = Path(tmp)
|
|
131
|
+
pack = tmp_path / "pack"
|
|
132
|
+
(pack / ".apm" / "commands").mkdir(parents=True)
|
|
133
|
+
(pack / ".apm" / "commands" / "qux.md").write_text(
|
|
134
|
+
"# qux command\n", encoding="utf-8"
|
|
135
|
+
)
|
|
136
|
+
out = tmp_path / "out"
|
|
137
|
+
project(pack, self.contract, out)
|
|
138
|
+
self.assertFalse(
|
|
139
|
+
any(out.rglob("qux.md")),
|
|
140
|
+
"command projection should be skipped (dropped)",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def test_hook_body_extensions_preserved(self) -> None:
|
|
144
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
145
|
+
tmp_path = Path(tmp)
|
|
146
|
+
pack = _seed_pack(tmp_path)
|
|
147
|
+
out = tmp_path / "out"
|
|
148
|
+
project(pack, self.contract, out)
|
|
149
|
+
self.assertTrue((out / "tools" / "hooks" / "baz.sh").exists())
|
|
150
|
+
self.assertTrue((out / "tools" / "hooks" / "baz.py").exists())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _seed_two_skill_pack(root: Path, name: str = "two-skill") -> Path:
|
|
154
|
+
"""Two skills: one flat, one with nested subdirectories."""
|
|
155
|
+
pack = root / name
|
|
156
|
+
flat = pack / ".apm" / "skills" / "flat"
|
|
157
|
+
flat.mkdir(parents=True)
|
|
158
|
+
(flat / "SKILL.md").write_text(
|
|
159
|
+
"---\ndescription: flat skill\n---\n# flat\nbody\n",
|
|
160
|
+
encoding="utf-8",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
nested = pack / ".apm" / "skills" / "nested"
|
|
164
|
+
(nested / "scripts").mkdir(parents=True)
|
|
165
|
+
(nested / "references").mkdir(parents=True)
|
|
166
|
+
(nested / "SKILL.md").write_text(
|
|
167
|
+
"---\ndescription: nested skill\n---\n# nested\nbody\n",
|
|
168
|
+
encoding="utf-8",
|
|
169
|
+
)
|
|
170
|
+
(nested / "scripts" / "run.sh").write_text(
|
|
171
|
+
"#!/bin/sh\necho run\n",
|
|
172
|
+
encoding="utf-8",
|
|
173
|
+
)
|
|
174
|
+
(nested / "references" / "notes.md").write_text(
|
|
175
|
+
"# Notes\nReference content.\n",
|
|
176
|
+
encoding="utf-8",
|
|
177
|
+
)
|
|
178
|
+
return pack
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _seed_symlinked_pack(root: Path, name: str = "symlinked") -> Path:
|
|
182
|
+
"""Skill body with a relative symlink under references/."""
|
|
183
|
+
pack = root / name
|
|
184
|
+
(pack / ".apm" / "assets").mkdir(parents=True)
|
|
185
|
+
(pack / ".apm" / "assets" / "shared.md").write_text(
|
|
186
|
+
"# Shared\nContent.\n",
|
|
187
|
+
encoding="utf-8",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
linker = pack / ".apm" / "skills" / "linker"
|
|
191
|
+
(linker / "references").mkdir(parents=True)
|
|
192
|
+
(linker / "SKILL.md").write_text(
|
|
193
|
+
"---\ndescription: linker skill\n---\n# linker\n",
|
|
194
|
+
encoding="utf-8",
|
|
195
|
+
)
|
|
196
|
+
(linker / "references" / "shared.md").symlink_to(Path("../../../assets/shared.md"))
|
|
197
|
+
return pack
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _seed_same_name_pack(root: Path, name: str, body: str) -> Path:
|
|
201
|
+
pack = root / name
|
|
202
|
+
skill_dir = pack / ".apm" / "skills" / "same-name"
|
|
203
|
+
skill_dir.mkdir(parents=True)
|
|
204
|
+
(skill_dir / "SKILL.md").write_text(body, encoding="utf-8")
|
|
205
|
+
return pack
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class TestDirectDirectoryProjection(unittest.TestCase):
|
|
209
|
+
"""Post-RFC-0009 Codex `skill` projection — `direct-directory` mode."""
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def setUpClass(cls) -> None:
|
|
213
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
214
|
+
|
|
215
|
+
def test_byte_equal_projection_two_skill(self) -> None:
|
|
216
|
+
# AC3, AC4.
|
|
217
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
218
|
+
tmp_path = Path(tmp)
|
|
219
|
+
pack = _seed_two_skill_pack(tmp_path)
|
|
220
|
+
out = tmp_path / "out"
|
|
221
|
+
|
|
222
|
+
project_packs([pack], self.contract, out)
|
|
223
|
+
|
|
224
|
+
for rel in (
|
|
225
|
+
"flat/SKILL.md",
|
|
226
|
+
"nested/SKILL.md",
|
|
227
|
+
"nested/scripts/run.sh",
|
|
228
|
+
"nested/references/notes.md",
|
|
229
|
+
):
|
|
230
|
+
source_bytes = (pack / ".apm" / "skills" / rel).read_bytes()
|
|
231
|
+
projected_bytes = (out / ".agents" / "skills" / rel).read_bytes()
|
|
232
|
+
self.assertEqual(
|
|
233
|
+
projected_bytes,
|
|
234
|
+
source_bytes,
|
|
235
|
+
f"byte mismatch at {rel}",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def test_symlink_pass_through(self) -> None:
|
|
239
|
+
# AC5.
|
|
240
|
+
import os
|
|
241
|
+
|
|
242
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
243
|
+
tmp_path = Path(tmp)
|
|
244
|
+
pack = _seed_symlinked_pack(tmp_path)
|
|
245
|
+
out = tmp_path / "out"
|
|
246
|
+
|
|
247
|
+
project_packs([pack], self.contract, out)
|
|
248
|
+
|
|
249
|
+
projected_link = out / ".agents" / "skills" / "linker" / "references" / "shared.md"
|
|
250
|
+
self.assertTrue(os.path.islink(projected_link))
|
|
251
|
+
self.assertEqual(
|
|
252
|
+
os.readlink(projected_link),
|
|
253
|
+
str(Path("../../../assets/shared.md")),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def test_same_name_last_wins(self) -> None:
|
|
257
|
+
# AC6 — Codex case.
|
|
258
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
259
|
+
tmp_path = Path(tmp)
|
|
260
|
+
pack_a = _seed_same_name_pack(
|
|
261
|
+
tmp_path, "pack-a", "# pack-a\nPACK_A_SENTINEL\n",
|
|
262
|
+
)
|
|
263
|
+
pack_b = _seed_same_name_pack(
|
|
264
|
+
tmp_path, "pack-b", "# pack-b\nPACK_B_SENTINEL\n",
|
|
265
|
+
)
|
|
266
|
+
out = tmp_path / "out"
|
|
267
|
+
|
|
268
|
+
project_packs([pack_a, pack_b], self.contract, out)
|
|
269
|
+
body = (out / ".agents" / "skills" / "same-name" / "SKILL.md").read_text(
|
|
270
|
+
encoding="utf-8",
|
|
271
|
+
)
|
|
272
|
+
self.assertIn("PACK_B_SENTINEL", body)
|
|
273
|
+
self.assertNotIn("PACK_A_SENTINEL", body)
|
|
274
|
+
|
|
275
|
+
def test_top_level_symlink_skill_is_skipped(self) -> None:
|
|
276
|
+
# Defense-in-depth: a malicious pack with `.apm/skills/<name>`
|
|
277
|
+
# as a symlink to a sensitive directory would exfiltrate its
|
|
278
|
+
# contents via `copytree` (the `symlinks=True` flag only
|
|
279
|
+
# governs symlinks *inside* the tree). The adapter must skip
|
|
280
|
+
# symlink entries at the iteration level so the contents do
|
|
281
|
+
# not land in the projection. `lint-packs` already refuses
|
|
282
|
+
# such packs; this is the adapter-layer safety net.
|
|
283
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
284
|
+
tmp_path = Path(tmp)
|
|
285
|
+
external = tmp_path / "external-secrets"
|
|
286
|
+
external.mkdir()
|
|
287
|
+
(external / "secret.txt").write_text("DO NOT LEAK\n", encoding="utf-8")
|
|
288
|
+
|
|
289
|
+
pack = tmp_path / "pack"
|
|
290
|
+
skills_dir = pack / ".apm" / "skills"
|
|
291
|
+
skills_dir.mkdir(parents=True)
|
|
292
|
+
# Top-level skill entry is a symlink-to-directory.
|
|
293
|
+
(skills_dir / "malicious").symlink_to(external, target_is_directory=True)
|
|
294
|
+
# And a legitimate skill — that one must still project.
|
|
295
|
+
legit = skills_dir / "legit"
|
|
296
|
+
legit.mkdir()
|
|
297
|
+
(legit / "SKILL.md").write_text("# legit\n", encoding="utf-8")
|
|
298
|
+
|
|
299
|
+
out = tmp_path / "out"
|
|
300
|
+
|
|
301
|
+
project_packs([pack], self.contract, out)
|
|
302
|
+
|
|
303
|
+
self.assertFalse((out / ".agents" / "skills" / "malicious").exists())
|
|
304
|
+
self.assertFalse((out / ".agents" / "skills" / "secret.txt").exists())
|
|
305
|
+
self.assertTrue((out / ".agents" / "skills" / "legit" / "SKILL.md").is_file())
|
|
306
|
+
# External directory untouched.
|
|
307
|
+
self.assertEqual(
|
|
308
|
+
(external / "secret.txt").read_text(encoding="utf-8"),
|
|
309
|
+
"DO NOT LEAK\n",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def test_destination_symlink_safe_overwrite(self) -> None:
|
|
313
|
+
# Spec § Never do: `shutil.rmtree` is barred against entries
|
|
314
|
+
# whose `is_symlink()` is true. If a previous run left a
|
|
315
|
+
# symlink at `<target>/skills/<name>`, the next projection
|
|
316
|
+
# must unlink it (removing the link, not the target).
|
|
317
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
318
|
+
tmp_path = Path(tmp)
|
|
319
|
+
pack = _seed_two_skill_pack(tmp_path)
|
|
320
|
+
out = tmp_path / "out"
|
|
321
|
+
target = out / ".agents" / "skills"
|
|
322
|
+
target.mkdir(parents=True)
|
|
323
|
+
|
|
324
|
+
external = tmp_path / "external"
|
|
325
|
+
external.mkdir()
|
|
326
|
+
(external / "anchor").write_text("keep me\n", encoding="utf-8")
|
|
327
|
+
(target / "flat").symlink_to(external, target_is_directory=True)
|
|
328
|
+
|
|
329
|
+
project_packs([pack], self.contract, out)
|
|
330
|
+
|
|
331
|
+
self.assertFalse((target / "flat").is_symlink())
|
|
332
|
+
self.assertTrue((target / "flat" / "SKILL.md").is_file())
|
|
333
|
+
self.assertTrue(external.is_dir())
|
|
334
|
+
self.assertEqual(
|
|
335
|
+
(external / "anchor").read_text(encoding="utf-8"),
|
|
336
|
+
"keep me\n",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def test_same_name_last_wins_reversed(self) -> None:
|
|
340
|
+
# AC6 — Codex case, reversed.
|
|
341
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
342
|
+
tmp_path = Path(tmp)
|
|
343
|
+
pack_a = _seed_same_name_pack(
|
|
344
|
+
tmp_path, "pack-a", "# pack-a\nPACK_A_SENTINEL\n",
|
|
345
|
+
)
|
|
346
|
+
pack_b = _seed_same_name_pack(
|
|
347
|
+
tmp_path, "pack-b", "# pack-b\nPACK_B_SENTINEL\n",
|
|
348
|
+
)
|
|
349
|
+
out = tmp_path / "out"
|
|
350
|
+
|
|
351
|
+
project_packs([pack_b, pack_a], self.contract, out)
|
|
352
|
+
body = (out / ".agents" / "skills" / "same-name" / "SKILL.md").read_text(
|
|
353
|
+
encoding="utf-8",
|
|
354
|
+
)
|
|
355
|
+
self.assertIn("PACK_A_SENTINEL", body)
|
|
356
|
+
self.assertNotIn("PACK_B_SENTINEL", body)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _seed_named_skills_pack(root: Path, pack_name: str, skill_names: list[str]) -> Path:
|
|
360
|
+
pack = root / pack_name
|
|
361
|
+
for skill_name in skill_names:
|
|
362
|
+
skill_dir = pack / ".apm" / "skills" / skill_name
|
|
363
|
+
skill_dir.mkdir(parents=True)
|
|
364
|
+
(skill_dir / "SKILL.md").write_text(
|
|
365
|
+
f"# {skill_name}\nfrom {pack_name}\n",
|
|
366
|
+
encoding="utf-8",
|
|
367
|
+
)
|
|
368
|
+
return pack
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class TestCodexOrphanSweep(unittest.TestCase):
|
|
372
|
+
"""T7 — `direct-directory` skill projection runs `sweep_orphans`
|
|
373
|
+
against the union of source skill names across the call's pack list.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
@classmethod
|
|
377
|
+
def setUpClass(cls) -> None:
|
|
378
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
379
|
+
|
|
380
|
+
def test_codex_two_stage_shrink(self) -> None:
|
|
381
|
+
# AC17: project {a, b, c} then {a, c} into the same output.
|
|
382
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
383
|
+
tmp_path = Path(tmp)
|
|
384
|
+
three = _seed_named_skills_pack(tmp_path, "three-skill", ["a", "b", "c"])
|
|
385
|
+
shrink = _seed_named_skills_pack(tmp_path, "two-skill-shrink", ["a", "c"])
|
|
386
|
+
out = tmp_path / "out"
|
|
387
|
+
|
|
388
|
+
project_packs([three], self.contract, out)
|
|
389
|
+
self.assertTrue((out / ".agents" / "skills" / "b").is_dir())
|
|
390
|
+
|
|
391
|
+
project_packs([shrink], self.contract, out)
|
|
392
|
+
children = {p.name for p in (out / ".agents" / "skills").iterdir()}
|
|
393
|
+
self.assertEqual(children, {"a", "c"})
|
|
394
|
+
|
|
395
|
+
def test_codex_two_pack_union(self) -> None:
|
|
396
|
+
# AC20: pack_a={a,b} + pack_b={b,c} → {a,b,c};
|
|
397
|
+
# then pack_a alone → {a,b}, c removed.
|
|
398
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
399
|
+
tmp_path = Path(tmp)
|
|
400
|
+
pack_a = _seed_named_skills_pack(tmp_path, "pack-a", ["a", "b"])
|
|
401
|
+
pack_b = _seed_named_skills_pack(tmp_path, "pack-b", ["b", "c"])
|
|
402
|
+
out = tmp_path / "out"
|
|
403
|
+
|
|
404
|
+
project_packs([pack_a, pack_b], self.contract, out)
|
|
405
|
+
children = {p.name for p in (out / ".agents" / "skills").iterdir()}
|
|
406
|
+
self.assertEqual(children, {"a", "b", "c"})
|
|
407
|
+
|
|
408
|
+
project_packs([pack_a], self.contract, out)
|
|
409
|
+
children = {p.name for p in (out / ".agents" / "skills").iterdir()}
|
|
410
|
+
self.assertEqual(children, {"a", "b"})
|
|
411
|
+
|
|
412
|
+
def test_codex_symlink_safe_sweep(self) -> None:
|
|
413
|
+
# AC21: pre-seed a symlink-to-external in the target dir; the
|
|
414
|
+
# sweep removes the symlink but leaves the external dir intact.
|
|
415
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
416
|
+
tmp_path = Path(tmp)
|
|
417
|
+
pack = _seed_named_skills_pack(tmp_path, "pack", ["a"])
|
|
418
|
+
external = tmp_path / "external"
|
|
419
|
+
external.mkdir()
|
|
420
|
+
(external / "anchor").write_text("keep me\n", encoding="utf-8")
|
|
421
|
+
out = tmp_path / "out"
|
|
422
|
+
target = out / ".agents" / "skills"
|
|
423
|
+
target.mkdir(parents=True)
|
|
424
|
+
link = target / "b"
|
|
425
|
+
link.symlink_to(external, target_is_directory=True)
|
|
426
|
+
|
|
427
|
+
project_packs([pack], self.contract, out)
|
|
428
|
+
|
|
429
|
+
self.assertTrue((target / "a").is_dir())
|
|
430
|
+
self.assertFalse(link.exists())
|
|
431
|
+
self.assertFalse(link.is_symlink())
|
|
432
|
+
self.assertTrue(external.is_dir())
|
|
433
|
+
self.assertEqual(
|
|
434
|
+
(external / "anchor").read_text(encoding="utf-8"),
|
|
435
|
+
"keep me\n",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class TestMigrationStripIntegrated(unittest.TestCase):
|
|
440
|
+
"""Codex `project_packs` strips the legacy block from `<output_root>/AGENTS.md`."""
|
|
441
|
+
|
|
442
|
+
@classmethod
|
|
443
|
+
def setUpClass(cls) -> None:
|
|
444
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
445
|
+
|
|
446
|
+
def _populated(self) -> str:
|
|
447
|
+
return (
|
|
448
|
+
"# Top\n\nIntroductory prose.\n\n"
|
|
449
|
+
f"{_LEGACY_SKILL_BLOCK_START}\n"
|
|
450
|
+
"- **a** — desc-a\n"
|
|
451
|
+
"- **b** — desc-b\n"
|
|
452
|
+
f"{_LEGACY_SKILL_BLOCK_END}\n"
|
|
453
|
+
"\nClosing prose.\n"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def test_happy_path_strips_delimiters_and_preserves_prose(self) -> None:
|
|
457
|
+
# AC10, AC11. The strip's only allowed mutation is removing
|
|
458
|
+
# the legacy delimiter region; outside-delimiter bytes must
|
|
459
|
+
# survive byte-for-byte. Substring `assertIn` would pass on
|
|
460
|
+
# munged surrounding bytes; the concatenation assertion
|
|
461
|
+
# below pins the byte-equality contract AC11(c) names.
|
|
462
|
+
outside_before = "# Top\n\nIntroductory prose.\n\n"
|
|
463
|
+
outside_after = "\nClosing prose.\n"
|
|
464
|
+
populated = (
|
|
465
|
+
f"{outside_before}"
|
|
466
|
+
f"{_LEGACY_SKILL_BLOCK_START}\n"
|
|
467
|
+
f"- **a** — desc-a\n"
|
|
468
|
+
f"- **b** — desc-b\n"
|
|
469
|
+
f"{_LEGACY_SKILL_BLOCK_END}\n"
|
|
470
|
+
f"{outside_after}"
|
|
471
|
+
)
|
|
472
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
473
|
+
tmp_path = Path(tmp)
|
|
474
|
+
pack = _seed_two_skill_pack(tmp_path)
|
|
475
|
+
out = tmp_path / "out"
|
|
476
|
+
out.mkdir()
|
|
477
|
+
(out / "AGENTS.md").write_text(populated, encoding="utf-8")
|
|
478
|
+
|
|
479
|
+
project_packs([pack], self.contract, out)
|
|
480
|
+
|
|
481
|
+
text = (out / "AGENTS.md").read_text(encoding="utf-8")
|
|
482
|
+
self.assertNotIn(_LEGACY_SKILL_BLOCK_START, text)
|
|
483
|
+
self.assertNotIn(_LEGACY_SKILL_BLOCK_END, text)
|
|
484
|
+
# Byte-for-byte preservation: the outside-delimiter prose
|
|
485
|
+
# appears unchanged, in order, with no munging.
|
|
486
|
+
self.assertIn(outside_before + outside_after, text)
|
|
487
|
+
|
|
488
|
+
def test_already_clean_is_byte_identical(self) -> None:
|
|
489
|
+
# AC12.
|
|
490
|
+
clean = "# Top\n\nNo managed block.\n"
|
|
491
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
492
|
+
tmp_path = Path(tmp)
|
|
493
|
+
pack = _seed_two_skill_pack(tmp_path)
|
|
494
|
+
out = tmp_path / "out"
|
|
495
|
+
out.mkdir()
|
|
496
|
+
(out / "AGENTS.md").write_text(clean, encoding="utf-8")
|
|
497
|
+
|
|
498
|
+
project_packs([pack], self.contract, out)
|
|
499
|
+
|
|
500
|
+
self.assertEqual(
|
|
501
|
+
(out / "AGENTS.md").read_text(encoding="utf-8"),
|
|
502
|
+
clean,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def test_idempotent_across_two_calls(self) -> None:
|
|
506
|
+
# AC13.
|
|
507
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
508
|
+
tmp_path = Path(tmp)
|
|
509
|
+
pack = _seed_two_skill_pack(tmp_path)
|
|
510
|
+
out = tmp_path / "out"
|
|
511
|
+
out.mkdir()
|
|
512
|
+
(out / "AGENTS.md").write_text(self._populated(), encoding="utf-8")
|
|
513
|
+
|
|
514
|
+
project_packs([pack], self.contract, out)
|
|
515
|
+
first = (out / "AGENTS.md").read_bytes()
|
|
516
|
+
project_packs([pack], self.contract, out)
|
|
517
|
+
second = (out / "AGENTS.md").read_bytes()
|
|
518
|
+
self.assertEqual(first, second)
|
|
519
|
+
|
|
520
|
+
def test_hand_edited_content_between_delimiters_is_lost(self) -> None:
|
|
521
|
+
# AC14.
|
|
522
|
+
sentinel = "<<HAND-EDITED-PRESERVE-ME>>"
|
|
523
|
+
body = (
|
|
524
|
+
"prefix\n"
|
|
525
|
+
f"{_LEGACY_SKILL_BLOCK_START}\n"
|
|
526
|
+
f"{sentinel}\n"
|
|
527
|
+
f"{_LEGACY_SKILL_BLOCK_END}\n"
|
|
528
|
+
"suffix\n"
|
|
529
|
+
)
|
|
530
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
531
|
+
tmp_path = Path(tmp)
|
|
532
|
+
pack = _seed_two_skill_pack(tmp_path)
|
|
533
|
+
out = tmp_path / "out"
|
|
534
|
+
out.mkdir()
|
|
535
|
+
(out / "AGENTS.md").write_text(body, encoding="utf-8")
|
|
536
|
+
|
|
537
|
+
project_packs([pack], self.contract, out)
|
|
538
|
+
|
|
539
|
+
text = (out / "AGENTS.md").read_text(encoding="utf-8")
|
|
540
|
+
self.assertNotIn(sentinel, text)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class TestMigrationStripPureFunction(unittest.TestCase):
|
|
544
|
+
"""Pure-function tests for `_strip_legacy_skill_block`.
|
|
545
|
+
|
|
546
|
+
No filesystem; the strip is a text transform. Integration with
|
|
547
|
+
`project_packs` is covered by T4's tests.
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
OUTSIDE_BEFORE = "# Top\n\nIntroductory prose.\n\n"
|
|
551
|
+
OUTSIDE_AFTER = "\nClosing prose.\n"
|
|
552
|
+
|
|
553
|
+
def _populated(self) -> str:
|
|
554
|
+
return (
|
|
555
|
+
f"{self.OUTSIDE_BEFORE}"
|
|
556
|
+
f"{_LEGACY_SKILL_BLOCK_START}\n"
|
|
557
|
+
f"- **a** — desc-a\n"
|
|
558
|
+
f"- **b** — desc-b\n"
|
|
559
|
+
f"{_LEGACY_SKILL_BLOCK_END}\n"
|
|
560
|
+
f"{self.OUTSIDE_AFTER}"
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def test_happy_path_strips_delimiters_and_preserves_outside_prose(self) -> None:
|
|
564
|
+
stripped = _strip_legacy_skill_block(self._populated())
|
|
565
|
+
self.assertNotIn(_LEGACY_SKILL_BLOCK_START, stripped)
|
|
566
|
+
self.assertNotIn(_LEGACY_SKILL_BLOCK_END, stripped)
|
|
567
|
+
self.assertIn("# Top\n", stripped)
|
|
568
|
+
self.assertIn("Introductory prose.", stripped)
|
|
569
|
+
self.assertIn("Closing prose.", stripped)
|
|
570
|
+
|
|
571
|
+
def test_already_clean_input_is_byte_identical(self) -> None:
|
|
572
|
+
clean = "# Top\n\nNo managed block here.\n"
|
|
573
|
+
self.assertEqual(_strip_legacy_skill_block(clean), clean)
|
|
574
|
+
|
|
575
|
+
def test_idempotent(self) -> None:
|
|
576
|
+
once = _strip_legacy_skill_block(self._populated())
|
|
577
|
+
twice = _strip_legacy_skill_block(once)
|
|
578
|
+
self.assertEqual(once, twice)
|
|
579
|
+
|
|
580
|
+
def test_non_list_content_between_delimiters_is_lost(self) -> None:
|
|
581
|
+
sentinel = "<<HAND-EDITED-PRESERVE-ME>>"
|
|
582
|
+
text = (
|
|
583
|
+
f"prefix\n"
|
|
584
|
+
f"{_LEGACY_SKILL_BLOCK_START}\n"
|
|
585
|
+
f"{sentinel}\n"
|
|
586
|
+
f"{_LEGACY_SKILL_BLOCK_END}\n"
|
|
587
|
+
f"suffix\n"
|
|
588
|
+
)
|
|
589
|
+
stripped = _strip_legacy_skill_block(text)
|
|
590
|
+
self.assertNotIn(sentinel, stripped)
|
|
591
|
+
self.assertIn("prefix", stripped)
|
|
592
|
+
self.assertIn("suffix", stripped)
|
|
593
|
+
|
|
594
|
+
def test_out_of_order_delimiters_refused(self) -> None:
|
|
595
|
+
# If the adopter pasted the delimiters in reverse order, the
|
|
596
|
+
# splice would otherwise corrupt the file silently. Refuse
|
|
597
|
+
# the input with a named error so the adopter can fix.
|
|
598
|
+
reversed_input = (
|
|
599
|
+
"prefix\n"
|
|
600
|
+
f"{_LEGACY_SKILL_BLOCK_END}\n"
|
|
601
|
+
f"{_LEGACY_SKILL_BLOCK_START}\n"
|
|
602
|
+
"suffix\n"
|
|
603
|
+
)
|
|
604
|
+
with self.assertRaises(ValueError) as caught:
|
|
605
|
+
_strip_legacy_skill_block(reversed_input)
|
|
606
|
+
self.assertIn("appears before", str(caught.exception))
|
|
607
|
+
|
|
608
|
+
def test_splice_managed_block_symbol_still_exists(self) -> None:
|
|
609
|
+
# AC23(i): a future refactor that inlines the splice and deletes
|
|
610
|
+
# the helper symbol breaks this import-and-call assertion.
|
|
611
|
+
self.assertTrue(callable(_splice_managed_block))
|
|
612
|
+
|
|
613
|
+
def test_strip_invokes_splice_managed_block_once(self) -> None:
|
|
614
|
+
# AC23(ii) — deliberate retention test. A refactor that
|
|
615
|
+
# inlines the splice and deletes `_splice_managed_block`
|
|
616
|
+
# breaks the import. A refactor that keeps the symbol but
|
|
617
|
+
# stops calling it from `_strip_legacy_skill_block` makes
|
|
618
|
+
# `call_count == 0`. Either signals the retention contract
|
|
619
|
+
# has been broken before the migration window closes. Do
|
|
620
|
+
# not "simplify" by removing the mock — the mock IS the
|
|
621
|
+
# contract. Patch with `wraps=` so the real function still
|
|
622
|
+
# runs and the strip behaviour is unchanged.
|
|
623
|
+
with mock.patch.object(
|
|
624
|
+
codex,
|
|
625
|
+
"_splice_managed_block",
|
|
626
|
+
wraps=codex._splice_managed_block,
|
|
627
|
+
) as spy:
|
|
628
|
+
_strip_legacy_skill_block(self._populated())
|
|
629
|
+
self.assertEqual(spy.call_count, 1)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class TestCodexProjectsEveryShippedSkill(unittest.TestCase):
|
|
633
|
+
"""AC29 — every skill any in-tree pack ships projects through Codex
|
|
634
|
+
into `.agents/skills/<name>/SKILL.md` (byte-equal to source).
|
|
635
|
+
|
|
636
|
+
The spec text references `dist/codex/` as a notional adopter path;
|
|
637
|
+
in this self-hosting repo, Codex projects to the repo root, so the
|
|
638
|
+
test runs the projection against a `tmp_path` and enumerates
|
|
639
|
+
`packs/*/.apm/skills/`. The sentinel set (`work-loop`, `new-spec`,
|
|
640
|
+
`new-rfc`, `new-adr`) spans multiple packs (core +
|
|
641
|
+
governance-extras), so the test must walk all packs — a core-only
|
|
642
|
+
walk would silently skip `new-rfc` / `new-adr` against the spec's
|
|
643
|
+
explicit sentinel list.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
@classmethod
|
|
647
|
+
def setUpClass(cls) -> None:
|
|
648
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
649
|
+
|
|
650
|
+
def test_every_shipped_skill_projects_with_equal_bytes(self) -> None:
|
|
651
|
+
packs_root = REPO_ROOT / "packs"
|
|
652
|
+
self.assertTrue(packs_root.is_dir())
|
|
653
|
+
pack_paths = sorted(p for p in packs_root.iterdir() if p.is_dir())
|
|
654
|
+
|
|
655
|
+
# Collect every source skill across every pack. Tracks the
|
|
656
|
+
# "winning" source path for same-name collisions so byte-equal
|
|
657
|
+
# comparisons use the last-supplied pack's body (matching
|
|
658
|
+
# AC6).
|
|
659
|
+
winning_source: dict[str, Path] = {}
|
|
660
|
+
for pack_path in pack_paths:
|
|
661
|
+
skills_dir = pack_path / ".apm" / "skills"
|
|
662
|
+
if not skills_dir.is_dir():
|
|
663
|
+
continue
|
|
664
|
+
for entry in skills_dir.iterdir():
|
|
665
|
+
if entry.is_dir():
|
|
666
|
+
winning_source[entry.name] = entry
|
|
667
|
+
|
|
668
|
+
self.assertGreater(len(winning_source), 0)
|
|
669
|
+
for sentinel in ("work-loop", "new-spec", "new-rfc", "new-adr"):
|
|
670
|
+
self.assertIn(
|
|
671
|
+
sentinel,
|
|
672
|
+
winning_source,
|
|
673
|
+
f"sentinel skill {sentinel!r} missing from any in-tree pack",
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
677
|
+
tmp_path = Path(tmp)
|
|
678
|
+
project_packs(pack_paths, self.contract, tmp_path)
|
|
679
|
+
|
|
680
|
+
for skill_name, source_skill_dir in winning_source.items():
|
|
681
|
+
projected_skill_md = (
|
|
682
|
+
tmp_path / ".agents" / "skills" / skill_name / "SKILL.md"
|
|
683
|
+
)
|
|
684
|
+
source_skill_md = source_skill_dir / "SKILL.md"
|
|
685
|
+
if not source_skill_md.exists():
|
|
686
|
+
continue
|
|
687
|
+
self.assertTrue(
|
|
688
|
+
projected_skill_md.is_file(),
|
|
689
|
+
f"skill {skill_name!r}: SKILL.md missing in projection",
|
|
690
|
+
)
|
|
691
|
+
self.assertEqual(
|
|
692
|
+
projected_skill_md.read_bytes(),
|
|
693
|
+
source_skill_md.read_bytes(),
|
|
694
|
+
f"skill {skill_name!r}: SKILL.md bytes differ",
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
if __name__ == "__main__":
|
|
699
|
+
unittest.main()
|