|
| 1 | +// The seam between the ranker's two halves, asserted. |
| 2 | +// |
| 3 | +// vendor/search-ranker/ is one submodule and two files: ranker.js is a portable scoring engine |
| 4 | +// with no DOM in it, and search.js is imqueue's browser UI, which reads the engine off |
| 5 | +// `window.SearchRanker`. The site serves them concatenated (scripts/lib/asset-manifest.js). |
| 6 | +// |
| 7 | +// WHY THIS FILE EXISTS. Before the split, a name used by the dialog and defined by the scorer was |
| 8 | +// one closure away and could not be wrong. Now it crosses a published object, and every way of |
| 9 | +// getting that wrong fails at RUNTIME IN A BROWSER, on the third keystroke, with the rest of the |
| 10 | +// site working perfectly: |
| 11 | +// |
| 12 | +// * the engine stops exporting a name the UI reads -> `undefined is not a function` |
| 13 | +// * the UI reads a name the engine never had -> the same, and it never had it |
| 14 | +// * a `document` reference lands in the engine -> @imqueue/mcp throws on require |
| 15 | +// * the UI references a name nothing declares or imports -> ReferenceError |
| 16 | +// |
| 17 | +// None of that is visible to check-search-ranking.js, which requires the engine alone and never |
| 18 | +// evaluates a line of the UI, nor to check-search-ui.js, which greps the UI as text. Both would |
| 19 | +// pass a build whose search box is dead. |
| 20 | +// |
| 21 | +// Read as TEXT rather than evaluated, for the reason check-search-ui.js gives: the interesting |
| 22 | +// declarations never leave the IIFE, so there is nothing to introspect. That makes the analysis |
| 23 | +// below approximate by construction — it is a tokenizer, not a parser — so every rule here is |
| 24 | +// written to fail only on something that is genuinely wrong, and the allowlist absorbs the rest. |
| 25 | + |
| 26 | +'use strict'; |
| 27 | + |
| 28 | +const fs = require('node:fs'); |
| 29 | + |
| 30 | +const { ENGINE_FILE, ENGINE_REL, UI_FILE, UI_REL, MISSING, exists } = require('./lib/ranker.js'); |
| 31 | + |
| 32 | +let failures = 0; |
| 33 | + |
| 34 | +const pass = (message) => console.log(` ok ${message}`); |
| 35 | + |
| 36 | +const fail = (message) => { |
| 37 | + console.error(` FAIL ${message}`); |
| 38 | + failures++; |
| 39 | +}; |
| 40 | + |
| 41 | +if (!exists()) { |
| 42 | + console.error(MISSING); |
| 43 | + process.exit(1); |
| 44 | +} |
| 45 | + |
| 46 | +const engineSrc = fs.readFileSync(ENGINE_FILE, 'utf8'); |
| 47 | +const uiSrc = fs.readFileSync(UI_FILE, 'utf8'); |
| 48 | + |
| 49 | +/** |
| 50 | + * Source with comments and string bodies blanked, so a name in prose is not a reference. |
| 51 | + * |
| 52 | + * The order is load-bearing and cost a debugging session when it was wrong: block comments first |
| 53 | + * because only they span lines, then line comments, and strings last — a `//` comment full of |
| 54 | + * prose apostrophes otherwise reaches the string pass, where its quote pairs with one hundreds of |
| 55 | + * lines later and blanks every declaration in between. The classes exclude newlines for the same |
| 56 | + * reason. |
| 57 | + */ |
| 58 | +function code(source) { |
| 59 | + return source |
| 60 | + .replace(/\/\*[\s\S]*?\*\//g, ' ') |
| 61 | + // Regex literals, which hold identifier-shaped text that is not an identifier: |
| 62 | + // /^(?:INPUT|TEXTAREA|SELECT)$/ reported three undefined names. Only in a position where a |
| 63 | + // regex can start, and only when no space follows the slash, so `length / 3` stays division. |
| 64 | + // |
| 65 | + // BEFORE the line-comment pass, and that order is not cosmetic. `/^https?:\/\//` ends in the |
| 66 | + // two characters `//`, so a line-comment pass that runs first treats the rest of the line as |
| 67 | + // a comment and deletes it — which is how `https` came to be reported as an undefined name, |
| 68 | + // and it silently removed real code from the analysis on every line with a URL regex in it. |
| 69 | + .replace(/([(,=:[!&|?+\-*%\s]|^)\/(?![*/\s])(?:[^/\\\n[]|\\.|\[[^\]\n]*\])+\/[gimsuy]*/g, '$1 0 ') |
| 70 | + .replace(/"(?:[^"\\\n]|\\.)*"/g, '""') |
| 71 | + .replace(/'(?:[^'\\\n]|\\.)*'/g, "''") |
| 72 | + .replace(/\/\/[^\n]*/g, '') |
| 73 | + // Object-literal KEYS, which are not references to anything: `{ credentials: "omit" }` and |
| 74 | + // GA4's `{ search_term: …, result_url: … }` accounted for eight of the first run's fifteen |
| 75 | + // false positives. Anchored to `{`, `,` or a line start so that a ternary's `? a : b` — where |
| 76 | + // `a` is a real reference that also happens to precede a colon — is left alone. |
| 77 | + .replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1_key$3') |
| 78 | + .replace(/(\n\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1_key$3'); |
| 79 | +} |
| 80 | + |
| 81 | +/** Identifiers referenced, ignoring property access: `q.terms` is not a use of `terms`. */ |
| 82 | +function referenced(source) { |
| 83 | + const out = new Set(); |
| 84 | + |
| 85 | + for (const m of code(source).matchAll(/(^|[^.\w$])([A-Za-z_$][\w$]*)\b/g)) out.add(m[2]); |
| 86 | + |
| 87 | + return out; |
| 88 | +} |
| 89 | + |
| 90 | +/** Every name bound anywhere in a file: declarations, locals, parameters, catch bindings. */ |
| 91 | +function bound(source) { |
| 92 | + const text = code(source); |
| 93 | + const out = new Set(); |
| 94 | + |
| 95 | + for (const m of text.matchAll(/\b(?:var|let|const|function)\s+([A-Za-z_$][\w$]*)/g)) out.add(m[1]); |
| 96 | + for (const m of text.matchAll(/\bfunction\s*[A-Za-z_$\w]*\s*\(([^)]*)\)/g)) { |
| 97 | + for (const p of m[1].split(',')) if (p.trim()) out.add(p.trim()); |
| 98 | + } |
| 99 | + for (const m of text.matchAll(/\bcatch\s*\(\s*([A-Za-z_$][\w$]*)/g)) out.add(m[1]); |
| 100 | + |
| 101 | + return out; |
| 102 | +} |
| 103 | + |
| 104 | +// ---- the engine's export surface -------------------------------------------- |
| 105 | + |
| 106 | +// `var API = { name: name, ... }` — matched rather than evaluated, because requiring the engine |
| 107 | +// here would prove only that Node's branch works and this check is about the browser's. |
| 108 | +const apiBlock = /var API = \{([\s\S]*?)\n {2}\};/.exec(engineSrc); |
| 109 | + |
| 110 | +if (!apiBlock) { |
| 111 | + fail(`${ENGINE_REL}: no \`var API = {...}\` block — nothing is exported to either environment`); |
| 112 | + process.exit(1); |
| 113 | +} |
| 114 | + |
| 115 | +const EXPORTED = new Set( |
| 116 | + [...apiBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*):/gm)].map((m) => m[1]), |
| 117 | +); |
| 118 | + |
| 119 | +pass(`${ENGINE_REL}: exports ${EXPORTED.size} names`); |
| 120 | + |
| 121 | +// The contract @imqueue/mcp compiles against (src/search-ranker.d.cts there). It is asserted |
| 122 | +// separately from what the UI needs because the two lists are not the same and nothing else says |
| 123 | +// so: `FEED_V` is read by the MCP server to check the feed shape and by no browser code at all, |
| 124 | +// so a surface derived from the UI alone would drop it and the server would assert `undefined`. |
| 125 | +const NODE_CONTRACT = ['parseQuery', 'prepare', 'prepareSections', 'search', 'groupKey', 'state', 'FEED_V']; |
| 126 | + |
| 127 | +for (const name of NODE_CONTRACT) { |
| 128 | + if (!EXPORTED.has(name)) { |
| 129 | + fail(`${ENGINE_REL}: \`${name}\` is not exported — @imqueue/mcp's src/search-ranker.d.cts ` |
| 130 | + + 'declares it, and TypeScript cannot catch a lie in a hand-written .d.cts'); |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +if (NODE_CONTRACT.every((name) => EXPORTED.has(name))) { |
| 135 | + pass(`${ENGINE_REL}: the ${NODE_CONTRACT.length} names @imqueue/mcp declares are all exported`); |
| 136 | +} |
| 137 | + |
| 138 | +// ---- the engine stays portable ---------------------------------------------- |
| 139 | + |
| 140 | +const engineCode = code(engineSrc); |
| 141 | + |
| 142 | +// `document` is the discriminator: it appears nowhere in a scoring engine, and its arrival is how |
| 143 | +// the UI creeps back in. @imqueue/mcp requires this file in a Cloudflare Worker, where it would |
| 144 | +// throw at load — which is a deploy failure, not a test failure. |
| 145 | +const documentUse = engineCode.match(/\bdocument\b/g); |
| 146 | + |
| 147 | +if (documentUse) { |
| 148 | + fail(`${ENGINE_REL}: references \`document\` ${documentUse.length}x — the engine runs in a ` |
| 149 | + + 'Cloudflare Worker, where that throws at load. Whatever needs a DOM belongs in search.js'); |
| 150 | +} else { |
| 151 | + pass(`${ENGINE_REL}: no \`document\` — still loadable outside a browser`); |
| 152 | +} |
| 153 | + |
| 154 | +// `fetch` for the same reason one step further out: an engine that fetches has decided WHERE the |
| 155 | +// corpus lives, which is the assumption that stopped this file being reusable in the first place. |
| 156 | +// The caller hands it feeds; imqueue's URLs for them are search.js's business. |
| 157 | +if (/\bfetch\s*\(/.test(engineCode)) { |
| 158 | + fail(`${ENGINE_REL}: calls \`fetch\` — the engine is given its feeds, it does not go and get ` |
| 159 | + + 'them. Feed URLs belong in search.js (TIER1/TIER2/PEER1/PEER2)'); |
| 160 | +} else { |
| 161 | + pass(`${ENGINE_REL}: fetches nothing — the caller supplies the corpus`); |
| 162 | +} |
| 163 | + |
| 164 | +// The browser half of the export, which is the half with no test coverage anywhere else: Node |
| 165 | +// takes the `module.exports` branch, so an edit that broke only the global assignment would pass |
| 166 | +// every other check in this repo and ship a dead search box. |
| 167 | +if (!/window\.SearchRanker = API;/.test(engineSrc)) { |
| 168 | + fail(`${ENGINE_REL}: does not assign \`window.SearchRanker\` — Node would still work and the ` |
| 169 | + + 'browser would not, which is the one failure no other check here can see'); |
| 170 | +} else { |
| 171 | + pass(`${ENGINE_REL}: publishes window.SearchRanker for the browser`); |
| 172 | +} |
| 173 | + |
| 174 | +// ---- the UI reads only what the engine exports ------------------------------ |
| 175 | + |
| 176 | +// Every `R.name`, where `R` is the local the UI binds the engine to. |
| 177 | +if (!/var R = window\.SearchRanker;/.test(uiSrc)) { |
| 178 | + fail(`${UI_REL}: does not read \`window.SearchRanker\` — the two halves are not connected`); |
| 179 | +} |
| 180 | + |
| 181 | +const readFromEngine = new Set([...code(uiSrc).matchAll(/\bR\.([A-Za-z_$][\w$]*)/g)].map((m) => m[1])); |
| 182 | +const missingFromApi = [...readFromEngine].filter((name) => !EXPORTED.has(name)).sort(); |
| 183 | + |
| 184 | +if (missingFromApi.length) { |
| 185 | + fail(`${UI_REL}: reads ${missingFromApi.map((n) => `R.${n}`).join(', ')} from the engine, which ` |
| 186 | + + `does not export ${missingFromApi.length === 1 ? 'it' : 'them'}`); |
| 187 | +} else { |
| 188 | + pass(`${UI_REL}: all ${readFromEngine.size} names it takes from the engine are exported`); |
| 189 | +} |
| 190 | + |
| 191 | +// ---- and nothing in the UI is simply undefined ------------------------------ |
| 192 | + |
| 193 | +// Browser and language globals the UI legitimately reaches for. Curated rather than inferred: |
| 194 | +// this list is the price of a tokenizer instead of a parser, and a name added here should be a |
| 195 | +// real global, not a way to silence the check. |
| 196 | +const GLOBALS = new Set([ |
| 197 | + // language |
| 198 | + 'Array', 'Boolean', 'Date', 'Error', 'Infinity', 'JSON', 'Math', 'NaN', 'Number', 'Object', |
| 199 | + 'Promise', 'RegExp', 'String', 'Set', 'Map', 'arguments', 'this', 'undefined', 'null', 'true', |
| 200 | + 'false', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'return', 'if', 'else', |
| 201 | + 'for', 'while', 'do', 'switch', 'case', 'default', 'break', 'continue', 'function', 'var', |
| 202 | + 'let', 'const', 'try', 'catch', 'finally', 'throw', 'class', 'extends', 'super', 'yield', |
| 203 | + 'await', 'async', 'static', 'get', 'set', |
| 204 | + // browser |
| 205 | + 'document', 'window', 'location', 'history', 'navigator', 'localStorage', 'sessionStorage', |
| 206 | + 'fetch', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'requestAnimationFrame', |
| 207 | + 'matchMedia', 'CustomEvent', 'Event', 'URL', 'URLSearchParams', 'AbortController', 'Node', |
| 208 | + 'HTMLElement', 'DocumentFragment', 'IntersectionObserver', 'MutationObserver', 'console', |
| 209 | + 'gtag', 'dataLayer', 'module', 'require', 'process', 'globalThis', |
| 210 | + 'encodeURIComponent', 'decodeURIComponent', 'parseInt', 'parseFloat', 'isNaN', 'isFinite', |
| 211 | + // The placeholder `code()` leaves where an object key was. |
| 212 | + '_key', |
| 213 | +]); |
| 214 | + |
| 215 | +const uiBound = bound(uiSrc); |
| 216 | +const dangling = [...referenced(uiSrc)] |
| 217 | + .filter((name) => !uiBound.has(name) && !GLOBALS.has(name) && !EXPORTED.has(name)) |
| 218 | + .sort(); |
| 219 | + |
| 220 | +if (dangling.length) { |
| 221 | + fail(`${UI_REL}: ${dangling.length} name(s) are neither declared here, imported from the ` |
| 222 | + + `engine, nor a known global: ${dangling.join(' ')}`); |
| 223 | +} else { |
| 224 | + pass(`${UI_REL}: every name it uses is declared, imported or a browser global`); |
| 225 | +} |
| 226 | + |
| 227 | +// ---- report ----------------------------------------------------------------- |
| 228 | + |
| 229 | +if (failures) { |
| 230 | + console.error(`\n${failures} check(s) failed.`); |
| 231 | + process.exit(1); |
| 232 | +} |
| 233 | + |
| 234 | +console.log('\nsearch-ranker: the engine/UI seam holds.'); |
0 commit comments