"""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")) )