Skip to main content

max / makenotwork

14.0 KB · 300 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.
29 //!
30 //! # The third gap, and the number moved a second time
31 //!
32 //! Problem `82315c45`, 2026-08-26. The two rules above both match an
33 //! *assignment*, and a classic script does not need one: a bare top-level
34 //! `function foo() {}` becomes a property of the global object just by being
35 //! declared. Every `static/*.js` file is loaded by a plain `<script src>` --
36 //! all 61 tags in `templates/`, none carrying `type="module"` -- so all 55 of
37 //! those declarations were globals this test could not see.
38 //!
39 //! Found by deleting one. `static/tab-item-sales.js` declared exactly one
40 //! function, was called by name from `data-action`, and went whole in
41 //! `1ea96868`. The seal did not move, which is the same false-progress failure
42 //! `7df1a7de` was about, wearing a different hat.
43 //!
44 //! **The rule is per tree, and that is the point.** A top-level function in an
45 //! ES module under `frontend/src` is scoped to the module and is not a global,
46 //! so [`count_script_globals`] runs only over `static/*.js` while
47 //! [`count_globals`] runs over both. Applying it everywhere would have counted
48 //! the typed tree's own functions as the surface the typed tree exists to
49 //! remove.
50 //!
51 //! `HIGH_WATER` was restated a second time against this. Neither restatement
52 //! raised the ratchet: both corrected what it can see. It must not move for any
53 //! other reason.
54
55 use std::fs;
56 use std::path::{Path, PathBuf};
57
58 /// Ratchets down only, never up. 115 at the start of the restructure
59 /// (2026-07-07); 113 after Phase 1 retired `mnw.js`. Restated to 124 on
60 /// 2026-08-10, when the measurement was corrected in two directions at once:
61 /// the typed core module's 12 bridged globals became visible, and htmx's one
62 /// vendored `window.htmx =` stopped counting. 113 + 12 - 1 = 124. The surface
63 /// did not change that day; what this test can see did.
64 ///
65 /// 117 on 2026-08-19, ratcheted while describing the settings sub-nav
66 /// (`6b24f2df` step 4). Only one of those seven is that step's own -- the
67 /// `setSettingsSectionBtn` wrapper, deleted with `tab-user-settings.js`. The
68 /// other six had already gone and nobody lowered the seal behind them, which is
69 /// what a ratchet that only refuses to grow will always let happen. Measure
70 /// before trusting this number as a progress figure.
71 ///
72 /// 115 on 2026-08-19, describing the user dashboard's strip (`6b24f2df` step 5)
73 /// and with it deleting `frontend/src/core/tabs.ts`: `w.setActiveTab` and the
74 /// `onSetActiveTab` wrapper go, and so does `blogTabNav`, which clicked a
75 /// `tab-blog` button the project dashboard has never had. `reloadSyncKitTab`
76 /// arrives in the same pass and puts one back, so three left and one came.
77 ///
78 /// 114 on 2026-08-20, closing slack rather than making progress. The markdown
79 /// rich field (`5c358ec4`, MNW@69979a5e) took `window.switchEditorTab` out of
80 /// `static/partial-item-text-editor.js` and left the seal at 115, so the
81 /// warning three paragraphs up describes this entry too. Nothing was removed
82 /// today; the number was walked down to what the tree already counted.
83 ///
84 /// 113 on 2026-08-22, and this one is progress rather than slack: Shape 6 step
85 /// 1 (`17050ff5`) described the five Export CSV buttons through
86 /// `crate::quasi::export_act`, so `window.exportCsvButton` has no call site
87 /// left and is deleted. The wrapper that survived every earlier batch because
88 /// it was reference-counted by four unconverted screens went without waiting
89 /// for any of them, which is what the glue-module ruling (`27d5e5b8`) is for.
90 ///
91 /// 111 on 2026-08-22, and also progress: Shape 4 step 1 (`6fb46f7c`) deleted
92 /// `static/media-picker.js` whole, taking `window.mediaPickerOpen` and
93 /// `window._mediaPickerSelect` with it. That step waited eleven days on a
94 /// vocabulary gap -- nothing could say "put this chosen value into that other
95 /// field" -- and shipped the day `Act::fills` did.
96 ///
97 /// 106 on 2026-08-23, and this one cost no vocabulary at all. Shape 6's
98 /// re-triage under the glue-module ruling found that `partials/link_row.html`
99 /// and the loop in `tabs/user_profile.html` were the same control rendered
100 /// twice under two spellings, so `moveLinkUp`, `moveLinkDown`, `editLinkBtn`,
101 /// `saveLinkBtn` and `cancelLinkBtn` existed only because one template would
102 /// not include the other. The include is the whole fix; the row's surviving
103 /// verbs live in `actions-partials.js` and serve both paths.
104 /// 161 on 2026-08-26, the second restatement and not progress. Problem
105 /// `82315c45`: 55 bare top-level `function` declarations in `static/*.js` are
106 /// globals in a classic script and had never been counted. 106 + 55 = 161. The
107 /// surface did not change that day either; what this test can see did.
108 const HIGH_WATER: usize = 161;
109
110 /// Every file whose global assignments count: the legacy scripts and the typed
111 /// modules that replaced them.
112 ///
113 /// `frontend/src` is walked rather than listed, because the whole point of the
114 /// restructure is that files move there, and a seal that has to be told about
115 /// each new one is a seal that silently stops covering the tree.
116 fn sources() -> Vec<PathBuf> {
117 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
118 let mut files = Vec::new();
119
120 for entry in fs::read_dir(root.join("static")).expect("read static/ dir") {
121 let path = entry.expect("dir entry").path();
122 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
123 continue;
124 };
125 // Vendored libraries are not our global surface, and a minified one
126 // aliases `window` internally dozens of times, so the alias rule below
127 // would read htmx's own bookkeeping as names this server put on the
128 // page. `htmx.min.js` did contribute one direct `window.htmx =` to the
129 // old count; that was noise the narrower regex happened to admit.
130 let is_js = path.extension().and_then(|e| e.to_str()) == Some("js");
131 if name.ends_with(".min.js") || !is_js {
132 continue;
133 }
134 files.push(path);
135 }
136 collect_ts(&root.join("frontend/src"), &mut files);
137
138 files
139 }
140
141 /// The typed tree, recursively. Type declarations and tests are skipped: a
142 /// `.d.ts` describes globals rather than creating them, and a test asserting on
143 /// one is not a page that ships one.
144 fn collect_ts(dir: &Path, out: &mut Vec<PathBuf>) {
145 let Ok(entries) = fs::read_dir(dir) else {
146 return;
147 };
148 for entry in entries {
149 let path = entry.expect("dir entry").path();
150 if path.is_dir() {
151 collect_ts(&path, out);
152 continue;
153 }
154 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
155 continue;
156 };
157 if name.ends_with(".d.ts") || name.contains(".test.") {
158 continue;
159 }
160 if matches!(path.extension().and_then(|e| e.to_str()), Some("ts" | "js")) {
161 out.push(path);
162 }
163 }
164 }
165
166 /// Count bare top-level function declarations, in one CLASSIC-script file.
167 ///
168 /// `82315c45`. A `<script src>` with no `type="module"` shares the global
169 /// scope, so `function foo() {}` at the top level of one lands `foo` on the
170 /// global object without ever writing an assignment. Neither rule in
171 /// [`count_globals`] can see that, and 55 of this server's globals are that
172 /// shape.
173 ///
174 /// **Only for `static/*.js`.** The same declaration in an ES module under
175 /// `frontend/src` is scoped to the module and is not a global, so counting it
176 /// there would charge the typed tree for the surface it exists to remove.
177 fn count_script_globals(src: &str) -> usize {
178 // Column zero is the whole test for "top level", and it is enough because
179 // every one of the 55 is written that way. A declaration indented inside an
180 // IIFE is correctly not counted: that is a function scope, not the global
181 // object. A regex cannot know the difference and does not have to.
182 regex::Regex::new(r"(?m)^(?:async )?function [A-Za-z_][A-Za-z0-9_]*\s*\(")
183 .unwrap()
184 .find_iter(src)
185 .count()
186 }
187
188 /// Count assignments that land a name on `window`, in one file.
189 ///
190 /// Two forms, because the code uses two. The direct `window.foo = …` is what
191 /// the legacy scripts write. The aliased form is what a typed module writes,
192 /// since assigning to `window.foo` in TypeScript needs the index signature the
193 /// alias provides, so the bridge binds `window` to a local first.
194 ///
195 /// The alias is followed only inside the file that binds it, and only when it
196 /// was bound to `window` itself, so a local named `w` holding anything else
197 /// contributes nothing.
198 ///
199 /// Runs over both trees, unlike [`count_script_globals`]: an assignment to
200 /// `window` is a global wherever it is written.
201 fn count_globals(src: &str) -> usize {
202 // `=[^=]` matches an assignment `=` while excluding the first `=` of a
203 // comparison operator. The regex crate has no lookahead, so this is the
204 // portable form.
205 let direct = regex::Regex::new(r"window\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]").unwrap();
206 let binding =
207 regex::Regex::new(r"(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*window\b").unwrap();
208
209 let mut total = direct.find_iter(src).count();
210
211 for bound in binding.captures_iter(src) {
212 let alias = &bound[1];
213 let through =
214 regex::Regex::new(&format!(r"\b{alias}\.[A-Za-z_][A-Za-z0-9_]*\s*=[^=]")).unwrap();
215 total += through.find_iter(src).count();
216 }
217
218 total
219 }
220
221 #[test]
222 fn frontend_globals_do_not_grow() {
223 let count: usize = sources()
224 .iter()
225 .map(|path| {
226 let src = fs::read_to_string(path).unwrap_or_default();
227 // A bare top-level function is a global only in a classic script,
228 // which is what `static/` holds and `frontend/src` does not. See
229 // `count_script_globals`.
230 let classic = path.parent().and_then(Path::file_name) == Some("static".as_ref());
231 count_globals(&src)
232 + if classic {
233 count_script_globals(&src)
234 } else {
235 0
236 }
237 })
238 .sum();
239
240 assert!(
241 count <= HIGH_WATER,
242 "globals across static/*.js and frontend/src rose to {count} \
243 (HIGH_WATER {HIGH_WATER}). New frontend code must be a typed ES module in \
244 frontend/src registered through the dispatcher, not a global -- and note \
245 that a bare top-level `function` in a classic script is one too. If you \
246 REMOVED globals, lower HIGH_WATER to {count}."
247 );
248 }
249
250 #[test]
251 fn the_seal_sees_a_bare_top_level_function_in_a_classic_script() {
252 // `82315c45`. The shape that was invisible until 2026-08-26: no assignment
253 // anywhere, and `exportItemSalesCSV` was still a global the dispatcher
254 // called by name.
255 assert_eq!(
256 count_script_globals("function exportItemSalesCSV() {\n}\n"),
257 1
258 );
259 assert_eq!(count_script_globals("async function loadThing(id) {}"), 1);
260
261 // Two shapes that are NOT the global scope, and must not be counted.
262 assert_eq!(
263 count_script_globals("(function () {\n function inner() {}\n})();"),
264 0,
265 "a declaration inside an IIFE is a function scope"
266 );
267 assert_eq!(count_script_globals("const f = function named() {};"), 0);
268 }
269
270 #[test]
271 fn the_two_counters_do_not_double_count_one_name() {
272 // A file that both declares and assigns is two globals by two routes, and
273 // the seal should say two rather than collapsing them. The point is that
274 // neither counter reaches into the other's shape.
275 let src = "function draw() {}\nwindow.draw = draw;\n";
276 assert_eq!(count_script_globals(src), 1);
277 assert_eq!(count_globals(src), 1);
278 }
279
280 #[test]
281 fn the_seal_sees_a_global_assigned_through_an_alias() {
282 // The hole `7df1a7de` names, as a unit. Without this the count is a
283 // measure of which directory a file sits in rather than of how many names
284 // reach `window`, and a migration that moves a file and keeps its global
285 // reads as progress.
286 let bridged = "const w = window as unknown as Record<string, unknown>;\n\
287 w.escapeHtml = escapeHtml;\n\
288 w.showToast = showToast;\n";
289 assert_eq!(count_globals(bridged), 2);
290
291 // A local that is not `window` contributes nothing, which is what keeps the
292 // alias rule from counting every object property assignment in the tree.
293 let ordinary = "const w = document.body;\nw.className = 'x';\n";
294 assert_eq!(count_globals(ordinary), 0);
295
296 // And a comparison is not an assignment, in either form.
297 let compared = "if (window.foo === bar) {}\nconst w = window;\nif (w.foo == bar) {}\n";
298 assert_eq!(count_globals(compared), 0);
299 }
300