From 78aac06d7a62939ef58e80b621779d2c4ceb6ab4 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Thu, 27 Aug 2026 18:52:25 -0400 Subject: [PATCH 1/4] refactor: pf-4402 search results grouping --- src/patternFly.search.ts | 58 +++++++++++++++++++++++++++++++++++- src/tool.searchPatternFly.ts | 23 ++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/patternFly.search.ts b/src/patternFly.search.ts index 1ccf5089..d4e70f20 100644 --- a/src/patternFly.search.ts +++ b/src/patternFly.search.ts @@ -170,6 +170,55 @@ type FilterPatternFlyMemoArgs = [ settings?: FilterPatternFlySettings | undefined ]; +/** + * Rate how closely a search result matches a search query. + * + * @param {SearchPatternFlyResult} result - Result object containing name and display name props. + * @param normalizedQuery - Query string for comparison. + * @returns `result` relevance to a `normalizedQuery`: + * - `0`: Exact match with name or entity slug. + * - `1`: Exact match with display name. + * - `2`: Prefix match with entity name. + * - `3`: Match display name at a word boundary. + * - `4`: Name substring match. + * - `5`: Match solely by descriptive content or keywords. + */ +const calculateRelevance = ( + result: SearchPatternFlyResult, + normalizedQuery: string +): number => { + const resultName = (result.name || '').toLowerCase(); + const displayName = 'displayName' in result ? (result.displayName as string).toLowerCase() : undefined; + + // Exact name / entity match + if (resultName === normalizedQuery) { + return 0; + } + + // Exact display name match + if (displayName && displayName === normalizedQuery) { + return 1; + } + + // Prefix match + if (resultName.startsWith(`${normalizedQuery}-`) || resultName.startsWith(normalizedQuery)) { + return 2; + } + + // Word boundary match in display name + if (displayName && new RegExp(`\\b${normalizedQuery}\\b`, 'i').test(displayName)) { + return 3; + } + + // Substring match in name + if (resultName.includes(normalizedQuery)) { + return 4; + } + + // Tier 5: Matched solely via prose keyword index / description + return 5; +}; + /** * Apply sequenced priority filters for predictable filtering, filter PatternFly data. * @@ -616,7 +665,14 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly return a.distance - b.distance; } - return a.name.localeCompare(b.name); + const relevantA = calculateRelevance(a, coercedSearchQuery.toLowerCase()); + const relevantB = calculateRelevance(b, coercedSearchQuery.toLowerCase()); + + if (relevantA !== relevantB) { + return relevantA - relevantB; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); }; const sortedExactMatches = exactMatches.sort(sortByDistanceByName); diff --git a/src/tool.searchPatternFly.ts b/src/tool.searchPatternFly.ts index aa6982d9..fdedb55d 100644 --- a/src/tool.searchPatternFly.ts +++ b/src/tool.searchPatternFly.ts @@ -89,10 +89,15 @@ const searchPatternFlyTool = (options = getOptions()): McpTool => { const results = new Map>(); const groupSortNames = new Map(); + const groupOrder = new Map(); let numberCollections = 0; let numberRecords = 0; - parseResults.forEach(result => { + parseResults.forEach((result, index) => { + if (!groupOrder.has(result.groupId)) { + groupOrder.set(result.groupId, index); + } + if (results.has(result.groupId)) { return; } @@ -164,8 +169,22 @@ const searchPatternFlyTool = (options = getOptions()): McpTool => { const gidA = a.groupId as string; const gidB = b.groupId as string; - // 1. Sort by Group (using the Collection's name) + // 1. Sort by Group (Relevance order from search results, or alphabetical for wildcard all) if (gidA !== gidB) { + if (isSearchWildCardAll) { + const nameA = groupSortNames.get(gidA) || ''; + const nameB = groupSortNames.get(gidB) || ''; + + return nameA.localeCompare(nameB); + } + + const orderA = groupOrder.get(gidA) ?? Number.MAX_SAFE_INTEGER; + const orderB = groupOrder.get(gidB) ?? Number.MAX_SAFE_INTEGER; + + if (orderA !== orderB) { + return orderA - orderB; + } + const nameA = groupSortNames.get(gidA) || ''; const nameB = groupSortNames.get(gidB) || ''; From bd39e7dc2abab2f82bfbc00a87353f4a9748fb4b Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Mon, 31 Aug 2026 23:56:53 -0400 Subject: [PATCH 2/4] fix: review update --- .../tool.searchPatternFly.test.ts.snap | 48 ++++++++-------- src/patternFly.search.ts | 55 ++++++++----------- 2 files changed, 48 insertions(+), 55 deletions(-) diff --git a/src/__tests__/__snapshots__/tool.searchPatternFly.test.ts.snap b/src/__tests__/__snapshots__/tool.searchPatternFly.test.ts.snap index 439a7abd..492c94bc 100644 --- a/src/__tests__/__snapshots__/tool.searchPatternFly.test.ts.snap +++ b/src/__tests__/__snapshots__/tool.searchPatternFly.test.ts.snap @@ -15,30 +15,6 @@ exports[`searchPatternFlyTool, callback should have a specific markdown format: Found 2 collections with 5 related resources. Use the attached resources to access and read full content.", "type": "text", }, - { - "description": "A resource collection series for Button", - "groupId": "123456", - "mimeType": "text/markdown", - "name": "Button (Collection)", - "type": "resource_link", - "uri": "patternfly://docs/123456", - }, - { - "description": "Design Guidelines", - "groupId": "123456", - "mimeType": "text/markdown", - "name": "Button - Design (v6)", - "type": "resource_link", - "uri": "patternfly://docs/34567b", - }, - { - "description": "Component JSON schema with property definitions for Button.", - "groupId": "123456", - "mimeType": "text/markdown", - "name": "Button - JSON Schema (v6)", - "type": "resource_link", - "uri": "patternfly://schemas/67b890", - }, { "description": "A resource collection series for Lorem Button", "groupId": "654321", @@ -71,6 +47,30 @@ Found 2 collections with 5 related resources. Use the attached resources to acce "type": "resource_link", "uri": "patternfly://schemas/678e90", }, + { + "description": "A resource collection series for Button", + "groupId": "123456", + "mimeType": "text/markdown", + "name": "Button (Collection)", + "type": "resource_link", + "uri": "patternfly://docs/123456", + }, + { + "description": "Design Guidelines", + "groupId": "123456", + "mimeType": "text/markdown", + "name": "Button - Design (v6)", + "type": "resource_link", + "uri": "patternfly://docs/34567b", + }, + { + "description": "Component JSON schema with property definitions for Button.", + "groupId": "123456", + "mimeType": "text/markdown", + "name": "Button - JSON Schema (v6)", + "type": "resource_link", + "uri": "patternfly://schemas/67b890", + }, ] `; diff --git a/src/patternFly.search.ts b/src/patternFly.search.ts index d4e70f20..fc2e571c 100644 --- a/src/patternFly.search.ts +++ b/src/patternFly.search.ts @@ -1,5 +1,6 @@ import { fuzzySearch, + normalizeString, type FuzzySearch, type FuzzySearchOptions, type FuzzySearchResult @@ -173,50 +174,42 @@ type FilterPatternFlyMemoArgs = [ /** * Rate how closely a search result matches a search query. * + * @note We prioritize **exact name matches** because users/agents respond best when + * the returned item’s `name` (or any of its display names) exactly equals their typed + * search, even if other metadata might be a better match. + * * @param {SearchPatternFlyResult} result - Result object containing name and display name props. - * @param normalizedQuery - Query string for comparison. + * @param query - Query string for comparison. * @returns `result` relevance to a `normalizedQuery`: - * - `0`: Exact match with name or entity slug. - * - `1`: Exact match with display name. - * - `2`: Prefix match with entity name. - * - `3`: Match display name at a word boundary. - * - `4`: Name substring match. - * - `5`: Match solely by descriptive content or keywords. + * - `0`: Exact match + * - `1`: Contains match + * - `2`: Everything else */ const calculateRelevance = ( result: SearchPatternFlyResult, - normalizedQuery: string + query: string ): number => { - const resultName = (result.name || '').toLowerCase(); - const displayName = 'displayName' in result ? (result.displayName as string).toLowerCase() : undefined; + const normalizedName = normalizeString.memo(result.name); + const normalizedQuery = normalizeString(query); - // Exact name / entity match - if (resultName === normalizedQuery) { + if (normalizedName === normalizedQuery) { return 0; } - // Exact display name match - if (displayName && displayName === normalizedQuery) { - return 1; - } + const displayNames = (result.entries || []) + .map(entry => (entry.displayName ? normalizeString.memo(entry.displayName) : '')) + .filter(Boolean); - // Prefix match - if (resultName.startsWith(`${normalizedQuery}-`) || resultName.startsWith(normalizedQuery)) { - return 2; - } - - // Word boundary match in display name - if (displayName && new RegExp(`\\b${normalizedQuery}\\b`, 'i').test(displayName)) { - return 3; + if (displayNames.some(name => name === normalizedQuery)) { + return 0; } - // Substring match in name - if (resultName.includes(normalizedQuery)) { - return 4; + if (normalizedName.includes(normalizedQuery) || + displayNames.some(name => name.includes(normalizedQuery))) { + return 1; } - // Tier 5: Matched solely via prose keyword index / description - return 5; + return 2; }; /** @@ -665,8 +658,8 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly return a.distance - b.distance; } - const relevantA = calculateRelevance(a, coercedSearchQuery.toLowerCase()); - const relevantB = calculateRelevance(b, coercedSearchQuery.toLowerCase()); + const relevantA = calculateRelevance(a, coercedSearchQuery); + const relevantB = calculateRelevance(b, coercedSearchQuery); if (relevantA !== relevantB) { return relevantA - relevantB; From be237bfe5c21ff9733c10cff99933fb35dfd993a Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Tue, 1 Sep 2026 10:29:09 -0400 Subject: [PATCH 3/4] fix: review update --- src/patternFly.search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/patternFly.search.ts b/src/patternFly.search.ts index fc2e571c..c7815563 100644 --- a/src/patternFly.search.ts +++ b/src/patternFly.search.ts @@ -190,7 +190,7 @@ const calculateRelevance = ( query: string ): number => { const normalizedName = normalizeString.memo(result.name); - const normalizedQuery = normalizeString(query); + const normalizedQuery = normalizeString.memo(query); if (normalizedName === normalizedQuery) { return 0; From 8f8935e400d5257100b0ac06ed1ad9739efae8c1 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Tue, 1 Sep 2026 10:41:22 -0400 Subject: [PATCH 4/4] fix: review update --- src/__tests__/patternFly.search.test.ts | 123 ++++++++++++++++++++++++ src/patternFly.search.ts | 1 + 2 files changed, 124 insertions(+) diff --git a/src/__tests__/patternFly.search.test.ts b/src/__tests__/patternFly.search.test.ts index d45ca7d3..bdb2a15f 100644 --- a/src/__tests__/patternFly.search.test.ts +++ b/src/__tests__/patternFly.search.test.ts @@ -1,10 +1,133 @@ import { + calculateRelevance, dynamicFilterPatternFly, filterPatternFly, searchPatternFly, type FilterPatternFlyFilters } from '../patternFly.search'; +describe('calculateRelevance', () => { + it.each([ + { + description: 'exact match for name', + query: 'button', + result: { + name: 'button', + entries: [] + }, + expected: 0 + }, + { + description: 'exact match for name, casing', + query: 'Button', + result: { + name: 'button', + entries: [] + }, + expected: 0 + }, + { + description: 'exact match on displayName', + query: 'Action Button', + result: { + name: 'btn-component', + entries: [ + { displayName: 'Action Button' } + ] + }, + expected: 0 + }, + { + description: 'exact match on displayName, casing', + query: 'action button', + result: { + name: 'btn-component', + entries: [ + { displayName: 'Action Button' } + ] + }, + expected: 0 + }, + { + description: 'exact match on multiple displayNames', + query: 'Secondary Button', + result: { + name: 'button', + entries: [ + { displayName: 'Primary Button' }, + { displayName: 'Secondary Button' } + ] + }, + expected: 0 + }, + { + description: 'contains match in name', + query: 'modal', + result: { + name: 'modal-box', + entries: [] + }, + expected: 1 + }, + { + description: 'contains match in displayName', + query: 'group', + result: { + name: 'btn', + entries: [ + { displayName: 'Button Group' } + ] + }, + expected: 1 + }, + { + description: 'contains match with casing', + query: 'ALERT', + result: { + name: 'inline-alert-box', + entries: [] + }, + expected: 1 + }, + { + description: 'no match on name or displayNames', + query: 'dropdown', + result: { + name: 'button', + entries: [ + { displayName: 'Primary Button' } + ] + }, + expected: 2 + }, + { + description: 'undefined entries', + query: 'table', + result: { + name: 'card', + entries: undefined + }, + expected: 2 + }, + { + description: 'missing or empty displayName entries', + query: 'toolbar', + result: { + name: 'page', + entries: [ + { displayName: '' }, + { displayName: undefined } + ] + }, + expected: 2 + } + ])('should create a relevance score, $description', ({ query, result, expected }) => { + const relevance = calculateRelevance(result as any, query); + + expect(relevance).toBe(expected); + }); +}); + describe('filterPatternFly', () => { const mockResources = new Map([ ['button', { diff --git a/src/patternFly.search.ts b/src/patternFly.search.ts index c7815563..17f4c03c 100644 --- a/src/patternFly.search.ts +++ b/src/patternFly.search.ts @@ -690,6 +690,7 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly searchPatternFly.memo = memo(searchPatternFly, DEFAULT_OPTIONS.toolMemoOptions.searchPatternFlyDocs); export { + calculateRelevance, dynamicFilterPatternFly, filterPatternFly, searchPatternFly,