PKG-14: cut 0.10.0, and say what an upgrade from 0.7.0 crosses
checks / unit (pull_request) Successful in 7m51s

`config.yaml` 0.9.0 -> 0.10.0, and the top `## Unreleased` block is closed
into `## 0.10.0` with a preamble that names the backward-incompatible
changes and who they reach.

MINOR, and NOT because eighteen tickets landed. A stored options blob the
previous version started on is REFUSED on this one:

  * TARIFF-07 retires three `price_provider` ids; a site set to one is
    refused BY NAME at startup. Measured on the reference blob, with a
    control on an accepted id so the refusals are about the ID.
  * TARIFF-18 moves `price_taxes_eur_kwh` 0.06 -> 0.11736. A site that
    STORED 0.06 is untouched, and - the non-obvious half - a taxes period
    table beside that stored 0.06 does NOT become a startup refusal on
    upgrade, because `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH` reads it as
    untouched. Measured, with the counterfactual that the rung is not
    weakened (ORES's 0.1198 is still refused).
  * SAFETY-10 adds `shadow_mode`, shipped `auto`. An existing CONTROLLING
    install does not change by one watt and a FRESH one comes up observing.
    Measured cross-revision, 8f82dd4 -> e6da478, same stimulus through the
    shipped `Controller.cycle()`: byte-identical write streams, 13/13/25
    writes. Positive control: the same driver at `shadow_mode: on` gets 13
    writes to 5000 W on the older tree and one 0 W write on the newer.

And the one a reader of the newest section alone would miss: `ha addons
update` does not walk the versions, so a 0.7.0 install meets 0.8.0's and
0.9.0's rules at the same moment as 0.10.0's. Of those, SAFETY-39's rule 10
refuses 0.7.0's OWN shipped default `slew_w: 1000` against a 555.6 W
ceiling on a 5000 W rail. Measured red on this tree; the reference
install's `slew_w: 400` is measured green.

SCOPE, and it is drawn honestly rather than tidily. The top block's 18
entries all merged after the 0.9.0 bump `86bdf49`, so the block is closed
as-is and its `_this branch_` placeholders are resolved to merge shas.
⚠️ 0.10.0 SHIPS THREE MORE RUNTIME TICKETS THAT ARE NOT IN THAT TABLE -
SAFETY-43, TEST-21 and SAFETY-44 - and they are NAMED in the preamble
rather than moved, because re-filing this file's misplaced entries is
PKG-08. SAFETY-44 (`app/main.py`, `app/store.py`) is in the changelog
nowhere at all; that is said in both places. The second `## Unreleased`
block below `## 0.9.0` is ANNOTATED, not rewritten or emptied - UX-02 in it
shipped in 0.9.0, the other three in 0.10.0. No released section is
rewritten.

`test_version_gate.py` gains part 11: the bump and the closed section are
one act. PKG-09 refuses a deploy whose version did not move; nothing graded
the mirror failure, in which the version moves and the block is never
closed. Two sides from two files, a slicer with a synthetic positive
control run on every invocation, the retired ids read off `app/price.py` by
AST rather than transcribed, and the count of still-misfiled blocks frozen
so it can only shrink.

Mutations, all purged, tree bind-mounted :ro, backups at mktemp paths and
the revert fingerprinted:

  M1  `## 0.10.0` -> `## Unreleased`       +4 B    RED 2
  M2  version 0.10.0 -> 0.10.1             +0 B    RED 1   (ZERO-BYTE)
  M3  an id stripped from the NEWEST       +10 B   RED 1
      section only, left below
  M4  a 4th retired provider in price.py   +62 B   RED 1 here, 4 in
                                                   test_price.py
  M5  `## 0.9.0` heading lost              -3 B    RED 1
  M6  a 2nd `## Unreleased` left standing  +27 B   RED 1
  M7  the slicer never closes              -32 B   RED 3
  M8  SUPERSEDED_TAXES... emptied          -5 B    RED 2 on the evidence
                                                   instrument, plus
                                                   test_validate/test_price

⚠️ M5 WAS A SURVIVOR ON THE FIRST DRAFT. The slicer control was a sentinel
(`## 0.8.0`) that only a slicer running to EOF could reach, and deleting one
heading never gets there - ARCH-02's fifteenth instance, in a fresh costume.
11a now asserts the invariant instead: exactly one contents table per
section, which is what a lost heading breaks.

29/29 test files green. `tools/predeploy_check.py --deployed-version 0.7.0
--options <live blob>` CLEARED, exit 0, before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-16 02:48:27 +02:00
co-authored by Claude Opus 5
parent 587495e143
commit cb3f686555
3 changed files with 329 additions and 31 deletions
+198 -30
View File
@@ -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 <repo>:/w:ro -v <dir>:/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
+1 -1
View File
@@ -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
+130
View File
@@ -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)