scripts/bootstrap.py resolves --group against <repo-root>/pyproject.toml, but package_plugin.py's INCLUDE tuple never shipped that file. An archive built and unpacked elsewhere could never bootstrap (F1). Verify: built an archive with package_plugin.py, unpacked it to a tmp dir, ran scripts/bootstrap.py against the unpacked copy -- "bootstrap: guard 1.3.0" / "bootstrap: ok", exit 0. Also fixed two stale pyproject.toml comments still naming the pre-port scripts/bootstrap.sh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""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 sys
|
|
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.py")
|
|
PACKAGE = os.path.join(REPO, "scripts", "package_plugin.py")
|
|
|
|
#: 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(
|
|
[sys.executable, PACKAGE, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True
|
|
)
|
|
assert kjort.returncode == 0, "package_plugin.py failed:\n%s" % kjort.stderr
|
|
assert ut.exists(), "package_plugin.py 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(
|
|
[sys.executable, 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.
|
|
# pyproject.toml is what scripts/bootstrap.py resolves `--group` against;
|
|
# an archive without it can never bootstrap once installed (F1).
|
|
for pakket in (".claude-plugin/plugin.json", ".mcp.json", "BUILD_STAMP", "LICENSE", "pyproject.toml"):
|
|
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"))
|
|
)
|