Replace the website with a daily cross-environment test dashboard built around promotion drift - #120
Merged
Conversation
pytest --report-jsonl PATH appends one JSON line per test (nodeid, phase, outcome, wasxfail, duration, user_properties, concise crash message) from a pytest_runtest_logreport hook. Only the xdist controller writes: workers forward their TestReports, so the guard on PYTEST_XDIST_WORKER prevents duplicate lines without any file locking. The two Google Sheet test functions now record category, source, source_url, query_id and query_label via record_property, so the dashboard gets that metadata structurally instead of regexing failure messages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src.babel_validation.tools.generate_report aggregates the --report-jsonl records per test (worst outcome wins across phases and subtests), classifies them into passed/failed/xfailed/xpassed/skipped/error (strict XPASS included), splits the target out of parametrize ids (either end, longest name first so ci-es beats ci), and merges in each target's NodeNorm/NameRes /status. report.json is published on a public website and everything feeding it is untrusted, so this module is the choke point: text is repr-escaped and truncated, issue ids and source URLs only become links when they match the targets.ini Repositories allowlist, /status responses pass a key whitelist, and blocklist test details are withheld entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Prefix Comparator has moved into Babel (NCATSTranslator/Babel#889) and Autocomplete is moving into Babel Explorer (TranslatorSRI/babel-explorer#7), so both pages are gone, along with the never-deployed website-vue3-vite app that duplicated the Google Sheet test logic in the browser. The Astro site now renders data/report.json and data/history.jsonl (produced by generate_report): per-environment status cards (Babel/NameRes versions, database sizes, NameRes p95 latency, outcome counts), a tests-by-environment matrix defaulting to 'interesting only' rows (failing, unexpectedly passing, or differing across environments) with links out to the Google Sheet row, the GitHub issue, direct NodeNorm/NameRes queries and Babel Explorer, and a trends page listing one summary row per daily run. All report text is rendered via {{ }} interpolation only, and links are constructed from generator-validated parts, since the report is derived from untrusted input. Dependencies used only by the deleted tools are dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new dashboard.yaml workflow (daily cron plus workflow_dispatch) runs the full test suite against every target in targets.ini except localhost — one sequential pytest run per target, capped at 45 minutes each so a hung environment cannot sink the job, with a nonzero exit treated as normal since the report is the artifact — then generates report.json/history.jsonl and deploys the built site to gh-pages. The previous run's history is fetched from the live site, because the deploy action force-pushes gh-pages as a single commit. The release-triggered deploy workflow is gone: it would publish a site with stale or missing data, and workflow_dispatch covers manual redeploys. README.md and CLAUDE.md now describe the dashboard, its generation flow, and the untrusted-input rules its generator and Vue components must follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecking it in The sheet is shared as 'anyone with the link' because the CSV export is fetched unauthenticated, so the ID is the capability that grants access. GoogleSheetTestCases() now resolves it from the BABEL_VALIDATION_SHEET_ID environment variable (loaded from .env locally; a repository secret of the same name in the dashboard workflow), fails loudly when it is missing, and rejects values that do not look like a sheet ID before they reach a URL. Its __str__ no longer embeds the ID, since that string is interpolated into assertion messages the dashboard publishes. Once the sheet is re-shared under a new ID, the old ID in the public Git history stops working. The leak-guard unit test now plants a fake ID in the environment and checks the generator cannot surface it in report.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The blocklist sheet logs Red Team offensive terms, so its ID is even more of a secret than the test-case sheet's. Both sheets now resolve their IDs through a shared resolve_sheet_id() helper: the environment variable (BABEL_VALIDATION_SHEET_ID / BABEL_VALIDATION_BLOCKLIST_SHEET_ID, loaded from .env locally, repository secrets in the dashboard workflow), failing loudly on a missing or implausible value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l, pagination, shareable links The per-environment status cards become one transposed table with environments as columns in deployment order (exp, dev, ci, ci-es, test, prod) and one row per status value, so a version working its way towards prod reads left to right and the odd environment out is highlighted (table-warning on cells that differ from the majority). Test-outcome counts follow as rows in the same table, and adding a status row is now one entry in STATUS_ROWS. The results matrix and the trends page use the same column order. The always-visible 'interesting only' checkbox — one accidental click away from rendering thousands of rows — moves into a collapsible Filters panel alongside text search, test-source and has-outcome filters, and the matrix is paginated (100 rows per page by default, selectable) instead of rendered in full. Filter state, page, and the expanded test live in the URL query string, so the address bar always reproduces the current view; a 'Copy link to this view' button copies it, and a shared link to a test hidden by the current filters pins that test at the top rather than showing nothing. Query parameters are untrusted input: they feed only string filters and key lookups, never markup or fetch targets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
workflow_dispatch only works once the file is on the default branch; this push trigger is for pre-merge test runs and comes out before merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pytest rejects '=' inside a -k expression, so the documented -k "row=42" never worked; select the full node ID instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…allows The Python-template 'lib/' pattern in the root .gitignore matched website/src/lib/, so deploymentOrder.js was never committed and the CI build failed with 'Could not resolve' while local builds — with the file present on disk — passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Without a path argument, pytest-xdist workers do not load tests/conftest.py
early enough to register --target/--report-jsonl, and every worker dies at
argparse ('unrecognized arguments') — which is exactly what happened on the
first dashboard run: six 'successful' pytest invocations, zero results.
The generator now refuses to succeed when every target is unreachable, so a
broken run stops the workflow before the deploy step instead of publishing
an empty dashboard. The report files are still written for debugging.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st tests' Node IDs are rootdir-relative: 'pytest tests --target dev' (the workflow's invocation) yields 'tests/github_issues/...' where 'pytest tests/nodenorm/...' yields 'nodenorm/...'. The generator's kind checks and the Dashboard's nodenorm//nameres/ prefix checks assumed the bare form, so the first real workflow run reported github_issues_ran=false despite 96 issues having run, and every service link would have been missing. Keys now have the tests/ prefix stripped, verified by regenerating the report from that run's actual raw artifact. Also: only github_issues/test_github_issues.py counts towards github_issues_ran (the github_issues/unit/ parser tests run without a token), and the workflow deselects '-m unit' — the per-target runs were repeating the unit suite six times into the report's unattributed bucket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both cost a broken CI run this session: pytest -n without an explicit tests path leaves workers ignorant of the conftest options, and the root .gitignore's Python-template 'lib/' pattern silently swallows website/src/lib/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The trends page sits under `<base href="/babel-validation/">`, so its `../` back-link resolved to the site root rather than the dashboard. Dashboard.vue's watchers are registered before created(), so they saw readUrl()'s assignments as edits: the filters handler reset page to 1 and rewrote the URL, and a link with ?page=3 always landed on page 1. Ignore watcher fire-ups until the initial read has flushed. Also index the pinned-row lookup with Object.hasOwn: ?test=constructor otherwise pinned an Object.prototype member and blanked the table. And drop non-object history lines in Trends.vue, which the template indexes as run.targets[target]. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fetch_status() called .rstrip() on a URL that targets.ini need not define, read_raw_records() passed a non-string id to parse_nodeid, and append_history() only checked that prior lines were valid JSON, not that they were objects with targets — each aborted the whole report rather than the one value. _annotate_result() also ran only for the first target seen, so a row whose first target failed during setup — before record_property runs — lost its category and service links even though the other targets had recorded them. Merge props across every target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README's own example (--report-jsonl raw/local.jsonl, no mkdir) died in pytest_configure before collecting anything. Appending was also wrong for a local re-run: build_results takes the worst outcome per test, so stale records kept a fixed test red. CI writes one fresh file per target, so it never noticed either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was only there so the job could be exercised before the file reached the default branch. Left in, every push to the branch would start a ~4.5h run that force-pushes gh-pages, racing the daily cron. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frontend work needs a report.json and a history.jsonl, and generating them locally means running the suite against at least one environment first. The live site already publishes both, so download them into website/public/data/ instead — the directory the dev server reads, and one the root .gitignore's data/ rule already covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shakedown run died before pytest reported anything, leaving a row of real /status values and all-zero counts at the top of the live history.jsonl. Every run copies the prior file forward verbatim, so that row would have sat in the trends table forever. A run where no target recorded a single test is a broken run rather than a data point, so drop it on the way in and decline to write one on the way out. The live file heals itself on the next daily run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three frontend bugs the last review round found — a shared link losing its page, a ?test= key reaching Object.prototype, a history line that is not an object blanking the trends table — were all invisible to the Python suite, which never loads a component. `npm test` mounts Dashboard and Trends against a stub report and asserts what a shared link restores. Four of the six tests fail against the components as they stood before that review. The Tests workflow runs them alongside the Python unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The site had no chrome at all — a bare container-fluid, no navigation, no way to reach the run history except a sentence of prose, and fourteen lines of CSS in the whole project. Everything rendered as one flat column of tables across the full width of the monitor. Content now sits in cards on the body background over a tinted page, inside a 1320px container, under a dark nav bar that links the sibling projects and the repository. src/styles/theme.css holds the whole custom layer, written in terms of Bootstrap's theme variables so that the data-bs-theme dark mode the nav bar's toggle sets works throughout; the toggle cycles light, dark and follow-the-system, and the choice is applied in <head> so the page never flashes the wrong theme. Also fixes three things the shell was hiding: the favicon was the stock Astro logo, referenced root-absolutely so it 404ed under the /babel- validation/ base; the viewport meta was missing initial-scale; and the failure-message background was a hardcoded rgba(0,0,0,.05) that is invisible on a dark surface. The Dashboard and Trends components are untouched, and their tests still pass unchanged. Bootstrap 5.3.2 -> 5.3.8 rides along here, alone, so any fallout from the upgrade is isolated to this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dashboard.vue was 604 lines because it held everything: the status-row spec, the row labels, the interestingness predicate, the link builders and the fetch, alongside the markup. Splitting the page in two means two components need all of that, so it moves out first, on its own, with no behaviour change — the six tests pass untouched. The link builders move byte for byte. Their regexes and their encodeURIComponent calls are the security boundary between untrusted report text and a URL, so the file says so where someone might otherwise tidy them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One page held everything: provenance, warnings, the environment table, the filters and 4,583 rows of results. Nothing led, so nothing was findable. The landing page now answers "is anything broken, and where" — the six environments as cards in promotion order, each linking to its own failures, over the status table (which gains the six NodeNorm databases the report has always carried and never showed). The matrix moves to /results/ behind a filter bar that sticks to the top of the window with the table header directly beneath it, and gains category, source and per-environment outcome filters. Choosing an environment narrows the outcome filter to it, so "failing in dev" is now a link rather than a reading exercise. Outcome badges are subtle everywhere except failures and errors, which stay solid: a screen of results should read as "mostly fine, these two are not" rather than as a wall of colour. It is also a dark-mode fix — the bg-*-subtle pairs flip with data-bs-theme and the text-bg-* helpers do not. Rows are keyboard-focusable now, and the empty state carries the reset button rather than hiding it in a panel that can be closed. Blocklist rows are withheld twice over: the expanded detail is now gated on the kind, not just on the label, and the category and source dropdowns skip them — a dropdown is as public as a cell, which a new test caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Of the 526 tests worth looking at in the current report, 518 behave differently in different environments — and they do it in a handful of recurring ways. 147 tests pass everywhere but prod; 100 fail in ci-es and prod alone; 68 are expected failures that prod passes. Those are three findings about three Babel versions, not 315 unrelated failures, but the matrix showed them as 315 rows. The dashboard now groups the interesting tests by their outcome across every environment, in promotion order, and lists the most common patterns with a link into the results page. The pattern is a short string, one character per environment — "pppppF" is "passes everywhere except prod" — which makes it small enough for a URL and, because the alphabet is closed, checkable on the way back in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trends page listed runs and left the reader to diff two columns by eye. It now says it outright: which environments moved to a new Babel or NameRes version, and which count went up or down, computed from the last two lines of the history file. Still no chart — four runs exist, and a table beats a sparkline at that length. There is a ponytail: note for when there are enough to plot. Renamed from /trends/ to /history/ to match the nav; nothing outside the site linked to the old path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Screenshots of the redesign against the live report showed the fix this needed: NodeNorm record counts, Solr index sizes and p95 latencies differ between environments by nature, so highlighting the minority value turned almost every cell amber and taught the reader to ignore the colour. Only the rows that mean something when they disagree — the Babel, Biolink and NameRes versions, and the two service statuses — are compared now. The environment cards also move from a flex row to a six-column grid. Flex sized them by content, so at 1440px they wrapped four and two, and the pipeline stopped reading left to right halfway through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
workflow_dispatch only works once the file is on the default branch, so a pre-merge run has to trigger on push. #118 carried that trigger and is now kept only for reference, so it moves here — the redesign is what needs deploying for review. Only one branch should have it at a time: each run takes about four hours and force-pushes gh-pages. Remove it before merging, as #118 did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 tasks
CI installs with `npm ci`, which refuses a lockfile that does not match package.json — and the lock left by the incremental installs that added vitest and upgraded Bootstrap was missing esbuild's platform packages for every OS but this one, so the new website-tests job could not start. Regenerated from scratch (72 @esbuild/* entries now present) and verified with npm ci, npm test and npm run build locally rather than on the runner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read_targets had no tests. It decides which environments the report contains, and as of the dashboard workflow taking its loop from it, which environments are run at all — so the localhost exclusion and the section order are load-bearing in a way they were not when the workflow carried its own list. Four tests: every section but localhost, order preserved as the file gives it, the repository allowlist lowercased and blank-stripped, and the checked-in targets.ini yielding a non-empty list without localhost in it. Dropping the localhost exclusion fails them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DEPLOYMENT_ORDER is the last hardcoded list of environments on the site: the workflow and generate_report both read targets.ini now, but promotion order is a semantic fact about the pipeline that no config file states, so it cannot be derived from one and has to stay written down. What it should not do is fail quietly. An unrecognised target sorted last, which is indistinguishable from "this is the end of the pipeline" — and the sentence above the cards then read "reaches exp first and <unknown> last", asserting something nobody had said. The Dashboard now names the environments the order does not know, and builds that sentence only from the ones it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`concurrency: dashboard-deploy` serialised runs but never cancelled, so pressing Run workflow waited out whatever was already going — up to 26 minutes to see the effect of a change. A blanket `cancel-in-progress: true` is the wrong fix: a push, or the next day's cron, could then kill a scheduled run mid-flight, and that run is what appends the day's line to history.jsonl. Cancelling is gated on the event instead, so only a manual dispatch supersedes. It is safe there because the run being cancelled would have produced the same two files as the one replacing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d .env The sheet IDs are the credentials — both sheets are shared as "anyone with the link" because the CSV export is unauthenticated — so setting up a checkout meant knowing which four variables exist, which two are secret, and what each one turns off when missing. env.default now says all of that, with the ID's format and where it sits in a sheet URL, so `cp env.default .env` is the whole setup. The CLAUDE.md rule is the other half. Everything a coding agent reads goes into a transcript that is stored, replayed and pasted into issues, so reading .env back — cat, grep, echo $BABEL_VALIDATION_SHEET_ID, printing a CSV export URL — turns a secret into a logged one, and an exposed ID means re-sharing the sheet and rotating the secret. Writing .env is fine; reading it is not, and with env.default there is no reason to. Also gives the presence check that prints a boolean instead of the value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An empty commit, deliberately: the repository secrets were replaced on 2026-09-01 and nothing has exercised them, but workflow_dispatch is unavailable until dashboard.yaml is on the default branch, so a push to this branch is the only way to start a run before merge. A mistyped secret fails at collection with "No Google Sheet ID", so one green run settles it. The temporary push trigger is removed once that run is green — after which this is the last thing that could have started one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note said to split the loop if runtime ever approached the 6h job limit. It is nowhere near that, and never will be — the reason to do it is that ~25 of the run's ~26 minutes are six pytest invocations waiting on each other, which is the feedback loop, not the ceiling. That is now filed, so the note points at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It existed because workflow_dispatch does not work until the workflow file is on the default branch, so a push to this branch was the only way to run the dashboard before merge. Left in, every push to a merged branch would start a ~26-minute run that force-pushes gh-pages, racing the daily cron. Once this merges, the file is on main and dispatch works: `gh workflow run dashboard.yaml`. A dispatch cancels a run already in progress, which the cron never does — see the concurrency block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`|| echo "target $t exited $?"` treated every nonzero exit the same. Exit 1 — tests failed — is the artifact this workflow publishes, so it must not fail the job. But 2 (a collection error), 3, 4 and 5 mean the run did not do what its green tick suggests, and they rendered identically: the target ends up with no records, the report marks it unreachable, and the dashboard reads that as someone else's deployment being down. Exits are now sorted into three: 0 and 1 pass silently; 124 is annotated as a warning, because `timeout` firing means the environment hung and the report already says so — going red every day an environment is down is how people stop reading the red; anything else is an error annotation and is recorded in BROKEN_TARGETS. A final step fails the job on that, deliberately after the deploy, so the targets that did work still publish. generate_report's all_targets_unreachable already covered total failure. This is the partial case, which is the one that looks fine. The loop also now uses `|| status=$?`: steps run under `bash -e`, so reading `$?` after an unguarded call would have aborted at the first target with a failing test — which is most of them. Verified by extracting the step from the YAML and running it under `bash -e` against stub exits: all-1 is clean, 124 warns without failing, 2 and 4 mark the target broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Replaces the retired website tools with a daily, cross-environment validation dashboard focused on promotion drift.
Changes:
- Adds pytest JSONL reporting, secure report generation, and daily GitHub Pages deployment.
- Adds Dashboard, Results, and History views with filtering, sharing, and frontend tests.
- Removes the obsolete Vue/Vite application and protects Google Sheet IDs through environment configuration.
Reviewed changes
Copilot reviewed 88 out of 96 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
.github/workflows/dashboard.yaml |
Adds daily testing, report generation, and deployment. |
.github/workflows/deploy-website-to-gh-pages.yaml |
Removes the superseded release deployment. |
.github/workflows/tests.yaml |
Adds website unit tests to CI. |
CLAUDE.md |
Documents dashboard workflows and security rules. |
README.md |
Documents dashboard usage and report generation. |
env.default |
Adds environment-variable setup guidance. |
src/babel_validation/sources/google_sheets/__init__.py |
Adds validated sheet-ID resolution. |
src/babel_validation/sources/google_sheets/blocklist.py |
Loads the blocklist sheet ID securely. |
src/babel_validation/sources/google_sheets/google_sheet_test_cases.py |
Removes the embedded validation sheet ID. |
src/babel_validation/tools/__init__.py |
Adds the report-tools package. |
src/babel_validation/tools/generate_report.py |
Generates sanitized dashboard and history data. |
tests/conftest.py |
Adds pytest JSONL outcome reporting. |
tests/nameres/test_nameres_from_gsheet.py |
Records structured NameRes metadata. |
tests/nodenorm/test_nodenorm_from_gsheet.py |
Records structured NodeNorm metadata. |
tests/targets.ini |
Removes the retired Test Redis target. |
tests/unit/__init__.py |
Adds the unit-test package marker. |
tests/unit/test_generate_report.py |
Tests report generation and sanitization. |
tests/unit/test_google_sheet_id.py |
Tests sheet-ID validation. |
website/README.md |
Documents dashboard development. |
website/package-lock.json |
Locks updated frontend dependencies. |
website/package.json |
Adds dashboard testing and data scripts. |
website/public/favicon.svg |
Replaces the site favicon. |
website/public/prefix_reports/prefix_report-2024oct24.json |
Removes retired comparator data. |
website/public/prefix_reports/prefix_report-dev-2025jan23.json |
Removes retired comparator data. |
website/src/components/Autocomplete.vue |
Removes the retired autocomplete tool. |
website/src/components/Card.astro |
Removes the obsolete card component. |
website/src/components/DriftPanel.vue |
Adds promotion-pattern grouping. |
website/src/components/FilterBar.vue |
Adds result filtering and link copying. |
website/src/components/History.vue |
Adds run history and change summaries. |
website/src/components/NavBar.astro |
Adds navigation and theme controls. |
website/src/components/Overview.vue |
Adds the dashboard overview. |
website/src/components/PrefixComparator.vue |
Removes the retired comparator. |
website/src/components/Results.vue |
Adds the filterable results matrix. |
website/src/components/StatusMatrix.vue |
Adds cross-environment status comparison. |
website/src/components/bootstrap/BCard.vue |
Removes an unused Bootstrap wrapper. |
website/src/deploymentOrder.js |
Defines semantic promotion order. |
website/src/env.d.ts |
Adds Vue component declarations. |
website/src/layouts/Layout.astro |
Adds the shared themed page shell. |
website/src/pages/autocomplete/index.astro |
Removes the autocomplete route. |
website/src/pages/history/index.astro |
Adds the History page. |
website/src/pages/index.astro |
Replaces the landing page with the dashboard. |
website/src/pages/prefix-comparator/index.astro |
Removes the comparator route. |
website/src/pages/results/index.astro |
Adds the Results page. |
website/src/reportData.js |
Centralizes report logic and safe link construction. |
website/src/styles/theme.css |
Adds dashboard styling and dark-mode support. |
website/test/driftPanel.test.js |
Tests promotion-pattern grouping. |
website/test/history.test.js |
Tests history parsing and comparisons. |
website/test/overview.test.js |
Tests overview ordering and links. |
website/test/reportData.test.js |
Tests report classification and links. |
website/test/results.test.js |
Tests filtering, pagination, and URL state. |
website/test/statusMatrix.test.js |
Tests disagreement highlighting. |
website/vitest.config.js |
Configures frontend unit tests. |
website-vue3-vite/.eslintrc.cjs |
Removes legacy lint configuration. |
website-vue3-vite/.gitignore |
Removes legacy ignore rules. |
website-vue3-vite/.prettierrc.json |
Removes legacy formatting configuration. |
website-vue3-vite/.vscode/extensions.json |
Removes legacy editor recommendations. |
website-vue3-vite/README.md |
Removes legacy app documentation. |
website-vue3-vite/cypress.config.js |
Removes legacy Cypress configuration. |
website-vue3-vite/cypress/e2e/example.cy.js |
Removes scaffolded Cypress test. |
website-vue3-vite/cypress/e2e/jsconfig.json |
Removes Cypress JavaScript configuration. |
website-vue3-vite/cypress/fixtures/example.json |
Removes scaffolded fixture data. |
website-vue3-vite/cypress/support/commands.js |
Removes scaffolded Cypress commands. |
website-vue3-vite/cypress/support/e2e.js |
Removes Cypress support setup. |
website-vue3-vite/index.html |
Removes the legacy app entry page. |
website-vue3-vite/package-lock.json |
Removes the legacy dependency lock. |
website-vue3-vite/package.json |
Removes the legacy package definition. |
website-vue3-vite/public/favicon.ico |
Removes the legacy favicon. |
website-vue3-vite/src/App.vue |
Removes the legacy root component. |
website-vue3-vite/src/assets/base.css |
Removes legacy base styling. |
website-vue3-vite/src/assets/logo.svg |
Removes the legacy logo. |
website-vue3-vite/src/assets/main.css |
Removes legacy application styling. |
website-vue3-vite/src/components/HelloWorld.vue |
Removes scaffolded content. |
website-vue3-vite/src/components/TestResult.vue |
Removes browser-side test rendering. |
website-vue3-vite/src/components/TextWithURLs.vue |
Removes legacy URL rendering. |
website-vue3-vite/src/components/TheWelcome.vue |
Removes scaffolded welcome content. |
website-vue3-vite/src/components/WelcomeItem.vue |
Removes scaffolded welcome items. |
website-vue3-vite/src/components/__tests__/HelloWorld.spec.js |
Removes scaffolded unit tests. |
website-vue3-vite/src/components/icons/IconCommunity.vue |
Removes scaffolded icon. |
website-vue3-vite/src/components/icons/IconDocumentation.vue |
Removes scaffolded icon. |
website-vue3-vite/src/components/icons/IconEcosystem.vue |
Removes scaffolded icon. |
website-vue3-vite/src/components/icons/IconSupport.vue |
Removes scaffolded icon. |
website-vue3-vite/src/components/icons/IconTooling.vue |
Removes scaffolded icon. |
website-vue3-vite/src/main.js |
Removes the legacy application bootstrap. |
website-vue3-vite/src/models/NameResTest.js |
Removes browser-side NameRes tests. |
website-vue3-vite/src/models/NodeNormTest.js |
Removes browser-side NodeNorm tests. |
website-vue3-vite/src/models/Test.js |
Removes the legacy test model. |
website-vue3-vite/src/models/TestResult.js |
Removes the legacy result model. |
website-vue3-vite/src/models/helpers.js |
Removes legacy CURIE helpers. |
website-vue3-vite/src/router/index.js |
Removes legacy client routing. |
website-vue3-vite/src/views/AboutView.vue |
Removes scaffolded About view. |
website-vue3-vite/src/views/Autocomplete.vue |
Removes legacy autocomplete view. |
website-vue3-vite/src/views/AutocompleteBulkValidator.vue |
Removes legacy bulk validator. |
website-vue3-vite/src/views/HomeView.vue |
Removes the legacy home view. |
website-vue3-vite/src/views/NameResValidator.vue |
Removes the legacy NameRes validator. |
website-vue3-vite/src/views/NodeNormValidator.vue |
Removes the legacy NodeNorm validator. |
website-vue3-vite/vite.config.js |
Removes legacy Vite configuration. |
Suppressed comments (1)
website/src/components/History.vue:51
- A target added to or removed from
targets.iniis silently skipped here. If that is the only difference, History reports “Nothing changed”; add a change entry for the missing side before continuing so environment-list changes remain visible.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The exit-code triage added in 5f90b3b reads 0 and 1 as "the run worked" — 1 being failing tests, which is the artifact this workflow publishes. But the command is `timeout 45m uv run pytest`, and uv exits 1 for its own failures too: a failed dependency sync, a resolution error, an unreadable pyproject. Those happen before pytest starts, so the target ends up with no records at all, the report renders it as unreachable during the run, BROKEN_TARGETS stays empty and the job goes green — the exact silent partial failure the triage was added to catch, arriving through the one branch that does not check anything. pytest_configure opens raw/$t.jsonl with mode 'w' before collection, so the file exists and is non-empty for any run that got as far as reporting a test. An empty or absent file on exit 0 or 1 therefore means pytest never ran, and that target is now recorded as broken like any other unusable run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read_raw_records is deliberately forgiving — "one corrupt line must not sink the
whole report" — but it only checks that a record is a dict whose id is a string.
Everything else is passed through as parsed, and both consumers of msg assume a
string: classify_record calls msg.startswith to spot the [XPASS(strict)] prefix,
and build_results joins the messages of a test's records with "\n---\n". A
record carrying {"msg": 123} therefore raises AttributeError or TypeError out of
build_results and takes every other result with it, which is the opposite of the
contract read_raw_records advertises.
Both sites now coerce with str(), the same thing sanitize already does one line
later. A hand-edited, truncated or partially-written raw file costs the record
it damaged and nothing more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parse_nodeid puts a test whose node ID names no known environment into the "?" bucket, and build_results wrote it into results like any other row. The site has no column for it: Results.vue renders one column per entry of report["targets"], which read_targets never puts "?" into. Such a row reaches /results/ as a label above an entirely blank set of cells, with nothing on that page to explain the blanks — the Dashboard's "could not be attributed" note is not rendered there. Today the only test in that bucket is test_env.py::test_google_sheet_has_test_cases, which is not parametrized by target. It passes, so isInteresting filters it out and nobody has seen this. The day it fails it becomes an interesting, entirely blank row. The cell is now skipped when building results. It is still counted, and still reported: counts["?"] is what the report publishes as unattributed_counts, and the Dashboard already renders that. Only the row is dropped, not the whole result — a key with both attributed and unattributed records keeps the columns it does have. github_issues_ran moved above the skip so it still sees every record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
report.results[key].outcomes is a plain object parsed from JSON, so it inherits Object.prototype. matchesOutcome indexed it directly with the environment name from the URL, and ?env=constructor — or toString, or valueOf — found the inherited function: truthy, so the `if (!cell) return false` guard passed it through, but with cell.o undefined. With no outcome filter set the next line returns true unconditionally, so the environment filter matched everything rather than nothing, and the page looked unfiltered for no visible reason. Object.hasOwn now gates it, which is what ?test= has done since the same bug was found in the pinned-row lookup. Both parameters are read straight from the URL before the report has loaded, so neither can be checked against an allowlist of real values; hasOwn is the check that does not need one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shared ?test= link whose row the current filters or page would hide is pinned at index 0 of rows so the link shows something. kindHeading derives the group headings purely from adjacent rows, so it read that pinned row as the first of its kind's block: the heading printed above it, and then again a few rows later where the sorted block for that kind actually began. "Babel Validation Google Sheet" twice on one page reads as a rendering fault. The pinned row is now flagged, gets its own "Linked test" heading, and never suppresses the heading of the block below it — its kind is whatever the link happened to point at, which says nothing about where the sorted rows start. That also makes the extra row legible: the page renders pageSize + 1 rows by design, because the pinned row is outside the page window rather than part of it, and until now nothing on screen said so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The since-last-run panel coloured every delta badge by its sign alone: positive red, negative green. That is right for failed and xpassed and exactly backwards for passed, which is the third entry of COUNTS. A run where 200 fewer tests passed — an environment going down, collection breaking, the sheet failing to load — rendered a green -200, and 200 more tests passing rendered red. The polarity now travels with the change: each one carries `worse`, computed per count when the change is built, and the badge class reads that instead of the sign. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This has been two bugs in Results.vue now, six weeks apart and in the same file: ?test= pinned an Object.prototype member as a table row, and ?env= made the environment filter match every row rather than none. Both times the value was read from the URL and used as a key on a plain object parsed from report.json, where the inherited members are truthy and carry none of the expected fields. A third occurrence is likely — every filter on that page is a URL parameter, and readUrl runs before the report loads, so there is no set of valid values to check against. Object.hasOwn is the guard that does not need one, and it is now written down next to the rest of the report's untrusted-input rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_trimmed_float coerced with float(), which accepts "NaN", "Infinity" and
"-Infinity" — and a /status response is service output we do not control, so
those are reachable. Python's json then writes them as the bare tokens NaN and
Infinity: valid Python, and not JSON. JSON.parse rejects the whole file, so one
service reporting a NaN latency would blank every page of the dashboard rather
than one cell of one card.
They are now dropped like any other unusable value, which is what the rest of
trim_status does with input it cannot use. _trimmed_int gains OverflowError for
the same reason: int(float("inf")) raises it, and that was not caught either.
json.dump also gets allow_nan=False. It should now be unreachable, but writing a
report no browser can parse is worse than failing the run that produced it, and
the workflow already knows how to report a target whose run broke.
Copilot: src/babel_validation/tools/generate_report.py — non-finite floats
survive the conversion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
COUNTS held failed, xpassed and passed. error is the fourth outcome the dashboard treats as actionable — a setup or teardown failure, which is a defect in this suite rather than a finding about a deployment — and it was in neither the since-last-run diff nor the per-run badges. A run that gained three errors and moved nothing else reported "Nothing changed", which is the one sentence on that page that has to be true. history.jsonl already carries the full counts dict, so the existing published file needs no regeneration for this to work. Copilot: website/src/components/History.vue — error counts are omitted from the previous-run comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The since-last-run diff compared each target field by field, and skipped any target missing from either side. An environment arriving or leaving is the largest change a run can carry, and it was the one change the diff could not express: with nothing else moving, the page said "Nothing changed" on the run that added or dropped a whole environment. That is not hypothetical on this branch — test-redis was removed from targets.ini during it, and #123 is the same shape of bug in the ?sig= links. Copilot (suppressed): website/src/components/History.vue — a target added to or removed from targets.ini is silently skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bootstrap's `.disabled` on the `<li>` is styling. The `<button>` inside it stays in the tab order, exposes no disabled state to assistive technology, and fires its handler on Enter or Space — so a keyboard user on page 1 could activate "Previous", which set page to 0. currentPage was capped but not floored, so it followed to 0, the row slice ran from a negative index to zero, and the table went blank with the pagination still reading "Page 0 of 22". Both buttons now carry a real :disabled binding, and currentPage is floored at 1 so the state is unreachable rather than merely unreached. Copilot: website/src/components/Results.vue — the Bootstrap disabled class only styles the surrounding <li>. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cards led with "N failed" and then listed passed, xfailed and skipped. error appeared nowhere, so a target with 500 passing tests and one setup error read as "0 failed" and looked healthy — while the results table, which counts error among the interesting outcomes, showed the row. generate_report's `unreachable` flag catches only the all-error case, which is the one nobody would miss anyway. The line is rendered only when the count is nonzero: an error is rare, and "0 errored" repeated across six cards is noise that would train people to skip the line that matters. Copilot: website/src/components/Overview.vue — these cards omit counts.error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
added a commit
that referenced
this pull request
Sep 2, 2026
pytest_configure opens it and nothing ever closed it; it was left to interpreter shutdown. Nothing is lost today, because every record is flush()ed as it is written — which is exactly what makes this worth fixing rather than shrugging at. The flush is per test phase on a file that gets thousands of them, so it is the obvious thing for someone to remove when this run feels slow, and the day that happens the last records of a run vanish with no error and the dashboard quietly under-reports. Noticed reviewing #120; not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
added a commit
that referenced
this pull request
Sep 2, 2026
beforeUnmount disconnected the ResizeObserver but left the custom property it had been writing. That property lives on documentElement, which outlives the component: Results.vue unmounts the bar on the load-error path, and the stale height then holds the table header's sticky offset down a page that no longer has a bar above it. Narrow — the load-error retry is the only way to reach it — but the fix is one line, and a component that writes to a shared global should be the thing that cleans it up. Noticed reviewing #120; not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The GitHub Pages site was an earlier, weaker version of what is now Babel Explorer. This replaces it with a dashboard that runs the validation suite against every environment daily and publishes the result, organised around the question the data says actually matters: where in the promotion pipeline did this appear?
Closes #113. Supersedes #118 (left as a draft until this merges) and #117, which is closed: its four code files were byte-identical to these and its prose is a subset of what is here.
What's here
The old site is gone. The Prefix Comparator moved into Babel (NCATSTranslator/Babel#889) and Autocomplete into Babel Explorer (TranslatorSRI/babel-explorer#7), so both pages are deleted, along with
website-vue3-vite/— a never-deployed app that duplicated the Google Sheet test logic in the browser (44 files, ~10k lines).The suite emits machine-readable results.
pytest --report-jsonl(apytest_runtest_logreporthook intests/conftest.py) writes one JSON line per test phase — raw pytest facts only.src/babel_validation/tools/generate_report.pyturns those intoreport.jsonand appends one summary line tohistory.jsonl; all classification into passed/failed/xfailed/xpassed/skipped/error happens there, where it is unit-testable, rather than in the fixture that produced it. A raw file is treated as damageable: one unparseable or malformed record costs that record and nothing else. A result whose node ID names no environment is counted as unattributed and published as a count, never as a table row, because the site has no column to render it in.A daily workflow runs it at 06:30 UTC: the suite against every environment, then the report, the site build and a deploy to
gh-pages. Three things about that loop are worth knowing, because each was a bug first:read_targets(), not from a second copy in the YAML. When those disagreed, the extra target reached the site as a permanently unreachable column, sorted after prod, and as an always-empty position in every?sig=signature — which silently invalidates previously shared links.uv run pytestand uv exits 1 for its own failures too, before pytest starts.timeoutfiring (124) is annotated as a warning, because the environment hung and the report already says so; anything else — a collection error, a usage error, nothing collected — is an error annotation. Every broken target fails the job in a final step after the deploy, so the targets that did work still publish.generate_reportalready refused to publish when every target was unreachable; this is the partial case, which is the one that looks fine.cancel-in-progresswould let a push, or the next day's cron, kill a scheduled run mid-flight — and that run is what appends the day's line tohistory.jsonl.Three pages, not one. Dashboard (run banner, per-environment pipeline cards, promotion-drift panel, environment detail matrix), Results (a sticky filter bar over the test matrix, filterable by category, source and environment, with shareable URL state), and History (what changed since the previous run, then a row per run).
The design follows the data. Measured against the 2026-09-01 report: of 4,584 results, 528 are interesting, and 519 of those differ across environments — only 9 fail everywhere. Failures per environment in promotion order run exp 21 → dev 79 → ci 81 → ci-es 222 → test 80 → prod 327, and the top outcome patterns are
pass ×5 FAIL(148),pass pass pass FAIL pass FAIL(100) andxfail ×5 XPASS(67). Drift dominates the file, and it used to be hidden behind a checkbox labelled "interesting only". The redesign promotes it to structure.Bootstrap 5.3 with a small theme layer, not PrimeVue. A component library only reaches inside the Vue islands, the matrix is a pivot we would hand-write regardless, and the default view is ~530 rows, so virtual scroll buys nothing.
The report is untrusted input, and is treated as such. No
v-htmlanywhere; report values render only through{{ }}; links are built from generator-validated parts; blocklist rows withhold their detail in the expanded-row markup as well as the label; no Google Sheet ID or link appears anywhere in the output. A facet is as public as a cell, so the filter dropdowns exclude blocklist rows exactly as the table does. The URL parameters are treated the same way: they are read before the report loads, so there is nothing to check them against, and every one of them goes throughObject.hasOwnrather than indexing a JSON-parsed object directly — a rule now inCLAUDE.md, because it has been two bugs in the same file.The Google Sheet IDs are out of the repository, and have been rotated. They live in
BABEL_VALIDATION_SHEET_IDandBABEL_VALIDATION_BLOCKLIST_SHEET_ID(.envlocally, repository secrets in Actions), resolved throughresolve_sheet_id(). This matters more than it looks: the sheets are shared as "anyone with the link" because the CSV export is unauthenticated, so the ID is the credential — and the old one is checked intomainand sits in this repository's public history, wheregit rmcannot reach it. Both sheets have been restricted, both old IDs now return 401 credential-free on thegvizCSV export and on/pub?output=csv, and both secrets hold new IDs as of 2026-09-01 (#126). A newenv.defaultdocuments every variable and what each one turns off when missing, socp env.default .envis the whole setup, andCLAUDE.mdnow tells coding agents not to read.envback — everything an agent reads lands in a transcript that is stored, replayed and pasted into issues.What it produces
A run takes 19–36 minutes, almost all of it the six sequential per-target pytest invocations; the report, build and deploy together are about 30 seconds. The most recent run published 4,152 Google Sheet results, 313 blocklist results and 96 GitHub issue results across six environments.
Tests: 46 vitest specs across
results,history,driftPanel,statusMatrix,overviewandreportData, run in CI beside the Python unit tests, which are now 192.What it deliberately does not do
[ci-es]) first. The Redis-backednodenorm.test.transltr.iowas switched off on 2026-08-31, so there is no Redis NodeNorm left in Test to compare against; dev, exp and prod still run one.ponytail:note inHistory.vue.gh-pagesdeploy, so it is Run the dashboard's per-environment tests as a matrix job (26 min -> ~7) #122 rather than another commit here.DEPLOYMENT_ORDERstays hardcoded. Promotion order is a semantic fact about the pipeline that no config file states, so unlike the workflow's target list it cannot be derived fromtargets.ini. What it no longer does is fail quietly: an unrecognised environment used to sort last, which is indistinguishable from being the end of the pipeline, so the Dashboard now names it instead.Before merging
Nothing outstanding. All four items that were blocking are done: the temporary
push:trigger is removed, #117 is resolved, the sheet IDs and repository secrets are rotated, and the dashboard has been run once against the new secrets — run 33469165720, whose published report carries 4,152 sheet rows and 313 blocklist rows, which is the evidence that both new IDs resolved. A green tick alone would not have shown that: a nonzero pytest exit is normal here.One thing to watch on the first run after merge:
workflow_dispatchonly starts working oncedashboard.yamlis on the default branch, so the exit-code triage above has been verified by running the step's script underbash -eagainst stubbed exits, but has never executed on a runner. That applies to the empty-results check as well.Follow-on work
Filed, on the Babel Validation v1.0 milestone:
?sig=link silently matches nothing once the environment list changes, because the signature is positional.configfile: pytest.ini, old tool versions).history.jsonlfrom the published copy fetched over HTTP, so one failed or CDN-stale fetch publishes a one-line file, and the deploy replaces the branch rather than appending to it.History — the stages this was built in, two review rounds, and the sheet-ID rotation. Kept for anyone tracing why a particular line looks the way it does; every durable conclusion is already in a code comment, in
CLAUDE.mdor inwebsite/README.mdabove.Built in six stages on top of #118: 0 chrome (nav bar, cards, container, footer, dark mode, favicon), 1 extract
reportData.js(pure moves), 2 split the pages (Overview.vue+StatusMatrix.vue+Results.vue+FilterBar.vue,/results/added), 3 sticky filter bar, empty/loading/error states, category/source/environment filters, 4 promotion-drift panel with?sig=links into Results, 5 History (/trends/→/history/, plus a since-last-run diff).Two things the screenshots changed after the fact: odd-one-out shading now applies only to the status rows environments should agree on — record counts and latencies differ by nature, and shading them made every cell amber — and the environment cards became a six-column grid rather than a flex row, which wrapped four-and-two at 1440px and broke the left-to-right reading of the pipeline.
A code review then found seven issues, one commit each:
history.replaceStatewas passed a relative?query, which resolves against<base href="/babel-validation/">rather than the current path, so the first filter change on/results/silently moved the address bar to the Dashboard — which ignores every one of those parameters, so copied and reloaded links came back empty. No test can reproduce it, because vitest mounts at/with no<base>; that is now written down inwebsite/README.md.?page=9999was clamped for rendering but not for the URL, so Copy link shared a page that did not exist.fetchHistorymappedJSON.parseover every line at once, so one truncated line took the whole History page down. The existing test fed valid JSON that wasn't an object, which the filter after the map already handled — the guard with a test was the one that wasn't needed.Mapinsertion order, shading half a row amber to say nothing.scroll-padding-topsat onbody, where it has no effect; the viewport takes it from the root element.test-redis— a targetread_targets()returned but the workflow never ran. Removed when NodeNorm Redis in Test was switched off, and the underlying divergence fixed by deriving the loop fromread_targets().The page-clamp regression test initially passed against the unfixed code, because editing a filter resets the page to 1 and hid the bug; it now expands a row instead, which rewrites the URL without touching the page. All four regression tests were re-checked against the unfixed code afterwards.
mainwas merged in after #116 landed, bringing the per-target OpenAPI paths, the/statusbackend check and.python-version. That merge is what surfacedtest-redis.A later pass added
env.defaultand the.envrule, the unknown-environment alert, the dispatch-only cancellation,read_targetstests, and the exit-code triage; it also found thattest_milestones_page.pyon #112 had six tests that had never run in CI for want of aunitmarker, which is now a rule inCLAUDE.md. The sheet-ID rotation went through #126: the old IDs appeared to still work after the sheets were restricted, which turned out to be owner-session access rather than caching — a credential-freecurlreturned 401 immediately.The verification run was started by an empty commit, because
workflow_dispatchdoes not work until the workflow file is on the default branch, and the trigger that would otherwise have started one was about to be removed.A third review round found six more, again one commit each, all of them cases where something wrong looked like something working:
uv run pytestexits 1 for uv's own failures too, which would have gone green with the target rendered as merely unreachable.read_raw_recordspromised that one corrupt line costs one record, but a record with a non-stringmsgreachedstr.startswithand"\n---\n".join, taking the whole report down.?env=was indexed straight into the outcomes object, so a prototype key made the environment filter match every row rather than none — the same bug already fixed for?test=, in the same file.?test=row was read as the start of its kind's block, printing that kind's heading twice on one page.passedcount: a run where 200 fewer tests passed showed a green-200.A Copilot review then raised five threads and one suppressed comment, all six fixed, again one commit each. Two of them were the same omission twice:
errorwas missing from History's since-last-run diff and from the environment cards, so a target with passing tests and one setup error read as "0 failed" on the Dashboard and as "Nothing changed" on History. The others:_trimmed_floataccepted"NaN"and"Infinity"from a/statusresponse, which Python'sjsonwrites as bare tokens thatJSON.parserejects — one bad latency would have blanked every page rather than one cell; the pagination arrows carried Bootstrap's.disabledclass but nodisabledattribute, so activating Previous on page 1 set the page to 0 and blanked the table; and the since-last-run diff skipped any target missing from either run, so adding or removing an environment reported "Nothing changed".Each fix's regression test was checked against the unfixed code first. Three findings from that round were left alone as facts about the target layout rather than about this diff:
[ci]and[ci-es]share aNodeNormURL, so the odd-one-out tally counts that instance twice;_report_fileis never closed, which is harmless only because every line is flushed; and the?page=clamp still only runs on a user edit, so?page=9999can be copied out of the address bar untouched, which is narrower than the case the clamp was added for.🤖 Generated with Claude Code