max / makenotwork
| 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 fs; |
| 56 | use ; |
| 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 | |
| 117 | let root = new; |
| 118 | let mut files = Vecnew; |
| 119 | |
| 120 | for entry in read_dir.expect |
| 121 | let path = entry.expect.path; |
| 122 | let Some = path.file_name.and_then 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 == Some; |
| 131 | if name.ends_with || !is_js |
| 132 | continue; |
| 133 | |
| 134 | files.push; |
| 135 | |
| 136 | collect_ts; |
| 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 | |
| 145 | let Ok = read_dir else |
| 146 | return; |
| 147 | ; |
| 148 | for entry in entries |
| 149 | let path = entry.expect.path; |
| 150 | if path.is_dir |
| 151 | collect_ts; |
| 152 | continue; |
| 153 | |
| 154 | let Some = path.file_name.and_then else |
| 155 | continue; |
| 156 | ; |
| 157 | if name.ends_with || name.contains |
| 158 | continue; |
| 159 | |
| 160 | if matches! |
| 161 | out.push; |
| 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 | |
| 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 | new |
| 183 | .unwrap |
| 184 | .find_iter |
| 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 | |
| 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 = new.unwrap; |
| 206 | let binding = |
| 207 | new.unwrap; |
| 208 | |
| 209 | let mut total = direct.find_iter.count; |
| 210 | |
| 211 | for bound in binding.captures_iter |
| 212 | let alias = &bound; |
| 213 | let through = |
| 214 | new.unwrap; |
| 215 | total += through.find_iter.count; |
| 216 | |
| 217 | |
| 218 | total |
| 219 | |
| 220 | |
| 221 | |
| 222 | |
| 223 | let count: usize = sources |
| 224 | .iter |
| 225 | .map |
| 226 | let src = read_to_string.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 == Some; |
| 231 | count_globals |
| 232 | + if classic |
| 233 | count_script_globals |
| 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 | |
| 251 | |
| 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, |
| 257 | 1 |
| 258 | ; |
| 259 | assert_eq!; |
| 260 | |
| 261 | // Two shapes that are NOT the global scope, and must not be counted. |
| 262 | assert_eq! |
| 263 | count_script_globals, |
| 264 | 0, |
| 265 | "a declaration inside an IIFE is a function scope" |
| 266 | ; |
| 267 | assert_eq!; |
| 268 | |
| 269 | |
| 270 | |
| 271 | |
| 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!; |
| 277 | assert_eq!; |
| 278 | |
| 279 | |
| 280 | |
| 281 | |
| 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!; |
| 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!; |
| 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!; |
| 299 | |
| 300 |