From 84c0d96fe5b127a0138ee71ffd201055b771fdc2 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 22 May 2026 13:50:53 -0400 Subject: [PATCH 1/2] refactor(tools): pf-3836 allow resource uris in search tool * build, tsconfig move to es2023 target * pf.getResource, hash, uri, and path index maps for search * pf.search, base for hash, uri, and path as search * resources, optimize entry lookup loops, separate responses for templates * server.getResources, optimize processDocsFunction for metadata, better errors * server.helpers, new isUrlObject, parseUrl, enhance buildSearchString, add decodeURIComponent * server.search, enhance findClosest with distance and memo * tool.patternFlyDocs, zod add min to urlList * tool.searchPatternFlyDocs, recommendations for available sections, categories --- .../patternFly.getResources.test.ts.snap | 2 + .../patternFly.search.test.ts.snap | 34 --- src/__tests__/patternFly.getResources.test.ts | 23 ++ src/__tests__/patternFly.search.test.ts | 226 +++++++++++++----- ...resource.patternFlyComponentsIndex.test.ts | 2 +- .../resource.patternFlyDocsIndex.test.ts | 6 +- ...resource.patternFlySchemasTemplate.test.ts | 7 + src/patternFly.getResources.ts | 161 ++++++++++--- src/patternFly.search.ts | 51 +++- src/resource.patternFlyComponentsIndex.ts | 15 +- src/resource.patternFlyDocsIndex.ts | 41 ++-- src/resource.patternFlyDocsTemplate.ts | 49 ++-- src/resource.patternFlySchemasIndex.ts | 16 +- src/resource.patternFlySchemasTemplate.ts | 38 ++- src/server.search.ts | 35 ++- src/tool.patternFlyDocs.ts | 4 +- src/tool.searchPatternFlyDocs.ts | 19 +- tests/e2e/httpTransport.test.ts | 2 +- tests/e2e/stdioTransport.test.ts | 34 ++- tsconfig.json | 2 +- 20 files changed, 517 insertions(+), 250 deletions(-) delete mode 100644 src/__tests__/__snapshots__/patternFly.search.test.ts.snap diff --git a/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap b/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap index a0850655..e6a52184 100644 --- a/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap +++ b/src/__tests__/__snapshots__/patternFly.getResources.test.ts.snap @@ -28,6 +28,8 @@ exports[`getPatternFlyMcpResources should return multiple organized facets: prop "keywordsIndex", "keywordsMap", "pathIndex", + "uriIndex", + "hashIndex", "byPath", "byUri", "byVersion", diff --git a/src/__tests__/__snapshots__/patternFly.search.test.ts.snap b/src/__tests__/__snapshots__/patternFly.search.test.ts.snap deleted file mode 100644 index abc8567c..00000000 --- a/src/__tests__/__snapshots__/patternFly.search.test.ts.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`searchPatternFly should attempt to return an array of all available results, all search: keys 1`] = ` -[ - "isSearchWildCardAll", - "firstExactMatch", - "exactMatches", - "remainingMatches", - "totalResults", - "totalPotentialMatches", -] -`; - -exports[`searchPatternFly should attempt to return an array of all available results, empty all search: keys 1`] = ` -[ - "isSearchWildCardAll", - "firstExactMatch", - "exactMatches", - "remainingMatches", - "totalResults", - "totalPotentialMatches", -] -`; - -exports[`searchPatternFly should attempt to return an array of all available results, wildcard search: keys 1`] = ` -[ - "isSearchWildCardAll", - "firstExactMatch", - "exactMatches", - "remainingMatches", - "totalResults", - "totalPotentialMatches", -] -`; diff --git a/src/__tests__/patternFly.getResources.test.ts b/src/__tests__/patternFly.getResources.test.ts index f1e9ac32..dfd76d44 100644 --- a/src/__tests__/patternFly.getResources.test.ts +++ b/src/__tests__/patternFly.getResources.test.ts @@ -151,4 +151,27 @@ describe('getPatternFlyMcpResources', () => { it('should have a memoized property', async () => { expect(getPatternFlyMcpResources).toHaveProperty('memo'); }); + + it('should have lowercased index keys', async () => { + const result = await getPatternFlyMcpResources(); + + expect(Array.from(result.pathIndex.keys()).some(key => /[A-Z]/.test(key))).toBe(false); + expect(Array.from(result.uriIndex.keys()).some(key => /[A-Z]/.test(key))).toBe(false); + expect(Array.from(result.hashIndex.keys()).some(key => /[A-Z]/.test(key))).toBe(false); + }); + + 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); + const ids = entries.map(entry => entry.id); + const pathlessEntries = entries.filter(entry => !entry.path); + + // Confirm that IDs generally exist. + expect(ids.length).toBeGreaterThan(0); + + const pathlessEntryIds = new Set(pathlessEntries.map(entry => entry.id)); + + // Confirm that all pathless entries have unique IDs. + expect(pathlessEntryIds.size).toBe(pathlessEntries.length); + }); }); diff --git a/src/__tests__/patternFly.search.test.ts b/src/__tests__/patternFly.search.test.ts index b3a58179..3a610fcd 100644 --- a/src/__tests__/patternFly.search.test.ts +++ b/src/__tests__/patternFly.search.test.ts @@ -1,35 +1,83 @@ import { filterPatternFly, searchPatternFly } from '../patternFly.search'; describe('filterPatternFly', () => { + const mockResources = new Map([ + ['button', { + name: 'button', + groupId: 'button-group-id', + entries: [ + { id: 'btn-v6-react', name: 'button', version: 'v6', section: 'components', category: 'action', groupId: 'button-group-id' }, + { id: 'btn-v5-react', name: 'button', version: 'v5', section: 'components', category: 'action', groupId: 'button-group-id' } + ], + versions: { + v6: { + isSchemasAvailable: true, + uri: 'patternfly://docs/button?version=v6', + uriSchemas: 'patternfly://schemas/button?version=v6', + uriSchemasId: 'button-group-id' + }, + v5: { + isSchemasAvailable: false, + uri: 'patternfly://docs/button?version=v5' + } + } + }], + ['modal', { + name: 'modal', + entries: [ + { name: 'modal', section: 'components', category: 'view', version: 'v6' } + ] + }] + ]); + it.each([ { - description: 'all filter', - filters: undefined + description: 'all entries, undefined', + filters: undefined, + expectedNames: ['button', 'button', 'modal'] + }, + { + description: 'all entries, empty object', + filters: {}, + expectedNames: ['button', 'button', 'modal'] + }, + { + description: 'by version', + filters: { version: 'v5' }, + expectedNames: ['button'] + }, + { + description: 'name, button', + filters: { name: 'button' }, + expectedNames: ['button', 'button'] }, { - description: 'all filter empty object', - filters: {} + description: 'name, modal', + filters: { name: 'modal' }, + expectedNames: ['modal'] }, { - description: 'all filter empty object', - filters: { version: 'v5' } + description: 'name, hash', + filters: { name: 'btn-v6-react' }, + expectedNames: ['button'] }, { description: 'section, components', - filters: { section: 'components' } + filters: { section: 'components' }, + expectedNames: ['button', 'button', 'modal'] }, { - description: 'category, accessibility', - filters: { category: 'accessibility' } + description: 'category, action', + filters: { category: 'action' }, + expectedNames: ['button', 'button'] } - ])('should attempt to return filtered results, $description', async ({ filters }) => { - const result = await filterPatternFly(filters as any); + ])('should return filtered results, $description', async ({ filters, expectedNames }) => { + const result = await filterPatternFly(filters as any, mockResources as any); - expect(result.byEntry.length).toBeGreaterThanOrEqual(0); - expect(Array.from(result.byResource).length).toBeGreaterThanOrEqual(0); + expect(result.byEntry.map(result => result.name)).toEqual(expectedNames); }); - it('should attempt to filter number results', async () => { + it('should filter number results', async () => { const result = await filterPatternFly( { section: 1 } as any, new Map([['loremIpsum', { entries: [{ section: 1 }, { section: 'dolor' }] }]]) as any @@ -41,77 +89,133 @@ describe('filterPatternFly', () => { }); describe('searchPatternFly', () => { + const mockMcpResources = { + resources: new Map([ + ['button', { + name: 'button', + groupId: 'btn-group', + entries: [ + { id: 'btn-v6-hash', name: 'button', version: 'v6', section: 'components', category: 'action', groupId: 'btn-group' }, + { id: 'btn-v5-hash', name: 'button', version: 'v5', section: 'components', category: 'action', groupId: 'btn-group' } + ], + versions: { + v6: { uri: 'patternfly://docs/button?version=v6', isSchemasAvailable: true }, + v5: { uri: 'patternfly://docs/button?version=v5', isSchemasAvailable: false } + } + }], + ['modal', { + name: 'modal', + groupId: 'mdl-group', + entries: [{ id: 'mdl-v6-hash', name: 'modal', version: 'v6', section: 'components', category: 'view', groupId: 'mdl-group' }], + versions: { v6: { uri: 'patternfly://docs/modal?version=v6', isSchemasAvailable: true } } + }] + ]), + keywordsIndex: [ + 'button', + 'modal', + 'btn-v6-hash', + 'mdl-v6-hash', + 'patternfly://docs/button', + 'patternfly://docs/modal' + ], + keywordsMap: new Map([ + ['button', new Map([['v6', ['button']], ['v5', ['button']]])], + ['modal', new Map([['v6', ['modal']]])], + ['btn-v6-hash', new Map([['v6', ['button']]])], + ['mdl-v6-hash', new Map([['v6', ['modal']]])], + ['patternfly://docs/button', new Map([['v6', ['button']], ['v5', ['button']]])], + ['patternfly://docs/modal', new Map([['v6', ['modal']]])] + ]), + latestVersion: 'v6' + }; + + const mockOptions = { mcpResources: Promise.resolve(mockMcpResources) as any }; + it.each([ { - description: 'wildcard search', - search: '*' + description: 'exact match', + search: 'button', + expectedLength: 1, + expectedName: 'button', + expectedType: 'exact' }, { - description: 'all search', - search: 'all' + description: 'partial prefix', + search: 'but', + expectedLength: 1, + expectedName: 'button', + expectedType: 'prefix' }, { - description: 'empty all search', - search: '' - } - ])('should attempt to return an array of all available results, $description', async ({ search }) => { - const { searchResults, ...rest } = await searchPatternFly(search, undefined, { allowWildCardAll: true }); - - expect(searchResults.length).toBeGreaterThan(0); - expect(Object.keys(rest)).toMatchSnapshot('keys'); - }); - - it.each([ + description: 'partial suffix', + search: 'ton', + expectedLength: 1, + expectedName: 'button', + expectedType: 'suffix' + }, { - description: 'exact match', - search: 'react', - matchType: 'exact' + description: 'partial contains', + search: 'utto', + expectedLength: 1, + expectedName: 'button', + expectedType: 'contains' }, { - description: 'partial prefix match', - search: 're', - matchType: 'prefix' + description: 'patternfly:// URI', + search: 'patternfly://docs/modal', + expectedLength: 1, + expectedName: 'modal', + expectedType: 'exact' }, { - description: 'partial suffix match', - search: 'act', - matchType: 'suffix' + description: 'hash entry id with filter', + search: 'btn-v6-hash', + options: {}, + expectedLength: 2, + expectedName: 'button', + expectedType: 'exact' }, { - description: 'partial contains match', - search: 'eac', - matchType: 'contains' + description: 'hash entry id without filter', + search: 'btn-v6-hash', + options: {}, + expectedLength: 2, + expectedName: 'button', + expectedType: 'exact' + }, + { + description: 'version filter', + search: 'button', + filters: { version: 'v5' }, + expectedLength: 1, + expectedName: 'button', + expectedType: 'exact' } - ])('should attempt to match components and keywords, $description', async ({ search, matchType }) => { - const { searchResults } = await searchPatternFly(search); + ])('should return search results, $description', async ({ search, filters, options, expectedLength, expectedName, expectedType }) => { + const { searchResults } = await searchPatternFly(search, { ...filters }, { ...options, ...mockOptions }); - expect(searchResults.find(({ matchType: returnMatchType }) => returnMatchType === matchType)).toEqual(expect.objectContaining({ - query: expect.stringMatching(search) - })); + expect(searchResults?.length).toBe(expectedLength); + expect(searchResults?.[0]?.matchType).toBe(expectedType); + expect(searchResults?.[0]?.name).toBe(expectedName); }); it.each([ { - description: 'version', - search: 'about modal', - filters: { version: 'v5' } + description: 'wildcard search', + search: '*' }, { - description: 'section', - search: 'popover', - filters: { section: 'components' } + description: 'all search', + search: 'all' }, { - description: 'category', - search: '*', - filters: { category: 'grammar' }, - options: { allowWildCardAll: true } + description: 'empty all search', + search: '' } - ])('should allow filtering, $description', async ({ search, filters, options }) => { - const { searchResults, totalResults, totalPotentialMatches } = await searchPatternFly(search, filters, options || {}); + ])('should return an array of all available results, $description', async ({ search }) => { + const { searchResults } = await searchPatternFly(search, undefined, { allowWildCardAll: true, ...mockOptions }); - expect(searchResults.length).toBeGreaterThanOrEqual(0); - expect(totalResults).toBeGreaterThanOrEqual(searchResults.length); - expect(totalPotentialMatches).toBeGreaterThanOrEqual(totalResults); + expect(searchResults?.length).toBe(2); + expect(searchResults?.[0]?.matchType).toBe('all'); }); }); diff --git a/src/__tests__/resource.patternFlyComponentsIndex.test.ts b/src/__tests__/resource.patternFlyComponentsIndex.test.ts index 6b477734..b8c1bf75 100644 --- a/src/__tests__/resource.patternFlyComponentsIndex.test.ts +++ b/src/__tests__/resource.patternFlyComponentsIndex.test.ts @@ -55,7 +55,7 @@ describe('resourceCallback', () => { variables: { category: 'accessibility' }, - expected: '?category=accessibility' + expected: 'category=accessibility' } ])('should return context content, $description', async ({ variables, expected }) => { const result = await resourceCallback(undefined as any, variables); diff --git a/src/__tests__/resource.patternFlyDocsIndex.test.ts b/src/__tests__/resource.patternFlyDocsIndex.test.ts index ec5dfcf5..5de2b034 100644 --- a/src/__tests__/resource.patternFlyDocsIndex.test.ts +++ b/src/__tests__/resource.patternFlyDocsIndex.test.ts @@ -211,14 +211,14 @@ describe('resourceCallback', () => { variables: { category: 'accessibility' }, - expected: '?category=accessibility' + expected: 'category=accessibility' }, { description: 'section', variables: { section: 'components' }, - expected: '?section=components' + expected: 'section=components' }, { description: 'category and section', @@ -226,7 +226,7 @@ describe('resourceCallback', () => { category: 'accessibility', section: 'components' }, - expected: '?category=accessibility§ion=components' + expected: 'category=accessibility§ion=components' } ])('should return context content, $description', async ({ variables, expected }) => { const result = await resourceCallback(undefined as any, variables); diff --git a/src/__tests__/resource.patternFlySchemasTemplate.test.ts b/src/__tests__/resource.patternFlySchemasTemplate.test.ts index 9f4cdf47..2e7cde35 100644 --- a/src/__tests__/resource.patternFlySchemasTemplate.test.ts +++ b/src/__tests__/resource.patternFlySchemasTemplate.test.ts @@ -77,6 +77,13 @@ describe('resourceCallback', () => { name: 'button', version: 'v6' } + }, + { + description: 'with hashed button name', + variables: { + name: 'ffcfb1b9b852a17ccb5b2adc12e3edd4a4ee41cb', + version: 'v6' + } } ])('should attempt to return resource content, $description', async ({ variables }) => { const mockContent = '$schema'; diff --git a/src/patternFly.getResources.ts b/src/patternFly.getResources.ts index c53b5a88..5900b1c7 100644 --- a/src/patternFly.getResources.ts +++ b/src/patternFly.getResources.ts @@ -3,6 +3,7 @@ import { getComponentSchema } from '@patternfly/patternfly-component-schemas/json'; import { memo } from './server.caching'; +import { buildSearchString, generateHash } from './server.helpers'; import { DEFAULT_OPTIONS } from './options.defaults'; import { getPatternFlyVersionContext, @@ -70,16 +71,25 @@ interface PatternFlyMcpComponentNames { /** * PatternFly JSON extended documentation metadata * - * @property name - The name of component entry. - * @property displayCategory - The display category of component entry. - * @property uri - The parent resource URI of component entry. - * @property uriSchemas - The parent resource URI of component schemas. **DO NOT EXPECT THIS PROPERTY TO EXIST**. **Contextual based on search and filtering.** + * @property id - The unique identifier of document entry. + * @property groupId - The unique identifier for the document's parent. + * @property name - The name of document entry. + * @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 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. */ type PatternFlyMcpDocsMeta = { + id: string; + groupId: string; name: string; displayCategory: string; uri: string; - uriSchemas?: string | undefined + uriId: string; + uriSchemas?: string | undefined; + uriSchemasId?: string | undefined; }; /** @@ -99,6 +109,8 @@ type PatternFlyMcpResourcesByPath = { /** * PatternFly resources by URI with a list of entries. + * + * @deprecated Under review. Use `uriIndex`. */ type PatternFlyMcpResourcesByUri = { [uri: string]: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; @@ -119,22 +131,29 @@ type PatternFlyMcpKeywordsMap = Map>; /** * PatternFly resource metadata. * - * @note This might need to be called resource metadata. `docs.json` doesn't just contain component metadata. + * Contextual properties + * - Contextual properties are populated based on search and filtering. + * - Do not expect them to exist, make sure to conditionally load them. * * @property name - The name of component entry. - * @property isSchemasAvailable - Whether schemas are available for this component **DO NOT EXPECT THIS PROPERTY TO EXIST**. **Contextual based on search and filtering.** - * @property uri - The URI of component entry. **DO NOT EXPECT THIS PROPERTY TO EXIST**. **Contextual based on search and filtering.** - * @property uriSchemas - The URI of component schemas. **DO NOT EXPECT THIS PROPERTY TO EXIST**. **Contextual based on search and filtering.** * @property entries - All entry PatternFly documentation entries. * @property versions - Entry segmented by versions. Contains all the same properties. + * @property groupId - The unique identifier for the document group. + * @property isSchemasAvailable - see {@link PatternFlyMcpDocsMeta.isSchemasAvailable} **CONTEXTUAL**. + * @property uri - see {@link PatternFlyMcpDocsMeta.uri} **CONTEXTUAL**. + * @property uriSchemas - see {@link PatternFlyMcpDocsMeta.uriSchemas} **CONTEXTUAL**. + * @property uriSchemasId - see {@link PatternFlyMcpDocsMeta.uriSchemasId} **CONTEXTUAL**. */ type PatternFlyMcpResourceMetadata = { name: string; + entries: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; + versions: Record>; + groupId: string; + isSchemasAvailable: boolean | undefined; uri: string | undefined; uriSchemas: string | undefined; - entries: (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta)[]; - versions: Record>; + uriSchemasId: string | undefined; }; /** @@ -147,14 +166,17 @@ type PatternFlyMcpResourceMetadata = { * @extends PatternFlyVersionContext * * @property resources - Patternfly available documentation and metadata by resource name. - * @property docsIndex - Patternfly available documentation index. - * @property componentsIndex - Patternfly available components index. + * @property docsIndex - `@deprecated Under review. Use Array.from(resources.keys()) instead`. Patternfly available documentation index. + * @property componentsIndex - `@deprecated Under review. Use keywordsIndex for search or byVersionComponentNames for lookups`. + * Patternfly available components index. * @property keywordsIndex - Patternfly available keywords index. * @property keywordsMap - Patternfly available keywords by resource name then by version. * @property isFallbackDocumentation - Whether the fallback documentation is used. - * @property pathIndex - Patternfly documentation path index. + * @property pathIndex - Patternfly documentation path->name map for helping refine search results. + * @property uriIndex - Patternfly documentation uri->name map for helping refine search results. + * @property hashIndex - Patternfly documentation hash->name map for helping refine search results. * @property byPath - Patternfly documentation by path with entries - * @property byUri - Patternfly documentation by uri with entries + * @property byUri - `@deprecated Under review. Use uriIndex`. Patternfly documentation by uri with entries * @property byVersion - Patternfly documentation by version with entries * @property byVersionComponentNames - Patternfly documentation by version with component names */ @@ -165,7 +187,9 @@ interface PatternFlyMcpAvailableResources extends PatternFlyVersionContext { keywordsIndex: string[]; keywordsMap: PatternFlyMcpKeywordsMap; isFallbackDocumentation: boolean; - pathIndex: string[]; + pathIndex: Map; + uriIndex: Map; + hashIndex: Map; byPath: PatternFlyMcpResourcesByPath; byUri: PatternFlyMcpResourcesByUri; byVersion: PatternFlyMcpResourcesByVersion; @@ -303,6 +327,26 @@ const getPatternFlyComponentNames = async (contextPathOverride?: string): Promis */ getPatternFlyComponentNames.memo = memo(getPatternFlyComponentNames); +/** + * Transform two string arrays into sets with all words lowercased and trimmed. + * + * @private + * @param {string[]} listA - First array of strings to be processed. + * @param {string[]} listB - Second array of strings to be processed. + * @returns {Object} An object containing two sets: + * - `listA`: A set containing unique processed words from the first array. + * - `listB`: A set containing unique processed words from the second array. + */ +const setWordLists = (listA: string[], listB: string[]) => ({ + listA: new Set(listA.map(word => word.toLowerCase().trim())), + listB: new Set(listB.map(word => word.toLowerCase().trim())) +}); + +/** + * Memoized version of setWordLists. + */ +setWordLists.memo = memo(setWordLists, { cacheLimit: 3 }); + /** * Filter keywords using the exception list and noise-word rules. * @@ -323,24 +367,28 @@ const filterKeywords = ( ) => { const filteredKeywords: PatternFlyMcpKeywordsMap = new Map(); + // Pre-process into Sets + const { listA: exceptionSet, listB: filterSet } = setWordLists.memo(exceptionList, filterList); + + // Keep the original list for prefix/suffix checks + const normalizedFilterList = Array.from(filterSet); + for (const [keyword, versionMap] of keywordsMap) { const updatedKeyword = keyword.toLowerCase().trim(); - // Exception match, never filter these out. - if (exceptionList.includes(updatedKeyword)) { + // Exception match + if (exceptionSet.has(updatedKeyword)) { filteredKeywords.set(keyword, versionMap); continue; } - const isVariant = filterList.some(word => { - const updatedWord = word.toLowerCase().trim(); - - // Exact match - if (updatedKeyword === updatedWord) { - return true; - } + // Exact filter match + if (filterSet.has(updatedKeyword)) { + continue; + } - // Related match, is filterList word related? + // Related match (prefix/suffix) + const isVariant = normalizedFilterList.some(updatedWord => { if (Math.abs(updatedKeyword.length - updatedWord.length) <= distanceMatch) { return updatedKeyword.startsWith(updatedWord) || updatedKeyword.endsWith(updatedWord); } @@ -389,6 +437,9 @@ const mutateKeyWordsMap = ( const initialSplit = normalizedKeyword.split(' ').filter(Boolean); const isMultipleWords = initialSplit.length > 1; + // Pre-process into Sets + const { listA: blockSet, listB: exceptionSet } = setWordLists.memo(blockList, exceptionList); + const mutateMap = (word: string) => { if (!keywordsMap.has(word)) { keywordsMap.set(word, new Map()); @@ -414,11 +465,11 @@ const mutateKeyWordsMap = ( for (const word of splitKeywords) { const lowerWord = word.toLowerCase(); - if (blockList.includes(lowerWord)) { + if (blockSet.has(lowerWord)) { continue; } - if (word.length <= lengthFilter && !exceptionList.includes(lowerWord)) { + if (word.length <= lengthFilter && !exceptionSet.has(lowerWord)) { continue; } @@ -433,6 +484,10 @@ const mutateKeyWordsMap = ( /** * Get a multifaceted resources breakdown from PatternFly. * + * @note `resources.set(name...` includes `undefined` PF version contextual metadata by design. These values + * are populated during search and filter services when `entries` are matched against PF versions. Review + * separating the typings for clarity. + * * @param contextPathOverride - Context path for updating the returned PatternFly versions. * @returns A multifaceted documentation breakdown. Use the "memoized" property for performance. */ @@ -446,63 +501,88 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< const byPath: PatternFlyMcpResourcesByPath = {}; const byUri: PatternFlyMcpResourcesByUri = {}; const byVersion: PatternFlyMcpResourcesByVersion = {}; - const pathIndex = new Set(); + const pathIndexMap = new Map(); + const uriIndexMap = new Map(); + const hashIndexMap = new Map(); const rawKeywordsMap: PatternFlyMcpKeywordsMap = new Map(); const catalog = [...Object.entries(originalDocs.docs), ...Array.from(componentNamesByDocs)]; catalog.forEach(([unifiedName, entries]) => { const name = unifiedName.toLowerCase(); + const groupId = generateHash(name); + + hashIndexMap.set(groupId.toLowerCase(), name); if (!resources.has(name)) { - // isSchemasAvailable, uri, and uriSchemas are contextually populated from the patternFly.search - // functions, search and filter. They are intended to be undefined here. + // Include search and filter contextual `undefined` metadata for each resource. resources.set(name, { name, + groupId, + entries: [], + versions: {}, isSchemasAvailable: undefined, uri: undefined, uriSchemas: undefined, - entries: [], - versions: {} + uriSchemasId: undefined }); } const resource = resources.get(name) as PatternFlyMcpResourceMetadata; entries.forEach(entry => { + // Technically, we could just dump `entry` into generateHash as the fallback, but it'd be prone to frequent shifting based on updates. const version = (entry.version || 'unknown').toLowerCase(); + 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)}?version=${encodeURIComponent(version)}`; + const uri = `patternfly://docs/${encodeURIComponent(name)}${buildSearchString({ version }, { prefix: true })}`; + const uriId = `patternfly://docs/${encodeURIComponent(id)}`; + + hashIndexMap.set(id.toLowerCase(), name); + uriIndexMap.set(uri.toLowerCase(), name); + uriIndexMap.set(uriId.toLowerCase(), name); if (path) { - pathIndex.add(path); + pathIndexMap.set(path.toLowerCase(), name); } resource.versions[version] ??= { + groupId, isSchemasAvailable, uri, uriSchemas: undefined, + uriSchemasId: undefined, entries: [] }; const displayName = entry.displayName || name; const displayCategory = setCategoryDisplayLabel(entry as PatternFlyMcpDocsCatalogDoc); let uriSchemas; + let uriSchemasId; if (isSchemasAvailable) { - uriSchemas = `patternfly://schemas/${encodeURIComponent(name)}?version=${encodeURIComponent(version)}`; + uriSchemas = `patternfly://schemas/${encodeURIComponent(name)}${buildSearchString({ version }, { prefix: true })}`; + uriSchemasId = `patternfly://schemas/${encodeURIComponent(groupId)}`; resource.versions[version].uriSchemas = uriSchemas; + resource.versions[version].uriSchemasId = uriSchemasId; + + uriIndexMap.set(uriSchemas.toLowerCase(), name); + uriIndexMap.set(uriSchemasId.toLowerCase(), name); } const extendedEntry = { ...entry, + id, + groupId, name, displayName, displayCategory, uri, - uriSchemas + uriId, + uriSchemas, + uriSchemasId } as (PatternFlyMcpDocsCatalogDoc & PatternFlyMcpDocsMeta); if (path) { @@ -552,7 +632,9 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< return { ...versionContext, resources, + // @deprecated docsIndex - Under review docsIndex: Array.from(resources.keys()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })), + // @deprecated componentsIndex - Under review componentsIndex: componentNamesIndex, isFallbackDocumentation: originalDocs.isFallback, keywordsIndex: Array.from(new Set([ @@ -560,8 +642,11 @@ const getPatternFlyMcpResources = async (contextPathOverride?: string): Promise< ...Array.from(filteredKeywords.keys()) ])).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })), keywordsMap: filteredKeywords, - pathIndex: Array.from(pathIndex).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })), + pathIndex: pathIndexMap, + uriIndex: uriIndexMap, + hashIndex: hashIndexMap, byPath, + // @deprecated byUri - Under review byUri, byVersion, byVersionComponentNames: componentNamesByVersion diff --git a/src/patternFly.search.ts b/src/patternFly.search.ts index 554fc8e2..fd42af69 100644 --- a/src/patternFly.search.ts +++ b/src/patternFly.search.ts @@ -1,4 +1,9 @@ -import { fuzzySearch, type FuzzySearch, type FuzzySearchResult } from './server.search'; +import { + fuzzySearch, + type FuzzySearch, + type FuzzySearchOptions, + type FuzzySearchResult +} from './server.search'; import { memo } from './server.caching'; import { DEFAULT_OPTIONS } from './options.defaults'; import { @@ -26,13 +31,15 @@ type PatternFlyMcpResourceFilteredMetadata = Omit group id -> group name + const matchesName = !updatedFilters.name || filterMatch(entry.id, updatedFilters.name) || + filterMatch(entry.groupId, updatedFilters.name) || filterMatch(entry.name, updatedFilters.name); // Any missing filter registers as true. Only filters that are active run their check. - return matchesVersion && matchesCategory && matchesSection && matchesName; + return matchesVersion && matchesCategory && matchesSection && matchesPath && matchesName; }); if (matchedEntries.length > 0) { @@ -173,12 +187,14 @@ const filterPatternFly = async ( const { versions, ...filteredResource } = resource; let versionContextualProperties = {}; - // Apply version contextual properties, typically URIs + // Apply version contextual properties, typically group/resource related URIs. if (updatedFilters.version && versions?.[updatedFilters.version]) { + // General props version dependent versionContextualProperties = { isSchemasAvailable: versions[updatedFilters.version]?.isSchemasAvailable, uri: versions[updatedFilters.version]?.uri, - uriSchemas: versions[updatedFilters.version]?.uriSchemas + uriSchemas: versions[updatedFilters.version]?.uriSchemas, + uriSchemasId: versions[updatedFilters.version]?.uriSchemasId }; } @@ -223,7 +239,7 @@ filterPatternFly.memo = memo(filterPatternFly, DEFAULT_OPTIONS.resourceMemoOptio * @param [settings.maxResults] - Maximum number of results to return. Defaults to `10`. * @returns Object containing search results and matched URLs * - `isSearchWildCardAll`: Whether the search query matched all resources - * - `firstExactMatch`: First exact match within search results + * - `firstExactMatch`: `@deprecated` See {@link SearchPatternFlyResults#exactMatches} Exact-ranked result * - `exactMatches`: Exact matches within search results * - `remainingMatches`: Contrast to `exactMatches`, the remaining matches within search results * - `searchResults`: All search results, exact and remaining matches @@ -241,21 +257,33 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly const updatedFilters = filters || {}; const isWildCardAll = coercedSearchQuery === '*' || coercedSearchQuery.toLowerCase() === 'all' || coercedSearchQuery === ''; const isSearchWildCardAll = allowWildCardAll && isWildCardAll; + const pathMatchName = updatedResources.pathIndex?.get(coercedSearchQuery.toLowerCase()); + const uriMatchName = updatedResources.uriIndex?.get(coercedSearchQuery.toLowerCase()); + const hashMatchName = updatedResources.hashIndex?.get(coercedSearchQuery.toLowerCase()); let search: FuzzySearch | undefined; let searchResults: FuzzySearchResult[] = []; // Perform wildcard all search or fuzzy search if (isSearchWildCardAll) { searchResults = updatedResources.keywordsIndex.map(name => ({ matchType: 'all', distance: 0, item: name } as FuzzySearchResult)); + } else if (pathMatchName || uriMatchName || hashMatchName) { + searchResults = [ + { + matchType: 'exact', + distance: 0, + item: pathMatchName || uriMatchName || hashMatchName + } as FuzzySearchResult + ]; } else { - // Pass the original searchQuery, fuzzySearch has its own normalization. - search = fuzzySearch(searchQuery, updatedResources.keywordsIndex, { + const fuzzySearchSettings: FuzzySearchOptions = { maxDistance, maxResults, isFuzzyMatch: true, deduplicateByNormalized: true - }); + }; + // Pass the original searchQuery, fuzzySearch has its own normalization. + search = fuzzySearch(searchQuery, updatedResources.keywordsIndex, fuzzySearchSettings); searchResults = search.results; } @@ -330,6 +358,7 @@ const searchPatternFly = async (searchQuery: unknown, filters?: FilterPatternFly return { isSearchWildCardAll, + // @deprecated firstExactMatch - Use exactMatches[0] or searchResults firstExactMatch: sortedExactMatches[0], exactMatches: sortedExactMatches.slice(0, maxResults), remainingMatches: (maxResults - exactMatches.length) < 0 ? [] : sortedRemainingMatches.slice(0, maxResults - exactMatches.length), diff --git a/src/resource.patternFlyComponentsIndex.ts b/src/resource.patternFlyComponentsIndex.ts index 351d4440..eddcbdab 100644 --- a/src/resource.patternFlyComponentsIndex.ts +++ b/src/resource.patternFlyComponentsIndex.ts @@ -159,15 +159,12 @@ const resourceCallback = async (passedUri: URL, variables: Record aData.name.localeCompare(bData.name)) - .map(([_name, data], index) => { - const searchString = buildSearchString({ - version: updatedVersion, - category - }, { prefix: true }); - - return `${index + 1}. [${data.name} (${updatedVersion})](${data.uri}${searchString || ''})`; + const docsIndex = Array.from(byResource.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((resource, index) => { + const searchString = buildSearchString({ category }, { prefix: true, base: resource.uri }); + + return `${index + 1}. [${resource.name} (${updatedVersion})](${resource.uri}${searchString || ''})`; }); return { diff --git a/src/resource.patternFlyDocsIndex.ts b/src/resource.patternFlyDocsIndex.ts index 04960937..ef4bf31c 100644 --- a/src/resource.patternFlyDocsIndex.ts +++ b/src/resource.patternFlyDocsIndex.ts @@ -184,6 +184,11 @@ uriVersionComplete.memo = memo(uriVersionComplete); /** * Resource callback for the documentation index. * + * @note The callback response is a high-level index potentially grouping multiple "entries" + * by a single URI. This is an optimization already, but we can review moving responses over + * to using resource IDs instead of the current grouping uri mechanism IF we opt to review + * pagination. + * * @param passedUri - URI of the resource. * @param variables - Variables for the resource. * @param options - Global options @@ -223,35 +228,23 @@ const resourceCallback = async (passedUri: URL, variables: Record }>(); - - byEntry.forEach(entry => { - if (!groupedByUri.has(entry.uri)) { - groupedByUri.set(entry.uri, { - name: entry.name, - version: entry.version, - categories: new Set([entry.displayCategory]) - }); - } else { - groupedByUri.get(entry.uri)?.categories.add(entry.displayCategory); - } - }); - - // Generate the consolidated list, apply search/query string - const docsIndex = Array.from(groupedByUri.entries()) - .sort(([_aUri, aData], [_bUri, bData]) => aData.name.localeCompare(bData.name)) - .map(([uri, data], index) => { - const categoryList = Array.from(data.categories).join(', '); - const searchString = buildSearchString({ section, category }, { prefix: true }); - - return `${index + 1}. [${data.name} - ${categoryList} (${data.version})](${uri}${searchString || ''})`; + // Generate the consolidated list, apply search/query string. + const docsIndex = Array.from(byResource.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((resource, index) => { + const firstEntry = resource.entries[0]; + const version = firstEntry?.version || updatedVersion; + const categories = new Set(resource.entries.map(entry => entry.displayCategory)); + const categoryList = Array.from(categories).sort().join(', '); + const searchString = buildSearchString({ section, category }, { prefix: true, base: resource.uri }); + + return `${index + 1}. [${resource.name} - ${categoryList} (${version})](${resource.uri}${searchString || ''})`; }); assertInput( diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts index 8472e3b4..3fd230d0 100644 --- a/src/resource.patternFlyDocsTemplate.ts +++ b/src/resource.patternFlyDocsTemplate.ts @@ -116,16 +116,25 @@ const resourceCallback = async (passedUri: URL, variables: Record entry.path).filter(Boolean); - - if (matchedUrls.length > 0) { - const processedDocs = await processDocsFunction.memo(matchedUrls); - - docs.push(...processedDocs); + const docPaths = byEntry + .filter(({ path }) => path) + .map(({ path, uriId }) => ({ doc: path, uri: uriId })); + + if (docPaths.length > 0) { + // `processDocsFunction` has de-dup docs baked in + const processedDocs = await processDocsFunction.memo(docPaths); + + // Failures are `log.debugged` in `processDocsFunction`. + for (const response of processedDocs) { + if (response.isSuccess) { + docs.push({ + ...response + }); + } + } } } catch (error) { throw new McpError( @@ -149,26 +158,20 @@ const resourceCallback = async (passedUri: URL, variables: Record ({ + uri, + mimeType: 'text/markdown', + text: stringJoin.newline( + `# Documentation from ${resolvedPath || path}`, + '', + content + ) + })) }; }; diff --git a/src/resource.patternFlySchemasIndex.ts b/src/resource.patternFlySchemasIndex.ts index 689eb642..1ab28319 100644 --- a/src/resource.patternFlySchemasIndex.ts +++ b/src/resource.patternFlySchemasIndex.ts @@ -111,17 +111,11 @@ const resourceCallback = async (passedUri: URL, variables: Record(); - - byResource.forEach(resource => { - if (resource.uriSchemas) { - groupedByUri.set(resource.uriSchemas, { name: resource.name, version: updatedVersion }); - } - }); - - docsIndex = Array.from(groupedByUri.entries()) - .sort(([_aUri, aData], [_bUri, bData]) => aData.name.localeCompare(bData.name)) - .map(([uri, data], index) => `${index + 1}. [${data.name} (${data.version})](${uri})`); + docsIndex = Array.from(byResource.values()) + .filter(resource => resource.uriSchemas) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((resource, index) => + `${index + 1}. [${resource.name} (${updatedVersion})](${resource.uriSchemas})`); } assertInput( diff --git a/src/resource.patternFlySchemasTemplate.ts b/src/resource.patternFlySchemasTemplate.ts index 64fe0575..57bbd751 100644 --- a/src/resource.patternFlySchemasTemplate.ts +++ b/src/resource.patternFlySchemasTemplate.ts @@ -9,8 +9,7 @@ import { getOptions, runWithOptions } from './options.context'; import { filterPatternFly } from './patternFly.search'; import { getPatternFlyComponentSchema, - getPatternFlyMcpResources, - type PatternFlyComponentSchema + getPatternFlyMcpResources } from './patternFly.getResources'; import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; import { uriCategoryComplete, uriVersionComplete } from './resource.patternFlyComponentsIndex'; @@ -97,26 +96,27 @@ const resourceCallback = async (passedUri: URL, variables: Record res.isSchemasAvailable); + const schemaResults = []; - byEntry.forEach(result => { - if (result.uriSchemas) { - matchedSchemas.push(result.name); - } - }); + for (const resource of matchedResources) { + const content = await getPatternFlyComponentSchema.memo(resource.name); - if (matchedSchemas[0]) { - result = await getPatternFlyComponentSchema.memo(matchedSchemas[0]); + if (content) { + schemaResults.push({ + uriSchemasId: resource.uriSchemasId as string, + content + }); + } } assertInput( - matchedSchemas.length > 0 && result !== undefined, + schemaResults.length > 0, () => { let suggestionMessage = ''; @@ -129,13 +129,11 @@ const resourceCallback = async (passedUri: URL, variables: Record ({ + uri: schema.uriSchemasId, + mimeType: 'application/json', + text: JSON.stringify(schema.content, null, 2) + })) }; }; diff --git a/src/server.search.ts b/src/server.search.ts index e64603cc..1861cd15 100644 --- a/src/server.search.ts +++ b/src/server.search.ts @@ -6,10 +6,12 @@ import { memo } from './server.caching'; * * @interface ClosestSearchOptions * + * @property maxDistance - Maximum edit distance for a match. Generally, stick to values `<= 5` for meaningful results. * @property missingReturnValue - The value to return when no match is found. * @property normalizeFn - Function to normalize strings for comparison. */ interface ClosestSearchOptions { + maxDistance?: number | undefined; missingReturnValue?: unknown; normalizeFn?: (str: unknown) => string; } @@ -84,6 +86,20 @@ interface FuzzySearchOptions { deduplicateByNormalized?: boolean; } +/** + * `distance` wrapper to calculate Levenshtein distance between two strings. + * + * @param a - First string to compare + * @param b - Second string to compare + * @returns Levenshtein distance between the two strings. + */ +const findDistance = (a: string, b: string) => distance(a, b); + +/** + * Memoized version of findDistance. + */ +findDistance.memo = memo(findDistance, { cacheLimit: 50 }); + /** * Internal lightweight normalization: coerce any value to string, trim, lowercase, * remove diacritics (a sign/accent character), squash separators. @@ -131,6 +147,7 @@ const findClosest = ( query: unknown, items: unknown[] = [], { + maxDistance, missingReturnValue = null, normalizeFn = normalizeString.memo }: ClosestSearchOptions = {} @@ -149,9 +166,24 @@ const findClosest = ( return missingReturnValue; } + if (items[itemIndex] && typeof maxDistance === 'number') { + const dis = findDistance.memo(normalizedQuery, normalizedItems[itemIndex] as string); + + if (dis <= maxDistance) { + return items[itemIndex]; + } else { + return missingReturnValue; + } + } + return items[itemIndex]; }; +/** + * Memoized version of findClosest + */ +findClosest.memo = memo(findClosest); + /** * Fuzzy search using fastest-levenshtein * @@ -232,7 +264,7 @@ const fuzzySearch = ( Math.abs(normalizedItem.length - normalizedQuery.length) <= maxDistance ) { matchType = 'fuzzy'; - editDistance = distance(normalizedItem, normalizedQuery); + editDistance = findDistance.memo(normalizedItem, normalizedQuery); } if (matchType === undefined) { @@ -275,6 +307,7 @@ export { normalizeString, fuzzySearch, findClosest, + findDistance, type ClosestSearchOptions, type FuzzySearch, type FuzzySearchResult, diff --git a/src/tool.patternFlyDocs.ts b/src/tool.patternFlyDocs.ts index d41ae0d8..687c6609 100644 --- a/src/tool.patternFlyDocs.ts +++ b/src/tool.patternFlyDocs.ts @@ -234,8 +234,8 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { - Component JSON schemas, if available `, inputSchema: { - urlList: z.array(z.string()).max(options.minMax.docsToLoad.max) - .optional().describe(`The list of URLs to fetch the documentation from (max ${options.minMax.docsToLoad.max} at a time`), + urlList: z.array(z.url().min(options.minMax.urlString.min).max(options.minMax.urlString.max)).max(options.minMax.docsToLoad.max) + .optional().describe(`The list of URLs to fetch the documentation from (max ${options.minMax.docsToLoad.max} at a time)`), name: z.string().max(options.minMax.inputStrings.max) .optional().describe('The name of a PatternFly component or resource to fetch documentation for (e.g., "Button", "Table", "Writing")'), version: z.enum(options.patternflyOptions.availableSearchVersions) diff --git a/src/tool.searchPatternFlyDocs.ts b/src/tool.searchPatternFlyDocs.ts index 7aa87441..6f2b469f 100644 --- a/src/tool.searchPatternFlyDocs.ts +++ b/src/tool.searchPatternFlyDocs.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { type McpTool } from './mcpSdk'; import { stringJoin } from './server.helpers'; import { assertInput, assertInputStringLength, assertInputStringNumberEnumLike } from './server.assertions'; +import { findClosest } from './server.search'; import { getOptions } from './options.context'; import { searchPatternFly } from './patternFly.search'; import { getPatternFlyMcpResources } from './patternFly.getResources'; @@ -39,7 +40,7 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { }); } - const { latestVersion } = await getPatternFlyMcpResources.memo(); + const { keywordsIndex, latestVersion } = await getPatternFlyMcpResources.memo(); const normalizedVersion = await normalizeEnumeratedPatternFlyVersion(version); const updatedVersion = normalizedVersion || latestVersion; @@ -59,11 +60,16 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { ); if (!isSearchWildCardAll && searchResults.length === 0) { + const suggestion = findClosest.memo(searchQuery, keywordsIndex.toReversed(), { maxDistance: 5 }); + return { content: [{ type: 'text', - text: stringJoin.newline( - `No PatternFly resources found matching "${searchQuery}"`, + text: stringJoin.newlineFiltered( + stringJoin.filtered( + `No PatternFly resources found matching "${searchQuery}".`, + suggestion && `Try a search for "${suggestion}".` + ), options.separator, '**Important**:', ' - Use a search all ("*") to find all available resources.' @@ -107,14 +113,9 @@ const searchPatternFlyDocsTool = (options = getOptions()): McpTool => { } const results = parseResults.map((result, index) => { - const availableVersions = new Set(); const urlList = result.entries .filter(entry => entry.path) - .map(entry => { - availableVersions.add(entry.version); - - return ` - [${entry.displayName} - (${entry.version}) - ${entry.description}](${entry.path})`; - }); + .map(entry => ` - [${entry.displayName} - (${entry.version}) - ${entry.description}](${entry.path})`); const uri = result.uri; const uriSchemas = result.uriSchemas; diff --git a/tests/e2e/httpTransport.test.ts b/tests/e2e/httpTransport.test.ts index 7873a123..b7436565 100644 --- a/tests/e2e/httpTransport.test.ts +++ b/tests/e2e/httpTransport.test.ts @@ -376,7 +376,7 @@ describe('Builtin resources, HTTP transport', () => { }); const content = response?.result.contents[0]; - expect(content.uri).toBe(uri); + expect(content.uri).toBe('patternfly://docs/19b2a9418c744e70da9e3dd0965d1948ec1ebbe4'); expect(content.text).toContain('This is a test document for mocking remote HTTP requests'); }); diff --git a/tests/e2e/stdioTransport.test.ts b/tests/e2e/stdioTransport.test.ts index 52fcf856..cb41852e 100644 --- a/tests/e2e/stdioTransport.test.ts +++ b/tests/e2e/stdioTransport.test.ts @@ -209,6 +209,38 @@ describe('Builtin tools, STDIO', () => { 'No PatternFly resources found matching "lorem ipsum dolor sit amet"', 'Use a search all' ] + }, + { + description: 'hash search query', + searchQuery: '19b2a9418c744e70da9e3dd0965d1948ec1ebbe4', + contains: [ + 'Showing 2 exact match', + '**button**' + ] + }, + { + description: 'partial hash search query', + searchQuery: '19b2a', + contains: [ + 'No PatternFly resources found matching "19b2a"', + 'Use a search all' + ] + }, + { + description: 'uri search query', + searchQuery: 'patternfly://docs/19b2a9418c744e70da9e3dd0965d1948ec1ebbe4', + contains: [ + 'Showing 2 exact match', + '**button**' + ] + }, + { + description: 'partial uri search query', + searchQuery: 'patternfly://docs/19b2a94', + contains: [ + 'No PatternFly resources found matching "patternfly://docs/19b2a94"', + 'Use a search all' + ] } ])('should perform searchPatternFlyDocs: $description', async ({ searchQuery, version, contains }) => { const req = { @@ -373,7 +405,7 @@ describe('Builtin resources, STDIO', () => { }); const content = response?.result.contents[0]; - expect(content.uri).toBe(uri); + expect(content.uri).toBe('patternfly://docs/19b2a9418c744e70da9e3dd0965d1948ec1ebbe4'); expect(content.text).toContain('This is a test document for mocking remote HTTP requests'); }); diff --git a/tsconfig.json b/tsconfig.json index c874d625..3481e0d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "module": "ESNext", "moduleResolution": "node", "allowSyntheticDefaultImports": true, From fa7d93cd303aea8be746c4581af467d4be72cfa1 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Thu, 28 May 2026 11:16:47 -0400 Subject: [PATCH 2/2] fix: review update --- src/__tests__/patternFly.search.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/__tests__/patternFly.search.test.ts b/src/__tests__/patternFly.search.test.ts index 3a610fcd..4ce03cbc 100644 --- a/src/__tests__/patternFly.search.test.ts +++ b/src/__tests__/patternFly.search.test.ts @@ -167,14 +167,6 @@ describe('searchPatternFly', () => { expectedName: 'modal', expectedType: 'exact' }, - { - description: 'hash entry id with filter', - search: 'btn-v6-hash', - options: {}, - expectedLength: 2, - expectedName: 'button', - expectedType: 'exact' - }, { description: 'hash entry id without filter', search: 'btn-v6-hash',