245 lines
9.6 KiB
Python
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"
|