Skip to main content

max / makenotwork

7.0 KB · 163 lines History Blame Raw
1 //! Forward fence, the frontend-globals ratchet.
2 //!
3 //! The legacy `static/*.js` files register behavior as `window.<name> = fn`
4 //! globals (the pre-module `data-action` dispatcher pattern). The frontend
5 //! restructure (`_private/docs/mnw/frontend/`) migrates these into typed ES
6 //! modules under `frontend/src`. This seal keeps the count monotonically
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.
9 //!
10 //! When you remove globals, lower `HIGH_WATER` to the new count (the failure
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.
29
30 use std::fs;
31 use std::path::{Path, PathBuf};
32
33 /// Ratchets down only, never up. 115 at the start of the restructure
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;
40
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 {
108 // `=[^=]` matches an assignment `=` while excluding the first `=` of a
109 // comparison operator. The regex crate has no lookahead, so this is the
110 // portable form.
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();
122 }
123
124 total
125 }
126
127 #[test]
128 fn frontend_globals_do_not_grow() {
129 let count: usize = sources()
130 .iter()
131 .map(|path| count_globals(&fs::read_to_string(path).unwrap_or_default()))
132 .sum();
133
134 assert!(
135 count <= HIGH_WATER,
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}."
140 );
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 }
163