Skip to main content

max / makenotwork

Count the globals a migration actually removed Problem 7df1a7de, and the plan says fix it before S4 or every batch reports progress it did not make. The seal read static/*.js and matched the literal text `window.<ident> =`. The typed core module defeats both halves at once: it lives in frontend/src, and installLegacyBridge assigns through an alias, because assigning to window.foo in TypeScript needs the index signature the alias provides. So its 12 globals were invisible, and moving a file out of static/ while keeping its global lowered the seal without lowering the global surface. Both trees are read now, and an alias bound to window is followed inside the file that binds it. Vendored minified libraries come out: htmx aliases window dozens of times internally, which the alias rule would have read as names this server put on the page, and its one direct window.htmx was noise the narrower regex happened to admit. 113 + 12 - 1 = 124, restated once against the corrected measurement. That is not the same act as raising the ratchet, and it does not move again. Two clippy warnings fixed in passing, both pre-existing.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 23:51 UTC
Signed with PGP, not checked
Commit: 9021a9d0d592fab6dcd8863b5dacc4d7aece2ff6
Parent: 9cb5f44
7 files changed, +213 insertions, -52 deletions
M pom/Cargo.lock +16 -16
@@ -3601,18 +3601,22 @@
3601 3601 source = "registry+https://github.com/rust-lang/crates.io-index"
3602 3602 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
3603 3603
3604 - [[patch.unused]]
3605 - name = "synckit-client"
3606 - version = "0.8.0"
3607 -
3608 - [[patch.unused]]
3609 - name = "synckit-config"
3610 - version = "0.2.0"
3611 -
3612 3604 [[patch.unused]]
3613 3605 name = "docengine"
3614 3606 version = "0.4.0"
3615 3607
3608 + [[patch.unused]]
3609 + name = "kberg"
3610 + version = "0.1.0"
3611 +
3612 + [[patch.unused]]
3613 + name = "painhours"
3614 + version = "0.1.0"
3615 +
3616 + [[patch.unused]]
3617 + name = "tagtree"
3618 + version = "0.4.0"
3619 +
3616 3620 [[patch.unused]]
3617 3621 name = "quasi-http"
3618 3622 version = "0.1.0"
@@ -3630,13 +3634,9 @@
3630 3634 version = "0.1.0"
3631 3635
3632 3636 [[patch.unused]]
3633 - name = "kberg"
3634 - version = "0.1.0"
3637 + name = "synckit-client"
3638 + version = "0.8.0"
3635 3639
3636 3640 [[patch.unused]]
3637 - name = "painhours"
3638 - version = "0.1.0"
3639 -
3640 - [[patch.unused]]
3641 - name = "tagtree"
3642 - version = "0.4.0"
3641 + name = "synckit-config"
3642 + version = "0.2.0"
M sando/Cargo.lock +20 -4
@@ -2266,12 +2266,20 @@
2266 2266 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
2267 2267
2268 2268 [[patch.unused]]
2269 - name = "synckit-client"
2270 - version = "0.8.0"
2269 + name = "quasi-http"
2270 + version = "0.1.0"
2271 2271
2272 2272 [[patch.unused]]
2273 - name = "synckit-config"
2274 - version = "0.2.0"
2273 + name = "quasi-router"
2274 + version = "0.1.0"
2275 +
2276 + [[patch.unused]]
2277 + name = "quasi-tauri"
2278 + version = "0.1.0"
2279 +
2280 + [[patch.unused]]
2281 + name = "quasi-webview"
2282 + version = "0.1.0"
2275 2283
2276 2284 [[patch.unused]]
2277 2285 name = "kberg"
@@ -2288,3 +2296,11 @@
2288 2296 [[patch.unused]]
2289 2297 name = "docengine"
2290 2298 version = "0.4.0"
2299 +
2300 + [[patch.unused]]
2301 + name = "synckit-client"
2302 + version = "0.8.0"
2303 +
2304 + [[patch.unused]]
2305 + name = "synckit-config"
2306 + version = "0.2.0"
@@ -3300,6 +3300,30 @@
3300 3300 source = "registry+https://github.com/rust-lang/crates.io-index"
3301 3301 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
3302 3302
3303 + [[patch.unused]]
3304 + name = "synckit-client"
3305 + version = "0.8.0"
3306 +
3307 + [[patch.unused]]
3308 + name = "synckit-config"
3309 + version = "0.2.0"
3310 +
3311 + [[patch.unused]]
3312 + name = "quasi-http"
3313 + version = "0.1.0"
3314 +
3315 + [[patch.unused]]
3316 + name = "quasi-router"
3317 + version = "0.1.0"
3318 +
3319 + [[patch.unused]]
3320 + name = "quasi-tauri"
3321 + version = "0.1.0"
3322 +
3323 + [[patch.unused]]
3324 + name = "quasi-webview"
3325 + version = "0.1.0"
3326 +
3303 3327 [[patch.unused]]
3304 3328 name = "docengine"
3305 3329 version = "0.4.0"
@@ -3315,11 +3339,3 @@
3315 3339 [[patch.unused]]
3316 3340 name = "tagtree"
3317 3341 version = "0.4.0"
3318 -
3319 - [[patch.unused]]
3320 - name = "synckit-client"
3321 - version = "0.8.0"
3322 -
3323 - [[patch.unused]]
3324 - name = "synckit-config"
3325 - version = "0.2.0"
@@ -595,7 +595,7 @@
595 595 // adapter carries its own state, resolved per request, and takes none from
596 596 // axum. The Askama route for a described screen is not registered (see
597 597 // `dashboard_routes`), so nothing here overlaps.
598 - let app = match quasi::router(&state) {
598 + let app = match quasi::router(state) {
599 599 Some(described) => app.nest_service("/dashboard/tabs/ssh-keys", described),
600 600 None => app,
601 601 };
@@ -4,46 +4,159 @@
4 4 //! globals (the pre-module `data-action` dispatcher pattern). The frontend
5 5 //! restructure (`_private/docs/mnw/frontend/`) migrates these into typed ES
6 6 //! modules under `frontend/src`. This seal keeps the count monotonically
7 - //! non-increasing: adding a new `window.*` global fails the build, and every
8 - //! migrated file must lower `HIGH_WATER`. Same idea as the migration
9 - //! HIGH_WATER seal.
7 + //! non-increasing: adding a new global fails the build, and every migrated file
8 + //! must lower `HIGH_WATER`. Same idea as the migration HIGH_WATER seal.
10 9 //!
11 10 //! When you remove globals, lower `HIGH_WATER` to the new count (the failure
12 11 //! message reports it). Never raise it.
12 + //!
13 + //! # What this used to miss, and why the number moved once
14 + //!
15 + //! Problem `7df1a7de`. Until 2026-08-10 this read `static/*.js` and nothing
16 + //! else, and matched the literal text `window.<ident> =`. The typed core module
17 + //! defeats both halves at once: it lives in `frontend/src`, and
18 + //! `installLegacyBridge` assigns through an alias
19 + //! (`const w = window as unknown as Record<string, unknown>; w.escapeHtml = …`).
20 + //! So its globals were invisible, and **moving a file out of `static/` while
21 + //! keeping its global lowered the seal without lowering the global surface**.
22 + //! A conversion batch could report progress it did not make, which is the one
23 + //! failure mode a ratchet exists to prevent.
24 + //!
25 + //! Both halves are fixed here: both trees are read, and an alias bound to
26 + //! `window` in a file is followed within that file. `HIGH_WATER` was restated
27 + //! once against the corrected measurement, which is not the same act as raising
28 + //! it. It has not moved since and must not.
13 29
14 30 use std::fs;
31 + use std::path::{Path, PathBuf};
15 32
16 33 /// Ratchets down only, never up. 115 at the start of the restructure
17 - /// (2026-07-07); 113 after Phase 1 retired `mnw.js` (its 2 `window.*` globals
18 - /// moved into the typed core module's bridge).
19 - const HIGH_WATER: usize = 113;
34 + /// (2026-07-07); 113 after Phase 1 retired `mnw.js`. Restated to 124 on
35 + /// 2026-08-10, when the measurement was corrected in two directions at once:
36 + /// the typed core module's 12 bridged globals became visible, and htmx's one
37 + /// vendored `window.htmx =` stopped counting. 113 + 12 - 1 = 124. The surface
38 + /// did not change that day; what this test can see did.
39 + const HIGH_WATER: usize = 124;
20 40
21 - /// Count `window.<ident> =` assignments (not `==`/`===` comparisons) across the
22 - /// legacy `static/*.js` files.
23 - fn count_window_globals() -> usize {
41 + /// Every file whose global assignments count: the legacy scripts and the typed
42 + /// modules that replaced them.
43 + ///
44 + /// `frontend/src` is walked rather than listed, because the whole point of the
45 + /// restructure is that files move there, and a seal that has to be told about
46 + /// each new one is a seal that silently stops covering the tree.
47 + fn sources() -> Vec<PathBuf> {
48 + let root = Path::new(env!("CARGO_MANIFEST_DIR"));
49 + let mut files = Vec::new();
50 +
51 + for entry in fs::read_dir(root.join("static")).expect("read static/ dir") {
52 + let path = entry.expect("dir entry").path();
53 + let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
54 + continue;
55 + };
56 + // Vendored libraries are not our global surface, and a minified one
57 + // aliases `window` internally dozens of times, so the alias rule below
58 + // would read htmx's own bookkeeping as names this server put on the
59 + // page. `htmx.min.js` did contribute one direct `window.htmx =` to the
60 + // old count; that was noise the narrower regex happened to admit.
61 + let is_js = path.extension().and_then(|e| e.to_str()) == Some("js");
62 + if name.ends_with(".min.js") || !is_js {
63 + continue;
64 + }
65 + files.push(path);
66 + }
67 + collect_ts(&root.join("frontend/src"), &mut files);
68 +
69 + files
70 + }
71 +
72 + /// The typed tree, recursively. Type declarations and tests are skipped: a
73 + /// `.d.ts` describes globals rather than creating them, and a test asserting on
74 + /// one is not a page that ships one.
75 + fn collect_ts(dir: &Path, out: &mut Vec<PathBuf>) {
76 + let Ok(entries) = fs::read_dir(dir) else {
77 + return;
78 + };
79 + for entry in entries {
80 + let path = entry.expect("dir entry").path();
81 + if path.is_dir() {
82 + collect_ts(&path, out);
83 + continue;
84 + }
85 + let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
86 + continue;
87 + };
88 + if name.ends_with(".d.ts") || name.contains(".test.") {
89 + continue;
90 + }
91 + if matches!(path.extension().and_then(|e| e.to_str()), Some("ts" | "js")) {
92 + out.push(path);
93 + }
94 + }
95 + }
96 +
97 + /// Count assignments that land a name on `window`, in one file.
98 + ///
99 + /// Two forms, because the code uses two. The direct `window.foo = …` is what
100 + /// the legacy scripts write. The aliased form is what a typed module writes,
101 + /// since assigning to `window.foo` in TypeScript needs the index signature the
102 + /// alias provides, so the bridge binds `window` to a local first.
103 + ///
104 + /// The alias is followed only inside the file that binds it, and only when it
105 + /// was bound to `window` itself, so a local named `w` holding anything else
106 + /// contributes nothing.
107 + fn count_globals(src: &str) -> usize {
24 108 // `=[^=]` matches an assignment `=` while excluding the first `=` of a
25 109 // comparison operator. The regex crate has no lookahead, so this is the
26 110 // portable form.
27 - let re = regex::Regex::new(r"window\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]").unwrap();
28 - let static_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/static");
29 - let mut total = 0;
30 - for entry in fs::read_dir(static_dir).expect("read static/ dir") {
31 - let path = entry.expect("dir entry").path();
32 - if path.extension().and_then(|e| e.to_str()) == Some("js") {
33 - let src = fs::read_to_string(&path).unwrap_or_default();
34 - total += re.find_iter(&src).count();
35 - }
111 + let direct = regex::Regex::new(r"window\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]").unwrap();
112 + let binding =
113 + regex::Regex::new(r"(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*window\b").unwrap();
114 +
115 + let mut total = direct.find_iter(src).count();
116 +
117 + for bound in binding.captures_iter(src) {
118 + let alias = &bound[1];
119 + let through =
120 + regex::Regex::new(&format!(r"\b{alias}\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]")).unwrap();
121 + total += through.find_iter(src).count();
36 122 }
123 +
37 124 total
38 125 }
39 126
40 127 #[test]
41 128 fn frontend_globals_do_not_grow() {
42 - let count = count_window_globals();
129 + let count: usize = sources()
130 + .iter()
131 + .map(|path| count_globals(&fs::read_to_string(path).unwrap_or_default()))
132 + .sum();
133 +
43 134 assert!(
44 135 count <= HIGH_WATER,
45 - "window.* global assignments in static/*.js rose to {count} (HIGH_WATER {HIGH_WATER}). \
46 - New frontend code must be a typed ES module in frontend/src registered through the \
47 - dispatcher, not a `window.*` global. If you REMOVED globals, lower HIGH_WATER to {count}."
136 + "global assignments across static/*.js and frontend/src rose to {count} \
137 + (HIGH_WATER {HIGH_WATER}). New frontend code must be a typed ES module in \
138 + frontend/src registered through the dispatcher, not a global. If you REMOVED \
139 + globals, lower HIGH_WATER to {count}."
48 140 );
49 141 }
142 +
143 + #[test]
144 + fn the_seal_sees_a_global_assigned_through_an_alias() {
145 + // The hole `7df1a7de` names, as a unit. Without this the count is a
146 + // measure of which directory a file sits in rather than of how many names
147 + // reach `window`, and a migration that moves a file and keeps its global
148 + // reads as progress.
149 + let bridged = "const w = window as unknown as Record<string, unknown>;\n\
150 + w.escapeHtml = escapeHtml;\n\
151 + w.showToast = showToast;\n";
152 + assert_eq!(count_globals(bridged), 2);
153 +
154 + // A local that is not `window` contributes nothing, which is what keeps the
155 + // alias rule from counting every object property assignment in the tree.
156 + let ordinary = "const w = document.body;\nw.className = 'x';\n";
157 + assert_eq!(count_globals(ordinary), 0);
158 +
159 + // And a comparison is not an assignment, in either form.
160 + let compared = "if (window.foo === bar) {}\nconst w = window;\nif (w.foo == bar) {}\n";
161 + assert_eq!(count_globals(compared), 0);
162 + }
@@ -1978,6 +1978,22 @@
1978 1978 name = "synckit-config"
1979 1979 version = "0.2.0"
1980 1980
1981 + [[patch.unused]]
1982 + name = "quasi-http"
1983 + version = "0.1.0"
1984 +
1985 + [[patch.unused]]
1986 + name = "quasi-router"
1987 + version = "0.1.0"
1988 +
1989 + [[patch.unused]]
1990 + name = "quasi-tauri"
1991 + version = "0.1.0"
1992 +
1993 + [[patch.unused]]
1994 + name = "quasi-webview"
1995 + version = "0.1.0"
1996 +
1981 1997 [[patch.unused]]
1982 1998 name = "docengine"
1983 1999 version = "0.4.0"
@@ -117,7 +117,7 @@
117 117
118 118 Ok(Response::fragment(
119 119 REGION,
120 - pane(&viewer.user.username.to_string(), &keys, &tokens, &themes),
120 + pane(viewer.user.username.as_ref(), &keys, &tokens, &themes),
121 121 ))
122 122 }
123 123