//! Forward fence, the frontend-globals ratchet. //! //! The legacy `static/*.js` files register behavior as `window. = fn` //! globals (the pre-module `data-action` dispatcher pattern). The frontend //! restructure (`_private/docs/mnw/frontend/`) migrates these into typed ES //! modules under `frontend/src`. This seal keeps the count monotonically //! non-increasing: adding a new global fails the build, and every migrated file //! must lower `HIGH_WATER`. Same idea as the migration HIGH_WATER seal. //! //! When you remove globals, lower `HIGH_WATER` to the new count (the failure //! message reports it). Never raise it. //! //! # What this used to miss, and why the number moved once //! //! Problem `7df1a7de`. Until 2026-08-10 this read `static/*.js` and nothing //! else, and matched the literal text `window. =`. The typed core module //! defeats both halves at once: it lives in `frontend/src`, and //! `installLegacyBridge` assigns through an alias //! (`const w = window as unknown as Record; w.escapeHtml = …`). //! So its globals were invisible, and **moving a file out of `static/` while //! keeping its global lowered the seal without lowering the global surface**. //! A conversion batch could report progress it did not make, which is the one //! failure mode a ratchet exists to prevent. //! //! Both halves are fixed here: both trees are read, and an alias bound to //! `window` in a file is followed within that file. `HIGH_WATER` was restated //! once against the corrected measurement, which is not the same act as raising //! it. It has not moved since and must not. use std::fs; use std::path::{Path, PathBuf}; /// Ratchets down only, never up. 115 at the start of the restructure /// (2026-07-07); 113 after Phase 1 retired `mnw.js`. Restated to 124 on /// 2026-08-10, when the measurement was corrected in two directions at once: /// the typed core module's 12 bridged globals became visible, and htmx's one /// vendored `window.htmx =` stopped counting. 113 + 12 - 1 = 124. The surface /// did not change that day; what this test can see did. /// /// 117 on 2026-08-19, ratcheted while describing the settings sub-nav /// (`6b24f2df` step 4). Only one of those seven is that step's own -- the /// `setSettingsSectionBtn` wrapper, deleted with `tab-user-settings.js`. The /// other six had already gone and nobody lowered the seal behind them, which is /// what a ratchet that only refuses to grow will always let happen. Measure /// before trusting this number as a progress figure. /// /// 115 on 2026-08-19, describing the user dashboard's strip (`6b24f2df` step 5) /// and with it deleting `frontend/src/core/tabs.ts`: `w.setActiveTab` and the /// `onSetActiveTab` wrapper go, and so does `blogTabNav`, which clicked a /// `tab-blog` button the project dashboard has never had. `reloadSyncKitTab` /// arrives in the same pass and puts one back, so three left and one came. /// /// 114 on 2026-08-20, closing slack rather than making progress. The markdown /// rich field (`5c358ec4`, MNW@69979a5e) took `window.switchEditorTab` out of /// `static/partial-item-text-editor.js` and left the seal at 115, so the /// warning three paragraphs up describes this entry too. Nothing was removed /// today; the number was walked down to what the tree already counted. /// /// 113 on 2026-08-22, and this one is progress rather than slack: Shape 6 step /// 1 (`17050ff5`) described the five Export CSV buttons through /// `crate::quasi::export_act`, so `window.exportCsvButton` has no call site /// left and is deleted. The wrapper that survived every earlier batch because /// it was reference-counted by four unconverted screens went without waiting /// for any of them, which is what the glue-module ruling (`27d5e5b8`) is for. /// /// 111 on 2026-08-22, and also progress: Shape 4 step 1 (`6fb46f7c`) deleted /// `static/media-picker.js` whole, taking `window.mediaPickerOpen` and /// `window._mediaPickerSelect` with it. That step waited eleven days on a /// vocabulary gap -- nothing could say "put this chosen value into that other /// field" -- and shipped the day `Act::fills` did. /// /// 106 on 2026-08-23, and this one cost no vocabulary at all. Shape 6's /// re-triage under the glue-module ruling found that `partials/link_row.html` /// and the loop in `tabs/user_profile.html` were the same control rendered /// twice under two spellings, so `moveLinkUp`, `moveLinkDown`, `editLinkBtn`, /// `saveLinkBtn` and `cancelLinkBtn` existed only because one template would /// not include the other. The include is the whole fix; the row's surviving /// verbs live in `actions-partials.js` and serve both paths. const HIGH_WATER: usize = 106; /// Every file whose global assignments count: the legacy scripts and the typed /// modules that replaced them. /// /// `frontend/src` is walked rather than listed, because the whole point of the /// restructure is that files move there, and a seal that has to be told about /// each new one is a seal that silently stops covering the tree. fn sources() -> Vec { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let mut files = Vec::new(); for entry in fs::read_dir(root.join("static")).expect("read static/ dir") { let path = entry.expect("dir entry").path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; // Vendored libraries are not our global surface, and a minified one // aliases `window` internally dozens of times, so the alias rule below // would read htmx's own bookkeeping as names this server put on the // page. `htmx.min.js` did contribute one direct `window.htmx =` to the // old count; that was noise the narrower regex happened to admit. let is_js = path.extension().and_then(|e| e.to_str()) == Some("js"); if name.ends_with(".min.js") || !is_js { continue; } files.push(path); } collect_ts(&root.join("frontend/src"), &mut files); files } /// The typed tree, recursively. Type declarations and tests are skipped: a /// `.d.ts` describes globals rather than creating them, and a test asserting on /// one is not a page that ships one. fn collect_ts(dir: &Path, out: &mut Vec) { let Ok(entries) = fs::read_dir(dir) else { return; }; for entry in entries { let path = entry.expect("dir entry").path(); if path.is_dir() { collect_ts(&path, out); continue; } let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; if name.ends_with(".d.ts") || name.contains(".test.") { continue; } if matches!(path.extension().and_then(|e| e.to_str()), Some("ts" | "js")) { out.push(path); } } } /// Count assignments that land a name on `window`, in one file. /// /// Two forms, because the code uses two. The direct `window.foo = …` is what /// the legacy scripts write. The aliased form is what a typed module writes, /// since assigning to `window.foo` in TypeScript needs the index signature the /// alias provides, so the bridge binds `window` to a local first. /// /// The alias is followed only inside the file that binds it, and only when it /// was bound to `window` itself, so a local named `w` holding anything else /// contributes nothing. fn count_globals(src: &str) -> usize { // `=[^=]` matches an assignment `=` while excluding the first `=` of a // comparison operator. The regex crate has no lookahead, so this is the // portable form. let direct = regex::Regex::new(r"window\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]").unwrap(); let binding = regex::Regex::new(r"(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*window\b").unwrap(); let mut total = direct.find_iter(src).count(); for bound in binding.captures_iter(src) { let alias = &bound[1]; let through = regex::Regex::new(&format!(r"\b{alias}\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]")).unwrap(); total += through.find_iter(src).count(); } total } #[test] fn frontend_globals_do_not_grow() { let count: usize = sources() .iter() .map(|path| count_globals(&fs::read_to_string(path).unwrap_or_default())) .sum(); assert!( count <= HIGH_WATER, "global assignments across static/*.js and frontend/src rose to {count} \ (HIGH_WATER {HIGH_WATER}). New frontend code must be a typed ES module in \ frontend/src registered through the dispatcher, not a global. If you REMOVED \ globals, lower HIGH_WATER to {count}." ); } #[test] fn the_seal_sees_a_global_assigned_through_an_alias() { // The hole `7df1a7de` names, as a unit. Without this the count is a // measure of which directory a file sits in rather than of how many names // reach `window`, and a migration that moves a file and keeps its global // reads as progress. let bridged = "const w = window as unknown as Record;\n\ w.escapeHtml = escapeHtml;\n\ w.showToast = showToast;\n"; assert_eq!(count_globals(bridged), 2); // A local that is not `window` contributes nothing, which is what keeps the // alias rule from counting every object property assignment in the tree. let ordinary = "const w = document.body;\nw.className = 'x';\n"; assert_eq!(count_globals(ordinary), 0); // And a comparison is not an assignment, in either form. let compared = "if (window.foo === bar) {}\nconst w = window;\nif (w.foo == bar) {}\n"; assert_eq!(count_globals(compared), 0); }