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,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())
|