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,703 @@
1
+ """Windows-portability lint: catches symlinks and Windows-poisonous
2
+ names in pack content before they reach a release artefact.
3
+
4
+ Also covers the per-target metadata gate landed under
5
+ docs/specs/lint-packs-target-vocab/: skill/agent name pattern, name
6
+ length, and description length per docs/contracts/target-vocab.toml.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import io
13
+ import re
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ import unittest
18
+ from contextlib import redirect_stderr
19
+ from pathlib import Path
20
+
21
+ from agentbundle.build.lint_packs import (
22
+ Constraints,
23
+ cmd_lint_packs,
24
+ lint_all_packs,
25
+ lint_pack,
26
+ )
27
+
28
+
29
+ def _make_constraints(
30
+ *,
31
+ description_max: int = 1024,
32
+ name_max: int = 64,
33
+ name_pattern: str = r"^[a-z][a-z0-9-]*$",
34
+ binding_targets: dict[str, list[str]] | None = None,
35
+ ) -> Constraints:
36
+ """Build a Constraints tuple inline for tests that need one but
37
+ don't want to materialise a vocab file on disk. Defaults match
38
+ the in-tree target-vocab.toml's strictest cap.
39
+ """
40
+ if binding_targets is None:
41
+ binding_targets = {
42
+ "description_max": ["codex", "kiro"],
43
+ "name_max": ["kiro"],
44
+ "name_pattern": ["claude-code", "codex", "copilot", "kiro"],
45
+ }
46
+ return Constraints(
47
+ description_max=description_max,
48
+ name_pattern=re.compile(name_pattern),
49
+ name_max=name_max,
50
+ binding_targets=binding_targets,
51
+ )
52
+
53
+
54
+ def _write_minimal_pack(pack_dir: Path, name: str = "fixture-pack") -> None:
55
+ """Drop a minimal pack.toml so lint_all_packs treats the dir as
56
+ a pack. Tests that materialise packs build on this."""
57
+ pack_dir.mkdir(parents=True, exist_ok=True)
58
+ (pack_dir / "pack.toml").write_text(
59
+ f'[pack]\nname = "{name}"\nversion = "0.0.1"\n',
60
+ encoding="utf-8",
61
+ )
62
+
63
+ # The repo-checked-in fixture lives under tests/fixtures/lint_packs/.
64
+ # Reserved-name violations are constructed at runtime under tmp_path
65
+ # (POSIX-only) because NTFS forbids `git checkout` from materialising
66
+ # a path like `seeds/CON.md` — keeping the fixture purely runtime-
67
+ # constructed lets the Windows CI runner clone the repo without
68
+ # `error: invalid path`. The symlink fixture is also runtime-only for
69
+ # the same portability reason.
70
+ FIXTURES = Path(__file__).resolve().parent.parent.parent.parent / "tests" / "fixtures" / "lint_packs"
71
+
72
+
73
+ def _materialise_with_reserved_fixture(root: Path) -> Path:
74
+ """Build the equivalent of the legacy `with_reserved/` fixture under
75
+ ``root``. POSIX-only — Windows refuses the `CON.md` create itself,
76
+ so callers MUST gate on ``sys.platform != "win32"``.
77
+ """
78
+ pack = root / "with_reserved"
79
+ (pack / "seeds").mkdir(parents=True)
80
+ (pack / "pack.toml").write_text(
81
+ '[pack]\n'
82
+ 'name = "with-reserved"\n'
83
+ 'version = "0.0.1"\n'
84
+ 'description = "Windows-portability lint fixture: ships seeds/CON.md to '
85
+ 'prove the lint rejects Windows-reserved names. Not for installation."\n'
86
+ '\n'
87
+ '[pack.adapter-contract]\n'
88
+ 'version = "0.2"\n'
89
+ '\n'
90
+ '[pack.install]\n'
91
+ 'default-scope = "repo"\n'
92
+ 'allowed-scopes = ["repo"]\n',
93
+ encoding="utf-8",
94
+ )
95
+ (pack / "seeds" / "CON.md").write_text("reserved\n", encoding="utf-8")
96
+ return pack
97
+
98
+
99
+ class LintPackTests(unittest.TestCase):
100
+ def test_clean_fixture_returns_no_findings(self) -> None:
101
+ findings = lint_pack(FIXTURES / "clean")
102
+ self.assertEqual(findings, [])
103
+
104
+ @unittest.skipIf(
105
+ sys.platform == "win32",
106
+ "NTFS refuses to materialise seeds/CON.md; lint logic is OS-agnostic "
107
+ "so POSIX coverage is sufficient",
108
+ )
109
+ def test_with_reserved_fixture_catches_con_md(self) -> None:
110
+ with tempfile.TemporaryDirectory() as tmp:
111
+ pack = _materialise_with_reserved_fixture(Path(tmp))
112
+ findings = lint_pack(pack)
113
+ self.assertEqual(len(findings), 1, findings)
114
+ self.assertIn("CON.md", findings[0])
115
+ self.assertIn("reserved", findings[0].lower())
116
+
117
+ def test_runtime_symlink_violation_detected(self) -> None:
118
+ """Build a pack with a symlink under seeds/ in a tmp dir;
119
+ assert the lint surfaces it. The symlink is created at test
120
+ time so the on-disk fixture stays portable."""
121
+ with tempfile.TemporaryDirectory() as tmp:
122
+ pack = Path(tmp) / "linky"
123
+ (pack / "seeds").mkdir(parents=True)
124
+ (pack / "pack.toml").write_text(
125
+ '[pack]\nname = "linky"\nversion = "0.0.1"\n',
126
+ encoding="utf-8",
127
+ )
128
+ (pack / "seeds" / "target.md").write_text("target\n", encoding="utf-8")
129
+ (pack / "seeds" / "alias.md").symlink_to("target.md")
130
+ findings = lint_pack(pack)
131
+ self.assertEqual(len(findings), 1, findings)
132
+ self.assertIn("symlink", findings[0])
133
+ self.assertIn("alias.md", findings[0])
134
+
135
+ def test_runtime_symlink_under_apm_detected(self) -> None:
136
+ with tempfile.TemporaryDirectory() as tmp:
137
+ pack = Path(tmp) / "linky-apm"
138
+ (pack / ".apm" / "skills").mkdir(parents=True)
139
+ (pack / "pack.toml").write_text(
140
+ '[pack]\nname = "linky-apm"\nversion = "0.0.1"\n',
141
+ encoding="utf-8",
142
+ )
143
+ (pack / ".apm" / "skills" / "real.md").write_text("x\n", encoding="utf-8")
144
+ (pack / ".apm" / "skills" / "link.md").symlink_to("real.md")
145
+ findings = lint_pack(pack)
146
+ self.assertTrue(any("symlink" in f for f in findings))
147
+
148
+ @unittest.skipIf(
149
+ sys.platform == "win32",
150
+ "NTFS refuses to materialise seeds/CON.md; lint logic is OS-agnostic "
151
+ "so POSIX coverage is sufficient",
152
+ )
153
+ def test_lint_all_packs_returns_per_pack_results(self) -> None:
154
+ with tempfile.TemporaryDirectory() as tmp:
155
+ packs_dir = Path(tmp)
156
+ # Mirror the legacy on-disk fixture tree at runtime: a
157
+ # `clean` pack alongside a `with_reserved` pack.
158
+ clean = packs_dir / "clean"
159
+ (clean / "seeds").mkdir(parents=True)
160
+ (clean / "pack.toml").write_text(
161
+ '[pack]\nname = "clean"\nversion = "0.0.1"\n',
162
+ encoding="utf-8",
163
+ )
164
+ (clean / "seeds" / "ok.md").write_text("ok\n", encoding="utf-8")
165
+ _materialise_with_reserved_fixture(packs_dir)
166
+ results = lint_all_packs(packs_dir)
167
+ self.assertIn("clean", results)
168
+ self.assertIn("with_reserved", results)
169
+ self.assertEqual(results["clean"], [])
170
+ self.assertEqual(len(results["with_reserved"]), 1)
171
+
172
+ def test_lint_skips_directories_without_pack_toml(self) -> None:
173
+ with tempfile.TemporaryDirectory() as tmp:
174
+ packs = Path(tmp)
175
+ (packs / "real-pack").mkdir()
176
+ (packs / "real-pack" / "pack.toml").write_text(
177
+ '[pack]\nname = "real-pack"\nversion = "0.0.1"\n',
178
+ encoding="utf-8",
179
+ )
180
+ (packs / "not-a-pack").mkdir() # no pack.toml
181
+ results = lint_all_packs(packs)
182
+ self.assertIn("real-pack", results)
183
+ self.assertNotIn("not-a-pack", results)
184
+
185
+ @unittest.skipIf(
186
+ sys.platform == "win32",
187
+ "NTFS refuses to materialise seeds/CON.md; lint logic is OS-agnostic "
188
+ "so POSIX coverage is sufficient",
189
+ )
190
+ def test_cmd_lint_packs_exits_one_on_violation(self) -> None:
191
+ with tempfile.TemporaryDirectory() as tmp:
192
+ packs_dir = Path(tmp)
193
+ _materialise_with_reserved_fixture(packs_dir)
194
+ args = argparse.Namespace(packs_dir=str(packs_dir))
195
+ buf = io.StringIO()
196
+ with redirect_stderr(buf):
197
+ rc = cmd_lint_packs(args)
198
+ self.assertEqual(rc, 1)
199
+ self.assertIn("CON.md", buf.getvalue())
200
+ self.assertIn("violation", buf.getvalue())
201
+
202
+ def test_findings_are_sorted_by_relpath(self) -> None:
203
+ """Findings come back in deterministic alphabetical order so
204
+ operators see the same first-fix-target on every run; the
205
+ underlying `rglob("*")` is sorted before each entry is
206
+ examined."""
207
+ with tempfile.TemporaryDirectory() as tmp:
208
+ pack = Path(tmp) / "multi-violation"
209
+ (pack / "seeds").mkdir(parents=True)
210
+ (pack / "pack.toml").write_text(
211
+ '[pack]\nname = "multi-violation"\nversion = "0.0.1"\n',
212
+ encoding="utf-8",
213
+ )
214
+ # Three deliberate violations across two segments. The
215
+ # sorted relpaths are: NUL.md, alpha/CON.md, beta/PRN.md.
216
+ (pack / "seeds" / "NUL.md").write_text("x\n", encoding="utf-8")
217
+ (pack / "seeds" / "alpha").mkdir()
218
+ (pack / "seeds" / "alpha" / "CON.md").write_text("x\n", encoding="utf-8")
219
+ (pack / "seeds" / "beta").mkdir()
220
+ (pack / "seeds" / "beta" / "PRN.md").write_text("x\n", encoding="utf-8")
221
+ findings = lint_pack(pack)
222
+ self.assertEqual(len(findings), 3)
223
+ relpaths = [f.rsplit(": ", 1)[-1] for f in findings]
224
+ self.assertEqual(relpaths, sorted(relpaths))
225
+
226
+ def test_cmd_lint_packs_exits_zero_on_clean_packs_dir(self) -> None:
227
+ with tempfile.TemporaryDirectory() as tmp:
228
+ packs = Path(tmp)
229
+ shutil.copytree(FIXTURES / "clean", packs / "only-clean")
230
+ args = argparse.Namespace(packs_dir=str(packs))
231
+ buf = io.StringIO()
232
+ with redirect_stderr(buf):
233
+ rc = cmd_lint_packs(args)
234
+ self.assertEqual(rc, 0)
235
+
236
+
237
+ class LintPackVocabTests(unittest.TestCase):
238
+ """Per-target metadata gate (spec: lint-packs-target-vocab)."""
239
+
240
+ def _build_skill(
241
+ self,
242
+ pack: Path,
243
+ dir_name: str,
244
+ description: str | None = "A short, single-line description.",
245
+ frontmatter_name: str | None = None,
246
+ ) -> Path:
247
+ skill_dir = pack / ".apm" / "skills" / dir_name
248
+ skill_dir.mkdir(parents=True, exist_ok=True)
249
+ lines = ["---"]
250
+ if frontmatter_name is not None:
251
+ lines.append(f"name: {frontmatter_name}")
252
+ if description is not None:
253
+ lines.append(f"description: {description}")
254
+ lines.append("---")
255
+ lines.append("Body.")
256
+ (skill_dir / "SKILL.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
257
+ return skill_dir / "SKILL.md"
258
+
259
+ def _build_agent(
260
+ self,
261
+ pack: Path,
262
+ stem: str,
263
+ description: str | None = "A short, single-line description.",
264
+ frontmatter_name: str | None = None,
265
+ ) -> Path:
266
+ agents_dir = pack / ".apm" / "agents"
267
+ agents_dir.mkdir(parents=True, exist_ok=True)
268
+ lines = ["---"]
269
+ if frontmatter_name is not None:
270
+ lines.append(f"name: {frontmatter_name}")
271
+ if description is not None:
272
+ lines.append(f"description: {description}")
273
+ lines.append("model: opus")
274
+ lines.append("---")
275
+ lines.append("Body.")
276
+ path = agents_dir / f"{stem}.md"
277
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
278
+ return path
279
+
280
+ # ------------------------------------------------------------------
281
+ # Skill checks (AC2 — name pattern, AC3 — name length, AC4 — desc)
282
+ # ------------------------------------------------------------------
283
+
284
+ def test_skill_dir_name_pattern_violation_detected(self) -> None:
285
+ constraints = _make_constraints()
286
+ with tempfile.TemporaryDirectory() as tmp:
287
+ pack = Path(tmp) / "vocab-fixture"
288
+ _write_minimal_pack(pack, name="vocab-fixture")
289
+ self._build_skill(pack, "Bad_Name")
290
+ findings = lint_pack(pack, constraints=constraints)
291
+ vocab_findings = [f for f in findings if "name does not match" in f]
292
+ self.assertEqual(len(vocab_findings), 1, findings)
293
+ self.assertIn("skill/Bad_Name", vocab_findings[0])
294
+ self.assertIn("name does not match", vocab_findings[0])
295
+ self.assertIn("binding target:", vocab_findings[0])
296
+
297
+ def test_skill_frontmatter_name_mismatch_pattern_detected(self) -> None:
298
+ constraints = _make_constraints()
299
+ with tempfile.TemporaryDirectory() as tmp:
300
+ pack = Path(tmp) / "vocab-fm-name"
301
+ _write_minimal_pack(pack, name="vocab-fm-name")
302
+ self._build_skill(pack, "valid-name", frontmatter_name="Bad_Name")
303
+ findings = lint_pack(pack, constraints=constraints)
304
+ fm_findings = [
305
+ f for f in findings if "Bad_Name" in f and "name does not match" in f
306
+ ]
307
+ self.assertEqual(len(fm_findings), 1, findings)
308
+ self.assertIn("skill/valid-name", fm_findings[0])
309
+
310
+ def test_skill_name_length_violation_detected(self) -> None:
311
+ constraints = _make_constraints()
312
+ long_name = "a" + "b" * 69 # 70 chars, kebab-valid
313
+ with tempfile.TemporaryDirectory() as tmp:
314
+ pack = Path(tmp) / "vocab-fixture"
315
+ _write_minimal_pack(pack, name="vocab-fixture")
316
+ self._build_skill(pack, long_name)
317
+ findings = lint_pack(pack, constraints=constraints)
318
+ length_findings = [f for f in findings if "name length exceeds" in f]
319
+ self.assertEqual(len(length_findings), 1, findings)
320
+ self.assertIn(f"name length exceeds 64 (got 70", length_findings[0])
321
+ self.assertIn("binding target: kiro", length_findings[0])
322
+
323
+ def test_skill_description_length_violation_detected(self) -> None:
324
+ constraints = _make_constraints()
325
+ long_desc = "x" * 1100
326
+ with tempfile.TemporaryDirectory() as tmp:
327
+ pack = Path(tmp) / "vocab-fixture"
328
+ _write_minimal_pack(pack, name="vocab-fixture")
329
+ self._build_skill(pack, "valid-name", description=long_desc)
330
+ findings = lint_pack(pack, constraints=constraints)
331
+ desc_findings = [
332
+ f for f in findings if "description length exceeds" in f
333
+ ]
334
+ self.assertEqual(len(desc_findings), 1, findings)
335
+ self.assertIn("description length exceeds 1024 (got 1100", desc_findings[0])
336
+ self.assertIn("binding target: codex, kiro", desc_findings[0])
337
+
338
+ def test_skill_description_singleline_required(self) -> None:
339
+ constraints = _make_constraints()
340
+ with tempfile.TemporaryDirectory() as tmp:
341
+ pack = Path(tmp) / "vocab-fixture"
342
+ _write_minimal_pack(pack, name="vocab-fixture")
343
+ skill_dir = pack / ".apm" / "skills" / "valid-name"
344
+ skill_dir.mkdir(parents=True, exist_ok=True)
345
+ (skill_dir / "SKILL.md").write_text(
346
+ "---\ndescription: >\n folded\n multi-line\nmodel: opus\n---\nBody.\n",
347
+ encoding="utf-8",
348
+ )
349
+ findings = lint_pack(pack, constraints=constraints)
350
+ ml_findings = [
351
+ f for f in findings if "description must be a single-line value" in f
352
+ ]
353
+ self.assertEqual(len(ml_findings), 1, findings)
354
+ self.assertIn("skill/valid-name", ml_findings[0])
355
+
356
+ # ------------------------------------------------------------------
357
+ # Agent checks (AC5 — name pattern + length, AC6 — desc length)
358
+ # ------------------------------------------------------------------
359
+
360
+ def test_agent_name_length_violation_detected(self) -> None:
361
+ constraints = _make_constraints()
362
+ long_stem = "a" + "b" * 69 # 70 chars
363
+ with tempfile.TemporaryDirectory() as tmp:
364
+ pack = Path(tmp) / "vocab-fixture"
365
+ _write_minimal_pack(pack, name="vocab-fixture")
366
+ self._build_agent(pack, long_stem)
367
+ findings = lint_pack(pack, constraints=constraints)
368
+ length_findings = [
369
+ f for f in findings if "agent/" in f and "name length exceeds" in f
370
+ ]
371
+ self.assertEqual(len(length_findings), 1, findings)
372
+ self.assertIn(f"agent/{long_stem}", length_findings[0])
373
+ self.assertIn("name length exceeds 64 (got 70", length_findings[0])
374
+
375
+ def test_agent_description_length_violation_detected(self) -> None:
376
+ constraints = _make_constraints()
377
+ long_desc = "x" * 1100
378
+ with tempfile.TemporaryDirectory() as tmp:
379
+ pack = Path(tmp) / "vocab-fixture"
380
+ _write_minimal_pack(pack, name="vocab-fixture")
381
+ self._build_agent(pack, "valid-agent", description=long_desc)
382
+ findings = lint_pack(pack, constraints=constraints)
383
+ desc_findings = [
384
+ f for f in findings
385
+ if "agent/" in f and "description length exceeds" in f
386
+ ]
387
+ self.assertEqual(len(desc_findings), 1, findings)
388
+ self.assertIn("description length exceeds 1024 (got 1100", desc_findings[0])
389
+
390
+ def test_agent_description_singleline_required(self) -> None:
391
+ constraints = _make_constraints()
392
+ with tempfile.TemporaryDirectory() as tmp:
393
+ pack = Path(tmp) / "vocab-fixture"
394
+ _write_minimal_pack(pack, name="vocab-fixture")
395
+ agents_dir = pack / ".apm" / "agents"
396
+ agents_dir.mkdir(parents=True, exist_ok=True)
397
+ (agents_dir / "valid-agent.md").write_text(
398
+ "---\ndescription: |\n folded\nmodel: opus\n---\nBody.\n",
399
+ encoding="utf-8",
400
+ )
401
+ findings = lint_pack(pack, constraints=constraints)
402
+ ml_findings = [
403
+ f for f in findings if "description must be a single-line value" in f
404
+ ]
405
+ self.assertEqual(len(ml_findings), 1, findings)
406
+ self.assertIn("agent/valid-agent", ml_findings[0])
407
+
408
+ # ------------------------------------------------------------------
409
+ # Clean pack — no vocab findings
410
+ # ------------------------------------------------------------------
411
+
412
+ def test_clean_pack_with_skills_and_agents_has_no_vocab_findings(self) -> None:
413
+ constraints = _make_constraints()
414
+ with tempfile.TemporaryDirectory() as tmp:
415
+ pack = Path(tmp) / "clean-vocab"
416
+ _write_minimal_pack(pack, name="clean-vocab")
417
+ self._build_skill(pack, "good-skill", description="x" * 100)
418
+ self._build_agent(pack, "good-agent", description="x" * 100)
419
+ findings = lint_pack(pack, constraints=constraints)
420
+ self.assertEqual(findings, [], findings)
421
+
422
+ # ------------------------------------------------------------------
423
+ # Sort invariant (AC10) — vocab + portability findings interleave
424
+ # ------------------------------------------------------------------
425
+
426
+ def test_findings_remain_sorted_when_vocab_and_portability_mix(self) -> None:
427
+ constraints = _make_constraints()
428
+ if sys.platform == "win32":
429
+ self.skipTest(
430
+ "NTFS refuses to materialise seeds/NUL.md; the sort invariant "
431
+ "is OS-agnostic so POSIX coverage is sufficient"
432
+ )
433
+ with tempfile.TemporaryDirectory() as tmp:
434
+ pack = Path(tmp) / "mix-pack"
435
+ _write_minimal_pack(pack, name="mix-pack")
436
+ (pack / "seeds").mkdir(parents=True, exist_ok=True)
437
+ (pack / "seeds" / "NUL.md").write_text("x\n", encoding="utf-8")
438
+ self._build_skill(pack, "Bad_Name")
439
+ findings = lint_pack(pack, constraints=constraints)
440
+ relpaths = [f.rsplit(": ", 1)[-1] for f in findings]
441
+ self.assertEqual(relpaths, sorted(relpaths))
442
+
443
+ @unittest.skipIf(
444
+ sys.platform == "win32",
445
+ "NTFS refuses to materialise seeds/NUL.md / .apm/agents/CON.md; sort "
446
+ "invariant is OS-agnostic so POSIX coverage is sufficient",
447
+ )
448
+ def test_portability_findings_sort_across_subtrees_when_constraints_supplied(
449
+ self,
450
+ ) -> None:
451
+ """The constraints-supplied path adds a cross-subtree sort step;
452
+ without it, portability findings come back subtree-by-subtree
453
+ (`seeds/` first, then `.apm/`). With it, the combined list is
454
+ sorted by trailing relpath."""
455
+ constraints = _make_constraints()
456
+ with tempfile.TemporaryDirectory() as tmp:
457
+ pack = Path(tmp) / "cross-subtree"
458
+ _write_minimal_pack(pack, name="cross-subtree")
459
+ (pack / "seeds").mkdir(parents=True, exist_ok=True)
460
+ (pack / "seeds" / "NUL.md").write_text("x\n", encoding="utf-8")
461
+ agents_dir = pack / ".apm" / "agents"
462
+ agents_dir.mkdir(parents=True, exist_ok=True)
463
+ (agents_dir / "CON.md").write_text("x\n", encoding="utf-8")
464
+ findings = lint_pack(pack, constraints=constraints)
465
+ relpaths = [f.rsplit(": ", 1)[-1] for f in findings]
466
+ # `.apm/agents/CON.md` sorts before `seeds/NUL.md` alphabetically.
467
+ # The constraints-supplied path must produce them in that order.
468
+ self.assertEqual(relpaths, sorted(relpaths))
469
+ self.assertGreater(len(relpaths), 1)
470
+
471
+ def test_multi_target_tie_renders_comma_joined_binding(self) -> None:
472
+ """When multiple targets share the binding cap (codex + kiro at
473
+ 1024), the finding renders `binding target: codex, kiro`."""
474
+ constraints = _make_constraints()
475
+ with tempfile.TemporaryDirectory() as tmp:
476
+ pack = Path(tmp) / "tie-pack"
477
+ _write_minimal_pack(pack, name="tie-pack")
478
+ self._build_skill(pack, "good-name", description="x" * 1100)
479
+ findings = lint_pack(pack, constraints=constraints)
480
+ desc_findings = [
481
+ f for f in findings if "description length exceeds" in f
482
+ ]
483
+ self.assertEqual(len(desc_findings), 1, findings)
484
+ self.assertIn("binding target: codex, kiro", desc_findings[0])
485
+
486
+ # ------------------------------------------------------------------
487
+ # AC11 — vocab file missing / inconsistent fails loud
488
+ # ------------------------------------------------------------------
489
+
490
+ def test_missing_vocab_file_fails_loud(self) -> None:
491
+ """When neither the --packs-dir walk nor the module-ancestor
492
+ fallback finds the vocab file, cmd_lint_packs exits non-zero
493
+ with a stderr line naming the config file. We patch
494
+ `_VOCAB_RELPATH` to a sentinel filename that exists nowhere so
495
+ both walks fail deterministically."""
496
+ from unittest.mock import patch
497
+ from agentbundle.build import lint_packs as lp_module
498
+ sentinel = Path("docs/contracts/__nonexistent_target_vocab__.toml")
499
+ with tempfile.TemporaryDirectory() as tmp:
500
+ packs_dir = Path(tmp) / "isolated" / "packs"
501
+ packs_dir.mkdir(parents=True)
502
+ _write_minimal_pack(packs_dir / "p", name="p")
503
+ args = argparse.Namespace(packs_dir=str(packs_dir))
504
+ buf = io.StringIO()
505
+ with patch.object(lp_module, "_VOCAB_RELPATH", sentinel), \
506
+ redirect_stderr(buf):
507
+ rc = cmd_lint_packs(args)
508
+ self.assertEqual(rc, 1)
509
+ self.assertIn("target-vocab.toml", buf.getvalue())
510
+
511
+ def test_skill_name_multiline_refused(self) -> None:
512
+ """A folded `name:` in frontmatter must be refused — same
513
+ rationale as AC12 for `description:`, applied to `name:`."""
514
+ constraints = _make_constraints()
515
+ with tempfile.TemporaryDirectory() as tmp:
516
+ pack = Path(tmp) / "ml-name-pack"
517
+ _write_minimal_pack(pack, name="ml-name-pack")
518
+ skill_dir = pack / ".apm" / "skills" / "valid-name"
519
+ skill_dir.mkdir(parents=True)
520
+ (skill_dir / "SKILL.md").write_text(
521
+ "---\nname: >\n Bad_Name\ndescription: short.\n---\nBody.\n",
522
+ encoding="utf-8",
523
+ )
524
+ findings = lint_pack(pack, constraints=constraints)
525
+ ml_findings = [
526
+ f for f in findings
527
+ if "name must be a single-line value" in f
528
+ ]
529
+ self.assertEqual(len(ml_findings), 1, findings)
530
+ self.assertIn("skill/valid-name", ml_findings[0])
531
+
532
+ def test_agent_name_multiline_refused(self) -> None:
533
+ constraints = _make_constraints()
534
+ with tempfile.TemporaryDirectory() as tmp:
535
+ pack = Path(tmp) / "ml-agent"
536
+ _write_minimal_pack(pack, name="ml-agent")
537
+ agents_dir = pack / ".apm" / "agents"
538
+ agents_dir.mkdir(parents=True)
539
+ (agents_dir / "valid-agent.md").write_text(
540
+ "---\nname: |\n Bad_Name\ndescription: short.\nmodel: opus\n---\nBody.\n",
541
+ encoding="utf-8",
542
+ )
543
+ findings = lint_pack(pack, constraints=constraints)
544
+ ml_findings = [
545
+ f for f in findings
546
+ if "name must be a single-line value" in f
547
+ ]
548
+ self.assertEqual(len(ml_findings), 1, findings)
549
+ self.assertIn("agent/valid-agent", ml_findings[0])
550
+
551
+ def test_loader_module_ancestor_fallback_succeeds(self) -> None:
552
+ """The loader walks up from the supplied start; when that
553
+ finds nothing, it falls back to walking from the module's
554
+ own ancestor chain. Production-side this is what makes
555
+ `cmd_lint_packs` work for an out-of-tree --packs-dir while
556
+ still reading the in-tree vocab. Direct test of the
557
+ fallback hit-path."""
558
+ from agentbundle.build.lint_packs import _load_target_vocab
559
+ with tempfile.TemporaryDirectory() as tmp:
560
+ vocab, err = _load_target_vocab(Path(tmp))
561
+ self.assertIsNone(err, err)
562
+ self.assertIsNotNone(vocab)
563
+ self.assertEqual(vocab["target"]["kiro"]["name-max-length"], 64)
564
+
565
+ def test_skill_frontmatter_with_bom_still_checked(self) -> None:
566
+ """A SKILL.md saved with a UTF-8 BOM must still have its
567
+ frontmatter parsed — otherwise an over-cap description would
568
+ slip through silently. Regression for the BOM under-counting
569
+ risk surfaced by quality-engineer review."""
570
+ constraints = _make_constraints()
571
+ with tempfile.TemporaryDirectory() as tmp:
572
+ pack = Path(tmp) / "bom-pack"
573
+ _write_minimal_pack(pack, name="bom-pack")
574
+ skill_dir = pack / ".apm" / "skills" / "good-name"
575
+ skill_dir.mkdir(parents=True)
576
+ long_desc = "x" * 1100
577
+ (skill_dir / "SKILL.md").write_text(
578
+ "---\ndescription: " + long_desc + "\n---\nBody.\n",
579
+ encoding="utf-8",
580
+ )
581
+ findings = lint_pack(pack, constraints=constraints)
582
+ desc_findings = [
583
+ f for f in findings if "description length exceeds" in f
584
+ ]
585
+ self.assertEqual(len(desc_findings), 1, findings)
586
+
587
+ def _run_with_bad_vocab(self, body: str) -> tuple[int, str]:
588
+ """Helper for AC11 refusal-branch coverage. Materialises an
589
+ isolated tree with a controlled `target-vocab.toml`, invokes
590
+ `cmd_lint_packs` against a minimal pack inside that tree, and
591
+ returns `(rc, stderr_text)`. The explicit `--packs-dir` walk
592
+ finds the tmp vocab first, so the module-ancestor fallback
593
+ doesn't shadow the bad config under test."""
594
+ with tempfile.TemporaryDirectory() as tmp:
595
+ root = Path(tmp) / "isolated"
596
+ packs_dir = root / "packs"
597
+ packs_dir.mkdir(parents=True)
598
+ _write_minimal_pack(packs_dir / "p", name="p")
599
+ vocab_dir = root / "docs" / "contracts"
600
+ vocab_dir.mkdir(parents=True)
601
+ (vocab_dir / "target-vocab.toml").write_text(body, encoding="utf-8")
602
+ args = argparse.Namespace(packs_dir=str(packs_dir))
603
+ buf = io.StringIO()
604
+ with redirect_stderr(buf):
605
+ rc = cmd_lint_packs(args)
606
+ return rc, buf.getvalue()
607
+
608
+ def test_malformed_toml_fails_loud(self) -> None:
609
+ rc, stderr = self._run_with_bad_vocab("not valid toml [[[\n")
610
+ self.assertEqual(rc, 1)
611
+ self.assertIn("failed to parse", stderr)
612
+ self.assertIn("configuration error", stderr)
613
+
614
+ def test_no_target_tables_fails_loud(self) -> None:
615
+ rc, stderr = self._run_with_bad_vocab(
616
+ '[contract]\nversion = "0.1"\n'
617
+ )
618
+ self.assertEqual(rc, 1)
619
+ self.assertIn("no [target.<name>] tables", stderr)
620
+ self.assertIn("configuration error", stderr)
621
+
622
+ def test_missing_name_pattern_on_target_fails_loud(self) -> None:
623
+ rc, stderr = self._run_with_bad_vocab(
624
+ '[target.alpha]\n'
625
+ 'description-max-length = 1024\n'
626
+ 'name-max-length = 64\n'
627
+ )
628
+ self.assertEqual(rc, 1)
629
+ self.assertIn("name-pattern", stderr)
630
+ self.assertIn("configuration error", stderr)
631
+
632
+ def test_no_description_cap_anywhere_fails_loud(self) -> None:
633
+ rc, stderr = self._run_with_bad_vocab(
634
+ '[target.alpha]\n'
635
+ 'name-pattern = "^[a-z][a-z0-9-]*$"\n'
636
+ 'name-max-length = 64\n'
637
+ )
638
+ self.assertEqual(rc, 1)
639
+ self.assertIn("description-max-length", stderr)
640
+ self.assertIn("configuration error", stderr)
641
+
642
+ def test_no_name_max_length_anywhere_fails_loud(self) -> None:
643
+ rc, stderr = self._run_with_bad_vocab(
644
+ '[target.alpha]\n'
645
+ 'name-pattern = "^[a-z][a-z0-9-]*$"\n'
646
+ 'description-max-length = 1024\n'
647
+ )
648
+ self.assertEqual(rc, 1)
649
+ self.assertIn("name-max-length", stderr)
650
+ self.assertIn("configuration error", stderr)
651
+
652
+ def test_portability_sort_no_constraints_also_sorts_across_subtrees(
653
+ self,
654
+ ) -> None:
655
+ """The unconditional sort step at the end of `lint_pack` keeps
656
+ the trailing-relpath ordering invariant in the no-constraints
657
+ path too. Regression-pin: a future change that re-gates the
658
+ sort behind `constraints is not None` would let this test
659
+ fail loudly."""
660
+ if sys.platform == "win32":
661
+ self.skipTest(
662
+ "NTFS refuses to materialise seeds/NUL.md / .apm/agents/CON.md"
663
+ )
664
+ with tempfile.TemporaryDirectory() as tmp:
665
+ pack = Path(tmp) / "no-constraints-mix"
666
+ _write_minimal_pack(pack, name="no-constraints-mix")
667
+ (pack / "seeds").mkdir(parents=True, exist_ok=True)
668
+ (pack / "seeds" / "NUL.md").write_text("x\n", encoding="utf-8")
669
+ agents_dir = pack / ".apm" / "agents"
670
+ agents_dir.mkdir(parents=True, exist_ok=True)
671
+ (agents_dir / "CON.md").write_text("x\n", encoding="utf-8")
672
+ findings = lint_pack(pack)
673
+ relpaths = [f.rsplit(": ", 1)[-1] for f in findings]
674
+ self.assertEqual(relpaths, sorted(relpaths))
675
+ self.assertGreater(len(relpaths), 1)
676
+
677
+ def test_inconsistent_name_pattern_fails_loud(self) -> None:
678
+ """A target-vocab.toml whose targets carry different name-pattern
679
+ values must be refused by the loader (AC11)."""
680
+ with tempfile.TemporaryDirectory() as tmp:
681
+ root = Path(tmp) / "isolated"
682
+ packs_dir = root / "packs"
683
+ packs_dir.mkdir(parents=True)
684
+ _write_minimal_pack(packs_dir / "p", name="p")
685
+ vocab_dir = root / "docs" / "contracts"
686
+ vocab_dir.mkdir(parents=True)
687
+ (vocab_dir / "target-vocab.toml").write_text(
688
+ '[target.alpha]\nname-pattern = "^[a-z][a-z0-9-]*$"\n'
689
+ 'description-max-length = 1024\n'
690
+ '[target.beta]\nname-pattern = "^[A-Z][A-Z0-9-]*$"\n'
691
+ 'description-max-length = 1024\n',
692
+ encoding="utf-8",
693
+ )
694
+ args = argparse.Namespace(packs_dir=str(packs_dir))
695
+ buf = io.StringIO()
696
+ with redirect_stderr(buf):
697
+ rc = cmd_lint_packs(args)
698
+ self.assertEqual(rc, 1)
699
+ self.assertIn("name-pattern", buf.getvalue())
700
+
701
+
702
+ if __name__ == "__main__": # pragma: no cover
703
+ sys.exit(unittest.main())