Skip to content

fix: six defects found reviewing the stack, and the tests that missed them - #32

Merged
mikim merged 1 commit into
mainfrom
claude/review-fixes
Sep 22, 2026
Merged

mikim merged 1 commit into
mainfrom
claude/review-fixes

Conversation

@mikim

@mikim mikim commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #30 → #31 — the last PR of the stack. Merge in order. This PR's diff is only its own commit.

A review of the whole stack (#26–#31, +7,991/−799 across 59 files) over nine dimensions — service correctness, polling and visibility, health semantics, touch and a11y, XSS/CSP, CI and serving, test quality, docs accuracy, deploy risk — then five more from angles the first pass did not take: simulated sessions, hostile upstream payloads, teardown and unbounded growth, regressions against main, and mid-deploy behaviour. Every finding went to independent verifiers whose instruction was to refute it.

No blocker. typecheck, build and the suite are green, and the same build was checked on the deploy host. Six findings survived verification; this PR is those six. Each comes with a test that fails without it, or an A/B against a real build.

A stale health reading could read as merely being replaced

pause() recorded healthFreshness().state === "stale" — the word on screen, not the age of the reading. A sweep in flight over a reading already past STALE_AFTER_MS reports as refreshing, so a tab hidden inside such a sweep recorded "not stale". When that sweep then landed nothing and the tab came back, resume() granted resumedSweep: the title dropped health stale, the tally dropped its as of HH:MM, and every measured body came out of STALE_DIM and breathed again — for a snapshot that could be hours old. It flipped back when the sweep settled, which is the flicker healthFreshness documents itself as preventing.

pause() now measures the age. The existing lifecycle tests pause below the threshold with nothing in flight, so none covered this path.

A registry answer with no list read as an ecosystem of nothing

fetchRegistry turned a 200 whose body has no services array into [], and pollRegistry cannot tell that from a real empty registry: it emptied the map, marked the registry loaded, and stood the early-retry ladder (5 s / 15 s / 30 s / 60 s) down to the ten-minute schedule. {"error": "maintenance"} is valid JSON. It now throws the way fetchAODebates and fetchBridgeOutcomes already do, so the read counts as failed and the page says Registry unreachable — retrying. {"services": []} is still a real answer.

Two frame sinks a non-string field would throw inside

(o.id ?? "").slice(0, 8) in BridgeZone and title.slice(0, 15) in AOZone take ?? for a type check. A number or an object in either field reaches .slice inside update(); main.ts's frame guard keeps the loop alive, so the page goes on looking alive while every surface drawn after that zone silently stops — for as long as the row stays in the cache.

Verified by building the tree twice and serving both against a proxy that poisons exactly those fields (id: 9007199254740991, id: {$oid}, title: {_, $}, title_ko: []):

unguarded build this build
console [loop] a frame threw; continuing TypeError: (d.id ?? "").slice is not a function clean
Bridge and AO views frozen behind the throw render unchanged

Both now use str, the guard the belt card already applied to these same fields. It moves to src/ui/html.ts beside esc — one implementation rather than a third copy. String() is not the alternative: it would put [object Object] on the belt.

Reduced motion never reached the tab bar above 768px

The prefers-reduced-motion block near the top of the stylesheet lists .zone-tab, but .zone-tab sets its own transition further down at the same specificity and wins. #30 found this cascade and fixed it — under max-width: 767px, while .zone-tab's transition is declared outside any breakpoint, so the tab bar kept cross-fading at every width from 768 px up. The corrective block is no longer scoped to phones.

A README line the CSS contradicts by one pixel

max-height: 700px includes 700, so "less than 700px tall" is wrong; both READMEs now read "700px or less" / "700px 이하".

What is verified, and what is not

  • ✅ typecheck, 242 tests (was 234), build, check-health-json on the real output
  • ✅ Each of the four code fixes mutation-checked: reverting it fails a test, or fails the A/B above
  • ✅ The same commands run on the deploy host (Node v22.22.1, npm 9.2.0) from a clean clone
  • ⚠️ The zone guards have no unit test — the Phaser classes are not unit-testable, which is why the A/B against a real build stands in. str itself is covered in tests/html.test.ts.

Looked at and deliberately left

  • The registry's url reaches an anchor and window.open with no scheme check. Both carry noopener noreferrer, which is where a browser refuses a javascript: URL, and it is pre-existing on main. Worth hardening on its own.
  • The sidebar says no stats this poll while the canvas prints the same figures unqualified. The canvas behaved this way on main too, and feat(hub): say what the services report, and how old that is #29 only made the panel more honest; qualifying the canvas is its own change.
  • deploy/nginx.conf.example stays reference-only. The live vhost is on another host and no deploy touches it, so the CSP and security headers there remain a separate, deliberate step — Report-Only first, as ci: test main after merge, and harden how a build is served #26 says.

🤖 Generated with Claude Code

… them

A review of #26–#31 over nine dimensions, then five more, with every finding
put to independent verifiers. It found no blocker: 242 tests, typecheck and
build are green, and the six below are what survived verification. Each fix
comes with a test that fails without it, or an A/B against a real build.

## A stale health reading could read as merely being replaced

`pause()` recorded `healthFreshness().state === "stale"`, which is the word on
screen, not the age of the reading. A sweep in flight over a reading that has
already aged past STALE_AFTER_MS reports as "refreshing", so a tab hidden
inside such a sweep recorded "not stale". When that sweep then landed nothing
and the tab came back, `resume()` granted `resumedSweep`: the title dropped
"health stale", the map tally dropped its `as of HH:MM`, and every measured
body came back out of STALE_DIM and breathed — for a snapshot that could be
hours old. It flipped back when the sweep settled, which is exactly the
flicker healthFreshness documents itself as preventing.

`pause()` now measures the age directly. The existing lifecycle tests pause
below the threshold with nothing in flight, so none of them covered this; the
new one does, and fails on the old line.

## A registry answer with no list read as an ecosystem of nothing

`fetchRegistry` turned a 200 whose body has no `services` array into `[]`, and
`pollRegistry` cannot tell that from a real empty registry: it emptied the
map, marked the registry loaded, and stood the early-retry ladder down to the
ten-minute schedule. `{"error": "maintenance"}` is valid JSON. It now throws,
the way fetchAODebates and fetchBridgeOutcomes already do, so the read counts
as failed and the map says "Registry unreachable — retrying". `{"services":
[]}` is still a real answer.

## Two frame sinks that a non-string field would throw inside

`(o.id ?? "").slice(0, 8)` in BridgeZone and `title.slice(0, 15)` in AOZone
take `??` for a type check. A number or an object in either field reaches
`.slice` inside `update()`, and main.ts's frame guard keeps the loop alive, so
the page goes on looking alive while everything drawn after that zone silently
stops — for as long as the row stays in the cache. Verified by building the
tree twice and serving both against a proxy that poisons exactly those fields:
the unguarded build logs `(d.id ?? "").slice is not a function` and freezes the
surfaces behind it; this one renders the Bridge and AO views unchanged.

Both now use `str`, the guard the belt card already applied to the same
fields. It moves to `src/ui/html.ts` beside `esc` — one implementation rather
than a third copy — and `String()` is not the alternative: it would put
"[object Object]" on the belt.

## Reduced motion never reached the tab bar above 768px

The `prefers-reduced-motion` block near the top of the stylesheet lists
`.zone-tab`, but `.zone-tab` sets its own transition further down at the same
specificity and wins. #30 found this cascade and fixed it — under
`max-width: 767px`, while `.zone-tab`'s transition is declared outside any
breakpoint, so the tab bar kept cross-fading at every width from 768px up.
The corrective block is no longer scoped to phones.

## A README line that the CSS contradicts by one pixel

`max-height: 700px` includes 700, so "less than 700px tall" is wrong in both
READMEs.

## Looked at and deliberately left

- The registry's `url` reaches an anchor and `window.open` with no scheme
  check. Both carry `noopener noreferrer`, which is where a browser refuses a
  `javascript:` URL, and it is pre-existing. Worth hardening on its own.
- The sidebar says "no stats this poll" while the canvas prints the same
  figures unqualified. The canvas behaved this way on main too, and this stack
  only made the panel more honest; qualifying the canvas is its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mikim
mikim changed the base branch from claude/docs-accuracy to main September 22, 2026 03:24
@mikim
mikim merged commit c04f2a2 into main Sep 22, 2026
1 check passed
@mikim
mikim deleted the claude/review-fixes branch September 22, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant