test(lint-skills): cover check_skill and check_command, not just settings

The linter's main job - frontmatter keys, allowed-tools targets, the
command title rule - had zero assertions; deleting the missing-
allowed-tools error left the suite green. The fixture's yaml stub now
parses the flat frontmatter the fixtures write instead of returning a
canned mapping, and four new cases pin both check functions.
Mutation-verified against the real linter. Review finding F23
(2026-08-19).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mads Lorentzen
2026-08-19 19:59:22 +02:00
co-authored by Claude Opus 5
parent c20458d768
commit 9a074b262d
2 changed files with 74 additions and 2 deletions
+6
View File
@@ -15,6 +15,12 @@ per-file diff commands.
### Added ### Added
- **Tests for `lint_skills.py`'s skill and command checks** - only `check_settings()`
had coverage; the linter's main job (frontmatter keys, `allowed-tools` targets
existing, the `# /<name>` command title rule) was unasserted, so deleting the
missing-allowed-tools error survived the whole suite. Four new cases in
`tests/test_lint_skills.py`, with the fixture's yaml stub upgraded to parse the real
frontmatter. Mutation-verified.
- **Discriminating tests for `robots_check`'s tie-break and browser-UA fallback** - the - **Discriminating tests for `robots_check`'s tie-break and browser-UA fallback** - the
existing tie test put Disallow first, the one ordering that cannot detect deletion of existing tie test put Disallow first, the one ordering that cannot detect deletion of
the tie-break clause; and the browser-readback recovery that `09-web-research.md` the tie-break clause; and the browser-readback recovery that `09-web-research.md`
+68 -2
View File
@@ -29,11 +29,19 @@ class LinterRepoFixture(unittest.TestCase):
shutil.copy(LINTER_SCRIPT, tools / "lint_skills.py") shutil.copy(LINTER_SCRIPT, tools / "lint_skills.py")
# The Python-test CI job does not install PyYAML; the separate lint job # The Python-test CI job does not install PyYAML; the separate lint job
# does. These settings-focused tests only need a valid frontmatter map. # does. These settings-focused tests only need a valid frontmatter map.
# The stub parses simple "key: value" lines, enough for the flat
# frontmatter these fixtures write, so the checks under test see the
# actual file content instead of a canned mapping.
(tools / "yaml.py").write_text( (tools / "yaml.py").write_text(
"class YAMLError(Exception):\n" "class YAMLError(Exception):\n"
" pass\n\n" " pass\n\n"
"def safe_load(_text):\n" "def safe_load(text):\n"
" return {'name': 'example', 'description': 'Example skill'}\n", " result = {}\n"
" for line in (text or '').splitlines():\n"
" if ':' in line:\n"
" key, _, value = line.partition(':')\n"
" result[key.strip()] = value.strip()\n"
" return result\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -103,5 +111,63 @@ class SettingsShapeTests(LinterRepoFixture):
self.assertEqual(result.returncode, 1) self.assertEqual(result.returncode, 1)
self.assertIn("expected permissions.allow to be a list", result.stdout) self.assertIn("expected permissions.allow to be a list", result.stdout)
self.assertNotIn("Traceback", result.stderr) self.assertNotIn("Traceback", result.stderr)
class SkillAndCommandCheckTests(LinterRepoFixture):
"""check_skill()/check_command() are the linter's main job and were
previously untested - only check_settings() had coverage, so deleting
e.g. the missing-allowed-tools error survived the whole suite (review
finding F23, 2026-08-19)."""
def write_skill(self, frontmatter: str):
skill = self.root / ".claude" / "skills" / "example" / "SKILL.md"
skill.write_text(frontmatter, encoding="utf-8")
def test_allowed_tools_referencing_a_missing_file_fails(self):
self.write_skill(
"---\n"
"name: example\n"
"description: Example skill\n"
"allowed-tools: Bash(bun run .claude/skills/example/DOES_NOT_EXIST.ts *)\n"
"---\n"
)
result = run_linter(self.root)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("allowed-tools references a missing file", result.stdout)
self.assertIn("DOES_NOT_EXIST.ts", result.stdout)
def test_allowed_tools_referencing_an_existing_file_passes(self):
target = self.root / ".claude" / "skills" / "example" / "cli.ts"
target.write_text("// present\n", encoding="utf-8")
self.write_skill(
"---\n"
"name: example\n"
"description: Example skill\n"
"allowed-tools: Bash(bun run .claude/skills/example/cli.ts *)\n"
"---\n"
)
result = run_linter(self.root)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_frontmatter_missing_description_fails(self):
self.write_skill("---\nname: example\ndescription:\n---\n")
result = run_linter(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn("missing required key 'description'", result.stdout)
def test_command_without_slash_title_fails(self):
command = self.root / ".claude" / "commands" / "setup.md"
command.write_text("# setup - missing the slash\n", encoding="utf-8")
result = run_linter(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn("must start with a '# /<name>' title", result.stdout)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()