@openhands/extensions 0.2.0 → 0.3.0
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.
- package/.release-please-manifest.json +1 -1
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/package.json +9 -1
- package/pyproject.toml +1 -1
- package/scripts/build-skills-catalog.mjs +100 -0
- package/skills/github-pr-review/SKILL.md +40 -2
- package/skills/index.d.ts +11 -0
- package/skills/index.js +465 -0
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/tests/test_github_pr_review_skill.py +114 -0
- package/tests/test_skills_catalog.py +336 -0
|
@@ -189,6 +189,7 @@ Source: [`examples/`](https://github.com/OpenHands/software-agent-sdk/tree/main/
|
|
|
189
189
|
- [`49_switch_llm_tool.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/49_switch_llm_tool.py)
|
|
190
190
|
- [`50_async_cancellation.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/50_async_cancellation.py)
|
|
191
191
|
- [`51_agent_hooks`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk/51_agent_hooks)
|
|
192
|
+
- [`52_dynamic_workflow.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/52_dynamic_workflow.py)
|
|
192
193
|
|
|
193
194
|
### [`02_remote_agent_server/`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server)
|
|
194
195
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Tests for the github-pr-review skill content.
|
|
2
|
+
|
|
3
|
+
These tests guard the guidance the agent reads when it builds a GitHub review
|
|
4
|
+
with inline suggestions. The skill must teach the agent how GitHub
|
|
5
|
+
``suggestion`` blocks actually behave (range replacement), so accepted
|
|
6
|
+
suggestions don't silently duplicate or delete lines, and so the prose
|
|
7
|
+
description always matches the resulting change.
|
|
8
|
+
|
|
9
|
+
See: https://github.com/OpenHands/extensions/issues/292
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
SKILL_PATH = (
|
|
17
|
+
Path(__file__).parent.parent / "skills" / "github-pr-review" / "SKILL.md"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(scope="module")
|
|
22
|
+
def skill_text() -> str:
|
|
23
|
+
return SKILL_PATH.read_text(encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_skill_file_exists():
|
|
27
|
+
assert SKILL_PATH.is_file(), f"missing skill file: {SKILL_PATH}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_skill_does_not_claim_suggestion_must_match_range_length(skill_text: str):
|
|
31
|
+
"""Regression: the old "same number of lines as the range" rule was wrong.
|
|
32
|
+
|
|
33
|
+
GitHub replaces the targeted range with the suggestion body regardless of
|
|
34
|
+
how many lines the body contains. Telling the agent the counts must match
|
|
35
|
+
pushed it to either delete lines (when shrinking a block) or duplicate
|
|
36
|
+
lines (when expanding one), depending on which side it adjusted to satisfy
|
|
37
|
+
the bogus rule. The skill must no longer state that constraint.
|
|
38
|
+
"""
|
|
39
|
+
forbidden_phrases = [
|
|
40
|
+
"suggestion must have the same number of lines as the range",
|
|
41
|
+
"must have the same number of lines as the range",
|
|
42
|
+
]
|
|
43
|
+
for phrase in forbidden_phrases:
|
|
44
|
+
assert phrase not in skill_text, (
|
|
45
|
+
f"SKILL.md still contains the misleading rule {phrase!r}; "
|
|
46
|
+
"GitHub suggestions replace the range with any number of lines."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_skill_explains_replace_semantics(skill_text: str):
|
|
51
|
+
"""The skill must say in plain language that suggestions REPLACE the range."""
|
|
52
|
+
assert "How Suggestions Actually Work" in skill_text
|
|
53
|
+
# Mention replacement explicitly so the agent does not treat the suggestion
|
|
54
|
+
# body as "extra lines added on top of the original".
|
|
55
|
+
lowered = skill_text.lower()
|
|
56
|
+
assert "replace" in lowered
|
|
57
|
+
assert "any number of lines" in lowered
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_skill_covers_add_remove_change_patterns(skill_text: str):
|
|
61
|
+
"""The skill should give explicit recipes for the three intents that
|
|
62
|
+
most commonly produced broken suggestions: changing, adding, and
|
|
63
|
+
deleting lines. Without these, the agent invents wrong patterns.
|
|
64
|
+
"""
|
|
65
|
+
for keyword in ("Change line", "Add", "Delete"):
|
|
66
|
+
assert keyword in skill_text, (
|
|
67
|
+
f"SKILL.md is missing guidance for intent {keyword!r}; "
|
|
68
|
+
"see issue #292."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_skill_warns_about_duplicated_and_deleted_lines(skill_text: str):
|
|
73
|
+
"""The skill must call out the two failure modes from issue #292:
|
|
74
|
+
accepted suggestions that duplicate or remove lines."""
|
|
75
|
+
lowered = skill_text.lower()
|
|
76
|
+
assert "duplicat" in lowered, (
|
|
77
|
+
"SKILL.md should warn about suggestions that duplicate lines."
|
|
78
|
+
)
|
|
79
|
+
# "disappear"/"disappearing" is unique to the failure-mode description in
|
|
80
|
+
# the "Common Mistakes" section. The generic terms "delete" and "remove"
|
|
81
|
+
# also appear in the recipe table for intentional deletions, so checking
|
|
82
|
+
# for those would let the failure-mode guidance be removed silently.
|
|
83
|
+
assert "disappear" in lowered, (
|
|
84
|
+
"SKILL.md should warn about suggestions that silently drop lines "
|
|
85
|
+
"(see 'Disappearing lines' under Common Mistakes)."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_skill_requires_description_to_match_suggestion(skill_text: str):
|
|
90
|
+
"""The skill must explicitly require the prose description to match the
|
|
91
|
+
actual code change in the suggestion block (the third bug from #292)."""
|
|
92
|
+
lowered = skill_text.lower()
|
|
93
|
+
assert any(phrase in lowered for phrase in ("description does not match", "prose description", "description must match"))
|
|
94
|
+
# The text should connect the description to the resulting code change.
|
|
95
|
+
assert (
|
|
96
|
+
"match" in lowered
|
|
97
|
+
), "SKILL.md should say the description must match the change."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_skill_has_pre_post_verification_step(skill_text: str):
|
|
101
|
+
"""Before posting, the agent must verify the suggestion against the file."""
|
|
102
|
+
assert "Mandatory Verification" in skill_text or "Verification" in skill_text
|
|
103
|
+
# Should point the agent at the real file content, not just the diff.
|
|
104
|
+
assert "sed -n" in skill_text or "read the actual file" in skill_text.lower()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_skill_offers_prose_fallback(skill_text: str):
|
|
108
|
+
"""When unsure, the agent should drop the suggestion block and use prose
|
|
109
|
+
instead — a correct comment beats a broken one-click fix."""
|
|
110
|
+
lowered = skill_text.lower()
|
|
111
|
+
assert "prose" in lowered, (
|
|
112
|
+
"SKILL.md should tell the agent to fall back to a prose comment "
|
|
113
|
+
"when the suggestion cannot be verified."
|
|
114
|
+
)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Tests for the skills catalog codegen (scripts/build-skills-catalog.mjs).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- parseFrontmatter edge cases (hyphenated keys, multiline, list items, etc.)
|
|
5
|
+
- End-to-end buildCatalog against temp fixtures
|
|
6
|
+
- Generated skills/index.js validity and structure
|
|
7
|
+
- Determinism: re-running the script produces identical output
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import textwrap
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
SCRIPT = ROOT / "scripts" / "build-skills-catalog.mjs"
|
|
17
|
+
SKILLS_INDEX = ROOT / "skills" / "index.js"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_node(script: str, *, cwd: str | Path = ROOT, check: bool = True) -> subprocess.CompletedProcess:
|
|
21
|
+
return subprocess.run(
|
|
22
|
+
["node", "--input-type=module", "-e", script],
|
|
23
|
+
cwd=str(cwd),
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
check=check,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# parseFrontmatter unit tests (via Node subprocess)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
class TestParseFrontmatter:
|
|
35
|
+
"""Unit tests for parseFrontmatter, exercised via Node import."""
|
|
36
|
+
|
|
37
|
+
def _parse(self, frontmatter_body: str) -> dict:
|
|
38
|
+
"""Run parseFrontmatter on *frontmatter_body* and return the result."""
|
|
39
|
+
escaped = json.dumps(frontmatter_body)
|
|
40
|
+
script = textwrap.dedent(f"""\
|
|
41
|
+
import {{ parseFrontmatter }} from './scripts/build-skills-catalog.mjs';
|
|
42
|
+
const result = parseFrontmatter({escaped});
|
|
43
|
+
process.stdout.write(JSON.stringify(result));
|
|
44
|
+
""")
|
|
45
|
+
result = run_node(script)
|
|
46
|
+
return json.loads(result.stdout)
|
|
47
|
+
|
|
48
|
+
def test_simple_key_value(self):
|
|
49
|
+
fm = self._parse("name: my-skill\ndescription: A test skill")
|
|
50
|
+
assert fm["name"] == "my-skill"
|
|
51
|
+
assert fm["description"] == "A test skill"
|
|
52
|
+
|
|
53
|
+
def test_hyphenated_keys(self):
|
|
54
|
+
fm = self._parse("name: my-skill\ncompatibility: works-everywhere")
|
|
55
|
+
assert fm["compatibility"] == "works-everywhere"
|
|
56
|
+
|
|
57
|
+
def test_hyphenated_key_not_silently_dropped(self):
|
|
58
|
+
"""Keys like event-key: should be parsed, not ignored."""
|
|
59
|
+
# We verify indirectly: if a hyphenated key precedes a list,
|
|
60
|
+
# the list items must be collected under that key.
|
|
61
|
+
script = textwrap.dedent("""\
|
|
62
|
+
import { parseFrontmatter } from './scripts/build-skills-catalog.mjs';
|
|
63
|
+
// parseFrontmatter returns a fixed shape, so test the internal
|
|
64
|
+
// parsing by checking a known hyphenated output key.
|
|
65
|
+
const fm = parseFrontmatter("license: MIT");
|
|
66
|
+
// Also verify a raw parse captures hyphenated keys internally
|
|
67
|
+
// by re-implementing a quick check:
|
|
68
|
+
const raw = "event-key: hello";
|
|
69
|
+
const match = raw.match(/^[\\w-]+:\\s*(.*)/);
|
|
70
|
+
if (!match) { console.error("Regex failed for hyphenated key"); process.exit(1); }
|
|
71
|
+
process.stdout.write("ok");
|
|
72
|
+
""")
|
|
73
|
+
result = run_node(script)
|
|
74
|
+
assert result.stdout == "ok"
|
|
75
|
+
|
|
76
|
+
def test_list_items(self):
|
|
77
|
+
fm = self._parse("name: test\ntriggers:\n- github\n- git\n- pull request")
|
|
78
|
+
assert fm["triggers"] == ["github", "git", "pull request"]
|
|
79
|
+
|
|
80
|
+
def test_empty_triggers_when_scalar(self):
|
|
81
|
+
fm = self._parse("name: test\ntriggers: not-a-list")
|
|
82
|
+
assert fm["triggers"] == []
|
|
83
|
+
|
|
84
|
+
def test_multiline_description_folded(self):
|
|
85
|
+
fm = self._parse("name: test\ndescription: >\n This is a long\n description text")
|
|
86
|
+
assert "This is a long" in fm["description"]
|
|
87
|
+
assert "description text" in fm["description"]
|
|
88
|
+
|
|
89
|
+
def test_multiline_description_literal(self):
|
|
90
|
+
fm = self._parse("name: test\ndescription: |\n Line one\n Line two")
|
|
91
|
+
assert "Line one" in fm["description"]
|
|
92
|
+
assert "Line two" in fm["description"]
|
|
93
|
+
|
|
94
|
+
def test_missing_name_returns_empty(self):
|
|
95
|
+
fm = self._parse("description: no name here")
|
|
96
|
+
assert fm["name"] == ""
|
|
97
|
+
|
|
98
|
+
def test_missing_description_returns_empty(self):
|
|
99
|
+
fm = self._parse("name: test")
|
|
100
|
+
assert fm["description"] == ""
|
|
101
|
+
assert fm["triggers"] == []
|
|
102
|
+
|
|
103
|
+
def test_optional_fields_omitted_when_absent(self):
|
|
104
|
+
fm = self._parse("name: test\ndescription: desc")
|
|
105
|
+
assert "license" not in fm
|
|
106
|
+
assert "compatibility" not in fm
|
|
107
|
+
|
|
108
|
+
def test_optional_fields_present_when_set(self):
|
|
109
|
+
fm = self._parse("name: test\nlicense: MIT\ncompatibility: all platforms")
|
|
110
|
+
assert fm["license"] == "MIT"
|
|
111
|
+
assert fm["compatibility"] == "all platforms"
|
|
112
|
+
|
|
113
|
+
def test_empty_input(self):
|
|
114
|
+
fm = self._parse("")
|
|
115
|
+
assert fm["name"] == ""
|
|
116
|
+
assert fm["description"] == ""
|
|
117
|
+
assert fm["triggers"] == []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# End-to-end codegen tests with temp fixtures (using buildCatalog)
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
class TestCodegenEndToEnd:
|
|
125
|
+
"""Run buildCatalog against temp SKILL.md fixtures."""
|
|
126
|
+
|
|
127
|
+
def _run_codegen(self, tmp_path: Path, skills: dict[str, str]) -> list[dict]:
|
|
128
|
+
"""Create temp skill dirs, run buildCatalog, return parsed catalog."""
|
|
129
|
+
skills_dir = tmp_path / "skills"
|
|
130
|
+
skills_dir.mkdir()
|
|
131
|
+
|
|
132
|
+
for name, content in skills.items():
|
|
133
|
+
skill_dir = skills_dir / name
|
|
134
|
+
skill_dir.mkdir()
|
|
135
|
+
(skill_dir / "SKILL.md").write_text(content)
|
|
136
|
+
|
|
137
|
+
script = textwrap.dedent(f"""\
|
|
138
|
+
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
139
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))});
|
|
140
|
+
process.stdout.write(JSON.stringify(entries));
|
|
141
|
+
""")
|
|
142
|
+
result = run_node(script)
|
|
143
|
+
return json.loads(result.stdout)
|
|
144
|
+
|
|
145
|
+
def test_basic_skill(self, tmp_path):
|
|
146
|
+
entries = self._run_codegen(tmp_path, {
|
|
147
|
+
"my-skill": "---\nname: my-skill\ndescription: A skill\ntriggers:\n- test\n---\nBody content here"
|
|
148
|
+
})
|
|
149
|
+
assert len(entries) == 1
|
|
150
|
+
assert entries[0]["name"] == "my-skill"
|
|
151
|
+
assert entries[0]["description"] == "A skill"
|
|
152
|
+
assert entries[0]["triggers"] == ["test"]
|
|
153
|
+
assert entries[0]["content"] == "Body content here"
|
|
154
|
+
|
|
155
|
+
def test_name_falls_back_to_directory(self, tmp_path):
|
|
156
|
+
entries = self._run_codegen(tmp_path, {
|
|
157
|
+
"fallback-dir": "---\ndescription: no name field\n---\nBody"
|
|
158
|
+
})
|
|
159
|
+
assert entries[0]["name"] == "fallback-dir"
|
|
160
|
+
|
|
161
|
+
def test_missing_frontmatter_skipped(self, tmp_path):
|
|
162
|
+
entries = self._run_codegen(tmp_path, {
|
|
163
|
+
"good-skill": "---\nname: good\ndescription: yes\n---\nBody",
|
|
164
|
+
"bad-skill": "No frontmatter delimiters at all",
|
|
165
|
+
})
|
|
166
|
+
assert len(entries) == 1
|
|
167
|
+
assert entries[0]["name"] == "good"
|
|
168
|
+
|
|
169
|
+
def test_missing_frontmatter_warns(self, tmp_path):
|
|
170
|
+
"""SKILL.md without frontmatter should produce a stderr warning."""
|
|
171
|
+
skills_dir = tmp_path / "skills"
|
|
172
|
+
skills_dir.mkdir()
|
|
173
|
+
(skills_dir / "bad").mkdir()
|
|
174
|
+
(skills_dir / "bad" / "SKILL.md").write_text("No frontmatter here")
|
|
175
|
+
|
|
176
|
+
script = textwrap.dedent(f"""\
|
|
177
|
+
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
178
|
+
buildCatalog({json.dumps(str(skills_dir))});
|
|
179
|
+
""")
|
|
180
|
+
result = run_node(script)
|
|
181
|
+
assert "Warning" in result.stderr
|
|
182
|
+
assert "missing frontmatter" in result.stderr
|
|
183
|
+
|
|
184
|
+
def test_body_with_triple_dashes_preserved(self, tmp_path):
|
|
185
|
+
content = "---\nname: test\ndescription: d\n---\nBefore\n---\nAfter"
|
|
186
|
+
entries = self._run_codegen(tmp_path, {"test-skill": content})
|
|
187
|
+
assert "---" in entries[0]["content"]
|
|
188
|
+
assert "Before" in entries[0]["content"]
|
|
189
|
+
assert "After" in entries[0]["content"]
|
|
190
|
+
|
|
191
|
+
def test_generated_file_is_valid_js(self, tmp_path):
|
|
192
|
+
"""Write a catalog to a file and verify it's importable as JS."""
|
|
193
|
+
skills_dir = tmp_path / "skills"
|
|
194
|
+
skills_dir.mkdir()
|
|
195
|
+
for name in ("alpha", "beta"):
|
|
196
|
+
(skills_dir / name).mkdir()
|
|
197
|
+
(skills_dir / name / "SKILL.md").write_text(
|
|
198
|
+
f"---\nname: {name}\ndescription: {name} desc\n---\nBody {name}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
output = tmp_path / "output.js"
|
|
202
|
+
script = textwrap.dedent(f"""\
|
|
203
|
+
import {{ writeFileSync }} from "node:fs";
|
|
204
|
+
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
205
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))});
|
|
206
|
+
const src = "export const SKILLS_CATALOG = " + JSON.stringify(entries) + ";\\nexport default SKILLS_CATALOG;\\n";
|
|
207
|
+
writeFileSync({json.dumps(str(output))}, src);
|
|
208
|
+
""")
|
|
209
|
+
run_node(script)
|
|
210
|
+
|
|
211
|
+
verify = textwrap.dedent(f"""\
|
|
212
|
+
const mod = await import({json.dumps(str(output))});
|
|
213
|
+
if (!Array.isArray(mod.SKILLS_CATALOG)) process.exit(1);
|
|
214
|
+
if (mod.SKILLS_CATALOG.length !== 2) process.exit(1);
|
|
215
|
+
if (mod.default !== mod.SKILLS_CATALOG) process.exit(1);
|
|
216
|
+
""")
|
|
217
|
+
run_node(verify)
|
|
218
|
+
|
|
219
|
+
def test_entries_sorted_by_directory_name(self, tmp_path):
|
|
220
|
+
entries = self._run_codegen(tmp_path, {
|
|
221
|
+
"zebra": "---\nname: zebra\ndescription: z\n---\nZ",
|
|
222
|
+
"alpha": "---\nname: alpha\ndescription: a\n---\nA",
|
|
223
|
+
"middle": "---\nname: middle\ndescription: m\n---\nM",
|
|
224
|
+
})
|
|
225
|
+
names = [e["name"] for e in entries]
|
|
226
|
+
assert names == ["alpha", "middle", "zebra"]
|
|
227
|
+
|
|
228
|
+
def test_optional_fields_included_when_present(self, tmp_path):
|
|
229
|
+
entries = self._run_codegen(tmp_path, {
|
|
230
|
+
"lic": "---\nname: lic\ndescription: d\nlicense: MIT\ncompatibility: all\n---\nBody"
|
|
231
|
+
})
|
|
232
|
+
assert entries[0]["license"] == "MIT"
|
|
233
|
+
assert entries[0]["compatibility"] == "all"
|
|
234
|
+
|
|
235
|
+
def test_optional_fields_omitted_when_absent(self, tmp_path):
|
|
236
|
+
entries = self._run_codegen(tmp_path, {
|
|
237
|
+
"bare": "---\nname: bare\ndescription: d\n---\nBody"
|
|
238
|
+
})
|
|
239
|
+
assert "license" not in entries[0]
|
|
240
|
+
assert "compatibility" not in entries[0]
|
|
241
|
+
|
|
242
|
+
def test_directories_without_skill_md_ignored(self, tmp_path):
|
|
243
|
+
skills_dir = tmp_path / "skills"
|
|
244
|
+
skills_dir.mkdir()
|
|
245
|
+
(skills_dir / "has-skill").mkdir()
|
|
246
|
+
(skills_dir / "has-skill" / "SKILL.md").write_text("---\nname: ok\ndescription: d\n---\nBody")
|
|
247
|
+
(skills_dir / "no-skill").mkdir() # no SKILL.md
|
|
248
|
+
|
|
249
|
+
script = textwrap.dedent(f"""\
|
|
250
|
+
import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs';
|
|
251
|
+
const entries = buildCatalog({json.dumps(str(skills_dir))});
|
|
252
|
+
process.stdout.write(JSON.stringify(entries));
|
|
253
|
+
""")
|
|
254
|
+
result = run_node(script)
|
|
255
|
+
entries = json.loads(result.stdout)
|
|
256
|
+
assert len(entries) == 1
|
|
257
|
+
assert entries[0]["name"] == "ok"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Generated skills/index.js validation (real repo data)
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
class TestGeneratedSkillsIndex:
|
|
265
|
+
"""Validate the checked-in skills/index.js against the live SKILL.md files."""
|
|
266
|
+
|
|
267
|
+
def test_index_js_is_valid_and_exports_array(self):
|
|
268
|
+
"""The generated skills/index.js is valid JS with a non-empty array export."""
|
|
269
|
+
script = textwrap.dedent("""\
|
|
270
|
+
import { SKILLS_CATALOG } from './skills/index.js';
|
|
271
|
+
if (!Array.isArray(SKILLS_CATALOG)) process.exit(1);
|
|
272
|
+
if (SKILLS_CATALOG.length === 0) process.exit(1);
|
|
273
|
+
import SKILLS from './skills/index.js';
|
|
274
|
+
if (SKILLS !== SKILLS_CATALOG) process.exit(1);
|
|
275
|
+
""")
|
|
276
|
+
run_node(script)
|
|
277
|
+
|
|
278
|
+
def test_every_entry_has_required_fields(self):
|
|
279
|
+
"""Each catalog entry must have name, description, triggers, and content."""
|
|
280
|
+
script = textwrap.dedent("""\
|
|
281
|
+
import { SKILLS_CATALOG } from './skills/index.js';
|
|
282
|
+
for (const entry of SKILLS_CATALOG) {
|
|
283
|
+
if (typeof entry.name !== 'string' || !entry.name) {
|
|
284
|
+
console.error('Missing name:', JSON.stringify(entry).slice(0, 100));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
if (typeof entry.description !== 'string') {
|
|
288
|
+
console.error('Missing description for:', entry.name);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
if (!Array.isArray(entry.triggers)) {
|
|
292
|
+
console.error('Missing triggers for:', entry.name);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
if (typeof entry.content !== 'string') {
|
|
296
|
+
console.error('Missing content for:', entry.name);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
""")
|
|
301
|
+
run_node(script)
|
|
302
|
+
|
|
303
|
+
def test_no_duplicate_names(self):
|
|
304
|
+
"""Skill names should be unique in the catalog."""
|
|
305
|
+
script = textwrap.dedent("""\
|
|
306
|
+
import { SKILLS_CATALOG } from './skills/index.js';
|
|
307
|
+
const names = SKILLS_CATALOG.map(e => e.name);
|
|
308
|
+
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
309
|
+
if (dupes.length > 0) {
|
|
310
|
+
console.error('Duplicate names:', dupes);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
""")
|
|
314
|
+
run_node(script)
|
|
315
|
+
|
|
316
|
+
def test_catalog_is_sorted(self):
|
|
317
|
+
"""Entries should be sorted alphabetically by name."""
|
|
318
|
+
script = textwrap.dedent("""\
|
|
319
|
+
import { SKILLS_CATALOG } from './skills/index.js';
|
|
320
|
+
const names = SKILLS_CATALOG.map(e => e.name);
|
|
321
|
+
const sorted = [...names].sort();
|
|
322
|
+
for (let i = 0; i < names.length; i++) {
|
|
323
|
+
if (names[i] !== sorted[i]) {
|
|
324
|
+
console.error('Not sorted: ' + names[i] + ' should be ' + sorted[i]);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
""")
|
|
329
|
+
run_node(script)
|
|
330
|
+
|
|
331
|
+
def test_index_is_up_to_date(self):
|
|
332
|
+
"""Re-running the codegen script should produce identical output."""
|
|
333
|
+
before = SKILLS_INDEX.read_text()
|
|
334
|
+
subprocess.run(["node", str(SCRIPT)], cwd=str(ROOT), check=True, capture_output=True)
|
|
335
|
+
after = SKILLS_INDEX.read_text()
|
|
336
|
+
assert before == after, "skills/index.js is out of date — run: node scripts/build-skills-catalog.mjs"
|