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.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. 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()