Skip to content

Commit 91b4b74

Browse files
committed
refactor(search): consume the ranker as an engine plus a UI
The submodule is two files now (search-ranker 9f874a3): ranker.js, a portable scoring engine with no DOM in it, and search.js, this site's search dialog. Five things here knew the single path and had to be told which half they mean, because after the split the wrong choice fails QUIETLY — `require` on the UI throws about `document`, and grepping the engine for a UI constant finds nothing and reports a pass. scripts/lib/ranker.js ENGINE_FILE/UI_FILE replace RANKER_FILE, exists() wants both halves, and bundle() returns them in browser order. A checkout with only search.js is a real state — a pin from before the split — and now says so. asset-manifest.js serves the CONCATENATION as one hashed asset, engine first, so a visitor still makes one request. The hash is over both halves: hashing one would serve a stale pairing under `immutable`, which is the exact bug content hashing was added to fix. check-search-ui.js reads the UI half. Pointed at the engine, all six of its analytics cases fail as "nothing reports which result was chosen" — a regression report for a check looking in the wrong file. harness.js baseline() tries ranker.js, then search.js, so a comparison ACROSS the split still runs. That is the comparison that mattered: proving the split moved nothing meant measuring a two-file tree against a one-file commit. compare.js drops its own copy of the extractor and calls harness.baseline(). The copy is what harness.baseline() was factored out to end, it outlived the factoring, and it knew only the name search.js — so it could no longer read a modern ref at all. New: check-search-ranker.js, and it earns its place by covering the one failure nothing else can see. check-search-ranking.js requires the engine and never evaluates a line of the UI; check-search-ui.js greps the UI as text. Both pass a build whose search box is dead because a name stopped being exported. Nine assertions: the export block exists, the seven names @imqueue/mcp's hand-written .d.cts declares are all there, the engine references no `document` and calls no `fetch`, it publishes window.SearchRanker, the UI reads only exported names, and no name in the UI is undefined. Wired into `npm test` — 227 checks now. Two tokenizer bugs found while writing it, both of which had been silently removing real code from the analysis: `/^https?:\/\//` ends in the characters `//`, so a line-comment pass running before regex literals deleted the rest of every line holding a URL regex; and blanking string bodies across joined lines let an apostrophe in a `//` comment pair with one hundreds of lines later. Verified: all four KPI sets at +0.00 over 12,413 queries against ec5cb35, 227 checks, both editions build, and one script tag serving one bundle that answers `commercial license` with three groups, highlighting and a labelled peer group.
1 parent 5c3b447 commit 91b4b74

9 files changed

Lines changed: 390 additions & 83 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ plans/
8585
# read it — see takeFrontmatter() for why deleting it was a correctness bug.
8686
.search-frontmatter-*.json
8787

