diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 6e47338..214e969 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,35 +1,185 @@ # Changelog -## Unreleased +## 0.10.0 -⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.** -PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`, -so `ha apps update` is a silent no-op on an unbumped change. This branch -deliberately does **not** pick the number, because the right one depends on a -fact the branch cannot observe - whether 0.9.0's image has already been built -and pulled. If it has not, this belongs inside 0.9.0's section before it ships; -if it has, it needs 0.10.0. Whoever deploys decides. +⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE +NUMBER OF TICKETS - IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.** Same +shape as 0.8.0's `slew_w` rule and the same test: is there a stored options +blob the previous version started on and this one will not? There is. A refusal +is `SystemExit(78)` (`EX_CONFIG`) - the add-on does not start, ⚠️ **and the +ingress panel goes with it**, because the add-on serving it is not running, so +recovery means the Supervisor UI. + +Measured on the reference install's own exported blob (70 keys), merged over +this version's 75 defaults the way the Supervisor merges it, inside the CI +image. The three below are what a reader who checks only one thing should +check. + +* ⚠️ **[[TARIFF-07]] - THREE `price_provider` IDS ARE RETIRED, AND A SITE SET + TO ONE OF THEM IS REFUSED BY NAME AT STARTUP.** `engie_dynamic`, + `totalenergies_dynamic` and `eneco_eddy` are gone from `PROVIDERS`. Each was + accepted, silently, at every startup on 0.9.0 and earlier; on 0.10.0 each is + a refusal that names the id, the endpoint, what that endpoint actually + answers - a 404 from a gateway with no route, a TLS handshake against a + hostname the certificate does not cover, a 404 with an empty body - which + tariff the site has really been running since the id was entered, and what to + set instead. **The refusal IS the migration.** Every fetch any of the three + ever made ended in `SourceUnavailable`, and §11.4 turns that into a SILENT + fall back to the static profile, so there is no working behaviour to preserve + and nothing on the status page ever differed from a provider that simply + never found a cheap hour. ⚠️ **All three ids stay in `config.yaml`'s + `price_provider` enum on purpose**: drop one and the Supervisor rejects the + options file *before the add-on runs*, the operator gets an opaque `expected + one of ...`, and `validate.py`'s message is never seen - on exactly the sites + that need it. `PROVIDERS` is three; the enum is seven. + ⚠️ **The reference install is on `price_provider: off` and is unaffected.** + +* **[[TARIFF-18]] - `price_taxes_eur_kwh` moves from `0.06` to `0.11736`, and + the non-obvious half is the refusal that deliberately does NOT happen.** A + site that *stored* `0.06` is unaffected in both directions. It keeps `0.06`, + because `App.options` is `deepmerge(config.yaml defaults, stored options)` + and a moved default reaches only keys the blob does not carry - so the new + figure arrives at a **fresh** install, and at an existing one only when its + options are re-saved. ⚠️ **AND IT DOES NOT BECOME A STARTUP REFUSAL EITHER, + WHICH TOOK A MECHANISM.** [[TARIFF-17]] made a `price_taxes_profile` period + table beside a **non-default** `price_taxes_eur_kwh` a refusal, comparing + against the shipped default precisely because the Supervisor writes the key + whether the operator typed it or not. Moving the default turns every stored + `0.06` into "non-default" **with nobody editing anything**, so a working + Walloon or Brussels site with a period table would have stopped starting on + this upgrade, over an inert number the table overrides anyway. + `price.py` carries `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)` and + `validate.py` reads a superseded default as *untouched*. **The rung is not + weakened**: the same table beside a figure that is neither the shipped nor a + superseded default - ORES's `0.1198` - is still refused, by name, and the + refusal now says which two figures it will accept. + +* **[[SAFETY-10]] - a new `shadow_mode` option, shipped `auto`, and BOTH halves + of what that does matter.** ⚠️ **AN EXISTING INSTALL THAT IS CONTROLLING DOES + NOT CHANGE BY ONE WATT.** `auto` is not a constant the Supervisor merges; it + is a value the add-on **resolves**, and it resolves to `not control_started` + - `shadow.engaged()` reads `Controller.auto`, the persisted start/stop state. + An install with `auto_start: true`, or whose operator has pressed *Start / + stop control*, is started, so `auto` resolves to live. An install that is + **stopped** keeps its actuator at 0 W exactly as before, from the same writer + on the same heartbeat; what changes is what the status page says and that the + decision path now runs and is recorded. ⚠️ **A FRESH INSTALL COMES UP + OBSERVING**: it ships `auto_start: false`, has no stored `auto` key, and + therefore runs its whole decision path in shadow before it is ever allowed to + command anything. That is the "default-on" the card asked for, delivered + where it is safe and withheld where it is not - there is no mechanism in the + add-on options system by which one key can default differently for a new + install and an existing one, so a literal `shadow_mode: true` default would + have stopped the battery at the reference site on the next update. + **Measured cross-revision** - the same stimulus through the shipped + `Controller.cycle()` on `8f82dd4` (the merge before SAFETY-10) and on + `e6da478` (SAFETY-10 itself), start button ON: the write streams are + **byte-identical**, 13 / 13 / 25 writes on a 6000 W import, a 4000 W export + and a 300 W trickle, same values in the same order. An install that is + **stopped** is byte-identical across the same pair too, at one 0 W write. + ⚠️ The positive control is what makes that readable: the same driver with + `shadow_mode: on` and the button ON gets 13 writes up to 5000 W on the older + tree and **one 0 W write** on the newer, so the driver does reach shadow mode + and an identical pair is a result rather than a silence. The shadow case is + also graded in `test_clamp.py` part 8 on what reached `set_number()`, against + a precondition run that puts 30 non-zero writes on the wire. ⚠️ An + unrecognised `shadow_mode` fails toward inaction + **and** is refused by name (rule 11), so the reinterpretation is never + silent; no blob written before 0.10.0 can carry one. + +⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL IS - +THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha +addons update` does not walk the versions; it installs 0.10.0 in one step, so +**every validator rule added in 0.8.0 and 0.9.0 lands at the same moment as +0.10.0's own**, and a config that has not been looked at since 0.7.0 meets all +of them at once. Of those, exactly one can refuse a config nobody has edited: + +> [[SAFETY-39]]'s **rule 10**, added in 0.8.0, refuses an explicit `slew_w` +> above `SLEW_RAIL_FRAC x` the site rail, where `SLEW_RAIL_FRAC` is +> `LOOP_TICK_S / PLANT_SETTLE_S` = 2/18. On a 5000 W rail that ceiling is +> **555.6 W** - and ⚠️ **0.7.0's own shipped default was `slew_w: 1000`**, so +> an install that never touched the key and simply took the default does not +> start on 0.10.0 until the value is lowered. + +Measured on this tree: the reference blob with `slew_w: 1000` is refused, by +name, and the same blob at its actual `slew_w: 400` is accepted. 0.9.0's own +additions cannot reach a pre-0.9.0 config at all - [[OPT-09]]'s two refusals +fire only on a roster entry that USES `capacity_kwh`, a key that did not exist +- so 0.8.0's is the whole of the crossing. **Read the live options and check +`slew_w` against the ceiling and `price_provider` against the withdrawn list +BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both. + +✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.** + + docker run --rm -v :/w:ro -v :/opt:ro goodwe-addon-checks:1 \ + python3 /w/goodwe_controller/tools/predeploy_check.py \ + --deployed-version 0.7.0 --options /opt/live-options.json + + deployed on the box : 0.7.0 + shipped in this tree: 0.10.0 + live options pass this version's validator + CLEARED for deploy. + +**The option surface: 71 keys -> 75, `options:` and `schema:` in step.** Four +new keys, and none of them can refuse a blob written before it existed: +`price_formula`, `price_export_formula` and `price_taxes_profile` are `str?` - +**note the trailing `?`, they are OPTIONAL** - defaulting to `""`; +`shadow_mode` is `list(auto|on|off)` with no `?`, which is the shape that +becomes exit 78, and it is safe for the same reason 0.9.0's `delivery_hold_s` +was: it carries an `options:` default, so the Supervisor's merge supplies it to +every stored blob that lacks it. Nothing was removed. One default moved +(`price_taxes_eur_kwh`, above) and one enum widened (`price_provider` gains +`custom_formula`). + +⚠️ **WHY 0.10.0 AND NOT 0.9.0, SINCE 0.9.0'S IMAGE NEVER REACHED THE BOX.** +The block this section closes used to say the choice depended on whether +0.9.0 had been built and pulled, and that if it had not, this work belonged +*inside* 0.9.0's section. That was written before 0.9.0's section was cut. It +has been cut since `86bdf49`, on 2026-09-07, and **28 runtime commits** have +landed behind it; folding them in would make the string `0.9.0` name two +different trees, and the Supervisor keys the built image on exactly that +string. Reusing it is the PKG-06/PKG-07 no-op in its worst form - a cached +image the Supervisor is entitled to serve while reporting success. The version +a tree has already shipped under is not available to be re-cut. + +⚠️ **AND THIS SECTION IS NOT THE WHOLE OF WHAT 0.10.0 SHIPS. THREE TICKETS +WITH RUNTIME CHANGES LANDED AFTER `86bdf49` AND ARE NOT IN THE TABLE BELOW.** +Saying so is cheaper than moving them, and moving them is [[PKG-08]]'s card, +not this one: + +| ticket | runtime files | where it is written down | +|---|---|---| +| [[SAFETY-43]] | `app/delivery.py`, `app/packshare.py`, `config.yaml` | the `## Unreleased` block **below `## 0.9.0`**, which is misfiled | +| [[TEST-21]] | `app/delivery.py`, `config.yaml`, `translations/` | the same block | +| [[SAFETY-44]] | `app/main.py`, `app/store.py` | ⚠️ **nowhere in this file** | + +Five more landed that touch no runtime file and so cannot change what the +image does - PKG-13, UX-04, SAFETY-31, ARCH-02, INSTR-01. The count that +matters for the no-op check is `tools/predeploy_check.py`'s own: **28 runtime +commits since `86bdf49`**, of which the table below accounts for 21. + +Contents of this release, in merge order since 0.9.0 (`86bdf49`): | | | |---|---| -| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart | -| _this branch_ | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard | -| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says | -| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser | -| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried | -| _this branch_ | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch | -| _this branch_ | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing | -| _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded | -| _this branch_ | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** | -| _this branch_ | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price | -| _this branch_ | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving | -| _this branch_ | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface | -| _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest | -| _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** | -| _this branch_ | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero | -| _this branch_ | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** | -| _this branch_ | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** | -| _this branch_ | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** | +| `c2787da` | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart | +| `5059e28` | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard | +| `958f5cd` | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says | +| `4257e38` | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser | +| `88c59a9` | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried | +| `559f0eb` | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch | +| `a6e64fb` | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing | +| `8d09b30` | TARIFF-14 - the price a trade is decided against is the price of the interval being traded | +| `aade2c7` | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** | +| `068b83f` | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price | +| `b4a3801` | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving | +| `c518007` | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface | +| `7796f16` | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest | +| `4dcd9b0` | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** | +| `478ba93` | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero | +| `8f82dd4` | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** | +| `e6da478` | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** | +| `587495e` | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** | ### SAFETY-47 — what a shadow run can show you, and the watts on its status row @@ -1635,12 +1785,30 @@ and pulled. If it has not, this belongs inside 0.8.0's section before it ships; if it has, it needs 0.8.1. Whoever deploys decides, and PKG-07 is the record of what happens when nobody does. +⚠️ **ANNOTATED BY PKG-14, 2026-09-16 - EVERY ROW BELOW HAS SHIPPED, AND THE +PARAGRAPH ABOVE IS NO LONGER TRUE OF ANY OF THEM.** It is annotated rather +than rewritten, and nothing is moved out of it, because re-filing this file's +misplaced entries is [[PKG-08]]'s card and guessing a boundary is what that +card exists to stop. What is measurable, and what PKG-14 measured: + +* **UX-02 shipped in 0.9.0.** Its merge `3d29993` is an ancestor of the 0.9.0 + bump `86bdf49` and it is already listed in `## 0.9.0`'s own contents table, + so this row is a duplicate of a released one. +* **UX-04, TEST-21 and SAFETY-43 shipped in 0.10.0.** All three merged after + `86bdf49`. TEST-21 (`app/delivery.py`, `config.yaml`, `translations/`) and + SAFETY-43 (`app/delivery.py`, `app/packshare.py`, `config.yaml`) are runtime + changes and are named in `## 0.10.0`'s preamble as shipping there and being + written down here; UX-04 touches no runtime file. +* ⚠️ **[[SAFETY-44]] also shipped in 0.10.0 and is nowhere in this file** - + `app/main.py` and `app/store.py`, three commits. It is named in + `## 0.10.0`'s preamble for the same reason. + | | | |---|---| -| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen | -| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed | -| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce | -| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression | +| `3d29993` | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen **⚠️ SHIPPED IN 0.9.0, see that section** | +| `bb46916` | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed **⚠️ SHIPPED IN 0.10.0** | +| `6719bd8` | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce **⚠️ SHIPPED IN 0.10.0** | +| `37dd0f0` | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression **⚠️ SHIPPED IN 0.10.0** | ⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 8bb947d..df56481 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -1,5 +1,5 @@ name: GoodWe RS485 Controller -version: "0.9.0" +version: "0.10.0" slug: goodwe_controller description: >- Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart diff --git a/goodwe_controller/test_version_gate.py b/goodwe_controller/test_version_gate.py index 0bd76e4..13a0bf8 100644 --- a/goodwe_controller/test_version_gate.py +++ b/goodwe_controller/test_version_gate.py @@ -272,6 +272,136 @@ check("REFUSED is exactly 1, not merely non-zero", 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)