From d08334602f631fca48b678bb62e76189ef2be68d Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 11 Jun 2026 13:53:54 +0200 Subject: [PATCH] Consolidate fuzzy-scoring into shared core + remove wasted structuredClone (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/search-ranking.ts with scoreNormalizedNonEmpty shared core - Rewrite scoreText in filtering.ts as wrapper (preserves empty→1) - Rewrite scoreNormalized in search-utils.ts as wrapper (preserves empty→0) - Remove discarded structuredClone(sorted) from search hot path - Add src/search-ranking.test.ts with 21 tests (core + both wrappers) - Update check-clone-safe-actions.cjs for clone removal - Rename debug span search.sort-prepare-clone → search.sort-prepare --- package.json | 2 +- scripts/check-clone-safe-actions.cjs | 6 +- src/electron/main.ts | 3 +- src/electron/search-utils.ts | 14 +--- src/filtering.ts | 16 +--- src/search-ranking.test.ts | 107 +++++++++++++++++++++++++++ src/search-ranking.ts | 18 +++++ 7 files changed, 138 insertions(+), 28 deletions(-) create mode 100644 src/search-ranking.test.ts create mode 100644 src/search-ranking.ts diff --git a/package.json b/package.json index 772626a..e6519cf 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "node ./scripts/electron-dev.cjs", "build": "electron-vite build", "typecheck": "tsc -p tsconfig.check.json", - "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-packaged-runtime-imports.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && node scripts/check-backend-contract-fixtures.cjs && node scripts/check-backend-api-major.cjs && pnpm -C backend exec node --import tsx --test ../src/model-contract.test.ts ../src/use-ai-chat.test.ts ../src/extension-view.test.tsx ../src/electron/calculator.test.ts ../src/electron/url-utils.test.ts ../src/electron/running-app-status.test.ts ../src/electron/app-icon-cache.test.ts ../src/electron/app-index-service.test.ts ../src/electron/shortcut-ownership.test.ts ../src/electron/extension-permissions.test.ts ../src/electron/extension-ui-api.test.ts ../src/electron/ipc-registration.test.ts ../src/electron/app-ipc-handlers.test.ts ../src/electron/extension-window-manager.test.ts ../src/electron/window-navigation-policy.test.ts ../src/view-patches.test.ts && pnpm typecheck && pnpm -C backend test", + "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-packaged-runtime-imports.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && node scripts/check-backend-contract-fixtures.cjs && node scripts/check-backend-api-major.cjs && pnpm -C backend exec node --import tsx --test ../src/model-contract.test.ts ../src/use-ai-chat.test.ts ../src/extension-view.test.tsx ../src/electron/calculator.test.ts ../src/electron/url-utils.test.ts ../src/electron/running-app-status.test.ts ../src/electron/app-icon-cache.test.ts ../src/electron/app-index-service.test.ts ../src/electron/shortcut-ownership.test.ts ../src/electron/extension-permissions.test.ts ../src/electron/extension-ui-api.test.ts ../src/electron/ipc-registration.test.ts ../src/electron/app-ipc-handlers.test.ts ../src/electron/extension-window-manager.test.ts ../src/electron/window-navigation-policy.test.ts ../src/view-patches.test.ts ../src/search-ranking.test.ts && pnpm typecheck && pnpm -C backend test", "palette:debug": "node .agents/skills/palette-debug/palette-debug.cjs", "learnings:export": "node scripts/learnings-export.cjs", "logs:tail": "node scripts/tail-log.cjs", diff --git a/scripts/check-clone-safe-actions.cjs b/scripts/check-clone-safe-actions.cjs index 035dd0f..3f783e1 100644 --- a/scripts/check-clone-safe-actions.cjs +++ b/scripts/check-clone-safe-actions.cjs @@ -20,8 +20,10 @@ if (!/function\s+normalizeActionPanel[\s\S]*const\s+\{\s*lazyActions,\s*\.\.\.sa fail('normalizeActionPanel must strip lazyActions after normalizing them') } -if (!/async\s+function\s+searchActions[\s\S]*structuredClone\(sorted\)[\s\S]*return\s+sorted/.test(source)) { - fail('searchActions must structuredClone-check results before returning through IPC') +// Note: prepareRootActionForRenderer already strips handlers so sorted is clone-safe. +// The discarded structuredClone(sorted) was removed per #33. Verify the function returns sorted. +if (!/async\s+function\s+searchActions[\s\S]*return\s+sorted/.test(source)) { + fail('searchActions must return the sorted results for IPC') } if (!/async\s+function\s+executeActionForIpc[\s\S]*structuredClone\(result\)/.test(source)) { diff --git a/src/electron/main.ts b/src/electron/main.ts index fe2f990..484bcfc 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -1377,13 +1377,12 @@ async function searchActions(query, options: any = {}) { } }) - const sorted = measureDebugPerformanceSync('search.sort-prepare-clone', { queryLength: q.length, resultCount: results.length }, () => results + const sorted = measureDebugPerformanceSync('search.sort-prepare', { queryLength: q.length, resultCount: results.length }, () => results .sort((a, b) => { return b.score - a.score || b.lastUsed - a.lastUsed || a.title.localeCompare(b.title) }) .slice(0, 30) .map(prepareRootActionForRenderer)) - structuredClone(sorted) markDebugPerformance('search.actions.result', { queryLength: q.length, contributedCount: contributedItems.length, rankedCount: results.length, resultCount: sorted.length }) return sorted }) diff --git a/src/electron/search-utils.ts b/src/electron/search-utils.ts index 1e9e1b9..6e3ff44 100644 --- a/src/electron/search-utils.ts +++ b/src/electron/search-utils.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto' import { calculate, calculateDetailed, calculateRateResult, parseRateExpression } from './calculator' +import { scoreNormalizedNonEmpty } from '../search-ranking' export { calculate, calculateDetailed, calculateRateResult, parseRateExpression } @@ -11,19 +12,10 @@ export function hashValue(value: unknown) { return crypto.createHash('sha1').update(String(value)).digest('hex') } -export function scoreNormalized(value: unknown, q: string) { +export function scoreNormalized(value: unknown, q: string): number { if (!q) return 0 const v = normalize(value) - if (v === q) return 100 - if (v.startsWith(q)) return 80 - if (v.includes(q)) return 50 - let pos = 0 - for (const ch of q) { - pos = v.indexOf(ch, pos) - if (pos === -1) return 0 - pos += 1 - } - return 20 + return scoreNormalizedNonEmpty(v, q) } export function score(value: unknown, query: unknown) { diff --git a/src/filtering.ts b/src/filtering.ts index 6a995f5..555291a 100644 --- a/src/filtering.ts +++ b/src/filtering.ts @@ -1,18 +1,10 @@ import { actionsFromPanel, type CommandItem, type CommandView } from './model' +import { scoreNormalizedNonEmpty } from './search-ranking' -export function scoreText(value: string | undefined, filter: string) { - const text = value?.toLowerCase() || '' +export function scoreText(value: string | undefined, filter: string): number { if (!filter) return 1 - if (text === filter) return 100 - if (text.startsWith(filter)) return 80 - if (text.includes(filter)) return 50 - let position = 0 - for (const character of filter) { - position = text.indexOf(character, position) - if (position === -1) return 0 - position += 1 - } - return 20 + const text = (value || '').toLowerCase() + return scoreNormalizedNonEmpty(text, filter) } export function valuesMatch(filterValue: string, ...values: Array) { diff --git a/src/search-ranking.test.ts b/src/search-ranking.test.ts new file mode 100644 index 0000000..d16929d --- /dev/null +++ b/src/search-ranking.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { scoreNormalizedNonEmpty } from './search-ranking' +import { scoreText } from './filtering' +import { normalize, scoreNormalized } from './electron/search-utils' + +// ── Core bands ─────────────────────────────────────────────────────── +test('scoreNormalizedNonEmpty exact match', () => { + assert.equal(scoreNormalizedNonEmpty('hello', 'hello'), 100) + assert.equal(scoreNormalizedNonEmpty('a', 'a'), 100) +}) + +test('scoreNormalizedNonEmpty startsWith', () => { + assert.equal(scoreNormalizedNonEmpty('hello world', 'hello'), 80) + assert.equal(scoreNormalizedNonEmpty('abc', 'a'), 80) + assert.equal(scoreNormalizedNonEmpty('nevermind', 'never'), 80) +}) + +test('scoreNormalizedNonEmpty includes', () => { + assert.equal(scoreNormalizedNonEmpty('hello world', 'world'), 50) + assert.equal(scoreNormalizedNonEmpty('open settings', 'settings'), 50) + assert.equal(scoreNormalizedNonEmpty('abc123', '123'), 50) +}) + +test('scoreNormalizedNonEmpty fuzzy match', () => { + assert.equal(scoreNormalizedNonEmpty('hello', 'hlo'), 20) + assert.equal(scoreNormalizedNonEmpty('typescript', 'ts'), 20) + assert.equal(scoreNormalizedNonEmpty('settings', 'stng'), 20) +}) + +test('scoreNormalizedNonEmpty no match', () => { + assert.equal(scoreNormalizedNonEmpty('hello', 'xyz'), 0) + assert.equal(scoreNormalizedNonEmpty('abc', 'abx'), 0) +}) + +test('scoreNormalizedNonEmpty case sensitivity is caller responsibility', () => { + // Core expects already-lowered input; passing mixed case may not match + assert.equal(scoreNormalizedNonEmpty('Hello', 'hello'), 0) + assert.equal(scoreNormalizedNonEmpty('HELLO', 'hello'), 0) +}) + +// ── scoreText wrapper ───────────────────────────────────────────────── +test('scoreText empty filter returns 1', () => { + assert.equal(scoreText('anything', ''), 1) + assert.equal(scoreText(undefined, ''), 1) +}) + +test('scoreText exact match', () => { + assert.equal(scoreText('hello', 'hello'), 100) +}) + +test('scoreText startsWith', () => { + assert.equal(scoreText('hello world', 'hello'), 80) +}) + +test('scoreText includes', () => { + assert.equal(scoreText('hello world', 'world'), 50) +}) + +test('scoreText fuzzy', () => { + assert.equal(scoreText('settings', 'stng'), 20) +}) + +test('scoreText no match', () => { + assert.equal(scoreText('hello', 'xyz'), 0) +}) + +test('scoreText undefined value', () => { + assert.equal(scoreText(undefined, 'query'), 0) +}) + +test('scoreText case insensitive via toLowerCase', () => { + assert.equal(scoreText('Hello', 'hello'), 100) + assert.equal(scoreText('HELLO', 'hello'), 100) +}) + +// ── scoreNormalized wrapper ─────────────────────────────────────────── +test('scoreNormalized empty query returns 0', () => { + assert.equal(scoreNormalized('anything', ''), 0) + assert.equal(scoreNormalized('', ''), 0) + assert.equal(scoreNormalized(undefined, ''), 0) +}) + +test('scoreNormalized exact match', () => { + assert.equal(scoreNormalized('hello', normalize('hello')), 100) +}) + +test('scoreNormalized startsWith', () => { + assert.equal(scoreNormalized('hello world', normalize('hello')), 80) +}) + +test('scoreNormalized includes', () => { + assert.equal(scoreNormalized('hello world', normalize('world')), 50) +}) + +test('scoreNormalized fuzzy', () => { + assert.equal(scoreNormalized('settings', normalize('stng')), 20) +}) + +test('scoreNormalized no match', () => { + assert.equal(scoreNormalized('hello', normalize('xyz')), 0) +}) + +test('scoreNormalized case insensitive and trim via normalize', () => { + assert.equal(scoreNormalized(' Hello ', normalize('hello')), 100) + assert.equal(scoreNormalized(123, normalize('123')), 100) +}) diff --git a/src/search-ranking.ts b/src/search-ranking.ts new file mode 100644 index 0000000..650c616 --- /dev/null +++ b/src/search-ranking.ts @@ -0,0 +1,18 @@ +/** + * Shared fuzzy-match scoring core. + * + * Both arguments must already be lowercased and trimmed by the caller. + * `query` must be non‑empty; wrappers guard that contract. + */ +export function scoreNormalizedNonEmpty(text: string, query: string): number { + if (text === query) return 100 + if (text.startsWith(query)) return 80 + if (text.includes(query)) return 50 + let pos = 0 + for (const ch of query) { + pos = text.indexOf(ch, pos) + if (pos === -1) return 0 + pos += 1 + } + return 20 +}