From 6aa7af6325652d2d0f66953151b636ec5c57d821 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 6 Aug 2026 18:35:22 -0700 Subject: [PATCH 1/4] Fail on media the site carries but links from nowhere The URL contract checked media in one direction only. The legacy list proves an inbound link still lands, and the asset check proves a reference names a real file. Both read outward from a reference, so neither can ask whether anything points at a given file. An image the WordPress conversion dropped from a page therefore stays on disk, stays reachable at its own URL, and reports green in both directions while appearing nowhere on the site. It was not hypothetical. 120 of the 1,048 carried media files are linked from no page, and five gallery shortcodes across three posts are empty. The ESP32 post announces "Below are pictures of the finished case and the utility meters:" and renders an empty div, with a sequential run of camera originals from that month sitting unreferenced. Every gate passed throughout. check_orphans reads inward from the file and is the only check that sees this. collect_refs and ref_to_path are factored out of check_assets, since the two checks are the same reference set read in opposite directions and walking the built site twice to build it would be waste. ORPHANED_MEDIA is an exact count rather than a bound. A ceiling would let restored media leave slack for a later regression to hide in, so the check fails when the count falls as well as when it rises, and names the new number to write when it falls. The rising message and the falling one differ, because "a page stopped linking media" and "media was restored" are opposite events that a single count cannot distinguish on its own. The explanation is printed rather than returned in the failure list, so the caller's "N missing" stays the orphan count. Carrying a diagnostic in that list reported 122 for 121 orphans. Demonstrated failing in both directions before being trusted, per the rule that a gate is only trusted after it has been watched failing. Removing a page's reference while leaving the file gives 121 and fails; linking one of the 120 gives 119 and fails with the instruction to lower the constant. The empty shortcodes are deliberately left in place. Deleting them would erase the evidence and leave the prose promising pictures that never arrive, and recovering the images needs the source export, since gallery membership and order are not derivable from what is carried here. TODO.md records the defect, names the three posts, and notes that the 120 are not all gallery losses. Verified through make-release.sh, which is the checker's real caller, then against the local mirror: PASS - 1245 URLs honored. Co-Authored-By: Claude Opus 5 (1M context) --- TODO.md | 1 + checks/README.md | 6 ++- checks/check-url-parity.py | 87 ++++++++++++++++++++++++++++++++------ 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/TODO.md b/TODO.md index da499b2..e22d35a 100644 --- a/TODO.md +++ b/TODO.md @@ -51,6 +51,7 @@ The reference leaf the hub now ships carries one step this repo's deploy does no ## Open decisions - `/robots.txt/` and `/osd.xml/` currently sit in `slugs.map` pointing at `/`. The first would be better pointing at the real `/robots.txt`. +- **The conversion dropped images from at least five galleries, and 120 carried media files are linked from no page.** Five `gallery` shortcodes are empty, across three posts: [`esp32-water-and-gas-utility-meter.md:25`](./content/posts/2021/08/09/esp32-water-and-gas-utility-meter.md), [`installing-flair-smart-vents-to-keep-room-temperatures-balanced.md:59,96,154`](./content/posts/2022/10/30/installing-flair-smart-vents-to-keep-room-temperatures-balanced.md), and [`halloween-pumpkins-and-lights.md:28`](./content/posts/2022/10/31/halloween-pumpkins-and-lights.md). `gallery` is the only shortcode this happens to. The ESP32 post is the clearest case: it says "Below are pictures of the finished case and the utility meters:" and then renders an empty div, while a sequential run of camera originals from that month sits on disk linked from nothing. Do not delete the empty shortcodes, which would erase the evidence and leave the prose promising pictures that never arrive. Recovering them needs the source export, since gallery membership and order are not derivable from what is carried here. `ORPHANED_MEDIA` in [`checks/check-url-parity.py`](./checks/check-url-parity.py) holds the count and falls as galleries are restored. The 120 are not all gallery losses; the remainder is unadjudicated and may include uploads the old platform never published. - Content is capped at a fixed 720px on every screen, because PaperMod's width is four CSS variables with no responsive term and no Hugo parameter. The prose measure is right and should stay; images and galleries inheriting the same cap is the part that costs something on a wide display. The knobs, the override location, and the `--gap` trap are documented under "Customization points" in [`themes/README.md`](./themes/README.md). ## Deliberate deviations from the fleet baseline diff --git a/checks/README.md b/checks/README.md index bb15965..4b673dd 100644 --- a/checks/README.md +++ b/checks/README.md @@ -10,7 +10,7 @@ The contract is enforced by two gates, because one cannot cover both halves: | Gate | Proves | Runs | | --- | --- | --- | -| [`check-url-parity.py`](./check-url-parity.py) | Every URL that must render exists as a built page, every legacy image URL resolves, every local asset reference points at a real file | Against `public/`, in CI and before any release is installed | +| [`check-url-parity.py`](./check-url-parity.py) | Every URL that must render exists as a built page, every legacy image URL resolves, every local asset reference points at a real file, and no carried media file is linked from nowhere | Against `public/`, in CI and before any release is installed | | [`check-live-urls.sh`](./check-live-urls.sh) | Every redirect resolves, and its destination answers | Against a running server, which is the only thing that exercises a redirect | ## The two lists @@ -73,3 +73,7 @@ wc -l checks/golden-urls.txt checks/redirect-urls.txt checks/golden-media-legacy ## Directionality The parity check fails on a **missing** URL and only notes an **extra** one. New posts, new tags, and deeper pagination legitimately add URLs, and nothing legitimately removes a URL the site has served. That asymmetry is what makes the lists append-only, which in turn is what makes the length-floor assertion in `check-live-urls.sh` sound: without it a truncated list would make every assertion below it pass vacuously. + +Media is the one surface checked in **both** directions, and it has to be, because each direction is blind to the other's failure. Outward from a reference, the legacy list proves an inbound link still lands and the asset check proves a reference names a real file. Neither asks whether anything points at a given file, so an image dropped from a page during the conversion stays on disk, stays reachable at its own URL, and reports green in both directions while appearing nowhere on the site. The orphan check reads inward from the file and is the only one that sees it. + +Its constant is an exact count rather than a bound. A ceiling would let restored media leave slack behind for a later regression to hide in, so restoring an image lowers the number in the same change, and the check says so by name when it drops. diff --git a/checks/check-url-parity.py b/checks/check-url-parity.py index b3232f6..6e63055 100755 --- a/checks/check-url-parity.py +++ b/checks/check-url-parity.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Verify the built site against the URL contract. -Checks that every URL which must render exists, that every legacy media URL resolves, and that -every local asset reference points at a file. Redirects need a running server and are checked -by check-live-urls.sh instead. +Checks that every URL which must render exists, that every legacy media URL resolves, that +every local asset reference points at a file, and that no carried media file is linked from +nowhere. Redirects need a running server and are checked by check-live-urls.sh instead. """ import pathlib @@ -21,6 +21,17 @@ CHECKS = pathlib.Path(__file__).resolve().parent +# Media carried by the import that no built page links to. +# The other two media checks run outward from a reference and cannot see these: a legacy URL +# resolving proves an inbound link still lands, and a reference resolving proves it names a real +# file. Neither asks whether anything points at a given file, so an image the conversion dropped +# from a page stays reachable by URL, invisible on the site, and green in both directions. +# Every one traces to the WordPress conversion rather than to anything this repo does. Five empty +# gallery shortcodes across three posts are the identified cause of some of them, and the rest are +# unadjudicated. The count is exact rather than a bound, so restoring a gallery lowers it here in +# the same change and slack can never accumulate for a later regression to hide in. +ORPHANED_MEDIA = 120 + def load(name): lines = [ln.strip() for ln in (CHECKS / name).read_text().splitlines()] @@ -69,30 +80,76 @@ def check_media(public): return missing -def check_assets(public): - """Check that every local asset a built page references exists on disk. +def collect_refs(public): + """Every local asset reference in the built pages. - Catches a media file renamed, dropped, or never localized. + Read once and shared, since the assets and orphans checks are the same reference set + read in opposite directions. """ # Minification drops the quotes around an attribute value that does not need them. # Matching only the quoted form checks a fraction of the references and calls it a pass. quoted = re.compile(r'(?:src|href|srcset)="(/(?:media|external)/[^"]+)"') bare = re.compile(r"(?:src|href|srcset)=(/(?:media|external)/[^\s\"'>]+)") - refs, missing = set(), [] + refs = set() for page in public.rglob("*.html"): text = page.read_text(encoding="utf-8", errors="ignore") refs.update(quoted.findall(text)) refs.update(bare.findall(text)) - for ref in sorted(refs): - # Imported references carry resize parameters a static file server ignores. - # Some also escape an underscore, which a server decodes before looking up the file. - path = unquote(ref.split("?", 1)[0].split("#", 1)[0]) - if not (public / path.lstrip("/")).is_file(): - missing.append(ref) + return refs + + +def ref_to_path(ref): + """Map a reference to the path under the built site it names.""" + # Imported references carry resize parameters a static file server ignores. + # Some also escape an underscore, which a server decodes before looking up the file. + return unquote(ref.split("?", 1)[0].split("#", 1)[0]).lstrip("/") + + +def check_assets(public, refs): + """Check that every local asset a built page references exists on disk. + + Catches a media file renamed, dropped, or never localized. + """ + missing = [ref for ref in sorted(refs) if not (public / ref_to_path(ref)).is_file()] print(f"assets : {len(refs) - len(missing)}/{len(refs)} local asset references resolve") return missing +def check_orphans(public, refs): + """Check that every carried media file is linked from some built page. + + The reverse of the assets check, and the only one that can see an image the conversion + dropped from a page: it stays on disk and reachable by URL, so nothing else objects. + """ + linked = {ref_to_path(ref) for ref in refs} + carried, orphaned = 0, [] + for tree in ("media", "external"): + root = public / tree + if not root.is_dir(): + continue + for path in root.rglob("*"): + if not path.is_file(): + continue + carried += 1 + rel = str(path.relative_to(public)) + if rel not in linked: + orphaned.append(rel) + orphaned.sort() + print(f"orphans: {len(orphaned)} of {carried} carried media files are linked from no page") + if len(orphaned) == ORPHANED_MEDIA: + return [] + # The explanation is printed rather than returned, so the caller's count stays the orphan + # count. A diagnostic carried in the failure list would make the reported total one too many. + if len(orphaned) > ORPHANED_MEDIA: + print(f" expected {ORPHANED_MEDIA} - a page stopped linking media it used to link") + return orphaned + print( + f" expected {ORPHANED_MEDIA} - media was restored to a page, so lower " + f"ORPHANED_MEDIA to {len(orphaned)} in this change rather than leaving the slack" + ) + return orphaned + + def main(argv): if len(argv) != 2: sys.exit(f"usage: {argv[0]} ") @@ -100,11 +157,13 @@ def main(argv): if not public.is_dir(): sys.exit(f"FAIL: {public} is not a directory - run hugo first") + refs = collect_refs(public) failures = [] for label, missing in ( ("render", check_render(public)), ("media", check_media(public)), - ("assets", check_assets(public)), + ("assets", check_assets(public, refs)), + ("orphans", check_orphans(public, refs)), ): if missing: failures.append((label, missing)) From cd7de9f1848eb16bd66eeaaf6cb793f4a14e6100 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 6 Aug 2026 18:39:50 -0700 Subject: [PATCH 2/4] Normalize the orphan check's path separator, as check_render already does check_orphans compared a native path against a set of URL paths. On Windows `relative_to` yields backslashes while every reference is forward-slashed, so nothing would have matched and all 1,048 carried files would have reported as orphans, failing the gate with a number that describes the separator rather than the site. check_render normalizes for exactly this reason, three functions above, so the new code was inconsistent with the convention beside it rather than establishing a new question. The comment names the cause, since the replace reads as redundant on the platform CI runs. Shown rather than asserted, via PureWindowsPath: 'media\2010\05\ami-warn3.png' is absent from a URL-keyed set and 'media/2010/05/ami-warn3.png' is present. The call is a no-op on POSIX, and all three gate states are unchanged there: 120 passes, 121 fails upward, 119 fails downward. Co-Authored-By: Claude Opus 5 (1M context) --- checks/check-url-parity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/checks/check-url-parity.py b/checks/check-url-parity.py index 6e63055..ed4abc3 100755 --- a/checks/check-url-parity.py +++ b/checks/check-url-parity.py @@ -131,7 +131,10 @@ def check_orphans(public, refs): if not path.is_file(): continue carried += 1 - rel = str(path.relative_to(public)) + # `linked` holds URL paths, which are always forward-slashed, so a native separator + # here would match nothing and report every carried file as an orphan. check_render + # normalizes for the same reason. + rel = str(path.relative_to(public)).replace("\\", "/") if rel not in linked: orphaned.append(rel) orphaned.sort() From eb013dc29b0a42f5a08bb6365da838dc4231c9ab Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 6 Aug 2026 18:45:24 -0700 Subject: [PATCH 3/4] Fail an empty build outright, and stop claiming the gate asserts zero orphans Three findings from the review of #43, all in the suppressed block. An empty build talked the reader into disabling the gate. With no media trees in the output, `carried` is 0 and `orphaned` is empty, and 0 is fewer than the baseline, so the check reached the branch that reads a drop as progress and advised lowering ORPHANED_MEDIA to 0. Following that advice would have retired the check permanently, on the strength of a build that had produced nothing. That is the failure mode the rule about gates failing loud rather than narrowing quietly exists for, and this one narrowed to nothing while printing a sentence that sounded like good news. No media at all is now a hard failure that names the cause, ahead of any comparison against the baseline. The module docstring and the gate table in checks/README.md both said the check proves "no carried media file is linked from nowhere". It does not, and cannot today: 120 are. It proves the count still equals its recorded baseline. Read beside output beginning `orphans: 120`, the old wording invited the reader to conclude the check was broken rather than that the site is. Both now say what it actually asserts. The new branch was demonstrated failing before being trusted: with the media trees removed the check exits 1, names an incomplete or mislocated build, and emits no advice to lower the constant. The other three states are unchanged at 120 pass, 121 fail, 119 fail. Co-Authored-By: Claude Opus 5 (1M context) --- checks/README.md | 2 +- checks/check-url-parity.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/checks/README.md b/checks/README.md index 4b673dd..4e7059f 100644 --- a/checks/README.md +++ b/checks/README.md @@ -10,7 +10,7 @@ The contract is enforced by two gates, because one cannot cover both halves: | Gate | Proves | Runs | | --- | --- | --- | -| [`check-url-parity.py`](./check-url-parity.py) | Every URL that must render exists as a built page, every legacy image URL resolves, every local asset reference points at a real file, and no carried media file is linked from nowhere | Against `public/`, in CI and before any release is installed | +| [`check-url-parity.py`](./check-url-parity.py) | Every URL that must render exists as a built page, every legacy image URL resolves, every local asset reference points at a real file, and the count of carried media linked from no page still equals its recorded baseline | Against `public/`, in CI and before any release is installed | | [`check-live-urls.sh`](./check-live-urls.sh) | Every redirect resolves, and its destination answers | Against a running server, which is the only thing that exercises a redirect | ## The two lists diff --git a/checks/check-url-parity.py b/checks/check-url-parity.py index ed4abc3..b3a20e9 100755 --- a/checks/check-url-parity.py +++ b/checks/check-url-parity.py @@ -2,8 +2,9 @@ """Verify the built site against the URL contract. Checks that every URL which must render exists, that every legacy media URL resolves, that -every local asset reference points at a file, and that no carried media file is linked from -nowhere. Redirects need a running server and are checked by check-live-urls.sh instead. +every local asset reference points at a file, and that the number of carried media files +linked from no page still equals its recorded baseline. Redirects need a running server and +are checked by check-live-urls.sh instead. """ import pathlib @@ -138,6 +139,12 @@ def check_orphans(public, refs): if rel not in linked: orphaned.append(rel) orphaned.sort() + # No media at all is a broken build, not progress. Left to the comparison below it reads as + # zero orphans, which is fewer than the baseline, and the advice would be to lower + # ORPHANED_MEDIA to 0 - a gate talking the reader into switching it off. + if carried == 0: + print("orphans: no media files in the built site - the output is incomplete or mislocated") + return ["public/media and public/external are both absent or empty"] print(f"orphans: {len(orphaned)} of {carried} carried media files are linked from no page") if len(orphaned) == ORPHANED_MEDIA: return [] From 3467cdfc32ff273282137ed8d65bc41d8acc0351 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 6 Aug 2026 18:49:28 -0700 Subject: [PATCH 4/4] Say what the orphan count observes, not which cause produced it A count is all check_orphans can see, and two causes reach each direction. It rises when a page stops linking media and equally when unlinked media is added. It falls when media is linked from a page and equally when orphaned files are deleted. The messages named one cause each and stated it as fact, so a run that had the other cause would have sent a reader looking for a page that never changed, with the check's own output vouching for the wrong theory. This is the same overclaiming the previous commit removed from the docstring, left in place one function lower. Asserting a cause the code cannot observe is worse in a failure message than in prose, because a failure message is read by someone who has no context yet and is deciding where to look first. Both messages now name both causes. The action after a drop is unchanged and still explicit, since lowering the constant is correct whether the count fell because media was linked or because orphaned files were removed. checks/README.md gains the same distinction, so the reasoning survives the next edit to the message text. All four states re-verified: 120 passes, 121 and 119 fail with the reworded messages, and an empty build still fails outright without advising that the constant be lowered. Co-Authored-By: Claude Opus 5 (1M context) --- checks/README.md | 4 +++- checks/check-url-parity.py | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/checks/README.md b/checks/README.md index 4e7059f..083197c 100644 --- a/checks/README.md +++ b/checks/README.md @@ -76,4 +76,6 @@ The parity check fails on a **missing** URL and only notes an **extra** one. New Media is the one surface checked in **both** directions, and it has to be, because each direction is blind to the other's failure. Outward from a reference, the legacy list proves an inbound link still lands and the asset check proves a reference names a real file. Neither asks whether anything points at a given file, so an image dropped from a page during the conversion stays on disk, stays reachable at its own URL, and reports green in both directions while appearing nowhere on the site. The orphan check reads inward from the file and is the only one that sees it. -Its constant is an exact count rather than a bound. A ceiling would let restored media leave slack behind for a later regression to hide in, so restoring an image lowers the number in the same change, and the check says so by name when it drops. +Its constant is an exact count rather than a bound. A ceiling would let a drop leave slack behind for a later regression to hide in, so whatever lowers the count lowers the constant in the same change, and the check names the new number when it drops. + +A count is all the check can observe, and two causes reach each direction: it rises when a page stops linking media **or** when unlinked media is added, and it falls when media is linked from a page **or** when orphaned files are deleted. The messages name both, because naming one would send a reader looking for a page that never changed. diff --git a/checks/check-url-parity.py b/checks/check-url-parity.py index b3a20e9..6f7fba7 100755 --- a/checks/check-url-parity.py +++ b/checks/check-url-parity.py @@ -150,12 +150,14 @@ def check_orphans(public, refs): return [] # The explanation is printed rather than returned, so the caller's count stays the orphan # count. A diagnostic carried in the failure list would make the reported total one too many. + # A count is all this can observe, and two causes reach each direction. Naming one of them + # would send a reader looking for a page that never changed. if len(orphaned) > ORPHANED_MEDIA: - print(f" expected {ORPHANED_MEDIA} - a page stopped linking media it used to link") + print(f" expected {ORPHANED_MEDIA} - a page stopped linking media, or unlinked media was added") return orphaned print( - f" expected {ORPHANED_MEDIA} - media was restored to a page, so lower " - f"ORPHANED_MEDIA to {len(orphaned)} in this change rather than leaving the slack" + f" expected {ORPHANED_MEDIA} - media was linked from a page, or orphaned files were " + f"removed; lower ORPHANED_MEDIA to {len(orphaned)} in this change rather than leaving the slack" ) return orphaned