jobbsok/tests/test_skills_contract.py

245 lines
9.6 KiB
Python

"""The contract every `skills/*/SKILL.md` has to hold (plan Steps 9 and 11).
A skill is a document, and a document has no unit tests -- which is exactly
why it needs a contract test. What can be checked mechanically is checked
here: the frontmatter parses, the name matches the directory, the trigger
phrases the operator would actually type are declared and unique, the body is
short enough to be loaded without crowding the context, every intra-plugin
path is written as `${CLAUDE_PLUGIN_ROOT}`, no absolute home path is baked in,
and every `references/` file is linked from the skill that owns it.
Two checks in the plan's Step 9 list read as behavioural, and they are
implemented as far as a zero-network test honestly can, with the boundary
stated rather than blurred:
* *"invoking the skill against a non-existent workspace scaffolds it and
reports the created tree"* -- the invocation is a model turn and cannot run
here. What runs is the mechanism the skill delegates to,
`jobbsok_lib.paths.scaffold`, against a path that does not exist, plus the
assertion that SKILL.md actually names it. A skill that stopped calling
scaffold would fail the second half.
* *"the version echo prints both the build stamp and the manifest version"* --
`BUILD_STAMP` is created in Step 13. So the check is that the skill names
both the stamp path and the manifest version, and that
`.claude-plugin/plugin.json` really carries a version to echo. The file's
existence becomes assertable at Step 13 and not before; claiming otherwise
here would be asserting a fact about a file this repository does not have.
The trigger-phrase rule deserves its own note. Build-brief 7 requires the
description to name the trigger phrases in Norwegian, and the plan requires
them "in both accented and ASCII spellings" -- because an operator typing in a
hurry writes `lonn` as often as `lønn`. So every accented phrase must have an
ASCII twin, folded through `paths.slug`'s rules so the fold is defined in one
place rather than twice.
Style note: this file follows tests/test_kandidatprofil_schema.py.
"""
import json
import os
import re
import pytest
from jobbsok_lib import frontmatter, paths
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKILLS = os.path.join(REPO, "skills")
MANIFEST = os.path.join(REPO, ".claude-plugin", "plugin.json")
NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
PLUGIN_ROOT = "${CLAUDE_PLUGIN_ROOT}"
#: A line that names one of these is naming a file inside the plugin, and has
#: to say so with the variable rather than a bare relative path.
INTRA_PLUGIN = (".py", "references/", "scripts/", "assets/", "templates/")
#: Absolute or home-anchored paths. `paths.py` refuses to guess at a home
#: directory; a skill that hardcodes one would put the guess back.
HOME_PATHS = ("/Users/", "/home/", "$HOME", "~/")
#: First and second person, word-bounded. Build-brief 7 wants a description
#: that says what the skill does, not one that addresses the operator.
PERSON_RE = re.compile(
r"(?<![a-zA-ZæøåÆØÅ])(jeg|meg|min|mitt|mine|du|deg|din|ditt|dine|I|you|your)"
r"(?![a-zA-ZæøåÆØÅ])"
)
ACCENTED_RE = re.compile(r"[æøåÆØÅ]")
MAX_ORD = 3000
def skill_dirs():
if not os.path.isdir(SKILLS):
return []
return sorted(
name for name in os.listdir(SKILLS)
if os.path.isfile(os.path.join(SKILLS, name, "SKILL.md"))
)
def load(name):
path = os.path.join(SKILLS, name, "SKILL.md")
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
meta, body = frontmatter.parse(text)
return meta, body, text
def body_words(body):
"""Word count with fenced blocks removed, per the plan's 3000-word rule."""
prose = re.sub(r"```.*?```", " ", body, flags=re.S)
return len(prose.split())
def ascii_fold(phrase):
"""Fold a phrase the way `paths.slug` folds a path component."""
return " ".join(paths.slug(word) for word in phrase.split() if paths.slug(word))
ALLE = skill_dirs()
@pytest.fixture(params=ALLE, ids=ALLE)
def skill(request):
name = request.param
meta, body, text = load(name)
return {"name": name, "meta": meta, "body": body, "text": text}
def test_there_is_at_least_one_skill_to_check():
# A parametrised suite over an empty list is a suite that passes without
# looking at anything.
assert ALLE, "skills/ holds no SKILL.md; every check below would be vacuous"
def test_frontmatter_parses_and_name_matches_the_directory(skill):
assert skill["meta"], "%s has no frontmatter" % skill["name"]
assert skill["meta"].get("name") == skill["name"]
assert NAME_RE.match(skill["name"]), "%r is not a valid skill name" % skill["name"]
def test_description_is_third_person_and_names_every_trigger(skill):
description = skill["meta"].get("description", "")
assert description, "%s has no description" % skill["name"]
triggers = skill["meta"].get("triggers")
assert isinstance(triggers, list) and triggers, "%s declares no triggers" % skill["name"]
for phrase in triggers:
assert phrase in description, (
"trigger %r is declared but does not appear verbatim in the description" % phrase
)
# The person check runs on the skill's own voice. The quoted trigger
# phrases are the operator's words -- "vis profilen min" is exactly what
# gets typed -- and holding them to third person would force the
# descriptions to name phrases nobody uses.
egen_stemme = description
for phrase in sorted(triggers, key=len, reverse=True):
egen_stemme = egen_stemme.replace(phrase, " ")
funnet = PERSON_RE.search(egen_stemme)
assert funnet is None, (
"%s description is not third person: %r" % (skill["name"], funnet.group(0))
)
def test_every_accented_trigger_has_an_ascii_twin(skill):
triggers = skill["meta"].get("triggers", [])
accented = [phrase for phrase in triggers if ACCENTED_RE.search(phrase)]
assert accented, "%s declares no Norwegian trigger phrase" % skill["name"]
for phrase in accented:
twin = ascii_fold(phrase)
# Declared verbatim, not merely derivable: folding the accented
# phrase and then looking for the fold among the folds would find
# itself, and pass on every input.
assert twin in triggers, (
"accented trigger %r has no ASCII spelling (%r) among the triggers"
% (phrase, twin)
)
def test_trigger_phrases_are_unique_across_skills():
seen = {}
for name in ALLE:
meta, _body, _text = load(name)
for phrase in meta.get("triggers", []):
assert phrase not in seen, (
"trigger %r is claimed by both %s and %s" % (phrase, seen[phrase], name)
)
seen[phrase] = name
assert seen, "no triggers declared anywhere"
def test_body_is_under_the_word_budget(skill):
ord_ = body_words(skill["body"])
assert ord_ < MAX_ORD, "%s body is %d words, budget is %d" % (
skill["name"], ord_, MAX_ORD
)
def test_every_intra_plugin_path_uses_the_plugin_root(skill):
for lineno, line in enumerate(skill["text"].split("\n"), start=1):
if not any(token in line for token in INTRA_PLUGIN):
continue
assert PLUGIN_ROOT in line, (
"%s:%d names a plugin file without %s: %r"
% (skill["name"], lineno, PLUGIN_ROOT, line.strip())
)
def test_no_absolute_home_path_is_baked_into_a_skill(skill):
for token in HOME_PATHS:
assert token not in skill["text"], (
"%s hardcodes %r; the workspace is resolved, never guessed" % (skill["name"], token)
)
def test_reference_files_resolve_in_both_directions(skill):
directory = os.path.join(SKILLS, skill["name"], "references")
on_disk = sorted(os.listdir(directory)) if os.path.isdir(directory) else []
linked = sorted(set(re.findall(r"references/([A-Za-z0-9_.-]+)", skill["text"])))
assert on_disk == linked, (
"%s: references/ on disk %r, referenced from SKILL.md %r"
% (skill["name"], on_disk, linked)
)
def test_skill_files_are_valid_utf8_and_carry_norwegian(skill):
for root, _dirs, files in os.walk(os.path.join(SKILLS, skill["name"])):
for name in files:
with open(os.path.join(root, name), "rb") as handle:
handle.read().decode("utf-8")
assert ACCENTED_RE.search(skill["text"]), (
"%s carries no Norwegian characters; operator-facing output is bokmaal"
% skill["name"]
)
def test_kandidatprofil_scaffolds_a_workspace_that_does_not_exist_yet(tmp_path):
# The invocation is a model turn. What is testable is the mechanism the
# skill delegates to, and that the skill still says it delegates to it.
root = str(tmp_path / "fersk-workspace")
assert not os.path.exists(root)
created = paths.scaffold(root)
assert set(paths.WORKSPACE_DIRS) <= set(created)
assert set(paths.WORKSPACE_FILES) <= set(created)
assert os.path.isdir(os.path.join(root, "profil"))
# Idempotent, so a second run reports an empty tree rather than claiming
# to have created what was already there.
assert paths.scaffold(root) == []
_meta, _body, text = load("kandidatprofil")
assert "scaffold" in text
def test_kandidatprofil_declares_the_version_echo_both_halves():
_meta, _body, text = load("kandidatprofil")
assert "BUILD_STAMP" in text
assert "plugin.json" in text
with open(MANIFEST, "r", encoding="utf-8") as handle:
manifest = json.load(handle)
# There has to be a version for the echo to print. The stamp file itself
# arrives in Step 13; asserting it here would assert a file this
# repository does not yet have.
assert manifest["version"]
assert manifest["name"] == "jobbsok"