Skip to main content

max / makenotwork

9.5 KB · 203 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 ///
40 /// 117 on 2026-08-19, ratcheted while describing the settings sub-nav
41 /// (`6b24f2df` step 4). Only one of those seven is that step's own -- the
42 /// `setSettingsSectionBtn` wrapper, deleted with `tab-user-settings.js`. The
43 /// other six had already gone and nobody lowered the seal behind them, which is
44 /// what a ratchet that only refuses to grow will always let happen. Measure
45 /// before trusting this number as a progress figure.
46 ///
47 /// 115 on 2026-08-19, describing the user dashboard's strip (`6b24f2df` step 5)
48 /// and with it deleting `frontend/src/core/tabs.ts`: `w.setActiveTab` and the
49 /// `onSetActiveTab` wrapper go, and so does `blogTabNav`, which clicked a
50 /// `tab-blog` button the project dashboard has never had. `reloadSyncKitTab`
51 /// arrives in the same pass and puts one back, so three left and one came.
52 ///
53 /// 114 on 2026-08-20, closing slack rather than making progress. The markdown
54 /// rich field (`5c358ec4`, MNW@69979a5e) took `window.switchEditorTab` out of
55 /// `static/partial-item-text-editor.js` and left the seal at 115, so the
56 /// warning three paragraphs up describes this entry too. Nothing was removed
57 /// today; the number was walked down to what the tree already counted.
58 ///
59 /// 113 on 2026-08-22, and this one is progress rather than slack: Shape 6 step
60 /// 1 (`17050ff5`) described the five Export CSV buttons through
61 /// `crate::quasi::export_act`, so `window.exportCsvButton` has no call site
62 /// left and is deleted. The wrapper that survived every earlier batch because
63 /// it was reference-counted by four unconverted screens went without waiting
64 /// for any of them, which is what the glue-module ruling (`27d5e5b8`) is for.
65 ///
66 /// 111 on 2026-08-22, and also progress: Shape 4 step 1 (`6fb46f7c`) deleted
67 /// `static/media-picker.js` whole, taking `window.mediaPickerOpen` and
68 /// `window._mediaPickerSelect` with it. That step waited eleven days on a
69 /// vocabulary gap -- nothing could say "put this chosen value into that other
70 /// field" -- and shipped the day `Act::fills` did.
71 ///
72 /// 106 on 2026-08-23, and this one cost no vocabulary at all. Shape 6's
73 /// re-triage under the glue-module ruling found that `partials/link_row.html`
74 /// and the loop in `tabs/user_profile.html` were the same control rendered
75 /// twice under two spellings, so `moveLinkUp`, `moveLinkDown`, `editLinkBtn`,
76 /// `saveLinkBtn` and `cancelLinkBtn` existed only because one template would
77 /// not include the other. The include is the whole fix; the row's surviving
78 /// verbs live in `actions-partials.js` and serve both paths.
79 const HIGH_WATER: usize = 106;
80
81 /// Every file whose global assignments count: the legacy scripts and the typed
82 /// modules that replaced them.
83 ///
84 /// `frontend/src` is walked rather than listed, because the whole point of the
85 /// restructure is that files move there, and a seal that has to be told about
86 /// each new one is a seal that silently stops covering the tree.
87 fn sources() -> Vec<PathBuf> {
88 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
89 let mut files = Vec::new();
90
91 for entry in fs::read_dir(root.join("static")).expect("read static/ dir") {
92 let path = entry.expect("dir entry").path();
93 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
94 continue;
95 };
96 // Vendored libraries are not our global surface, and a minified one
97 // aliases `window` internally dozens of times, so the alias rule below
98 // would read htmx's own bookkeeping as names this server put on the
99 // page. `htmx.min.js` did contribute one direct `window.htmx =` to the
100 // old count; that was noise the narrower regex happened to admit.
101 let is_js = path.extension().and_then(|e| e.to_str()) == Some("js");
102 if name.ends_with(".min.js") || !is_js {
103 continue;
104 }
105 files.push(path);
106 }
107 collect_ts(&root.join("frontend/src"), &mut files);
108
109 files
110 }
111
112 /// The typed tree, recursively. Type declarations and tests are skipped: a
113 /// `.d.ts` describes globals rather than creating them, and a test asserting on
114 /// one is not a page that ships one.
115 fn collect_ts(dir: &Path, out: &mut Vec<PathBuf>) {
116 let Ok(entries) = fs::read_dir(dir) else {
117 return;
118 };
119 for entry in entries {
120 let path = entry.expect("dir entry").path();
121 if path.is_dir() {
122 collect_ts(&path, out);
123 continue;
124 }
125 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
126 continue;
127 };
128 if name.ends_with(".d.ts") || name.contains(".test.") {
129 continue;
130 }
131 if matches!(path.extension().and_then(|e| e.to_str()), Some("ts" | "js")) {
132 out.push(path);
133 }
134 }
135 }
136
137 /// Count assignments that land a name on `window`, in one file.
138 ///
139 /// Two forms, because the code uses two. The direct `window.foo = …` is what
140 /// the legacy scripts write. The aliased form is what a typed module writes,
141 /// since assigning to `window.foo` in TypeScript needs the index signature the
142 /// alias provides, so the bridge binds `window` to a local first.
143 ///
144 /// The alias is followed only inside the file that binds it, and only when it
145 /// was bound to `window` itself, so a local named `w` holding anything else
146 /// contributes nothing.
147 fn count_globals(src: &str) -> usize {
148 // `=[^=]` matches an assignment `=` while excluding the first `=` of a
149 // comparison operator. The regex crate has no lookahead, so this is the
150 // portable form.
151 let direct = regex::Regex::new(r"window\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]").unwrap();
152 let binding =
153 regex::Regex::new(r"(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*window\b").unwrap();
154
155 let mut total = direct.find_iter(src).count();
156
157 for bound in binding.captures_iter(src) {
158 let alias = &bound[1];
159 let through =
160 regex::Regex::new(&format!(r"\b{alias}\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]")).unwrap();
161 total += through.find_iter(src).count();
162 }
163
164 total
165 }
166
167 #[test]
168 fn frontend_globals_do_not_grow() {
169 let count: usize = sources()
170 .iter()
171 .map(|path| count_globals(&fs::read_to_string(path).unwrap_or_default()))
172 .sum();
173
174 assert!(
175 count <= HIGH_WATER,
176 "global assignments across static/*.js and frontend/src rose to {count} \
177 (HIGH_WATER {HIGH_WATER}). New frontend code must be a typed ES module in \
178 frontend/src registered through the dispatcher, not a global. If you REMOVED \
179 globals, lower HIGH_WATER to {count}."
180 );
181 }
182
183 #[test]
184 fn the_seal_sees_a_global_assigned_through_an_alias() {
185 // The hole `7df1a7de` names, as a unit. Without this the count is a
186 // measure of which directory a file sits in rather than of how many names
187 // reach `window`, and a migration that moves a file and keeps its global
188 // reads as progress.
189 let bridged = "const w = window as unknown as Record<string, unknown>;\n\
190 w.escapeHtml = escapeHtml;\n\
191 w.showToast = showToast;\n";
192 assert_eq!(count_globals(bridged), 2);
193
194 // A local that is not `window` contributes nothing, which is what keeps the
195 // alias rule from counting every object property assignment in the tree.
196 let ordinary = "const w = document.body;\nw.className = 'x';\n";
197 assert_eq!(count_globals(ordinary), 0);
198
199 // And a comparison is not an assignment, in either form.
200 let compared = "if (window.foo === bar) {}\nconst w = window;\nif (w.foo == bar) {}\n";
201 assert_eq!(count_globals(compared), 0);
202 }
203