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,65 @@
|
|
|
1
|
+
"""Tests for `sweep_orphans` — the shared post-pass for
|
|
2
|
+
`direct-directory` skill projections."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from agentbundle.build.projections.direct_directory import sweep_orphans
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_removes_orphan_directory(tmp_path: Path) -> None:
|
|
12
|
+
for name in ("a", "b", "c"):
|
|
13
|
+
(tmp_path / name).mkdir()
|
|
14
|
+
|
|
15
|
+
sweep_orphans(tmp_path, {"a", "c"})
|
|
16
|
+
|
|
17
|
+
assert (tmp_path / "a").is_dir()
|
|
18
|
+
assert (tmp_path / "c").is_dir()
|
|
19
|
+
assert not (tmp_path / "b").exists()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_noop_on_full_match(tmp_path: Path) -> None:
|
|
23
|
+
for name in ("a", "b"):
|
|
24
|
+
(tmp_path / name).mkdir()
|
|
25
|
+
|
|
26
|
+
sweep_orphans(tmp_path, {"a", "b"})
|
|
27
|
+
|
|
28
|
+
assert (tmp_path / "a").is_dir()
|
|
29
|
+
assert (tmp_path / "b").is_dir()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_noop_on_missing_target(tmp_path: Path) -> None:
|
|
33
|
+
sweep_orphans(tmp_path / "does-not-exist", {"a"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_ignores_root_files(tmp_path: Path) -> None:
|
|
37
|
+
(tmp_path / "a").mkdir()
|
|
38
|
+
readme = tmp_path / "README.md"
|
|
39
|
+
readme.write_text("hello\n", encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
sweep_orphans(tmp_path, set())
|
|
42
|
+
|
|
43
|
+
assert not (tmp_path / "a").exists()
|
|
44
|
+
assert readme.is_file()
|
|
45
|
+
assert readme.read_text(encoding="utf-8") == "hello\n"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_symlink_safe_sweep(tmp_path: Path) -> None:
|
|
49
|
+
external = tmp_path / "outside"
|
|
50
|
+
external.mkdir()
|
|
51
|
+
(external / "anchor").write_text("keep me\n", encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
target = tmp_path / "skills"
|
|
54
|
+
target.mkdir()
|
|
55
|
+
(target / "a").mkdir()
|
|
56
|
+
link = target / "b"
|
|
57
|
+
link.symlink_to(external, target_is_directory=True)
|
|
58
|
+
|
|
59
|
+
sweep_orphans(target, {"a"})
|
|
60
|
+
|
|
61
|
+
assert (target / "a").is_dir()
|
|
62
|
+
assert not link.exists()
|
|
63
|
+
assert not link.is_symlink()
|
|
64
|
+
assert external.is_dir()
|
|
65
|
+
assert (external / "anchor").read_text(encoding="utf-8") == "keep me\n"
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""End-to-end pipeline test (T8).
|
|
2
|
+
|
|
3
|
+
Drives `make build` (via `python -m agentbundle.build build`) against
|
|
4
|
+
the four reference fixture packs at
|
|
5
|
+
`packages/agentbundle/agentbundle/build/tests/fixtures/packs/` on a
|
|
6
|
+
clean checkout and asserts the dist/ shape AC #7 + AC #13 require.
|
|
7
|
+
Production-pack migration into a top-level `packs/` directory is out
|
|
8
|
+
of scope per the spec's amended AC #7 (RFC-0001 F-dist follow-on
|
|
9
|
+
owns it).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import unittest
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
22
|
+
FIXTURES_PACKS = (
|
|
23
|
+
Path(__file__).resolve().parent / "fixtures" / "packs"
|
|
24
|
+
)
|
|
25
|
+
REFERENCE_PACKS = ("core", "governance-extras", "user-guide-diataxis", "monorepo-extras")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_build(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
29
|
+
return subprocess.run(
|
|
30
|
+
[sys.executable, "-m", "agentbundle.build", *args],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
cwd=REPO_ROOT,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EndToEndBuildTests(unittest.TestCase):
|
|
38
|
+
def test_default_build_produces_expected_shape(self) -> None:
|
|
39
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
40
|
+
result = _run_build(
|
|
41
|
+
[
|
|
42
|
+
"build",
|
|
43
|
+
"--packs-dir",
|
|
44
|
+
str(FIXTURES_PACKS),
|
|
45
|
+
"--output-dir",
|
|
46
|
+
tmp,
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
|
50
|
+
tmp_path = Path(tmp)
|
|
51
|
+
marketplace = tmp_path / "claude-plugins" / "marketplace.json"
|
|
52
|
+
self.assertTrue(marketplace.exists())
|
|
53
|
+
entries = json.loads(marketplace.read_text(encoding="utf-8"))
|
|
54
|
+
self.assertEqual(
|
|
55
|
+
{entry["name"] for entry in entries["plugins"]},
|
|
56
|
+
set(REFERENCE_PACKS),
|
|
57
|
+
)
|
|
58
|
+
for pack in REFERENCE_PACKS:
|
|
59
|
+
self.assertTrue((tmp_path / "claude-plugins" / pack).exists())
|
|
60
|
+
self.assertTrue((tmp_path / "apm" / pack).exists())
|
|
61
|
+
|
|
62
|
+
# AC #7 + integrated-journey coverage: assert each of the five
|
|
63
|
+
# primitives lands at its declared output under the `core` pack
|
|
64
|
+
# — the only fixture that exercises every primitive type.
|
|
65
|
+
core_plugin = tmp_path / "claude-plugins" / "core"
|
|
66
|
+
self.assertTrue((core_plugin / ".claude" / "skills" / "example").exists())
|
|
67
|
+
self.assertTrue((core_plugin / ".claude" / "agents" / "bar.md").exists())
|
|
68
|
+
self.assertTrue((core_plugin / "tools" / "hooks" / "baz.sh").exists())
|
|
69
|
+
self.assertTrue((core_plugin / "tools" / "hooks" / "baz.py").exists())
|
|
70
|
+
self.assertTrue(
|
|
71
|
+
(core_plugin / ".claude" / "settings.local.json").exists()
|
|
72
|
+
)
|
|
73
|
+
self.assertTrue((core_plugin / ".claude" / "commands" / "qux.md").exists())
|
|
74
|
+
|
|
75
|
+
def test_plain_build_does_not_invoke_self_host_recipes(self) -> None:
|
|
76
|
+
"""AC: plain `make build` produces only dist/apm, dist/claude-plugins,
|
|
77
|
+
and dist/claude-plugins/marketplace.json — never the three self-host
|
|
78
|
+
recipes' artefacts (overlay output in the working tree, composite
|
|
79
|
+
AGENTS.md, composite marketplace). Verifies AC #14: working tree is
|
|
80
|
+
unchanged after the run (git status --porcelain returns byte-
|
|
81
|
+
identical output before and after)."""
|
|
82
|
+
before = subprocess.run(
|
|
83
|
+
["git", "status", "--porcelain"],
|
|
84
|
+
cwd=REPO_ROOT,
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
check=False,
|
|
88
|
+
).stdout
|
|
89
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
90
|
+
result = _run_build(
|
|
91
|
+
[
|
|
92
|
+
"build",
|
|
93
|
+
"--packs-dir",
|
|
94
|
+
str(FIXTURES_PACKS),
|
|
95
|
+
"--output-dir",
|
|
96
|
+
tmp,
|
|
97
|
+
]
|
|
98
|
+
)
|
|
99
|
+
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
|
100
|
+
tmp_path = Path(tmp)
|
|
101
|
+
self.assertFalse((tmp_path / "AGENTS.md").exists())
|
|
102
|
+
self.assertFalse((tmp_path / ".claude-plugin" / "marketplace.json").exists())
|
|
103
|
+
after = subprocess.run(
|
|
104
|
+
["git", "status", "--porcelain"],
|
|
105
|
+
cwd=REPO_ROOT,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
check=False,
|
|
109
|
+
).stdout
|
|
110
|
+
self.assertEqual(before, after, "plain `make build` modified the working tree")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CheckCommandTests(unittest.TestCase):
|
|
114
|
+
def test_make_build_check_on_a_clean_pre_projected_tree_exits_zero(self) -> None:
|
|
115
|
+
"""Render once into a temp working tree, then `check` it. The check
|
|
116
|
+
should exit 0 because the tree matches the rendered output."""
|
|
117
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
118
|
+
working = Path(tmp) / "tree"
|
|
119
|
+
working.mkdir()
|
|
120
|
+
# Use git init to make it dirty-tree-detection-ready.
|
|
121
|
+
import os
|
|
122
|
+
env = os.environ.copy()
|
|
123
|
+
env["GIT_AUTHOR_NAME"] = "test"
|
|
124
|
+
env["GIT_AUTHOR_EMAIL"] = "test@example.com"
|
|
125
|
+
env["GIT_COMMITTER_NAME"] = "test"
|
|
126
|
+
env["GIT_COMMITTER_EMAIL"] = "test@example.com"
|
|
127
|
+
subprocess.run(["git", "init", "-q", str(working)], check=True, env=env)
|
|
128
|
+
|
|
129
|
+
# Seed `.adapt-discovery.toml` so `make build-check`'s
|
|
130
|
+
# fail-fast (spec AC14) doesn't reject the call. Canonical
|
|
131
|
+
# v0.1 shape per adapt-to-project AC9.
|
|
132
|
+
(working / ".adapt-discovery.toml").write_text(
|
|
133
|
+
'discovery-schema-version = "0.1"\n', encoding="utf-8"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
from agentbundle.build.adapters import ADAPTERS
|
|
137
|
+
from agentbundle.build.contract import load as load_contract
|
|
138
|
+
from agentbundle.build.main import discover_packs
|
|
139
|
+
from agentbundle.build.self_host import run_self_host
|
|
140
|
+
|
|
141
|
+
contract = load_contract(
|
|
142
|
+
REPO_ROOT / "docs" / "contracts" / "adapter.toml"
|
|
143
|
+
)
|
|
144
|
+
# Pre-seed using the self-host runner so the working
|
|
145
|
+
# tree exactly matches what `make build-check` will render
|
|
146
|
+
# (including new seed/marketplace/symlink outputs).
|
|
147
|
+
run_self_host(
|
|
148
|
+
working_tree=working,
|
|
149
|
+
packs_dir=FIXTURES_PACKS,
|
|
150
|
+
dry_run=False,
|
|
151
|
+
force=True,
|
|
152
|
+
contract=contract,
|
|
153
|
+
)
|
|
154
|
+
subprocess.run(["git", "-C", str(working), "add", "-A"], check=True, env=env)
|
|
155
|
+
subprocess.run(
|
|
156
|
+
["git", "-C", str(working), "commit", "-q", "-m", "seed"],
|
|
157
|
+
check=True,
|
|
158
|
+
env=env,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# `make build-check` depends on `make build` (Makefile:63) so the
|
|
162
|
+
# writer-template / APM drift gates introduced in commits
|
|
163
|
+
# 25590fe + 89c0db3 always see a populated dist/ tree. Mirror
|
|
164
|
+
# that dependency here: run `build` into <working>/dist/ before
|
|
165
|
+
# invoking `check --output-dir <working>` so `<working>/dist/
|
|
166
|
+
# claude-plugins/` and `<working>/dist/apm/` exist.
|
|
167
|
+
build_result = _run_build(
|
|
168
|
+
[
|
|
169
|
+
"build",
|
|
170
|
+
"--packs-dir",
|
|
171
|
+
str(FIXTURES_PACKS),
|
|
172
|
+
"--output-dir",
|
|
173
|
+
str(working / "dist"),
|
|
174
|
+
]
|
|
175
|
+
)
|
|
176
|
+
self.assertEqual(
|
|
177
|
+
build_result.returncode, 0, msg=build_result.stderr
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
result = _run_build(
|
|
181
|
+
[
|
|
182
|
+
"check",
|
|
183
|
+
"--packs-dir",
|
|
184
|
+
str(FIXTURES_PACKS),
|
|
185
|
+
"--output-dir",
|
|
186
|
+
str(working),
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class ScaffoldCommandTests(unittest.TestCase):
|
|
193
|
+
def test_scaffold_copies_seeds_into_output(self) -> None:
|
|
194
|
+
"""Scaffold copies a pack's seeds/ to the named output directory.
|
|
195
|
+
|
|
196
|
+
Uses a tempfile-based packs dir so the source fixture tree stays
|
|
197
|
+
untouched even if the test is interrupted mid-run.
|
|
198
|
+
"""
|
|
199
|
+
import shutil
|
|
200
|
+
|
|
201
|
+
with tempfile.TemporaryDirectory() as workspace:
|
|
202
|
+
workspace_path = Path(workspace)
|
|
203
|
+
packs_clone = workspace_path / "packs"
|
|
204
|
+
shutil.copytree(FIXTURES_PACKS, packs_clone)
|
|
205
|
+
(packs_clone / "core" / "seeds").mkdir()
|
|
206
|
+
(packs_clone / "core" / "seeds" / "AGENTS.md").write_text(
|
|
207
|
+
"# seeded\n", encoding="utf-8"
|
|
208
|
+
)
|
|
209
|
+
output_dir = workspace_path / "out"
|
|
210
|
+
|
|
211
|
+
result = _run_build(
|
|
212
|
+
[
|
|
213
|
+
"scaffold",
|
|
214
|
+
"--packs-dir",
|
|
215
|
+
str(packs_clone),
|
|
216
|
+
"--pack",
|
|
217
|
+
"core",
|
|
218
|
+
"--output",
|
|
219
|
+
str(output_dir),
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
|
223
|
+
self.assertTrue((output_dir / "AGENTS.md").exists())
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
if __name__ == "__main__":
|
|
227
|
+
unittest.main()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""T9 / AC31 — `tools/lint-agents-md.py` warns when a projected
|
|
2
|
+
`AGENTS.md` still carries the legacy `<!-- agent-skills:start -->`
|
|
3
|
+
literal *and* the contract declares Codex `skill` as `direct-directory`.
|
|
4
|
+
|
|
5
|
+
The check fires through the linter's existing `warn(...)` closure
|
|
6
|
+
(prefix `⚠` on stderr), not `note(...)` — so the exit code remains 0.
|
|
7
|
+
|
|
8
|
+
Test shape: CLI subprocess invocation in a `tmp_path` scratch tree
|
|
9
|
+
that mirrors the repo's layout (root `AGENTS.md`, `CLAUDE.md` symlink,
|
|
10
|
+
synthetic `docs/contracts/adapter.toml`). The linter is run with
|
|
11
|
+
`cwd=tmp_path`; the test asserts the return code and stderr.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import unittest
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
24
|
+
LINTER = REPO_ROOT / "tools" / "lint-agents-md.py"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_CONTRACT_DIRECT_DIRECTORY = """\
|
|
28
|
+
[primitive.skill]
|
|
29
|
+
source-path = ".apm/skills/"
|
|
30
|
+
|
|
31
|
+
[[adapter.codex.projection]]
|
|
32
|
+
primitive = "skill"
|
|
33
|
+
mode = "direct-directory"
|
|
34
|
+
target-path = ".agents/skills/"
|
|
35
|
+
on-conflict = "prompt-then-preserve"
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_CONTRACT_LEGACY = """\
|
|
39
|
+
[primitive.skill]
|
|
40
|
+
source-path = ".apm/skills/"
|
|
41
|
+
|
|
42
|
+
[[adapter.codex.projection]]
|
|
43
|
+
primitive = "skill"
|
|
44
|
+
mode = "managed-block-inline"
|
|
45
|
+
target-path = "AGENTS.md"
|
|
46
|
+
managed-block-delimiter-start = "<!-- agent-skills:start -->"
|
|
47
|
+
managed-block-delimiter-end = "<!-- agent-skills:end -->"
|
|
48
|
+
on-conflict = "preserve-outside-block"
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _seed_tree(
|
|
53
|
+
root: Path,
|
|
54
|
+
contract_body: str,
|
|
55
|
+
agents_md_body: str,
|
|
56
|
+
) -> None:
|
|
57
|
+
contracts = root / "docs" / "contracts"
|
|
58
|
+
contracts.mkdir(parents=True)
|
|
59
|
+
(contracts / "adapter.toml").write_text(contract_body, encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
(root / "AGENTS.md").write_text(agents_md_body, encoding="utf-8")
|
|
62
|
+
(root / "CLAUDE.md").symlink_to("AGENTS.md")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run_linter(cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
66
|
+
env = dict(os.environ)
|
|
67
|
+
return subprocess.run(
|
|
68
|
+
[sys.executable, str(LINTER)],
|
|
69
|
+
cwd=cwd,
|
|
70
|
+
env=env,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LegacyBlockWarningTests(unittest.TestCase):
|
|
78
|
+
def test_warns_when_legacy_marker_present_with_direct_directory_contract(self) -> None:
|
|
79
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
80
|
+
tmp_path = Path(tmp)
|
|
81
|
+
_seed_tree(
|
|
82
|
+
tmp_path,
|
|
83
|
+
contract_body=_CONTRACT_DIRECT_DIRECTORY,
|
|
84
|
+
agents_md_body=(
|
|
85
|
+
"# AGENTS.md\n\n"
|
|
86
|
+
"Outside content.\n\n"
|
|
87
|
+
"<!-- agent-skills:start -->\n"
|
|
88
|
+
"- **work-loop** — desc\n"
|
|
89
|
+
"<!-- agent-skills:end -->\n"
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
result = _run_linter(tmp_path)
|
|
94
|
+
# The warning must fire and must name the offending file
|
|
95
|
+
# path. It must NOT change the exit code — other checks
|
|
96
|
+
# (e.g. CLAUDE.md symlink, AGENTS.md size) determine the
|
|
97
|
+
# return value.
|
|
98
|
+
self.assertIn("legacy-codex-skill-block", result.stderr)
|
|
99
|
+
self.assertIn("AGENTS.md", result.stderr)
|
|
100
|
+
self.assertIn("⚠", result.stderr)
|
|
101
|
+
|
|
102
|
+
def test_no_warn_when_legacy_marker_absent(self) -> None:
|
|
103
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
104
|
+
tmp_path = Path(tmp)
|
|
105
|
+
_seed_tree(
|
|
106
|
+
tmp_path,
|
|
107
|
+
contract_body=_CONTRACT_DIRECT_DIRECTORY,
|
|
108
|
+
agents_md_body="# AGENTS.md\n\nNo legacy block here.\n",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
result = _run_linter(tmp_path)
|
|
112
|
+
self.assertNotIn("legacy-codex-skill-block", result.stderr)
|
|
113
|
+
|
|
114
|
+
def test_no_warn_when_contract_still_managed_block(self) -> None:
|
|
115
|
+
# If the contract hasn't flipped (some adopter mid-upgrade
|
|
116
|
+
# against an older bundle), the marker is expected — the linter
|
|
117
|
+
# does not warn.
|
|
118
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
119
|
+
tmp_path = Path(tmp)
|
|
120
|
+
_seed_tree(
|
|
121
|
+
tmp_path,
|
|
122
|
+
contract_body=_CONTRACT_LEGACY,
|
|
123
|
+
agents_md_body=(
|
|
124
|
+
"# AGENTS.md\n\n"
|
|
125
|
+
"<!-- agent-skills:start -->\n"
|
|
126
|
+
"<!-- agent-skills:end -->\n"
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
result = _run_linter(tmp_path)
|
|
131
|
+
self.assertNotIn("legacy-codex-skill-block", result.stderr)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
unittest.main()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Local regression check for `tools/lint-agents-md.py` check 10g, which
|
|
2
|
+
fails when the `risk-triggers:start`..`:end` block diverges between the
|
|
3
|
+
four docs that carry it (work-loop-light-mode spec AC2).
|
|
4
|
+
|
|
5
|
+
The standing CI guard is the lint itself (run in `.github/workflows/docs.yml`);
|
|
6
|
+
this test — like its sibling `test_lint_agents_md_legacy_block.py` — runs
|
|
7
|
+
only under a local `pytest` invocation, not in CI, and exists to pin the
|
|
8
|
+
check's behaviour against regressions.
|
|
9
|
+
|
|
10
|
+
Test shape mirrors test_lint_agents_md_legacy_block.py: CLI subprocess
|
|
11
|
+
invocation in a `tmp_path` scratch tree, asserting on stderr (the linter
|
|
12
|
+
fails on other missing-repo-structure checks regardless, so the exit code
|
|
13
|
+
is not a clean signal — the `risk-trigger-block drift` substring is).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import unittest
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
26
|
+
LINTER = REPO_ROOT / "tools" / "lint-agents-md.py"
|
|
27
|
+
|
|
28
|
+
_DRIFT_MARKER = "risk-trigger-block drift"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _block(third_bullet: str) -> str:
|
|
32
|
+
return (
|
|
33
|
+
"<!-- risk-triggers:start — canonical wording lives here. -->\n"
|
|
34
|
+
"**Risk triggers — any one routes the work to full mode:**\n\n"
|
|
35
|
+
"- **Unfamiliar** — territory you don't know well.\n"
|
|
36
|
+
"- **Multi-person** — more than one person builds or reviews it.\n"
|
|
37
|
+
f"- {third_bullet}\n"
|
|
38
|
+
"<!-- risk-triggers:end -->\n"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _seed(root: Path, canonical_block: str, agents_block: str) -> None:
|
|
43
|
+
skill = root / ".claude" / "skills" / "work-loop"
|
|
44
|
+
skill.mkdir(parents=True)
|
|
45
|
+
(skill / "SKILL.md").write_text(
|
|
46
|
+
"# Skill: work-loop\n\n" + canonical_block, encoding="utf-8"
|
|
47
|
+
)
|
|
48
|
+
(root / "AGENTS.md").write_text(
|
|
49
|
+
"# AGENTS.md\n\n" + agents_block, encoding="utf-8"
|
|
50
|
+
)
|
|
51
|
+
(root / "CLAUDE.md").symlink_to("AGENTS.md")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_linter(cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
55
|
+
return subprocess.run(
|
|
56
|
+
[sys.executable, str(LINTER)],
|
|
57
|
+
cwd=cwd,
|
|
58
|
+
env=dict(os.environ),
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
check=False,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RiskBlockEqualityTests(unittest.TestCase):
|
|
66
|
+
def test_fires_when_blocks_diverge(self) -> None:
|
|
67
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
68
|
+
tmp_path = Path(tmp)
|
|
69
|
+
_seed(
|
|
70
|
+
tmp_path,
|
|
71
|
+
canonical_block=_block("**New dependency** — it adds a dependency."),
|
|
72
|
+
agents_block=_block("**New dependency** — it adds a DIFFERENT thing."),
|
|
73
|
+
)
|
|
74
|
+
result = _run_linter(tmp_path)
|
|
75
|
+
self.assertIn(_DRIFT_MARKER, result.stderr)
|
|
76
|
+
self.assertIn("AGENTS.md", result.stderr)
|
|
77
|
+
|
|
78
|
+
def test_silent_when_blocks_identical(self) -> None:
|
|
79
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
80
|
+
tmp_path = Path(tmp)
|
|
81
|
+
same = _block("**New dependency** — it adds a dependency.")
|
|
82
|
+
_seed(tmp_path, canonical_block=same, agents_block=same)
|
|
83
|
+
result = _run_linter(tmp_path)
|
|
84
|
+
self.assertNotIn(_DRIFT_MARKER, result.stderr)
|
|
85
|
+
|
|
86
|
+
def test_fires_on_truncated_block(self) -> None:
|
|
87
|
+
# A `:start` with no matching `:end` is itself drift — fail closed.
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
tmp_path = Path(tmp)
|
|
90
|
+
same = _block("**New dependency** — it adds a dependency.")
|
|
91
|
+
_seed(tmp_path, canonical_block=same, agents_block=same)
|
|
92
|
+
# Truncate the AGENTS.md copy: drop its closing marker.
|
|
93
|
+
agents = tmp_path / "AGENTS.md"
|
|
94
|
+
agents.write_text(
|
|
95
|
+
agents.read_text(encoding="utf-8").replace(
|
|
96
|
+
"<!-- risk-triggers:end -->\n", ""
|
|
97
|
+
),
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
result = _run_linter(tmp_path)
|
|
101
|
+
self.assertIn(_DRIFT_MARKER, result.stderr)
|
|
102
|
+
self.assertIn("truncated", result.stderr)
|
|
103
|
+
|
|
104
|
+
def test_silent_when_no_marker(self) -> None:
|
|
105
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
106
|
+
tmp_path = Path(tmp)
|
|
107
|
+
(tmp_path / "AGENTS.md").write_text(
|
|
108
|
+
"# AGENTS.md\n\nNo risk-trigger block here.\n", encoding="utf-8"
|
|
109
|
+
)
|
|
110
|
+
(tmp_path / "CLAUDE.md").symlink_to("AGENTS.md")
|
|
111
|
+
result = _run_linter(tmp_path)
|
|
112
|
+
self.assertNotIn(_DRIFT_MARKER, result.stderr)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
unittest.main()
|