checks / unit (pull_request) Successful in 6m20s
UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is LIVE, so all three describe the running install. No behaviour, no schema and no default moves here: config.yaml, translations/ and every module under app/ are byte-identical to6e7b233. ⚠️ AC 3 - "the visible count drops to ~15" - IS WITHDRAWN AS UNSATISFIABLE, and the arithmetic is the reason rather than the effort. options: and schema: both declare all 71 keys, so the set HA's toggle can hide is EMPTY - it hides a key only when it is ?-optional AND absent from options:, and nothing is absent. But the deeper fact is that no manifest could have met it: Supervisor's own UiOptions marks 59 of the 71 REQUIRED, and the frontend's own filter - `schema.filter(e => e.name in opts || e.required)`, transcribed out of the bundle in the release image - shows a required field unconditionally. So 59 is the floor with options: EMPTIED ALTOGETHER. The target was ~4x below anything reachable. ⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH. A key absent from options: has no default for the Supervisor to deep-merge into an existing box's stored blob. That is exactly the delivery_hold_s mechanism this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to its options: default of 185 instead of becoming exit 78. Hiding a key is precisely the edit that removes that. It would also promote the 27 unpinned in-code literal fallbacks the new check ledgers into the ACTUAL source of the defaults on a live install - the CFG-03 defect the same card's AC 4 exists to police, made load-bearing by its AC 3. ⚠️ THE COUNT WAS PRINTED AND NEVER GRADED, WHICH IS HOW THIS PASSED REVIEW. supervisor_shape_probe.py section F has printed "the Configuration tab renders 71 fields" since the day it was written and exited 0 through UX-01's whole review while displaying the number that contradicted the criterion it was evidence for. Three assertions replace the bare print, every number computed from the manifest and from Supervisor's own `required` flag rather than typed, including an ISOLATED COUNTERFACTUAL that empties options: and nothing else. MEASURED RED: delete one key (price_feed_url) from options: and two of them fail - `Hideable: ['price_feed_url']`, `70 of 71 fields render` - exit 1. --- AC 4: the AST check, built -------------------------------------------- AC 4's first clause is VACUOUS over an empty set (see above) and its second - "the table changed goes red" - already works six times over. The third, the one with the content, was never written: the only literal scan in the repository is test_validate.py's regex over eight maintenance_* keys, which is a grep and predates the card. test_defaults_pin.py walks ast.Call nodes over app/*.py. It: - DISCOVERS the six *_OPTION_DEFAULTS mirrors from the tree rather than naming them. Every existing pin names its own table by hand, so a SEVENTH table added tomorrow is pinned by nobody and nothing says so; - grades all 30 mirrored keys against config.yaml, and requires that no module carries a literal fallback for any of them; - FREEZES the CFG-03 backlog at exactly today's 27 (module, key) pairs, by pair and not by count - a count goes green again the moment one site is deleted and another added. Line numbers are deliberately out of the key: a ledger that reddens on an unrelated reformat is one people re-bless without reading. It can only shrink; - grades every remaining literal AGAINST config.yaml, which is the half that catches CFG-04's actual failure - two copies of one number, one moves. ⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for: main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10. It cannot reach an operator - the deep-merge guarantees the key is present - and the only paths that hand over a partial dict are sim/inject.py and this repo's fixtures. Recorded as the single permitted divergence with its reason rather than corrected; changing it alters the control loop's target and belongs to a card that grades the loop. A second divergence goes red. ⭐ THE RED DIRECTION WAS RUN, TWICE OVER. Part 5 feeds the scanner a synthetic module containing a deliberate violation of each rule and REQUIRES it to be caught, in-process, through the same function the graded parts call - so a scanner that goes blind stops working there too - and the site total is graded against a floor. And a literal was planted in the REAL tree: main.py:290 float(opts.get("maintenance_charge_w", 2000)) -> FAIL no literal fallback survives for any of the 30 keys a mirror already answers for exit 1 main.py:290 opts.get("p1_host", "x"), opts.get("slew_w", 999) -> FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')] FAIL every remaining literal still AGREES with config.yaml main.py slew_w: code 999 vs config.yaml 0 exit 1 Both plants removed. --- AC 5: CFG-04 is closed, and not by this card's predecessor -------------fef6ef2touched config.yaml, translations/, test_translations.py and the probe - no defaults code at all - and on 0.7.0 the max_w 2000 -> 2500 triple mutation survives 21 files, 0 RED. CONFIRMED CLOSED on release/1.0 by re-running the same triple mutation on a throwaway copy of6e7b233: 26 files, RED at test_selfconfig.py, exit 1 - 1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the rail collapsing to the 2000 default under an explicit 400 W slew git log -L 310,325:test_selfconfig.py attributes that check tobcee235, "UX-02: pin both halves of the max_w fix, because neither was graded". The AC is met BY UX-02 and ships with the pending 0.9.0 deploy. Nothing was re-done. --- the 8 per-entry translations: they stay -------------------------------- Measured negative and not a defect in them: UiOptions gives inverters type schema WITH multiple, the frontend sends that case to ha-selector-object, which labels fields `fields[name].label || name`, and the converter sets neither label nor translation_key. The Supervisor tab CANNOT be made to render them. They stay because UX-02's ingress panel is the surface that can, because they cost nothing, and because deleting them removes the only thing that would catch a per-entry key added without a translation - which is how SAFETY-32's REQUIRED bms_soc_floor would have joined the roster unlabelled. A written decision, not a change; translations/ is untouched. tools/supervisor_selfapi_probe.py stays red on its hardcoded len(_sub) == 7. That is TOOL-04, already filed, and it is not re-filed or fixed here. --- evidence --------------------------------------------------------------- positive control test_defaults_pin.py's mirror floor moved 6 -> 7 inside the harness container: "### RED: test_defaults_pin.py", exit 1 suite 27 files, ALL GREEN, exit 0 (goodwe-addon-checks:1, the checkout mounted read-only) shape probe all cases passed, exit 0, on ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 Nothing here touched hardware, the live Home Assistant at 192.168.2.158, or sim/. Both probe runs were throwaway containers over a read-only mount. DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md carries all four decisions, the frontend transcription, and the two counterfactual transcripts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
373 lines
17 KiB
Python
373 lines
17 KiB
Python
"""UX-04 - the defaults pin UX-01 AC 4 promised and never built.
|
|
`python3 -B test_defaults_pin.py`
|
|
|
|
WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
|
|
`options:` has exactly one defaults-table entry ... the table changed goes red
|
|
... and NO MODULE CARRIES ITS OWN LITERAL FALLBACK FOR A SCHEMA DEFAULT - an AST
|
|
check, not a grep."
|
|
|
|
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
|
|
71 keys, so "every schema key absent from options" quantifies over the empty set
|
|
and is true of any tree, including one with no defaults at all. Clause two -
|
|
"the table changed goes red" - does work, six times over, once per mirror in its
|
|
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
|
|
literal scan in this repository before this file is
|
|
`test_validate.py`'s `re.findall(r'"(maintenance_[a-z_]+)"...')`, which is a
|
|
grep, is restricted to eight `maintenance_*` keys and three named files, and
|
|
predates UX-01.
|
|
|
|
⚠️ AND A CHECK THAT CANNOT GO RED IS NOT A CHECK, so this file does not repeat
|
|
clause one's mistake. Every part below states the size of the set it quantifies
|
|
over and grades that size against a floor derived from the tree, and part 5
|
|
plants a literal in a synthetic module and REQUIRES the scanner to catch it, so
|
|
the red direction is exercised on every run rather than attested in a commit
|
|
message. MUTATION.md habit 4.
|
|
|
|
--------------------------------------------------------------------------- #
|
|
WHAT AN "IN-CODE LITERAL FALLBACK" IS, STRUCTURALLY, so a reader can argue with
|
|
the definition rather than guess it. It is a call in `app/` that takes a STRING
|
|
LITERAL naming a `schema:` key and, in the next positional slot, a CONSTANT:
|
|
|
|
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
|
|
|
|
Two shapes, one rule, found by walking `ast.Call` nodes - not by matching text,
|
|
so `opts.get(\n "max_w",\n 2000,\n)` and a renamed `opts` are found too and
|
|
a `max_w` inside a comment or a docstring is not.
|
|
|
|
TWO EXCLUSIONS, both structural and both narrow:
|
|
|
|
* `getattr(obj, "max_w", 0.0)` reads an ATTRIBUTE off an object, not an option
|
|
off the operator's blob. Its third argument is an absent-attribute guard and
|
|
has no relationship to config.yaml. Excluded by function name.
|
|
|
|
* THE EMPTY STRING. `opts.get("static_profile", "")` is Python's "this key is
|
|
not stated", and every one of the six sites in `validate.py` immediately
|
|
normalises it - `str(opts.get(k, "") or "").strip()`. It cannot be a stale
|
|
copy of a NUMBER, which is the whole of the CFG-04 defect class, and
|
|
rewriting those six to index a mirror would replace a sentence that reads
|
|
"absent" with one that reads "the shipped default" at sites where absent is
|
|
what is meant. Excluded by value. `0` is NOT excluded: `slew_w: 0` and
|
|
`integrator_max_w: 0` are real shipped defaults with real meanings.
|
|
|
|
--------------------------------------------------------------------------- #
|
|
WHAT THIS FILE DOES *NOT* DO, said before the parts rather than after them.
|
|
|
|
It does not remove the 27 (module, key) pairs in part 4's ledger. That is
|
|
CFG-03's open ticket - option defaults living in four places - and it is a body
|
|
of behaviour change across `main.py`, `p1.py`, `inverter.py` and `validate.py`
|
|
that no single card should make in passing. What this file does instead is
|
|
FREEZE the ledger: the set is graded EXACTLY, so the twenty-eighth pair is red
|
|
on the commit that introduces it and the ledger can only ever shrink. A backlog
|
|
that cannot grow is a backlog being paid down; one that is merely written in a
|
|
comment is the state UX-01 left.
|
|
|
|
MUTATION.md is the method for the red-direction evidence in part 5, and
|
|
`tools/mutation_run.sh` is the harness.
|
|
"""
|
|
|
|
import ast
|
|
import io
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
APP = HERE / "app"
|
|
|
|
FAILED = 0
|
|
|
|
|
|
def check(what, ok):
|
|
global FAILED
|
|
print((" ok " if ok else " FAIL ") + what)
|
|
if not ok:
|
|
FAILED += 1
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# config.yaml, parsed by hand.
|
|
#
|
|
# ⚠️ NO PyYAML, AND NOT AS A PREFERENCE. The add-on image installs py3-aiohttp
|
|
# and py3-paho-mqtt only; `import yaml` raises in the checks container and a
|
|
# check that skips on ImportError is not a check. `test_maintenance.py` and
|
|
# `test_price.py` each carry their own two-line reader for the same reason, and
|
|
# this is the third - which is itself an instance of the defect this file
|
|
# grades, one level up. It is written down rather than tidied away because
|
|
# tidying it into a shared helper is a change to two suites that are green.
|
|
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
|
_SCHEMA_AT = CFG_TEXT.index("\nschema:")
|
|
_OPTS_BLOCK, _SCHEMA_BLOCK = CFG_TEXT[:_SCHEMA_AT], CFG_TEXT[_SCHEMA_AT:]
|
|
|
|
|
|
def _scalar(raw):
|
|
"""One `options:` value, as the yaml module this image lacks would read it."""
|
|
raw = raw.split("#", 1)[0].strip().strip('"').strip("'")
|
|
if raw in ("true", "false"):
|
|
return raw == "true"
|
|
if raw == "[]":
|
|
return []
|
|
for cast in (int, float):
|
|
try:
|
|
return cast(raw)
|
|
except ValueError:
|
|
pass
|
|
return raw
|
|
|
|
|
|
def _block_keys(block):
|
|
return [m.group(1) for m in re.finditer(r"^ ([a-z0-9_]+):", block, re.M)]
|
|
|
|
|
|
SHIPPED = {m.group(1): _scalar(m.group(2))
|
|
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", _OPTS_BLOCK, re.M)}
|
|
SCHEMA = set(_block_keys(_SCHEMA_BLOCK))
|
|
|
|
print("UX-04 - the defaults pin\n")
|
|
print("1. the surface this file quantifies over")
|
|
print(f" config.yaml: {len(SHIPPED)} keys under `options:`, {len(SCHEMA)} "
|
|
f"under `schema:`")
|
|
# The floor, for part 1's own reason: a reader that returned {} would make every
|
|
# check below vacuously true. 60 is the count of REQUIRED schema keys and is the
|
|
# number `tools/supervisor_shape_probe.py` section F now grades; any floor well
|
|
# inside the real size does the job, and this one cannot be reached by accident.
|
|
check(f"the hand reader found a real options block ({len(SHIPPED)} keys, floor 60)",
|
|
len(SHIPPED) >= 60)
|
|
check(f"...and a real schema block ({len(SCHEMA)} keys, floor 60)",
|
|
len(SCHEMA) >= 60)
|
|
check("every shipped default names a schema key, so a mirror pinned to one is "
|
|
"pinned to the other",
|
|
sorted(set(SHIPPED) - SCHEMA) == [])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("2. the mirrors, DISCOVERED from the tree rather than listed here")
|
|
# ⚠️ WHY DISCOVERED. Six suites already pin their own mirror to config.yaml -
|
|
# test_maintenance, test_price, test_watchdog (twice), test_peakguard,
|
|
# test_delivery. Every one of those pins names its table by hand, so a SEVENTH
|
|
# table added tomorrow is pinned by nobody and nothing says so. This walks
|
|
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
|
|
# grades whatever it finds, so the seventh is covered on the commit that
|
|
# introduces it. That is the property the six hand-written pins cannot have.
|
|
MODULES = sorted(p.name for p in APP.glob("*.py"))
|
|
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
|
|
for name in MODULES}
|
|
|
|
MIRROR = {} # schema key -> (module, table name, value)
|
|
TABLES = []
|
|
for name in MODULES:
|
|
for node in TREES[name].body:
|
|
if not (isinstance(node, ast.Assign) and len(node.targets) == 1
|
|
and isinstance(node.targets[0], ast.Name)
|
|
and node.targets[0].id.endswith("OPTION_DEFAULTS")):
|
|
continue
|
|
table = node.targets[0].id
|
|
TABLES.append((name, table))
|
|
for k, v in zip(node.value.keys, node.value.values):
|
|
# A literal key with a literal value is the only shape a mirror
|
|
# has; anything else is not a table this file can grade, and
|
|
# saying so out loud beats skipping it silently.
|
|
MIRROR[k.value] = (name, table, ast.literal_eval(v))
|
|
|
|
print(f" {len(TABLES)} table(s): " + ", ".join(f"{m}:{t}" for m, t in TABLES))
|
|
print(f" {len(MIRROR)} mirrored key(s) of {len(SCHEMA)} in `schema:`")
|
|
check(f"the walk found the mirrors that exist ({len(TABLES)} tables, floor 6)",
|
|
len(TABLES) >= 6)
|
|
_unschemad = sorted(k for k in MIRROR if k not in SCHEMA)
|
|
check(f"every mirrored key is a real schema key {_unschemad or ''}",
|
|
_unschemad == [])
|
|
_unshipped = sorted(k for k in MIRROR if k not in SHIPPED)
|
|
check(f"...and is shipped in the options block {_unshipped or ''}",
|
|
_unshipped == [])
|
|
_drift = sorted(k for k, (m, t, v) in MIRROR.items()
|
|
if k in SHIPPED and v != SHIPPED[k])
|
|
check(f"...and reproduces config.yaml exactly, so ANY mirror - including one no "
|
|
f"suite names by hand - goes red on drift {_drift or ''}",
|
|
_drift == [])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("3. the AST scan: no module carries its own literal fallback for a "
|
|
"MIRRORED schema default")
|
|
|
|
|
|
def literal_fallbacks(tree, name):
|
|
"""Every `f(..., "<schema key>", <constant>, ...)` in one module.
|
|
|
|
Yields (module, lineno, callee, key, value). See the header for the two
|
|
exclusions and the argument for each.
|
|
"""
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
fn = node.func
|
|
callee = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", "")
|
|
if callee == "getattr":
|
|
continue
|
|
for i, arg in enumerate(node.args[:-1]):
|
|
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)
|
|
and arg.value in SCHEMA):
|
|
continue
|
|
default = node.args[i + 1]
|
|
if not isinstance(default, ast.Constant):
|
|
continue
|
|
if default.value is None or default.value == "":
|
|
continue
|
|
yield (name, node.lineno, callee, arg.value, default.value)
|
|
|
|
|
|
ALL_SITES = [s for name in MODULES for s in literal_fallbacks(TREES[name], name)]
|
|
MIRRORED_SITES = [s for s in ALL_SITES if s[3] in MIRROR]
|
|
UNMIRRORED_SITES = [s for s in ALL_SITES if s[3] not in MIRROR]
|
|
|
|
print(f" scanned {len(MODULES)} modules, {len(ALL_SITES)} literal fallback "
|
|
f"site(s): {len(MIRRORED_SITES)} over mirrored keys, "
|
|
f"{len(UNMIRRORED_SITES)} over unmirrored ones")
|
|
# ⚠️ THE SCANNER'S OWN FLOOR, and it is the line that stops this whole file
|
|
# rotting into clause one. A scanner that silently stopped matching - a renamed
|
|
# helper, an ast API change, a `SCHEMA` that came back empty - would report ZERO
|
|
# sites and every check below would pass. So the total is graded against a floor
|
|
# the tree already exceeds by a wide margin, and part 5 additionally feeds the
|
|
# scanner a source it MUST flag.
|
|
check(f"the scanner is not blind: it found {len(ALL_SITES)} sites (floor 20)",
|
|
len(ALL_SITES) >= 20)
|
|
for site in sorted(MIRRORED_SITES):
|
|
print(f" {site[0]}:{site[1]} {site[2]}({site[3]!r}, {site[4]!r})")
|
|
check(f"no literal fallback survives for any of the {len(MIRROR)} keys a mirror "
|
|
f"already answers for - those sites must index the mirror",
|
|
MIRRORED_SITES == [])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("4. the CFG-03 ledger: the unmirrored keys that still carry a literal, "
|
|
"frozen at exactly today's set")
|
|
# ⚠️ WHY A FROZEN SET AND NOT A COUNT. A count goes green again the moment
|
|
# somebody deletes one site and adds another, which is the same-size drift this
|
|
# repository keeps finding. The pair (module, key) is stable under every edit
|
|
# that is not the one being guarded against: reformatting, moving the call
|
|
# within the file, renaming the local `opts`. It changes only when a module
|
|
# starts or stops carrying a literal for a key - which is exactly the event.
|
|
#
|
|
# LINE NUMBERS ARE DELIBERATELY NOT IN THE KEY. A ledger that goes red when an
|
|
# unrelated edit shifts a line is a ledger people learn to re-bless without
|
|
# reading, and a ledger re-blessed without reading grades nothing.
|
|
LEDGER = {
|
|
("inverter.py", "max_w"),
|
|
("main.py", "auto_start"),
|
|
("main.py", "cycle_floor_s"),
|
|
("main.py", "deadband_w"),
|
|
("main.py", "estop_fitted"),
|
|
("main.py", "gain"),
|
|
("main.py", "heartbeat_s"),
|
|
("main.py", "log_level"),
|
|
("main.py", "meter_freeze_s"),
|
|
("main.py", "meter_max_age_s"),
|
|
("main.py", "meter_phases"),
|
|
("main.py", "peak_cap_w"),
|
|
("main.py", "saturation_cycles"),
|
|
("main.py", "saturation_w"),
|
|
("main.py", "sign_disagree_s"),
|
|
("main.py", "sign_disagree_w"),
|
|
("main.py", "slew_w"),
|
|
("main.py", "stale_input_s"),
|
|
("main.py", "step_w"),
|
|
("main.py", "target_grid_w"),
|
|
("p1.py", "meter_poll_s"),
|
|
("p1.py", "meter_source"),
|
|
("validate.py", "max_w"),
|
|
("validate.py", "meter_max_age_s"),
|
|
("validate.py", "meter_phases"),
|
|
("validate.py", "sign_disagree_w"),
|
|
("validate.py", "slew_w"),
|
|
}
|
|
_found = {(s[0], s[3]) for s in UNMIRRORED_SITES}
|
|
_new = sorted(_found - LEDGER)
|
|
_gone = sorted(LEDGER - _found)
|
|
print(f" {len(_found)} (module, key) pair(s) carry a literal for a key no "
|
|
f"mirror answers for")
|
|
check(f"no NEW module/key pair has started carrying a literal fallback {_new or ''}",
|
|
_new == [])
|
|
check(f"...and none has been paid off without the ledger being shortened - "
|
|
f"shrink LEDGER when it has {_gone or ''}",
|
|
_gone == [])
|
|
|
|
# --- and every one of them is graded AGAINST config.yaml, which is the half
|
|
# that catches CFG-04's actual failure: two copies of one number, one moves.
|
|
DIVERGENT = {
|
|
# main.py:290 `target_grid_w=float(opts.get("target_grid_w", 0))`.
|
|
# config.yaml ships -10 W. MEASURED here for the first time, 2026-09-07,
|
|
# and it is a real divergence, not a sentinel: a dict handed to
|
|
# `Controller` WITHOUT the key holds net exchange at 0 W instead of the
|
|
# shipped -10 W bias into import. On a Supervisor install the key is always
|
|
# present - the deep-merge of `options:` guarantees it - so this cannot
|
|
# reach an operator; the paths that CAN hand over a partial dict are
|
|
# `sim/inject.py` and this repository's own fixtures. It is recorded rather
|
|
# than corrected because changing it is a behaviour change to the control
|
|
# loop's target, which belongs to a card that grades the loop.
|
|
("main.py", "target_grid_w"): (0, -10),
|
|
}
|
|
_diverge = {}
|
|
for mod, line, callee, key, value in UNMIRRORED_SITES:
|
|
want = SHIPPED.get(key)
|
|
same = (isinstance(value, bool) == isinstance(want, bool)) and value == want
|
|
if not same:
|
|
_diverge[(mod, key)] = (value, want)
|
|
for pair, (got, want) in sorted(_diverge.items()):
|
|
print(f" {pair[0]} {pair[1]}: code {got!r} vs config.yaml {want!r}")
|
|
check(f"every remaining literal still AGREES with config.yaml, except the "
|
|
f"{len(DIVERGENT)} divergence(s) recorded above it",
|
|
_diverge == DIVERGENT)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("5. the red direction, RUN on every invocation")
|
|
# ⚠️ A CHECK THAT HAS NEVER BEEN SHOWN FAILING IS NOT A CHECK. Parts 3 and 4
|
|
# both pass on a clean tree, which is exactly what UX-01 AC 4's clause one did
|
|
# while grading nothing. So the scanner is fed a source that is a deliberate
|
|
# violation of each rule and is REQUIRED to flag it - in-process, against the
|
|
# same `literal_fallbacks()` the parts above call, so a scanner that stops
|
|
# working stops working here too.
|
|
_PLANTED = '''
|
|
def build(opts):
|
|
a = opts.get("maintenance_charge_w", 2000) # mirrored key -> part 3
|
|
b = _number(opts, "cycle_floor_s", 15) # unmirrored, main.py's key
|
|
c = opts.get("static_profile", "") # the "" sentinel: NOT a hit
|
|
d = getattr(unit, "max_w", 0.0) # attribute guard: NOT a hit
|
|
e = opts.get("peak_soc_floor", 20) # mirrored key -> part 3
|
|
return a, b, c, d, e
|
|
'''
|
|
_hits = list(literal_fallbacks(ast.parse(_PLANTED, "<planted>"), "<planted>"))
|
|
_keys = sorted(h[3] for h in _hits)
|
|
for h in _hits:
|
|
print(f" caught {h[2]}({h[3]!r}, {h[4]!r}) at line {h[1]}")
|
|
check("a planted literal for a MIRRORED key is caught - this is part 3's red "
|
|
"direction, exercised",
|
|
"maintenance_charge_w" in _keys and "peak_soc_floor" in _keys)
|
|
check("a planted literal for an unmirrored key is caught too - part 4's",
|
|
"cycle_floor_s" in _keys)
|
|
check('...and the two documented exclusions still hold: the "" sentinel and '
|
|
"getattr are not reported",
|
|
"static_profile" not in _keys and _keys.count("max_w") == 0)
|
|
check(f"exactly the three intended sites, no more {_keys}",
|
|
_keys == ["cycle_floor_s", "maintenance_charge_w", "peak_soc_floor"])
|
|
|
|
# ...and the mirror comparison's red direction, likewise executed rather than
|
|
# claimed: part 2 goes red when a table and config.yaml disagree by one.
|
|
_drifted = dict(MIRROR)
|
|
_pick = sorted(MIRROR)[0]
|
|
_mod, _tab, _val = _drifted[_pick]
|
|
_drifted[_pick] = (_mod, _tab, "###drifted###")
|
|
check(f"part 2's comparison rejects a mirror that has drifted (simulated on "
|
|
f"{_pick!r})",
|
|
any(v != SHIPPED.get(k) for k, (_, _, v) in _drifted.items()))
|
|
|
|
|
|
print()
|
|
if FAILED:
|
|
print(f"{FAILED} FAILED")
|
|
sys.exit(1)
|
|
print("all checks passed")
|