88+
# Build intermediate: the ranker's two halves — vendor/search-ranker/ranker.js and
89+
# search.js — concatenated into the one asset the site serves. Written by
90+
# scripts/lib/asset-manifest.js on every eleventy config load, because
91+
# addPassthroughCopy takes a path rather than bytes. Never edited; edit the submodule.
92+
.search-bundle.js
93+
8894
# The artificial KPI query set: 1.2 MB, and regenerated exactly by
8995
# `npm run kpi:search:gen` from the built index plus a fixed PRNG seed, so committing it
9096
# would only duplicate the content it is derived from. The NATURAL set beside it IS

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@
2727
}
2828
},
2929
"scripts": {
30-
"test": "npm run check:redirects && npm run check:agent-analytics && npm run check:dates && npm run check:links && npm run check:sitemap && npm run check:llms && npm run check:search-index && npm run check:search-ranking && npm run check:search-ui && npm run check:jsonld && npm run check:mermaid",
30+
"test": "npm run check:redirects && npm run check:agent-analytics && npm run check:dates && npm run check:links && npm run check:sitemap && npm run check:llms && npm run check:search-ranker && npm run check:search-index && npm run check:search-ranking && npm run check:search-ui && npm run check:jsonld && npm run check:mermaid",
3131
"check:redirects": "node scripts/check-redirects.js",
3232
"check:agent-analytics": "node scripts/check-agent-analytics.js",
3333
"probe:agent-analytics": "node scripts/probe-agent-analytics.js",
3434
"check:dates": "node scripts/gen-page-dates.js --check",
3535
"check:sitemap": "node scripts/check-sitemap.js",
3636
"check:llms": "node scripts/check-llms.js",
37+
"check:search-ranker": "node scripts/check-search-ranker.js",
3738
"check:search-index": "node scripts/check-search-index.js _site-org && node scripts/check-search-index.js _site-com",
3839
"check:search-ranking": "node scripts/check-search-ranking.js",
3940
"check:search-ui": "node scripts/check-search-ui.js",

scripts/check-search-ranker.js

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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.');

scripts/check-search-ui.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,14 +175,20 @@ for (const [re, ok, why] of JS_CASES) {
175175
// Read as TEXT, not required: these cases assert on source strings, which is the only way to
176176
// check a constant that never leaves the IIFE. From the submodule — see scripts/lib/ranker.js,
177177
// and check its presence first so a plain clone gets the instruction rather than an ENOENT.
178+
//
179+
// The UI half specifically. Every case below is about what the site REPORTS — the settle window,
180+
// the click watchers, the flush on close — and all of it lives in search.js; ranker.js is the
181+
// engine and has no analytics in it at all. Point this at the engine and all six cases fail with
182+
// "nothing reports which result was chosen", which reads as a regression rather than as a check
183+
// looking in the wrong file.
178184
const rankerLib = require('./lib/ranker.js');
179185

180186
if (!rankerLib.exists()) {
181187
console.error(rankerLib.MISSING);
182188
process.exit(1);
183189
}
184190

185-
const searchJs = read(rankerLib.RANKER_FILE);
191+
const searchJs = read(rankerLib.UI_FILE);
186192

187193
// A settle window is the difference between "queries people asked" and a report full of
188194
// their own prefixes. Asserted as a NUMBER, not a mention: `var SETTLE = 0` would satisfy

scripts/lib/asset-manifest.js

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,24 @@ const crypto = require("crypto");
2727
const fs = require("fs");
2828
const path = require("path");
2929

30-
const { RANKER_FILE, RANKER_REL, MISSING, exists } = require("./ranker.js");
30+
const { MISSING, exists, bundle } = require("./ranker.js");
31+
32+
// Where the concatenated ranker lands. Repo root and gitignored, following
33+
// .search-frontmatter-*.json: a build intermediate, regenerated on every config load,
34+
// and never a file anybody edits. It exists at all because addPassthroughCopy takes a
35+
// PATH, not bytes, so the one asset the site serves has to be a real file somewhere.
36+
const BUNDLE_REL = ".search-bundle.js";
3137

3238
// 8 hex chars of sha256. Collision risk across a handful of files is nil, and it
3339
// keeps the URLs readable in devtools and in the link checker's output.
3440
const HASH_LEN = 8;
3541

42+
function hash(bytes) {
43+
return crypto.createHash("sha256").update(bytes).digest("hex").slice(0, HASH_LEN);
44+
}
45+
3646
function hashFile(absPath) {
37-
return crypto
38-
.createHash("sha256")
39-
.update(fs.readFileSync(absPath))
40-
.digest("hex")
41-
.slice(0, HASH_LEN);
47+
return hash(fs.readFileSync(absPath));
4248
}
4349

4450
/**
@@ -49,11 +55,12 @@ function hashFile(absPath) {
4955
* shadow a shared name, which is how theme-<skin>.css works. Later sources win,
5056
* matching the passthrough-copy order in eleventy.config.js.
5157
*
52-
* One JS file comes from outside src/ entirely: the search ranker is a git submodule
53-
* (see scripts/lib/ranker.js). It is handled by name rather than by adding its
54-
* directory to the scan below, and that is the whole point — an unpopulated submodule
55-
* is an EMPTY DIRECTORY, so a directory scan would find no *.js, report nothing, and
56-
* emit a site with no search.js in it. Naming the file lets its absence throw.
58+
* One JS asset comes from outside src/ entirely: the search ranker is a git submodule
59+
* of TWO files, engine and UI, concatenated here into one (see scripts/lib/ranker.js).
60+
* It is handled by name rather than by adding its directory to the scan below, and that
61+
* is the whole point — an unpopulated submodule is an EMPTY DIRECTORY, so a directory
62+
* scan would find no *.js, report nothing, and emit a site with no search.js in it.
63+
* Naming the files lets their absence throw.
5764
*
5865
* @param {string} root Repository root.
5966
* @param {string} edition "org" | "com".
@@ -100,8 +107,9 @@ function buildAssetManifest(root, edition) {
100107
throw new Error(
101108
`A second search.js exists in src/: ${copies.find((c) => c[1].startsWith("js/search."))[0]}\n\n` +
102109
"The ranker is a submodule now (see scripts/lib/ranker.js). Delete the copy in\n" +
103-
"src/ and edit vendor/search-ranker/search.js instead, or the site would serve the\n" +
104-
"copy while the MCP server serves the submodule — which is the drift the split ended.",
110+
"src/ and edit vendor/search-ranker/ instead — ranker.js for anything that scores,\n" +
111+
"search.js for anything a reader sees — or the site would serve the copy while the\n" +
112+
"MCP server serves the submodule, which is the drift the split ended.",
105113
);
106114
}
107115

@@ -111,12 +119,21 @@ function buildAssetManifest(root, edition) {
111119
throw new Error(MISSING);
112120
}
113121

114-
const hashed = `search.${hashFile(RANKER_FILE)}.js`;
122+
// The ranker is TWO files — engine then UI — and the site serves them as one. The
123+
// hash is over the concatenation rather than over either half, which is the whole
124+
// point: an edit to the engine alone still changes the URL, so the `immutable`
125+
// caching this manifest exists to make safe stays safe. Hashing one half would
126+
// silently serve a stale pairing of the two.
127+
const source = bundle();
128+
const hashed = `search.${hash(source)}.js`;
129+
const bundleAbs = path.join(root, BUNDLE_REL);
130+
131+
fs.writeFileSync(bundleAbs, source);
115132

116133
manifest["/js/search.js"] = `/js/${hashed}`;
117-
copies.push([RANKER_REL, `js/${hashed}`]);
134+
copies.push([BUNDLE_REL, `js/${hashed}`]);
118135

119136
return { manifest, copies };
120137
}
121138

122-
module.exports = { buildAssetManifest, HASH_LEN };
139+
module.exports = { buildAssetManifest, HASH_LEN, BUNDLE_REL };

0 commit comments

Comments
 (0)