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. It has not moved since and must not. |
| 29 | |
| 30 | use fs; |
| 31 | use ; |
| 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 | |
| 88 | let root = new; |
| 89 | let mut files = Vecnew; |
| 90 | |
| 91 | for entry in read_dir.expect |
| 92 | let path = entry.expect.path; |
| 93 | let Some = path.file_name.and_then 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 == Some; |
| 102 | if name.ends_with || !is_js |
| 103 | continue; |
| 104 | |
| 105 | files.push; |
| 106 | |
| 107 | collect_ts; |
| 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 | |
| 116 | let Ok = read_dir else |
| 117 | return; |
| 118 | ; |
| 119 | for entry in entries |
| 120 | let path = entry.expect.path; |
| 121 | if path.is_dir |
| 122 | collect_ts; |
| 123 | continue; |
| 124 | |
| 125 | let Some = path.file_name.and_then else |
| 126 | continue; |
| 127 | ; |
| 128 | if name.ends_with || name.contains |
| 129 | continue; |
| 130 | |
| 131 | if matches! |
| 132 | out.push; |
| 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 | |
| 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 = new.unwrap; |
| 152 | let binding = |
| 153 | new.unwrap; |
| 154 | |
| 155 | let mut total = direct.find_iter.count; |
| 156 | |
| 157 | for bound in binding.captures_iter |
| 158 | let alias = &bound; |
| 159 | let through = |
| 160 | new.unwrap; |
| 161 | total += through.find_iter.count; |
| 162 | |
| 163 | |
| 164 | total |
| 165 | |
| 166 | |
| 167 | |
| 168 | |
| 169 | let count: usize = sources |
| 170 | .iter |
| 171 | .map |
| 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 | |
| 184 | |
| 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!; |
| 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!; |
| 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!; |
| 202 | |
| 203 |