Files
goodwe-addon/goodwe_controller/test_version_gate.py
T
glenn schrooyenandClaude Opus 5.5 2c516a032c
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s
UX-08 2c: short refusals, error texts and option texts
Validator refusals name the setting by its label and key, say what is
wrong and what to change, and keep the rejected value and limit. Option
names and descriptions are short in en and nl; citations moved to DOCS
"For installers" and the test_translations citation check now pins the
instruction on the tab and the record in DOCS. No rule, key, schema,
default or exit code changed. Allowlist 167 -> 43 (log lines only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 05:55:45 +02:00

413 lines
20 KiB
Python

"""PKG-09 - grade the check that refuses a no-op deploy. `python3 test_version_gate.py`
⚠️ THE POINT OF THIS FILE IS THE REFUSALS, NOT THE CLEARANCES. A pre-deploy
check that only ever says "CLEARED" is the defect `MUTATION.md` habit 4 names -
"a check whose DATA cannot reach the failure it names" - which this repository
has recorded twelve times. So every refusal below is DRIVEN: a repository is
built in which the failure is really present, and the check is required to say
so. A clearance is only meaningful next to a refusal from the same harness.
The third property is the one that is easy to lose: a SKIPPED check must not
look like a PASSED one. Omitting `--options` cannot exit 0.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
GATE = os.path.join(HERE, "tools", "predeploy_check.py")
sys.path.insert(0, os.path.join(HERE, "tools"))
fails = []
tmpdirs = []
def check(name, ok):
print(f" {'ok ' if ok else 'FAIL'} {name}")
if not ok:
fails.append(name)
def git(repo, *args):
return subprocess.run(
["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", *args],
capture_output=True, text=True)
def make_repo(version="0.7.0"):
"""A repo shaped like this one: <root>/goodwe_controller/{config.yaml,app,tools}."""
root = tempfile.mkdtemp(prefix="test_version_gate.")
tmpdirs.append(root)
addon = os.path.join(root, "goodwe_controller")
os.makedirs(os.path.join(addon, "app"))
write(addon, "config.yaml", f'name: t\nversion: "{version}"\n')
write(addon, "app/control.py", "X = 1\n")
write(addon, "CHANGELOG.md", "# Changelog\n")
git(root, "init", "-q", ".")
git(root, "add", "-A")
git(root, "commit", "-q", "-m", f"initial at {version}")
return root, addon
def write(addon, rel, text):
p = os.path.join(addon, rel)
with open(p, "w", encoding="utf-8") as fh:
fh.write(text)
def run_gate(addon, root, deployed, options=None):
"""Run the REAL gate against a throwaway tree.
⚠️ The script is invoked where it SHIPS, not copied into the fixture. It
imports the validator from its own sibling `app/`, which is the behaviour
that matters: the gate must check a config against the validator it ships
beside, not against whatever happens to sit next to a copy. Copying it into
the fixture made every validator assertion silently SKIP - the first draft
of this file did exactly that and reported four green skips as passes.
"""
cmd = [sys.executable, GATE,
"--deployed-version", deployed, "--tree", addon, "--repo", root]
if options:
cmd += ["--options", options]
return subprocess.run(cmd, capture_output=True, text=True)
VALID = {"max_w": 2000, "slew_w": 0, "target_grid_w": -10, "gain": 0.6,
"inverters": [], "setpoint_entity": "number.goodwe_setpoint_w"}
def opts_file(d):
fd, p = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump(d, fh)
return p
print("classification - what forces a rebuild and what does not")
from predeploy_check import is_runtime, shipped_version # noqa: E402
for p in ("goodwe_controller/app/control.py", "goodwe_controller/config.yaml",
"goodwe_controller/translations/en.yaml", "goodwe_controller/run.sh"):
check(f"runtime: {p}", is_runtime(p))
for p in ("goodwe_controller/CHANGELOG.md", "goodwe_controller/DOCS.md",
"goodwe_controller/test_control.py", "goodwe_controller/tools/x.py",
"goodwe_controller/DECISIONS/a.md", "README.md"):
check(f"NOT runtime: {p}", not is_runtime(p))
print("\nTHE REFUSAL - a runtime change behind an unchanged version")
root, addon = make_repo("0.7.0")
write(addon, "app/control.py", "X = 2\n")
git(root, "commit", "-qam", "SAFETY-99: change behaviour, bump nothing")
r = run_gate(addon, root, "0.7.0", opts_file(VALID))
check("refuses (exit 1)", r.returncode == 1)
check("...and says the deploy would be a NO-OP", "NO-OP" in r.stdout)
check("...and names the offending commit", "SAFETY-99" in r.stdout)
check("...and does NOT say cleared", "CLEARED for deploy" not in r.stdout)
print("\nPOSITIVE CONTROL - the same check must NOT fire on a docs-only change")
root2, addon2 = make_repo("0.7.0")
write(addon2, "CHANGELOG.md", "# Changelog\n\n## notes\n")
git(root2, "commit", "-qam", "PKG-99: documentation only")
r2 = run_gate(addon2, root2, "0.7.0", opts_file(VALID))
check("does not refuse a docs-only tree", r2.returncode == 0)
check("...and says there is nothing to deploy",
"nothing to deploy" in r2.stdout)
print("\nTHE CLEARANCE - a bumped version with a valid config")
root3, addon3 = make_repo("0.7.0")
write(addon3, "app/control.py", "X = 3\n")
write(addon3, "config.yaml", 'name: t\nversion: "0.8.0"\n')
git(root3, "commit", "-qam", "PKG-99: change and bump together")
r3 = run_gate(addon3, root3, "0.7.0", opts_file(VALID))
check("clears (exit 0)", r3.returncode == 0)
check("...and says so", "CLEARED for deploy" in r3.stdout)
print("\n⭐ A SKIPPED CHECK MUST NOT LOOK LIKE A PASSED ONE")
r4 = run_gate(addon3, root3, "0.7.0") # no --options
check("omitting --options cannot exit 0", r4.returncode != 0)
check("...says SKIPPED", "SKIPPED" in r4.stdout)
check("...says the config was NEVER CHECKED", "NEVER CHECKED" in r4.stdout)
check("...and does NOT claim clearance", "CLEARED for deploy" not in r4.stdout)
print("\nTHE OTHER REFUSAL - a config this version's validator rejects")
bad = dict(VALID, slew_w=1000) # rule 10: > 2/18 x 2000 = 222 W
r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
check("refuses a config the validator rejects", r5.returncode == 1)
check("...and warns it is exit 78", "78" in r5.stdout)
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
check("...and prints the validator's own words", "(slew_w) is 1000 W" in r5.stdout)
print()
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
# ⚠️ EVERY FIXTURE ABOVE BUILDS A FRESH REPOSITORY WHOSE `version:` LINE IS
# INTRODUCED ONCE AND NEVER EDITED. That is precisely the shape in which the
# original `git log -S 'version: "'` looked correct - the pickaxe finds commits
# that change a string's COUNT, and introducing the line changes it from 0 to 1.
# A real repository edits the line IN PLACE, the count never moves again, and
# the lookup silently returns the commit that first added it.
#
# MEASURED on admin/goodwe-addon before the fix: `-S` named `0a1e61d`
# (2026-08-23, the original packaging commit) as "the last bump", so a correctly
# bumped tree was reported as 87 commits behind an unbumped version - with
# PKG-06's and PKG-07's own bump commits listed among the offenders. The tool
# cried wolf on every run, and none of the fixtures above could see it, because
# a test that builds its own one-bump history is a test asserting against a
# structure it built itself. MUTATION.md habit 4, in the file written to
# prevent a recurrence.
from predeploy_check import last_version_change # noqa: E402
root4 = tempfile.mkdtemp(prefix="test_version_gate.hist.")
tmpdirs.append(root4)
addon4 = os.path.join(root4, "goodwe_controller")
os.makedirs(os.path.join(addon4, "app"))
write(addon4, "config.yaml", 'name: t\nversion: "0.5.0"\n')
write(addon4, "app/control.py", "X = 1\n")
git(root4, "init", "-q", ".")
git(root4, "add", "-A")
git(root4, "commit", "-q", "-m", "initial at 0.5.0")
_first = git(root4, "rev-parse", "HEAD").stdout.strip()
write(addon4, "app/control.py", "X = 2\n")
git(root4, "commit", "-qam", "runtime change between bumps")
write(addon4, "config.yaml", 'name: t\nversion: "0.6.0"\n')
git(root4, "commit", "-qam", "bump to 0.6.0")
write(addon4, "app/control.py", "X = 3\n")
git(root4, "commit", "-qam", "another runtime change")
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0"\n')
git(root4, "commit", "-qam", "bump to 0.7.0")
_last_bump = git(root4, "rev-parse", "HEAD").stdout.strip()
check("⭐ THE LAST BUMP IS FOUND, NOT THE FIRST. An in-place edit of the "
"version line does not change how many times the string occurs, so a "
"pickaxe search returns the commit that introduced it and every bump "
"after that is invisible",
last_version_change(root4) == _last_bump)
check("...and it is emphatically not the commit that introduced the line",
last_version_change(root4) != _first)
r6 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("a tree bumped with nothing after it clears, even with bumps in history",
r6.returncode == 0 and "CLEARED for deploy" in r6.stdout)
write(addon4, "app/control.py", "X = 4\n")
git(root4, "commit", "-qam", "SAFETY-98: runtime change after the last bump")
r7 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("and one runtime commit after that last bump is refused", r7.returncode == 1)
check("...naming only the commit since the LAST bump, not since the first",
"SAFETY-98" in r7.stdout and "runtime change between bumps" not in r7.stdout)
# ⚠️ AND THE FIXTURE ABOVE STILL COULD NOT TELL TWO IMPLEMENTATIONS APART.
# Review swapped `last_version_change` for "-G's newest raw hit, skip the value
# comparison entirely" and THE WHOLE SUITE STAYED GREEN, including the section
# above - because a linear history of real bumps never produces a commit that
# touches the version LINE without changing the version VALUE. So the docstring's
# central claim ("the VALUE is compared, and that is the only thing that answers
# it") was not pinned by anything, and `-G` alone would have shipped as if it
# were the fix. That is the third distinct shape of habit 4 in this one file.
#
# A cosmetic touch of the version line is the discriminator: `-G '^version: "'`
# matches it, and the value comparison does not.
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0" # pinned deliberately\n')
git(root4, "commit", "-qam", "COSMETIC: annotate the version line, same value")
_cosmetic = git(root4, "rev-parse", "HEAD").stdout.strip()
check("⭐ A COMMIT THAT TOUCHES THE VERSION LINE WITHOUT CHANGING THE VERSION "
"IS NOT A BUMP. `-G` matches it and a filter-only lookup would name it; "
"only comparing the VALUE against the parent's answers the question the "
"tool is actually asking",
last_version_change(root4) == _last_bump)
check("...and it is emphatically not the cosmetic commit",
last_version_change(root4) != _cosmetic)
r8 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("...and because config.yaml IS a runtime path, that cosmetic commit is "
"still counted as something unshipped - conservative, and correct: the "
"Supervisor rebuilds on the version string alone, so a changed file "
"behind an unchanged version is a no-op whatever changed in it",
r8.returncode == 1 and "COSMETIC" in r8.stdout)
print()
print("⭐ A GIT LOOKUP THAT GENUINELY FAILS - THE SHAPE NO FIXTURE COULD REACH")
# ⚠️ EVERY FIXTURE ABOVE CREATES ITS OWN REPOSITORY AS ITS OWN UID, so none of
# them could ever meet the failure that actually happened: the documented
# invocation runs the container as uid 0 against a checkout owned by the
# operator, git refuses with "detected dubious ownership", `_git` returns
# non-zero, and the no-op half reports SKIPPED. It degraded honestly - exit 2,
# never a false clearance - but the check this file exists for had never once
# run in its documented form, and nothing here could see that.
#
# `_git` now sets `safe.directory`, so ownership no longer breaks it. This
# section grades the REMAINING failure mode: git answering not at all.
root5 = tempfile.mkdtemp(prefix="test_version_gate.nogit.")
tmpdirs.append(root5)
addon5 = os.path.join(root5, "goodwe_controller")
os.makedirs(addon5)
write(addon5, "config.yaml", 'name: t\nversion: "0.7.0"\n') # NOT a git repo
check("a repository git cannot answer for reports no last-bump commit",
last_version_change(root5) is None)
r9 = run_gate(addon5, root5, "0.7.0", opts_file(VALID))
check("⭐ AND THE GATE DOES NOT CLEAR ON IT. An unanswerable git lookup is "
"INCONCLUSIVE, not clean - the one thing it must never do is call a "
"tree it could not inspect deployable",
r9.returncode != 0 and "CLEARED for deploy" not in r9.stdout)
check("...it says INCONCLUSIVE in those words", "INCONCLUSIVE" in r9.stdout)
# ⚠️ AND THE EXIT CODES ARE PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0`
# is satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
# earlier draft could not tell apart. 1 and 2 mean different things to an
# operator: 1 says "this deploy is wrong", 2 says "nobody checked".
check("NOT CLEARED is exactly 2, not merely non-zero", r9.returncode == 2)
r10 = run_gate(addon3, root3, "0.7.0", opts_file(dict(VALID, slew_w=1000)))
check("REFUSED is exactly 1, not merely non-zero",
r10.returncode == 1 and "REFUSED" in r10.stdout)
print("\nthe shipped tree's own version is readable")
check("shipped_version() finds a version", bool(shipped_version(HERE)))
# --------------------------------------------------------------------------- #
# PKG-14 - THE OTHER HALF OF THE NO-OP, AND NOTHING GRADED IT.
#
# PKG-09 refuses a deploy whose `version:` did not move. It cannot see the
# mirror failure, which this repository has now produced twice: the version
# DOES move and the changelog's `## Unreleased` block is never closed, so the
# operator who opens the file to find out what they are about to install reads
# "THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE" about a
# tree that is being installed. `## 0.8.0` records one such block that
# accumulated across three releases; `## 0.9.0` was cut with a second one left
# standing underneath it.
#
# ⚠️ THE TWO SIDES COME FROM DIFFERENT FILES ON PURPOSE (MUTATION.md habit 4).
# config.yaml's `version:` and CHANGELOG.md's newest heading are written by
# different hands at different times, and the whole defect is that they drift.
# A check that read the number once and compared it with itself would be the
# DEV-02 fixture again: a number against itself.
#
# ⚠️ AND IT IS NOT A SUBSTRING SCAN OVER THE WHOLE FILE. Every id this section
# looks for occurs dozens of times lower down, so a scan of the file would pass
# on any tree whatever the newest section said - CFG-04's defect, which asserted
# against the TEXT of the thing under test. The section is SLICED first, and
# part 11a grades the slicer against a sentinel that exists only below it.
print("\n11. PKG-14 - the bump and the closed section are one act")
_CHANGELOG = os.path.join(HERE, "CHANGELOG.md")
with open(_CHANGELOG, encoding="utf-8") as fh:
_cl_lines = fh.read().splitlines()
_heads = [(i, l[3:].strip()) for i, l in enumerate(_cl_lines)
if l.startswith("## ")]
check(f"precondition: the file has {len(_heads)} `## ` headings, so the "
f"quantifiers below are not over an empty set", len(_heads) >= 8)
# 11a. THE SLICE, AND TWO CONTROLS ON THE SLICER.
# ARCH-02's fifteenth instance: a splitter that never closes attributes
# everything to the last thing it opened, and every per-section check
# downstream silently becomes a total over the whole file. Both controls below
# exist because 11b/11c would go on passing for a tree in which nothing they
# name is true.
def slice_newest(lines):
"""(heading text, the lines of the FIRST `## ` section) - closing at the
next `## `, which is the half that has to be graded rather than assumed."""
heads = [i for i, l in enumerate(lines) if l.startswith("## ")]
if not heads:
return None, []
start = heads[0]
end = heads[1] if len(heads) > 1 else len(lines)
return lines[start][3:].strip(), lines[start:end]
# CONTROL ONE, run on every invocation rather than attested in a commit
# message: a synthetic document whose token appears ONLY below the first
# section. A slicer that runs to the end of the file finds it; this one must
# not. test_defaults_pin.py part 5 plants a literal for the same reason.
_SYNTH = ["# Changelog", "", "## 9.9.9", "", "the newest section.", "",
"## 9.9.8", "", "PLANTED-ONLY-BELOW", ""]
_syn_name, _syn_body = slice_newest(_SYNTH)
check("11a. the slicer CLOSES - on a synthetic document it excludes a token "
"that exists only in the section BELOW the newest one",
_syn_name == "9.9.9" and "PLANTED-ONLY-BELOW" in "\n".join(_SYNTH)
and "PLANTED-ONLY-BELOW" not in "\n".join(_syn_body))
_top_name, _top_body = slice_newest(_cl_lines)
_newest = "\n".join(_top_body)
_SENTINEL = "## 0.8.0"
check(f"...and on the shipped file too: it does not contain {_SENTINEL!r}, "
f"which the file does", _SENTINEL in "\n".join(_cl_lines)
and _SENTINEL not in _newest)
check(f"...and it is not empty: {len(_top_body)} lines under {_top_name!r}",
len(_top_body) > 20)
# CONTROL TWO - and this is the one that catches a LOST HEADING rather than a
# loose slicer. Every version section in this file carries exactly one
# "in merge order since" contents table. Delete a released section's `## `
# heading and its table is swallowed by the section above, which is a released
# record disappearing into an unreleased one - the shape `## 0.7.0` already
# records for 0.6.0, and the one PKG-08 is left holding.
_tables = [l for l in _top_body if "in merge order since" in l]
check(f"...and it carries EXACTLY ONE contents table, not {len(_tables)} - a "
f"second one means a released section below lost its `## ` heading and "
f"was absorbed into this one", len(_tables) == 1)
# 11b. THE BLOCK IS CLOSED, AND IT CARRIES THE SHIPPED NUMBER.
check("11b. the newest `## ` heading is a VERSION and not `Unreleased` - the "
"block was closed by whoever bumped the version",
_top_name.lower() != "unreleased")
_shipped = shipped_version(HERE)
check(f"...and it is exactly config.yaml's `version:` ({_shipped!r} in "
f"config.yaml, {_top_name!r} in CHANGELOG.md) - two files, so a bump "
f"without a section, or a section without a bump, is red",
_top_name == _shipped)
# 11c. A WITHDRAWN price_provider IS NAMED IN THE SECTION THAT WITHDRAWS IT.
# ⚠️ READ OFF app/price.py BY AST, NOT TYPED HERE. A list transcribed into this
# file would go on passing after a fourth id was retired and documented
# nowhere, which is the exact failure TARIFF-07 exists to prevent: an id the
# add-on refuses at startup and no release note mentions.
import ast # noqa: E402
_price_src = open(os.path.join(HERE, "app", "price.py"),
encoding="utf-8").read()
_retired = []
for _node in ast.walk(ast.parse(_price_src)):
if isinstance(_node, ast.AnnAssign) and getattr(
_node.target, "id", None) == "RETIRED_PROVIDERS":
_retired = [k.value for k in _node.value.keys]
elif isinstance(_node, ast.Assign) and any(
getattr(t, "id", None) == "RETIRED_PROVIDERS" for t in _node.targets):
_retired = [k.value for k in _node.value.keys]
check(f"11c. precondition: RETIRED_PROVIDERS was read off app/price.py and is "
f"not empty - {sorted(_retired)}", len(_retired) > 0)
_unnamed = sorted(p for p in _retired if p not in _newest)
check(f"...and every one of the {len(_retired)} withdrawn id(s) is named in "
f"`## {_top_name}` - an id the validator refuses at startup that no "
f"release note mentions is the TARIFF-07 defect with the sign flipped",
_unnamed == [])
# 11d. THE LEDGER OF BLOCKS STILL MISFILED, FROZEN RATHER THAN FIXED.
# PKG-08 owns splitting them; this file owns the promise that the number can
# only go DOWN. A release that cuts its section and leaves a second
# `## Unreleased` standing is red here on the commit that does it.
_stale = [n for _, n in _heads[1:] if n.lower() == "unreleased"]
check(f"11d. exactly {len(_stale)} `## Unreleased` block(s) remain BELOW the "
f"newest section, and the count is pinned EXACTLY so the ledger can "
f"only shrink - splitting them is PKG-08, not this file",
len(_stale) == 1)
for d in tmpdirs:
shutil.rmtree(d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")