| 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 |
+ |
}
|