diff --git a/cspell.config.json b/cspell.config.json index 027b244e..9c3c6072 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -7,6 +7,7 @@ "deprioritized", "gatekeep", "ized", + "jaccard", "llms", "localappdata", "midrun", diff --git a/src/__tests__/patternFly.getResources.test.ts b/src/__tests__/patternFly.getResources.test.ts index 0818d927..fb7c490a 100644 --- a/src/__tests__/patternFly.getResources.test.ts +++ b/src/__tests__/patternFly.getResources.test.ts @@ -228,6 +228,30 @@ describe('getPatternFlyMcpResources', () => { expect(Array.from(result.hashIndex.keys()).some(key => /[A-Z]/.test(key))).toBe(false); }); + it('should index uriGroupId, unparameterized doc URIs, and schema URIs in uriIndex', async () => { + const result = await getPatternFlyMcpResources(); + const buttonResource = result.resources.get('button'); + + expect(buttonResource).toBeDefined(); + expect(buttonResource?.groupId).toBeDefined(); + + // Confirm uriIndex contains groupId and keys + const groupId = buttonResource?.groupId || ''; + + expect(result.uriIndex.get(`patternfly://docs/${groupId}`.toLowerCase())).toBe('button'); + expect(result.uriIndex.get(`patternfly://schemas/${groupId}`.toLowerCase())).toBe('button'); + expect(result.uriIndex.get('patternfly://docs/button')).toBe('button'); + expect(result.uriIndex.get('patternfly://schemas/button')).toBe('button'); + + // Confirm hashIndex maps groupId + expect(result.hashIndex.get(groupId.toLowerCase())).toBe('button'); + + // Confirm entries have uriGroupId + const entries = buttonResource?.entries || []; + + expect(entries.every(entry => (entry as any).uriGroupId.includes(groupId))).toBe(true); + }); + it('should generate unique hash IDs for pathless component entries', async () => { const result = await getPatternFlyMcpResources(); const entries = Array.from(result.resources.values()).flatMap(resource => resource.entries); diff --git a/src/__tests__/patternFly.search.test.ts b/src/__tests__/patternFly.search.test.ts index bdb2a15f..cb19c103 100644 --- a/src/__tests__/patternFly.search.test.ts +++ b/src/__tests__/patternFly.search.test.ts @@ -87,7 +87,7 @@ describe('calculateRelevance', () => { name: 'inline-alert-box', entries: [] }, - expected: 1 + expected: 2 }, { description: 'no match on name or displayNames', @@ -98,7 +98,7 @@ describe('calculateRelevance', () => { { displayName: 'Primary Button' } ] }, - expected: 2 + expected: 3 }, { description: 'undefined entries', @@ -107,7 +107,7 @@ describe('calculateRelevance', () => { name: 'card', entries: undefined }, - expected: 2 + expected: 3 }, { description: 'missing or empty displayName entries', @@ -119,7 +119,7 @@ describe('calculateRelevance', () => { { displayName: undefined } ] }, - expected: 2 + expected: 3 } ])('should create a relevance score, $description', ({ query, result, expected }) => { const relevance = calculateRelevance(result as any, query); @@ -361,12 +361,19 @@ describe('dynamicFilterPatternFly', () => { expectedNames: ['button', 'button', 'modal', 'card'] }, { - description: 'fallback to original when using a broad category', + description: 'fallback to original when using a broad category and defined maxResultsLimit', searchQuery: 'view', filters: {}, - options: {}, + options: { maxResultsLimit: 1 }, expectedNames: ['button', 'button', 'modal', 'card'] }, + { + description: 'do not fallback to original when using a broad category with dynamic maxResultsLimit', + searchQuery: 'view', + filters: {}, + options: {}, + expectedNames: ['modal', 'card'] + }, { description: 'skip iterative filter if useExistingFilters is true and filter is already set', searchQuery: 'modal', @@ -397,14 +404,28 @@ describe('dynamicFilterPatternFly', () => { description: 'name filter wins and aborts sibling section scans', searchQuery: 'modal', filters: {}, - options: { searchFilters: ['name', 'section'] as const }, + options: { searchFilters: ['name', 'section'], maxResultsLimit: 1 }, + expectedNames: ['modal'] + }, + { + description: 'name filter wins and aborts sibling section scans, dynamic maxResultsLimit', + searchQuery: 'modal', + filters: {}, + options: { searchFilters: ['name', 'section'] }, expectedNames: ['modal'] }, { description: 'section filter wins and aborts sibling name scans', searchQuery: 'layouts', filters: {}, - options: { searchFilters: ['section', 'name'] as const }, + options: { searchFilters: ['section', 'name'], maxResultsLimit: 1 }, + expectedNames: ['card'] + }, + { + description: 'section filter wins and aborts sibling name scans, dynamic maxResultsLimit', + searchQuery: 'layouts', + filters: {}, + options: { searchFilters: ['section', 'name'] }, expectedNames: ['card'] } ])('should wire parallel filter passes with shared signal when $description', async ({ @@ -468,7 +489,7 @@ describe('dynamicFilterPatternFly', () => { 'modal', {}, mockResources as any, - { searchFilters: oversizedFilters as (keyof FilterPatternFlyFilters)[] } + { searchFilters: oversizedFilters as (keyof FilterPatternFlyFilters)[], maxResultsLimit: 1 } ); expect(result.byEntry.map(entry => entry.name)).toEqual(['modal']); @@ -598,6 +619,30 @@ describe('searchPatternFly', () => { ['patternfly://docs/button', new Map([['v6', ['button']], ['v5', ['button']]])], ['patternfly://docs/modal', new Map([['v6', ['modal']]])] ]), + uriIndex: new Map([ + ['patternfly://docs/button', 'button'], + ['patternfly://docs/button?version=v6', 'button'], + ['patternfly://docs/button?version=v5', 'button'], + ['patternfly://docs/modal', 'modal'], + ['patternfly://docs/modal?version=v6', 'modal'], + ['patternfly://docs/btn-group', 'button'], + ['patternfly://docs/mdl-group', 'modal'], + ['patternfly://docs/btn-v6-hash', 'button'], + ['patternfly://docs/btn-v5-hash', 'button'], + ['patternfly://docs/mdl-v6-hash', 'modal'], + ['patternfly://schemas/button', 'button'], + ['patternfly://schemas/button?version=v6', 'button'], + ['patternfly://schemas/btn-group', 'button'], + ['patternfly://schemas/mdl-group', 'modal'] + ]), + hashIndex: new Map([ + ['btn-group', 'button'], + ['mdl-group', 'modal'], + ['btn-v6-hash', 'button'], + ['btn-v5-hash', 'button'], + ['mdl-v6-hash', 'modal'] + ]), + pathIndex: new Map(), latestVersion: 'v6' }; @@ -647,6 +692,45 @@ describe('searchPatternFly', () => { expectedName: 'modal', expectedType: 'exact' }, + { + description: 'patternfly://docs/{groupId} with filter', + search: 'patternfly://docs/btn-group', + options: { dynamicFilter: true }, + expectedLength: 1, + expectedName: 'button', + expectedType: 'exact' + }, + { + description: 'patternfly://schemas/{groupId} with filter', + search: 'patternfly://schemas/btn-group', + options: { dynamicFilter: true }, + expectedLength: 1, + expectedName: 'button', + expectedType: 'exact' + }, + { + description: 'unparameterized doc URI patternfly://docs/button', + search: 'patternfly://docs/button', + options: { dynamicFilter: true }, + expectedLength: 1, + expectedName: 'button', + expectedType: 'exact' + }, + { + description: 'unparameterized schema URI patternfly://schemas/button', + search: 'patternfly://schemas/button', + options: { dynamicFilter: true }, + expectedLength: 1, + expectedName: 'button', + expectedType: 'exact' + }, + { + description: 'groupId bare hash without filter', + search: 'btn-group', + expectedLength: 1, + expectedName: 'button', + expectedType: 'exact' + }, { description: 'hash entry id with filter', search: 'btn-v6-hash', @@ -659,7 +743,7 @@ describe('searchPatternFly', () => { description: 'hash entry id without filter', search: 'btn-v6-hash', options: { dynamicFilter: false }, - expectedLength: 2, + expectedLength: 1, expectedName: 'button', expectedType: 'exact' }, diff --git a/src/__tests__/server.helpers.test.ts b/src/__tests__/server.helpers.test.ts index ca8b6482..1ba8a46f 100644 --- a/src/__tests__/server.helpers.test.ts +++ b/src/__tests__/server.helpers.test.ts @@ -8,6 +8,7 @@ import { isPlainObject, isPromise, isReferenceLike, + isShaHexLike, isUrl, isUrlObject, isPath, @@ -708,6 +709,105 @@ describe('isReferenceLike', () => { }); }); +describe('isShaHexLike', () => { + it.each([ + { + description: 'lowercase 40-char SHA-1', + value: '96b45dfa08a88ff87015bf35852ce1bce2085de5', + expected: true + }, + { + description: 'uppercase 40-char SHA-1', + value: '96B45DFA08A88FF87015BF35852CE1BCE2085DE5', + expected: true + }, + { + description: 'mixed-case 40-char SHA-1 with whitespace', + value: ' 96b45dfa08A88ff87015bf35852ce1bce2085de5 ', + expected: true + }, + { + description: 'valid 8-char SHA prefix', + value: '96b45dfa', + expected: true + }, + { + description: 'valid 12-char SHA prefix', + value: '96b45dfa08a8', + expected: true + }, + { + description: 'valid 39-char SHA prefix', + value: '96b45dfa08a88ff87015bf35852ce1bce2085de', + expected: true + }, + { + description: 'string below default minLength, 7 chars', + value: '96b45df', + expected: false + }, + { + description: 'string above default maxLength, 41 chars', + value: '96b45dfa08a88ff87015bf35852ce1bce2085de5a', + expected: false + }, + { + description: 'non-hex characters in string', + value: '96b45dfa08a88ff87015bf35852ce1bce2085xyz', + expected: false + }, + { + description: 'string with leading non-hex character', + value: 'g6b45dfa08a88ff87015bf35852ce1bce2085de5', + expected: false + }, + { + description: 'empty string', + value: '', + expected: false + }, + { + description: 'whitespace string', + value: ' ', + expected: false + }, + { + description: 'null value', + value: null, + expected: false + }, + { + description: 'undefined value', + value: undefined, + expected: false + }, + { + description: 'number value', + value: 12345678, + expected: false + }, + { + description: 'object value', + value: { sha: '96b45dfa' }, + expected: false + }, + { + description: 'custom minLength matching, short prefix', + value: '96b4', + options: { minLength: 4 }, + expected: true + }, + { + description: 'custom maxLength rejecting a longer value', + value: '96b45dfa08a8', + options: { maxLength: 10 }, + expected: false + } + ])('should check if value is SHA hex-like, $description', ({ value, options, expected }) => { + expect(isShaHexLike(value, options)).toBe(expected); + }); +}); + describe('isUrl', () => { it.each([ { description: 'http', url: 'http://example.com' }, diff --git a/src/docs.filterWords.ts b/src/docs.filterWords.ts index 1350d35d..f02b63b3 100644 --- a/src/docs.filterWords.ts +++ b/src/docs.filterWords.ts @@ -4,7 +4,7 @@ * @note It's tempting to remove category and section names from this list, don't. Instead, the search * should be leveraging filters which allow for "section" and "category" specifically. */ -const INDEX_BLOCKLIST_WORDS = ['patternfly', 'component', 'components', 'documentation', 'example', 'examples', 'view']; +const INDEX_BLOCKLIST_WORDS = ['patternfly', 'component', 'components', 'documentation', 'example', 'examples', 'type', 'types', 'view']; /** * Technical terms and acronyms that should be exempt from length and noise filtering. diff --git a/src/patternFly.getResources.ts b/src/patternFly.getResources.ts index 633ad7bc..0585546e 100644 --- a/src/patternFly.getResources.ts +++ b/src/patternFly.getResources.ts @@ -77,6 +77,7 @@ interface PatternFlyMcpComponentNames { * @property displayCategory - The display category of document entry. * @property uri - The parent resource's general URI that can reflect a grouping of document entries. * @property uriId - The resource's exact URI for the document entry. + * @property uriGroupId - The resource's exact URI for the document entry's parent. * @property uriSchemas - The parent resource's general URI for the related component schemas, if they exist. * @property uriSchemasId - The resource's schemas URI for the component schemas, if they exist. Keyed by * the parent resource's `groupId` since the URIs are the same for sibling entries. @@ -88,6 +89,7 @@ type PatternFlyMcpDocsMeta = { displayCategory: string; uri: string; uriId: string; + uriGroupId: string; uriSchemas?: string | undefined; uriSchemasId?: string | undefined; }; @@ -510,8 +512,10 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< catalog.forEach(([unifiedName, entries]) => { const name = normalizeKey(unifiedName); const groupId = generateHash(name); + const uriGroupId = `patternfly://docs/${encodeURIComponent(groupId)}`; hashIndexMap.set(groupId.toLowerCase(), name); + uriIndexMap.set(uriGroupId.toLowerCase(), name); if (!resources.has(name)) { // Include search and filter contextual `undefined` metadata for each resource. @@ -535,10 +539,12 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< const id = generateHash(entry.path || `${name}:${version}:${entry.section}:${entry.category}:${entry.pathSlug}`.toLowerCase()); const isSchemasAvailable = versionContext.latestSchemasVersion === version && componentNamesByVersion.get(version)?.[name]?.isSchemasAvailable; const path = entry.path; - const uri = `patternfly://docs/${encodeURIComponent(name)}${buildSearchString({ version }, { prefix: true })}`; + const uriBase = `patternfly://docs/${encodeURIComponent(name)}`; + const uri = `${uriBase}${buildSearchString({ version }, { prefix: true })}`; const uriId = `patternfly://docs/${encodeURIComponent(id)}`; hashIndexMap.set(id.toLowerCase(), name); + uriIndexMap.set(uriBase.toLowerCase(), name); uriIndexMap.set(uri.toLowerCase(), name); uriIndexMap.set(uriId.toLowerCase(), name); @@ -561,12 +567,15 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< let uriSchemasId; if (isSchemasAvailable) { - uriSchemas = `patternfly://schemas/${encodeURIComponent(name)}${buildSearchString({ version }, { prefix: true })}`; + const uriSchemasBase = `patternfly://schemas/${encodeURIComponent(name)}`; + + uriSchemas = `${uriSchemasBase}${buildSearchString({ version }, { prefix: true })}`; uriSchemasId = `patternfly://schemas/${encodeURIComponent(groupId)}`; resource.versions[version].uriSchemas = uriSchemas; resource.versions[version].uriSchemasId = uriSchemasId; + uriIndexMap.set(uriSchemasBase.toLowerCase(), name); uriIndexMap.set(uriSchemas.toLowerCase(), name); uriIndexMap.set(uriSchemasId.toLowerCase(), name); } @@ -580,6 +589,7 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< displayCategory, uri, uriId, + uriGroupId, uriSchemas, uriSchemasId } as (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta); diff --git a/src/patternFly.search.ts b/src/patternFly.search.ts index 17f4c03c..72fbebd6 100644 --- a/src/patternFly.search.ts +++ b/src/patternFly.search.ts @@ -1,12 +1,11 @@ import { fuzzySearch, - normalizeString, type FuzzySearch, type FuzzySearchOptions, type FuzzySearchResult } from './server.search'; import { memo } from './server.caching'; -import { generateHash } from './server.helpers'; +import { generateHash, isShaHexLike } from './server.helpers'; import { DEFAULT_OPTIONS } from './options.defaults'; import { getPatternFlyMcpResources, @@ -15,6 +14,7 @@ import { type PatternFlyMcpResourceMetadata } from './patternFly.getResources'; import { type PatternFlyMcpDocsCatalogDoc } from './docs.embedded'; +import { isPatternFlyUri } from './patternFly.support'; /** * A filtered MCP resource. @@ -172,7 +172,13 @@ type FilterPatternFlyMemoArgs = [ ]; /** - * Rate how closely a search result matches a search query. + * Re-rank how closely a search result matches a search query. + * + * @note: This entire function needs to be refactored or removed since it's + * re-running distance checks just to resolved tied results. Future refactor + * should include expanding the `server.search` functions to use `Levenshtein`, + * `Jaccard`, and `cosine similarity`. We're temporarily leaving it in place + * as a patch to help move the PF API work forward. * * @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 @@ -180,36 +186,28 @@ type FilterPatternFlyMemoArgs = [ * * @param {SearchPatternFlyResult} result - Result object containing name and display name props. * @param query - Query string for comparison. - * @returns `result` relevance to a `normalizedQuery`: - * - `0`: Exact match - * - `1`: Contains match - * - `2`: Everything else + * @param options - Option settings + * @param options.maxDistance - Max distance allowed for results. + * @returns Re-rank tied distance. */ const calculateRelevance = ( result: SearchPatternFlyResult, - query: string + query: string, + { maxDistance = 3 }: { maxDistance?: number } = {} ): number => { - const normalizedName = normalizeString.memo(result.name); - const normalizedQuery = normalizeString.memo(query); - - if (normalizedName === normalizedQuery) { - return 0; - } + const candidateNames = [ + result.name, + ...(result.entries || []).map(entry => entry.name || ''), + ...(result.entries || []).map(entry => entry.displayName || '') + ].filter(Boolean); - const displayNames = (result.entries || []) - .map(entry => (entry.displayName ? normalizeString.memo(entry.displayName) : '')) - .filter(Boolean); - - if (displayNames.some(name => name === normalizedQuery)) { - return 0; - } + const nameMatch = fuzzySearch(query, candidateNames, { maxDistance }).results; - if (normalizedName.includes(normalizedQuery) || - displayNames.some(name => name.includes(normalizedQuery))) { - return 1; + if (nameMatch.length) { + return Math.min(...nameMatch.map(result => result.distance)); } - return 2; + return maxDistance; }; /** @@ -314,9 +312,13 @@ const filterPatternFly = async ( const matchesVersion = !updatedFilters.version || String(entry.version).toLowerCase() === updatedFilters.version; const matchesCategory = !updatedFilters.category || filterMatch(entry.category, updatedFilters.category); const matchesSection = !updatedFilters.section || filterMatch(entry.section, updatedFilters.section); - const matchesPath = !updatedFilters.path || filterMatch(entry.path, updatedFilters.path) || - filterMatch(entry.uriId, updatedFilters.path) || filterMatch(entry.uriSchemas, updatedFilters.path) || - filterMatch(entry.uriSchemasId, updatedFilters.path) || filterMatch(entry.uri, updatedFilters.path); + const matchesPath = !updatedFilters.path || + filterMatch(entry.path, updatedFilters.path) || + filterMatch(entry.uriId, updatedFilters.path) || + filterMatch(entry.uriGroupId, updatedFilters.path) || + filterMatch(entry.uriSchemas, updatedFilters.path) || + filterMatch(entry.uriSchemasId, updatedFilters.path) || + filterMatch(entry.uri, updatedFilters.path); // Filter order matters specific id -> group id -> group name const matchesName = !updatedFilters.name || filterMatch(entry.id, updatedFilters.name) || @@ -436,10 +438,25 @@ const dynamicFilterPatternFly = async ( { searchFilters = SEARCH_FILTERS, maxFilterPasses = MAX_DYNAMIC_FILTER_PASSES, - maxResultsLimit = 1, + maxResultsLimit, useExistingFilters = true - }: { searchFilters?: (keyof FilterPatternFlyFilters)[]; maxFilterPasses?: number; maxResultsLimit?: number; useExistingFilters?: boolean } = {} + }: { searchFilters?: (keyof FilterPatternFlyFilters)[]; maxFilterPasses?: number; maxResultsLimit?: number | undefined; useExistingFilters?: boolean } = {} ): Promise => { + let isDynamicLimit = false; + let updatedMaxResultsLimit = maxResultsLimit ?? 1; + let updatedSearchFilters = searchFilters; + + if (isPatternFlyUri(searchQuery)) { + updatedSearchFilters = ['path']; + } else if (isShaHexLike(searchQuery)) { + updatedSearchFilters = ['name']; + } + + if (maxResultsLimit === undefined) { + isDynamicLimit = true; + updatedMaxResultsLimit = updatedSearchFilters.length; + } + // Error name const dynamicFilterPassNotMatched = 'DynamicFilterPassNotMatchedError'; @@ -454,7 +471,7 @@ const dynamicFilterPatternFly = async ( // Matching conditions based on options const isCloseMatch = (output: FilterPatternFlyResults) => - output.byEntry.length === maxResultsLimit; + (isDynamicLimit ? output.byEntry.length > 0 : output.byEntry.length === updatedMaxResultsLimit); const abortController = new AbortController(); const { signal } = abortController; @@ -478,7 +495,7 @@ const dynamicFilterPatternFly = async ( }); // Limit the filters to ones not already set; cap parallel passes to avoid runaway fan-out. - const filtersToTry = searchFilters + const filtersToTry = updatedSearchFilters .filter(filter => !(useExistingFilters && filters && filters[filter])) .slice(0, maxFilterPasses); @@ -556,6 +573,8 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly const coercedSearchQuery = String(searchQuery).trim(); const updatedResources = await (mcpResources || getPatternFlyMcpResources.memo()); const updatedFilters = filters || {}; + const isUri = isPatternFlyUri(coercedSearchQuery); + const isSha = isShaHexLike(coercedSearchQuery); const isWildCardAll = coercedSearchQuery === '*' || coercedSearchQuery.toLowerCase() === 'all' || coercedSearchQuery === ''; const isSearchWildCardAll = allowWildCardAll && isWildCardAll; const pathMatchName = updatedResources.pathIndex?.get(coercedSearchQuery.toLowerCase()); @@ -566,7 +585,11 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly // Perform wildcard all search or fuzzy search if (isSearchWildCardAll) { - searchResults = updatedResources.keywordsIndex.map(name => ({ matchType: 'all', distance: 0, item: name } as FuzzySearchResult)); + searchResults = updatedResources.keywordsIndex.map(name => ({ + matchType: 'all', + distance: 0, + item: name + } as FuzzySearchResult)); } else if (pathMatchName || uriMatchName || hashMatchName) { searchResults = [ { @@ -575,7 +598,7 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly item: pathMatchName || uriMatchName || hashMatchName } as FuzzySearchResult ]; - } else { + } else if (!isUri && !isSha) { const fuzzySearchSettings: FuzzySearchOptions = { maxDistance, maxResults, @@ -624,7 +647,7 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly let filtered: FilterPatternFlyResults; - // Filter resources. Dynamic filtering applies the search query to each filter as a fallback. + // Filter resources. Dynamic filtering applies the search query to each filter as a fallback to help focus broad result sets. if (dynamicFilter && !isSearchWildCardAll) { filtered = await dynamicFilterPatternFly.memo(coercedSearchQuery, updatedFilters, searchResultsFilterMap); } else { @@ -658,8 +681,8 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly return a.distance - b.distance; } - const relevantA = calculateRelevance(a, coercedSearchQuery); - const relevantB = calculateRelevance(b, coercedSearchQuery); + const relevantA = calculateRelevance(a, coercedSearchQuery, { maxDistance }); + const relevantB = calculateRelevance(b, coercedSearchQuery, { maxDistance }); if (relevantA !== relevantB) { return relevantA - relevantB; diff --git a/src/server.helpers.ts b/src/server.helpers.ts index 018b8b48..80a3d678 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -417,6 +417,40 @@ const generateHash = (anyValue: unknown, { isLowercase = false }: { isLowercase? return hashCode(isLowercase ? stringify.toLowerCase() : stringify); }; +/** + * Check if a value is an SHA-1 hex string. + * + * @param value - Value to check. + * @param [options] - Options. + * @param [options.minLength] - Minimum length of the SHA-1 hex string. + * @param [options.maxLength] - Maximum length of the SHA-1 hex string. + * @returns `true` if the value is an SHA-1 hex-like string + */ +const isShaHexLike = ( + value: unknown, + { + minLength = 8, + maxLength = 40 + }: { minLength?: number; maxLength?: number } = {} +): boolean => { + const updatedValue = typeof value === 'string' ? value.trim() : ''; + const shaHexLeading = /^[a-f0-9]{1}/i; + + if (!updatedValue || updatedValue.length < minLength || updatedValue.length > maxLength || !shaHexLeading.test(updatedValue)) { + return false; + } + + const shaHexFull = /^[a-f0-9]{40}$/i; + + if (shaHexFull.test(updatedValue)) { + return true; + } + + const shaHexPartial = /^[a-f0-9]{4,39}$/i; + + return shaHexPartial.test(updatedValue); +}; + /** * Check if a string URL matches a whitelist entry * @@ -792,6 +826,7 @@ export { isPlainObject, isPromise, isReferenceLike, + isShaHexLike, isUrl, isUrlObject, isWhitelistedUrl, diff --git a/tests/e2e/__snapshots__/httpTransport.test.ts.snap b/tests/e2e/__snapshots__/httpTransport.test.ts.snap index 126c1adb..4c86a54f 100644 --- a/tests/e2e/__snapshots__/httpTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/httpTransport.test.ts.snap @@ -107,7 +107,7 @@ exports[`Builtin tools, HTTP transport should initialize MCP session over HTTP 1 `; exports[`Builtin tools, HTTP transport should return expected markdown structure for search results: markdown 1`] = ` -"# Search results for PatternFly version "v6" and "button". Showing 2 exact matches. +"# Search results for PatternFly version "v6" and "button". Showing 1 exact match. 1. **button**: "usePatternFlyDocs" resource parameter "name" and "URLs" - **Name**: button @@ -119,21 +119,6 @@ exports[`Builtin tools, HTTP transport should return expected markdown structure - **URI**: patternfly://docs/button?version=v6 - **JSON Schemas**: patternfly://schemas/button?version=v6 -2. **patterns**: - "usePatternFlyDocs" resource parameter "name" and "URLs" - - **Name**: patterns - - **URLs**: - - [Actions - (v6) - The best practices for designing processes that a user can trigger by clicking or selecting a UI element, such as a button or link.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/actions/actions.md) - - [Bulk selection - (v6) - A defined method for users to select or deselect multiple items within complex content views or data tables.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/bulk-selection/bulk-selection.md) - - [Card view - (v6) - A structured layout designed to display a grid of cards in a gallery, optimizing for browsing and interaction.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/card-view/card-view.md) - - [Component usage and behavior - (v6) - Guidance on how to choose between similar components and use them appropriately based on specific user contexts and use cases.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/usage-and-behavior.md) - - [Dashboard - (v6) - A highly customizable layout that serves as a high-level overview of key metrics or performance indicators.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/dashboard/dashboard.md) - - [Filters - (v6) - The design rules for implementing filtering mechanisms that allow users to narrow down content from large datasets or complex views.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/filters/filters.md) - - [Primary-detail - (v6) - A two-pane layout that shows a list of items and corresponding details for the currently selected item.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/primary-detail/primary-detail.md) - - [Status and severity - (v6) - Guidance on the consistent and accessible use of color and iconography to communicate status and severity across the UI.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/status-and-severity/status-and-severity.md) - - **Resources**: - - **URI**: patternfly://docs/patterns?version=v6 - --- diff --git a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap index 6e3f4718..5399a28c 100644 --- a/tests/e2e/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/e2e/__snapshots__/stdioTransport.test.ts.snap @@ -107,7 +107,7 @@ exports[`Builtin tools, STDIO should expose expected tools and stable shape 1`] `; exports[`Builtin tools, STDIO should return expected markdown structure for search results: markdown 1`] = ` -"# Search results for PatternFly version "v6" and "button". Showing 2 exact matches. +"# Search results for PatternFly version "v6" and "button". Showing 1 exact match. 1. **button**: "usePatternFlyDocs" resource parameter "name" and "URLs" - **Name**: button @@ -119,21 +119,6 @@ exports[`Builtin tools, STDIO should return expected markdown structure for sear - **URI**: patternfly://docs/button?version=v6 - **JSON Schemas**: patternfly://schemas/button?version=v6 -2. **patterns**: - "usePatternFlyDocs" resource parameter "name" and "URLs" - - **Name**: patterns - - **URLs**: - - [Actions - (v6) - The best practices for designing processes that a user can trigger by clicking or selecting a UI element, such as a button or link.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/actions/actions.md) - - [Bulk selection - (v6) - A defined method for users to select or deselect multiple items within complex content views or data tables.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/bulk-selection/bulk-selection.md) - - [Card view - (v6) - A structured layout designed to display a grid of cards in a gallery, optimizing for browsing and interaction.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/card-view/card-view.md) - - [Component usage and behavior - (v6) - Guidance on how to choose between similar components and use them appropriately based on specific user contexts and use cases.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/usage-and-behavior.md) - - [Dashboard - (v6) - A highly customizable layout that serves as a high-level overview of key metrics or performance indicators.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/dashboard/dashboard.md) - - [Filters - (v6) - The design rules for implementing filtering mechanisms that allow users to narrow down content from large datasets or complex views.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/filters/filters.md) - - [Primary-detail - (v6) - A two-pane layout that shows a list of items and corresponding details for the currently selected item.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/primary-detail/primary-detail.md) - - [Status and severity - (v6) - Guidance on the consistent and accessible use of color and iconography to communicate status and severity across the UI.](https://raw.githubusercontent.com/patternfly/patternfly-org/957756128e8ddfc4be5db49e72312a2c43b9d220/packages/documentation-site/patternfly-docs/content/patterns/status-and-severity/status-and-severity.md) - - **Resources**: - - **URI**: patternfly://docs/patterns?version=v6 - --- diff --git a/tests/e2e/httpTransport.test.ts b/tests/e2e/httpTransport.test.ts index 2e47b3f8..5c938d05 100644 --- a/tests/e2e/httpTransport.test.ts +++ b/tests/e2e/httpTransport.test.ts @@ -211,6 +211,38 @@ describe('Builtin tools, HTTP transport', () => { 'No PatternFly resources found matching "lorem ipsum dolor sit amet"', 'Use a search all' ] + }, + { + description: 'groupId search query', + searchQuery: '96b45dfa08a88ff87015bf35852ce1bce2085de5', + contains: [ + 'Showing 1 exact match', + '**select**' + ] + }, + { + description: 'groupId doc URI search query', + searchQuery: 'patternfly://docs/96b45dfa08a88ff87015bf35852ce1bce2085de5', + contains: [ + 'Showing 1 exact match', + '**select**' + ] + }, + { + description: 'groupId schema URI search query', + searchQuery: 'patternfly://schemas/96b45dfa08a88ff87015bf35852ce1bce2085de5', + contains: [ + 'Showing 1 exact match', + '**select**' + ] + }, + { + description: 'unparameterized doc URI search query', + searchQuery: 'patternfly://docs/button', + contains: [ + 'Showing 1 exact match', + '**button**' + ] } ])('should perform searchPatternFlyDocs: $description', async ({ searchQuery, version, contains }) => { const req = { diff --git a/tests/e2e/stdioTransport.test.ts b/tests/e2e/stdioTransport.test.ts index ed75851b..bb18f769 100644 --- a/tests/e2e/stdioTransport.test.ts +++ b/tests/e2e/stdioTransport.test.ts @@ -210,6 +210,38 @@ describe('Builtin tools, STDIO', () => { 'Use a search all' ] }, + { + description: 'groupId search query', + searchQuery: '96b45dfa08a88ff87015bf35852ce1bce2085de5', + contains: [ + 'Showing 1 exact match', + '**select**' + ] + }, + { + description: 'groupId doc URI search query', + searchQuery: 'patternfly://docs/96b45dfa08a88ff87015bf35852ce1bce2085de5', + contains: [ + 'Showing 1 exact match', + '**select**' + ] + }, + { + description: 'groupId schema URI search query', + searchQuery: 'patternfly://schemas/96b45dfa08a88ff87015bf35852ce1bce2085de5', + contains: [ + 'Showing 1 exact match', + '**select**' + ] + }, + { + description: 'unparameterized doc URI search query', + searchQuery: 'patternfly://docs/button', + contains: [ + 'Showing 1 exact match', + '**button**' + ] + }, { description: 'hash search query', searchQuery: '3ca3928cad9f1dba8587c6080d2c213283f09260',