test(m1): assert plugin manifest and pin a build stamp
This commit is contained in:
parent
89bcd1b038
commit
0e3295f52b
4 changed files with 421 additions and 0 deletions
264
tests/test_plugin_manifest.py
Normal file
264
tests/test_plugin_manifest.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""The plugin manifest, the build stamp and what the archive may contain
|
||||
(plan Step 13).
|
||||
|
||||
Two failures this file exists to prevent, and they are different in kind.
|
||||
|
||||
**The false green.** Cowork caches an uploaded plugin, and a cached build
|
||||
answers every question correctly except the one being asked. So the plugin
|
||||
carries a `BUILD_STAMP` -- the short hash of the commit it was built from --
|
||||
and the `Verifiser i Cowork` steps compare it against `git rev-parse --short
|
||||
HEAD`. `plugin.json`'s version cannot do that job: it does not change between
|
||||
milestones, so a stale build would echo the right number (risk H11).
|
||||
|
||||
**The over-broad archive.** The packaging command is printed in a public
|
||||
README and run before every Cowork upload. An archiver that walked the
|
||||
repository root would ship `.git`, the virtualenv, the local-only `STATE.md`
|
||||
and everything under `.claude/`, which holds the operator's decisions and
|
||||
absolute home paths. `zip` does not consult `.gitignore`, so the include list
|
||||
is the only thing standing between those files and an uploaded archive -- and
|
||||
the test reads the built archive's own table of contents rather than trusting
|
||||
the script's include list to be what it says it is.
|
||||
|
||||
The stamp being gitignored is asserted twice, deliberately: once against the
|
||||
text of `.gitignore` and once against `git check-ignore`, which is the only
|
||||
one of the two that knows about precedence, negations and later rules. An
|
||||
untested claim of "gitignored" is exactly how a stamp ends up tracked and the
|
||||
suite ends up permanently red from this step onward.
|
||||
|
||||
One divergence from the plan, stated rather than smuggled. The plan asks that
|
||||
`plugin.json`'s version equal "the top heading in CHANGELOG.md". The top
|
||||
heading today is `## [Unreleased]`, because nothing has been released --
|
||||
README says so on its first screen. Writing a `## [0.1.0]` heading to satisfy
|
||||
a test would assert a release that has not happened. So the check is the one
|
||||
the rule is actually for: **if** the changelog carries a version heading, the
|
||||
topmost one must equal `plugin.json`'s version. The day a release lands, the
|
||||
check tightens by itself.
|
||||
|
||||
Style note: this file follows tests/test_skills_contract.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MANIFEST = os.path.join(REPO, ".claude-plugin", "plugin.json")
|
||||
CHANGELOG = os.path.join(REPO, "CHANGELOG.md")
|
||||
GITIGNORE = os.path.join(REPO, ".gitignore")
|
||||
SKILLS = os.path.join(REPO, "skills")
|
||||
BUILD_STAMP = os.path.join(REPO, "scripts", "build_stamp.sh")
|
||||
PACKAGE = os.path.join(REPO, "scripts", "package_plugin.sh")
|
||||
|
||||
#: Build-brief section 4. The count assertion -- that all fourteen exist --
|
||||
#: belongs to M6, when the last of them lands; what is asserted here is that
|
||||
#: no directory outside the list ever appears.
|
||||
SKILL_NAVN = (
|
||||
"kandidatprofil",
|
||||
"annonse-uttrekk",
|
||||
"kandidatvurdering",
|
||||
"beslutning",
|
||||
"sak",
|
||||
"korrespondanse",
|
||||
"soknad",
|
||||
"intervju",
|
||||
"referanser",
|
||||
"forhandling",
|
||||
"dagens",
|
||||
"utfall",
|
||||
"laering",
|
||||
"datahygiene",
|
||||
)
|
||||
|
||||
#: A path component that must never appear in the uploaded archive. Each of
|
||||
#: these carries something that is local-only, operator-private, or both.
|
||||
FORBUDT_I_ARKIV = (".git", ".venv", "STATE.md", ".claude/", "__pycache__", ".pytest_cache")
|
||||
|
||||
VERSJONSOVERSKRIFT = re.compile(r"^##\s*\[?(\d+\.\d+\.\d+)\]?", re.MULTILINE)
|
||||
|
||||
|
||||
def git(*args):
|
||||
ut = subprocess.run(
|
||||
["git", "-C", REPO] + list(args), capture_output=True, text=True
|
||||
)
|
||||
assert ut.returncode == 0, "git %s failed: %s" % (" ".join(args), ut.stderr)
|
||||
return ut.stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def manifest():
|
||||
with open(MANIFEST, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def arkiv(tmp_path):
|
||||
"""Build the plugin archive into a temporary file and return its path.
|
||||
|
||||
A deliberately stale stamp is planted first. Without it the archive would
|
||||
carry a correct hash whether or not the packaging script regenerated one,
|
||||
and a mutation that dropped the regeneration survived every assertion in
|
||||
this file -- which is the H11 false green wearing the test's own colours.
|
||||
"""
|
||||
stempel = os.path.join(REPO, "BUILD_STAMP")
|
||||
fantes = os.path.exists(stempel)
|
||||
forrige = None
|
||||
if fantes:
|
||||
with open(stempel, "r", encoding="utf-8") as handle:
|
||||
forrige = handle.read()
|
||||
with open(stempel, "w", encoding="utf-8") as handle:
|
||||
handle.write("foreldet\n")
|
||||
|
||||
try:
|
||||
ut = tmp_path / "jobbsok.plugin"
|
||||
kjort = subprocess.run(
|
||||
["bash", PACKAGE, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True
|
||||
)
|
||||
assert kjort.returncode == 0, "package_plugin.sh failed:\n%s" % kjort.stderr
|
||||
assert ut.exists(), "package_plugin.sh reported success but wrote no archive"
|
||||
yield str(ut)
|
||||
finally:
|
||||
if forrige is None:
|
||||
os.remove(stempel)
|
||||
else:
|
||||
with open(stempel, "w", encoding="utf-8") as handle:
|
||||
handle.write(forrige)
|
||||
|
||||
|
||||
def test_the_manifest_carries_the_five_fields_a_plugin_needs(manifest):
|
||||
for felt in ("name", "version", "description", "author", "license"):
|
||||
assert felt in manifest, "plugin.json is missing %r" % felt
|
||||
assert manifest[felt], "plugin.json has an empty %r" % felt
|
||||
assert manifest["name"] == "jobbsok"
|
||||
assert re.match(r"^\d+\.\d+\.\d+$", manifest["version"]), (
|
||||
"version %r is not semantic" % manifest["version"]
|
||||
)
|
||||
assert isinstance(manifest["author"], dict) and manifest["author"].get("name")
|
||||
|
||||
|
||||
def test_the_version_does_not_contradict_the_changelog(manifest):
|
||||
with open(CHANGELOG, "r", encoding="utf-8") as handle:
|
||||
tekst = handle.read()
|
||||
versjoner = VERSJONSOVERSKRIFT.findall(tekst)
|
||||
if not versjoner:
|
||||
# Pre-release: the only heading is [Unreleased], and that is honest.
|
||||
assert "[Unreleased]" in tekst, (
|
||||
"the changelog carries neither a version heading nor an "
|
||||
"Unreleased section; one of the two has to be true"
|
||||
)
|
||||
return
|
||||
assert versjoner[0] == manifest["version"], (
|
||||
"plugin.json says %r but the newest changelog heading says %r"
|
||||
% (manifest["version"], versjoner[0])
|
||||
)
|
||||
|
||||
|
||||
def test_this_plugin_is_skills_only_and_never_grows_a_commands_directory():
|
||||
assert not os.path.exists(os.path.join(REPO, "commands")), (
|
||||
"this plugin is skills-only (build-brief section 4); a commands/ "
|
||||
"directory would be a second, undocumented surface"
|
||||
)
|
||||
sporet = [p for p in git("ls-files").split("\n") if p.startswith("commands/")]
|
||||
assert sporet == [], "commands/ is tracked: %r" % sporet
|
||||
|
||||
|
||||
def test_every_skill_directory_is_one_the_brief_names():
|
||||
if not os.path.isdir(SKILLS):
|
||||
pytest.fail("skills/ does not exist")
|
||||
funnet = sorted(
|
||||
navn for navn in os.listdir(SKILLS)
|
||||
if os.path.isdir(os.path.join(SKILLS, navn))
|
||||
)
|
||||
ukjente = [navn for navn in funnet if navn not in SKILL_NAVN]
|
||||
assert ukjente == [], (
|
||||
"skills/ holds %r, which build-brief section 4 does not name. The "
|
||||
"fourteen are %r" % (ukjente, list(SKILL_NAVN))
|
||||
)
|
||||
assert funnet, "skills/ is empty; M1 ships two of the fourteen"
|
||||
|
||||
|
||||
def test_the_stamp_script_writes_the_hash_of_the_commit_it_ran_on(tmp_path):
|
||||
ut = tmp_path / "BUILD_STAMP"
|
||||
kjort = subprocess.run(
|
||||
["bash", BUILD_STAMP, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True
|
||||
)
|
||||
assert kjort.returncode == 0, kjort.stderr
|
||||
with open(ut, "r", encoding="utf-8") as handle:
|
||||
stempel = handle.read().strip()
|
||||
assert stempel == git("rev-parse", "--short", "HEAD"), (
|
||||
"the stamp says %r but HEAD is %r" % (stempel, git("rev-parse", "--short", "HEAD"))
|
||||
)
|
||||
# Trailing newline and nothing else: the skills read this file and echo it.
|
||||
with open(ut, "r", encoding="utf-8") as handle:
|
||||
assert handle.read() == stempel + "\n"
|
||||
|
||||
|
||||
def test_the_build_artefacts_are_ignored_and_git_agrees():
|
||||
with open(GITIGNORE, "r", encoding="utf-8") as handle:
|
||||
linjer = [line.strip() for line in handle]
|
||||
assert "BUILD_STAMP" in linjer, (
|
||||
".gitignore has no BUILD_STAMP line. The stamp is regenerated on every "
|
||||
"package run, so a tracked stamp is red from the next commit onward."
|
||||
)
|
||||
assert "jobbsok.plugin" in linjer, (
|
||||
".gitignore has no jobbsok.plugin line; the README tells the operator "
|
||||
"to build that archive in the repository root"
|
||||
)
|
||||
|
||||
# The text is one thing; what git actually does is the thing. check-ignore
|
||||
# knows about precedence and negation, and a later rule could undo the line
|
||||
# above without the assertion over the text noticing.
|
||||
for navn in ("BUILD_STAMP", "jobbsok.plugin"):
|
||||
sti = os.path.join(REPO, navn)
|
||||
fantes = os.path.exists(sti)
|
||||
if not fantes:
|
||||
with open(sti, "w", encoding="utf-8") as handle:
|
||||
handle.write("probe\n")
|
||||
try:
|
||||
kjort = subprocess.run(
|
||||
["git", "-C", REPO, "check-ignore", "-q", navn],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert kjort.returncode == 0, (
|
||||
"git does not ignore %s despite the .gitignore line" % navn
|
||||
)
|
||||
finally:
|
||||
if not fantes:
|
||||
os.remove(sti)
|
||||
|
||||
|
||||
def test_the_archive_ships_no_local_only_or_operator_private_path(arkiv):
|
||||
with zipfile.ZipFile(arkiv) as pakke:
|
||||
oppforinger = pakke.namelist()
|
||||
|
||||
assert oppforinger, "the archive is empty"
|
||||
for oppforing in oppforinger:
|
||||
for forbudt in FORBUDT_I_ARKIV:
|
||||
assert forbudt not in oppforing, (
|
||||
"the archive ships %r, which contains %r. The include list is "
|
||||
"the only thing keeping it out -- zip does not read .gitignore."
|
||||
% (oppforing, forbudt)
|
||||
)
|
||||
|
||||
# And the things it must carry, so an over-eager exclusion is caught too.
|
||||
for pakket in (".claude-plugin/plugin.json", ".mcp.json", "BUILD_STAMP", "LICENSE"):
|
||||
assert pakket in oppforinger, (
|
||||
"the archive is missing %r; it holds %r" % (pakket, sorted(oppforinger))
|
||||
)
|
||||
assert any(o.startswith("skills/") for o in oppforinger)
|
||||
assert any(o.startswith("scripts/") for o in oppforinger)
|
||||
|
||||
# And the stamp it ships is the one this commit deserves. The fixture
|
||||
# planted a stale one on purpose, so a packaging script that stopped
|
||||
# regenerating would archive "foreldet" and be caught here rather than in
|
||||
# Cowork, six uploads later.
|
||||
with zipfile.ZipFile(arkiv) as pakke:
|
||||
pakket_stempel = pakke.read("BUILD_STAMP").decode("utf-8").strip()
|
||||
assert pakket_stempel == git("rev-parse", "--short", "HEAD"), (
|
||||
"the archive ships stamp %r while HEAD is %r; the packaging script did "
|
||||
"not regenerate it" % (pakket_stempel, git("rev-parse", "--short", "HEAD"))
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue