@igstack/app-catalog-shared-core 1.0.0-alpha-20260913172344 → 1.0.0-alpha-20260914153414

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -1 +1,2 @@
1
1
  export * from './nullToUndefined.js';
2
+ export * from './searchResources.js';
package/dist/esm/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { nullToUndefined, undefinedToNull } from "./nullToUndefined.js";
2
+ import { searchResources, searchResourcesRanked, searchWithinApp } from "./searchResources.js";
2
3
  export {
3
4
  nullToUndefined,
5
+ searchResources,
6
+ searchResourcesRanked,
7
+ searchWithinApp,
4
8
  undefinedToNull
5
9
  };
6
10
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;"}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Relevance ranking for catalog resources.
3
+ *
4
+ * Lives in shared-core so that every consumer — the catalog grid and the MCP
5
+ * server — ranks identically and cannot drift. It must NOT import `Resource`
6
+ * from backend-core: backend-core already depends on shared-core, so that would
7
+ * be a cycle. Instead the functions are generic over a structural
8
+ * `SearchableResource`, which `Resource` satisfies; each caller gets its own
9
+ * type back.
10
+ */
11
+ /** The subset of a resource this engine reads. */
12
+ export interface SearchableResource {
13
+ slug: string;
14
+ displayName: string;
15
+ abbreviation?: string;
16
+ nicknames?: string[];
17
+ aliases?: string[];
18
+ description?: string;
19
+ tags?: string[];
20
+ teams?: string[];
21
+ /** Undefined for top-level apps; the parent app's slug for sub-resources. */
22
+ parentSlug?: string;
23
+ }
24
+ export interface SearchMatch {
25
+ /** Field where the match occurred */
26
+ field: 'displayName' | 'abbreviation' | 'nicknames' | 'slug' | 'aliases' | 'tags' | 'teams' | 'description' | 'subResource';
27
+ /** Type of match */
28
+ type: 'exact' | 'prefix' | 'contains';
29
+ }
30
+ export interface SearchResult<T extends SearchableResource> {
31
+ app: T;
32
+ /**
33
+ * Which field matched and how. Absent only for the empty-query browse case,
34
+ * where nothing was matched against.
35
+ */
36
+ match?: SearchMatch;
37
+ }
38
+ /**
39
+ * Search and rank resources by relevance, keeping the match info.
40
+ *
41
+ * Only root resources (no `parentSlug`) are scored and returned. Child
42
+ * resources contribute to their parent's score — a query matching a child's
43
+ * displayName, alias or description surfaces the parent.
44
+ *
45
+ * Priority order:
46
+ * 0. Exact match in abbreviation
47
+ * 1. Exact match in displayName
48
+ * 2. Exact match in nickname
49
+ * 3. Prefix match in abbreviation
50
+ * 4. Prefix match in displayName
51
+ * 5. Prefix match in nickname
52
+ * 6. Exact match in tags
53
+ * 7. Prefix match in tags
54
+ * 8. Contains match in abbreviation
55
+ * 9. Contains match in displayName
56
+ * 10. Contains match in nickname
57
+ * 11. Contains match in tags
58
+ * 12. Teams
59
+ * 13. Sub-resource
60
+ * 14. Description
61
+ *
62
+ * @param resources - Array of all resources (roots + children)
63
+ * @param searchQuery - Search query string
64
+ * @returns Filtered and sorted root resources with their match info
65
+ */
66
+ export declare function searchResourcesRanked<T extends SearchableResource>(resources: T[], searchQuery: string): SearchResult<T>[];
67
+ /**
68
+ * Search and sort resources by relevance — the roots-only roll-up the catalog
69
+ * grid uses. Same ranking as {@link searchResourcesRanked}, without the match
70
+ * info.
71
+ */
72
+ export declare function searchResources<T extends SearchableResource>(resources: T[], searchQuery: string): T[];
73
+ /**
74
+ * Ranked search *within* one app's sub-resources — the `"<app>/<term>"` form.
75
+ *
76
+ * Matches a child's `displayName`, `slug`, `aliases` and `description`. Alias
77
+ * matching is load-bearing: in a deployment where every sub-resource carries a
78
+ * numeric alias, searching that bare number must find the sub-resource.
79
+ *
80
+ * @param resources - Array of all resources (roots + children)
81
+ * @param appSlug - Parent app slug whose children to search
82
+ * @param searchQuery - Search query string; empty returns every child
83
+ */
84
+ export declare function searchWithinApp<T extends SearchableResource>(resources: T[], appSlug: string, searchQuery: string): SearchResult<T>[];
@@ -0,0 +1,145 @@
1
+ function allTermsMatcher(terms) {
2
+ return (text) => terms.every((term) => text.includes(term));
3
+ }
4
+ function searchResourcesRanked(resources, searchQuery) {
5
+ const normalizedQuery = searchQuery.trim().toLowerCase();
6
+ const rootResources = resources.filter((r) => !r.parentSlug);
7
+ if (normalizedQuery === "") {
8
+ return rootResources.map((app) => ({ app }));
9
+ }
10
+ const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
11
+ const allTermsMatch = allTermsMatcher(queryTerms);
12
+ const childrenByParent = /* @__PURE__ */ new Map();
13
+ for (const r of resources) {
14
+ if (r.parentSlug) {
15
+ const list = childrenByParent.get(r.parentSlug) ?? [];
16
+ list.push(r);
17
+ childrenByParent.set(r.parentSlug, list);
18
+ }
19
+ }
20
+ const scoredApps = rootResources.map((app) => {
21
+ var _a, _b, _c, _d, _e, _f, _g;
22
+ const name = app.displayName.toLowerCase();
23
+ const abbreviation = ((_a = app.abbreviation) == null ? void 0 : _a.toLowerCase()) || "";
24
+ const nicknames = ((_b = app.nicknames) == null ? void 0 : _b.map((n) => n.toLowerCase())) || [];
25
+ const description = ((_c = app.description) == null ? void 0 : _c.toLowerCase()) || "";
26
+ const tags = ((_d = app.tags) == null ? void 0 : _d.join(" ").toLowerCase()) || "";
27
+ const teams = ((_e = app.teams) == null ? void 0 : _e.join(" ").toLowerCase()) || "";
28
+ const hit = (field, type, score) => ({ result: { app, match: { field, type } }, score });
29
+ if (abbreviation && abbreviation === normalizedQuery) {
30
+ return hit("abbreviation", "exact", 0);
31
+ }
32
+ if (name === normalizedQuery) {
33
+ return hit("displayName", "exact", 1);
34
+ }
35
+ if (nicknames.some((n) => n === normalizedQuery)) {
36
+ return hit("nicknames", "exact", 2);
37
+ }
38
+ if (abbreviation && abbreviation.startsWith(normalizedQuery)) {
39
+ return hit("abbreviation", "prefix", 3);
40
+ }
41
+ if (name.startsWith(normalizedQuery)) {
42
+ return hit("displayName", "prefix", 4);
43
+ }
44
+ if (nicknames.some((n) => n.startsWith(normalizedQuery))) {
45
+ return hit("nicknames", "prefix", 5);
46
+ }
47
+ if ((_f = app.tags) == null ? void 0 : _f.some((tag) => tag.toLowerCase() === normalizedQuery)) {
48
+ return hit("tags", "exact", 6);
49
+ }
50
+ if ((_g = app.tags) == null ? void 0 : _g.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))) {
51
+ return hit("tags", "prefix", 7);
52
+ }
53
+ if (abbreviation && abbreviation.includes(normalizedQuery)) {
54
+ return hit("abbreviation", "contains", 8);
55
+ }
56
+ if (name.includes(normalizedQuery)) {
57
+ return hit("displayName", "contains", 9);
58
+ }
59
+ if (nicknames.some((n) => n.includes(normalizedQuery))) {
60
+ return hit("nicknames", "contains", 10);
61
+ }
62
+ if (tags.includes(normalizedQuery)) {
63
+ return hit("tags", "contains", 11);
64
+ }
65
+ if (allTermsMatch(teams)) {
66
+ return hit("teams", "contains", 12);
67
+ }
68
+ if (allTermsMatch(description)) {
69
+ return hit("description", "contains", 14);
70
+ }
71
+ const children = childrenByParent.get(app.slug);
72
+ if (children) {
73
+ const subMatch = children.some(
74
+ (r) => allTermsMatch(r.displayName.toLowerCase()) || (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) || (r.description ? allTermsMatch(r.description.toLowerCase()) : false)
75
+ );
76
+ if (subMatch) {
77
+ return hit("subResource", "contains", 13);
78
+ }
79
+ }
80
+ return null;
81
+ }).filter(
82
+ (item) => item !== null
83
+ );
84
+ scoredApps.sort((a, b) => {
85
+ if (a.score !== b.score) {
86
+ return a.score - b.score;
87
+ }
88
+ return a.result.app.displayName.localeCompare(b.result.app.displayName);
89
+ });
90
+ return scoredApps.map((item) => item.result);
91
+ }
92
+ function searchResources(resources, searchQuery) {
93
+ return searchResourcesRanked(resources, searchQuery).map((r) => r.app);
94
+ }
95
+ function searchWithinApp(resources, appSlug, searchQuery) {
96
+ const children = resources.filter((r) => r.parentSlug === appSlug);
97
+ const normalizedQuery = searchQuery.trim().toLowerCase();
98
+ const byName = (a, b) => a.displayName.localeCompare(b.displayName);
99
+ if (normalizedQuery === "") {
100
+ return [...children].sort(byName).map((app) => ({ app }));
101
+ }
102
+ const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
103
+ const allTermsMatch = allTermsMatcher(queryTerms);
104
+ const scored = children.map((app) => {
105
+ var _a, _b;
106
+ const name = app.displayName.toLowerCase();
107
+ const slug = app.slug.toLowerCase();
108
+ const aliases = ((_a = app.aliases) == null ? void 0 : _a.map((a) => a.toLowerCase())) ?? [];
109
+ const description = ((_b = app.description) == null ? void 0 : _b.toLowerCase()) ?? "";
110
+ const hit = (field, type, score) => ({ result: { app, match: { field, type } }, score });
111
+ if (name === normalizedQuery) return hit("displayName", "exact", 0);
112
+ if (slug === normalizedQuery) return hit("slug", "exact", 1);
113
+ if (aliases.some((a) => a === normalizedQuery)) {
114
+ return hit("aliases", "exact", 2);
115
+ }
116
+ if (name.startsWith(normalizedQuery)) {
117
+ return hit("displayName", "prefix", 3);
118
+ }
119
+ if (slug.startsWith(normalizedQuery)) return hit("slug", "prefix", 4);
120
+ if (aliases.some((a) => a.startsWith(normalizedQuery))) {
121
+ return hit("aliases", "prefix", 5);
122
+ }
123
+ if (allTermsMatch(name)) return hit("displayName", "contains", 6);
124
+ if (allTermsMatch(slug)) return hit("slug", "contains", 7);
125
+ if (aliases.some((a) => allTermsMatch(a))) {
126
+ return hit("aliases", "contains", 8);
127
+ }
128
+ if (description && allTermsMatch(description)) {
129
+ return hit("description", "contains", 9);
130
+ }
131
+ return null;
132
+ }).filter(
133
+ (item) => item !== null
134
+ );
135
+ scored.sort(
136
+ (a, b) => a.score !== b.score ? a.score - b.score : byName(a.result.app, b.result.app)
137
+ );
138
+ return scored.map((item) => item.result);
139
+ }
140
+ export {
141
+ searchResources,
142
+ searchResourcesRanked,
143
+ searchWithinApp
144
+ };
145
+ //# sourceMappingURL=searchResources.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"searchResources.js","sources":["../../src/searchResources.ts"],"sourcesContent":["/**\n * Relevance ranking for catalog resources.\n *\n * Lives in shared-core so that every consumer — the catalog grid and the MCP\n * server — ranks identically and cannot drift. It must NOT import `Resource`\n * from backend-core: backend-core already depends on shared-core, so that would\n * be a cycle. Instead the functions are generic over a structural\n * `SearchableResource`, which `Resource` satisfies; each caller gets its own\n * type back.\n */\n\n/** The subset of a resource this engine reads. */\nexport interface SearchableResource {\n slug: string\n displayName: string\n abbreviation?: string\n nicknames?: string[]\n aliases?: string[]\n description?: string\n tags?: string[]\n teams?: string[]\n /** Undefined for top-level apps; the parent app's slug for sub-resources. */\n parentSlug?: string\n}\n\nexport interface SearchMatch {\n /** Field where the match occurred */\n field:\n | 'displayName'\n | 'abbreviation'\n | 'nicknames'\n | 'slug'\n | 'aliases'\n | 'tags'\n | 'teams'\n | 'description'\n | 'subResource'\n /** Type of match */\n type: 'exact' | 'prefix' | 'contains'\n}\n\nexport interface SearchResult<T extends SearchableResource> {\n app: T\n /**\n * Which field matched and how. Absent only for the empty-query browse case,\n * where nothing was matched against.\n */\n match?: SearchMatch\n}\n\n/** All terms appear in the text, order-independent (AND logic). */\nfunction allTermsMatcher(terms: string[]): (text: string) => boolean {\n return (text: string) => terms.every((term) => text.includes(term))\n}\n\n/**\n * Search and rank resources by relevance, keeping the match info.\n *\n * Only root resources (no `parentSlug`) are scored and returned. Child\n * resources contribute to their parent's score — a query matching a child's\n * displayName, alias or description surfaces the parent.\n *\n * Priority order:\n * 0. Exact match in abbreviation\n * 1. Exact match in displayName\n * 2. Exact match in nickname\n * 3. Prefix match in abbreviation\n * 4. Prefix match in displayName\n * 5. Prefix match in nickname\n * 6. Exact match in tags\n * 7. Prefix match in tags\n * 8. Contains match in abbreviation\n * 9. Contains match in displayName\n * 10. Contains match in nickname\n * 11. Contains match in tags\n * 12. Teams\n * 13. Sub-resource\n * 14. Description\n *\n * @param resources - Array of all resources (roots + children)\n * @param searchQuery - Search query string\n * @returns Filtered and sorted root resources with their match info\n */\nexport function searchResourcesRanked<T extends SearchableResource>(\n resources: T[],\n searchQuery: string,\n): SearchResult<T>[] {\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n // Separate root resources from children\n const rootResources = resources.filter((r) => !r.parentSlug)\n\n if (normalizedQuery === '') {\n return rootResources.map((app) => ({ app }))\n }\n\n // Split query into terms for multi-word matching (AND logic)\n const queryTerms = normalizedQuery.split(/\\s+/).filter(Boolean)\n const allTermsMatch = allTermsMatcher(queryTerms)\n\n // Build children lookup: parentSlug -> T[]\n const childrenByParent = new Map<string, T[]>()\n for (const r of resources) {\n if (r.parentSlug) {\n const list = childrenByParent.get(r.parentSlug) ?? []\n list.push(r)\n childrenByParent.set(r.parentSlug, list)\n }\n }\n\n // Filter and score root resources\n const scoredApps = rootResources\n .map((app): { result: SearchResult<T>; score: number } | null => {\n const name = app.displayName.toLowerCase()\n const abbreviation = app.abbreviation?.toLowerCase() || ''\n const nicknames = app.nicknames?.map((n) => n.toLowerCase()) || []\n const description = app.description?.toLowerCase() || ''\n const tags = app.tags?.join(' ').toLowerCase() || ''\n const teams = app.teams?.join(' ').toLowerCase() || ''\n\n const hit = (\n field: SearchMatch['field'],\n type: SearchMatch['type'],\n score: number,\n ) => ({ result: { app, match: { field, type } }, score })\n\n // Check exact matches first - prioritize abbreviation over displayName\n if (abbreviation && abbreviation === normalizedQuery) {\n return hit('abbreviation', 'exact', 0)\n }\n if (name === normalizedQuery) {\n return hit('displayName', 'exact', 1)\n }\n if (nicknames.some((n) => n === normalizedQuery)) {\n return hit('nicknames', 'exact', 2)\n }\n\n // Check prefix matches\n if (abbreviation && abbreviation.startsWith(normalizedQuery)) {\n return hit('abbreviation', 'prefix', 3)\n }\n if (name.startsWith(normalizedQuery)) {\n return hit('displayName', 'prefix', 4)\n }\n if (nicknames.some((n) => n.startsWith(normalizedQuery))) {\n return hit('nicknames', 'prefix', 5)\n }\n\n // Check exact match in tags (any tag exactly matches query)\n if (app.tags?.some((tag) => tag.toLowerCase() === normalizedQuery)) {\n return hit('tags', 'exact', 6)\n }\n\n // Check tags - prefix match (any tag starts with query)\n if (\n app.tags?.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))\n ) {\n return hit('tags', 'prefix', 7)\n }\n\n // Check contains matches - prioritize abbreviation over displayName\n if (abbreviation && abbreviation.includes(normalizedQuery)) {\n return hit('abbreviation', 'contains', 8)\n }\n if (name.includes(normalizedQuery)) {\n return hit('displayName', 'contains', 9)\n }\n if (nicknames.some((n) => n.includes(normalizedQuery))) {\n return hit('nicknames', 'contains', 10)\n }\n\n // Check tags - contains match\n if (tags.includes(normalizedQuery)) {\n return hit('tags', 'contains', 11)\n }\n\n // Check teams (multi-word)\n if (allTermsMatch(teams)) {\n return hit('teams', 'contains', 12)\n }\n\n // Check description (multi-word)\n if (allTermsMatch(description)) {\n return hit('description', 'contains', 14)\n }\n\n // Check child resources (name, aliases, description) — supports\n // multi-word queries. Alias matching is what makes a bare numeric\n // identifier find its parent app in deployments where sub-resources\n // carry numeric aliases.\n const children = childrenByParent.get(app.slug)\n if (children) {\n const subMatch = children.some(\n (r) =>\n allTermsMatch(r.displayName.toLowerCase()) ||\n (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) ||\n (r.description\n ? allTermsMatch(r.description.toLowerCase())\n : false),\n )\n if (subMatch) {\n return hit('subResource', 'contains', 13)\n }\n }\n\n // No match found\n return null\n })\n .filter(\n (item): item is { result: SearchResult<T>; score: number } =>\n item !== null,\n )\n\n // Sort by score (ascending - lower score = higher priority)\n scoredApps.sort((a, b) => {\n if (a.score !== b.score) {\n return a.score - b.score\n }\n // If same score, sort alphabetically by display name\n return a.result.app.displayName.localeCompare(b.result.app.displayName)\n })\n\n return scoredApps.map((item) => item.result)\n}\n\n/**\n * Search and sort resources by relevance — the roots-only roll-up the catalog\n * grid uses. Same ranking as {@link searchResourcesRanked}, without the match\n * info.\n */\nexport function searchResources<T extends SearchableResource>(\n resources: T[],\n searchQuery: string,\n): T[] {\n return searchResourcesRanked(resources, searchQuery).map((r) => r.app)\n}\n\n/**\n * Ranked search *within* one app's sub-resources — the `\"<app>/<term>\"` form.\n *\n * Matches a child's `displayName`, `slug`, `aliases` and `description`. Alias\n * matching is load-bearing: in a deployment where every sub-resource carries a\n * numeric alias, searching that bare number must find the sub-resource.\n *\n * @param resources - Array of all resources (roots + children)\n * @param appSlug - Parent app slug whose children to search\n * @param searchQuery - Search query string; empty returns every child\n */\nexport function searchWithinApp<T extends SearchableResource>(\n resources: T[],\n appSlug: string,\n searchQuery: string,\n): SearchResult<T>[] {\n const children = resources.filter((r) => r.parentSlug === appSlug)\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n const byName = (a: T, b: T) => a.displayName.localeCompare(b.displayName)\n\n if (normalizedQuery === '') {\n return [...children].sort(byName).map((app) => ({ app }))\n }\n\n const queryTerms = normalizedQuery.split(/\\s+/).filter(Boolean)\n const allTermsMatch = allTermsMatcher(queryTerms)\n\n const scored = children\n .map((app): { result: SearchResult<T>; score: number } | null => {\n const name = app.displayName.toLowerCase()\n const slug = app.slug.toLowerCase()\n const aliases = app.aliases?.map((a) => a.toLowerCase()) ?? []\n const description = app.description?.toLowerCase() ?? ''\n\n const hit = (\n field: SearchMatch['field'],\n type: SearchMatch['type'],\n score: number,\n ) => ({ result: { app, match: { field, type } }, score })\n\n if (name === normalizedQuery) return hit('displayName', 'exact', 0)\n if (slug === normalizedQuery) return hit('slug', 'exact', 1)\n if (aliases.some((a) => a === normalizedQuery)) {\n return hit('aliases', 'exact', 2)\n }\n\n if (name.startsWith(normalizedQuery)) {\n return hit('displayName', 'prefix', 3)\n }\n if (slug.startsWith(normalizedQuery)) return hit('slug', 'prefix', 4)\n if (aliases.some((a) => a.startsWith(normalizedQuery))) {\n return hit('aliases', 'prefix', 5)\n }\n\n if (allTermsMatch(name)) return hit('displayName', 'contains', 6)\n if (allTermsMatch(slug)) return hit('slug', 'contains', 7)\n if (aliases.some((a) => allTermsMatch(a))) {\n return hit('aliases', 'contains', 8)\n }\n if (description && allTermsMatch(description)) {\n return hit('description', 'contains', 9)\n }\n\n return null\n })\n .filter(\n (item): item is { result: SearchResult<T>; score: number } =>\n item !== null,\n )\n\n scored.sort((a, b) =>\n a.score !== b.score\n ? a.score - b.score\n : byName(a.result.app, b.result.app),\n )\n\n return scored.map((item) => item.result)\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-shared-core",
3
- "version": "1.0.0-alpha-20260913172344",
3
+ "version": "1.0.0-alpha-20260914153414",
4
4
  "description": "Shared core utilities for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "engines": {
43
43
  "node": ">=16"
44
44
  },
45
- "gitHead": "8203a6980bcd00fcb50dfc9a04408997a69f3130",
45
+ "gitHead": "c7c6a2375602656dffd01e81b151f3b9adcdc97f",
46
46
  "scripts": {
47
47
  "build": "vite build",
48
48
  "clean": "premove ./dist ./coverage ./dist-ts",
@@ -0,0 +1,223 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { SearchableResource } from '../searchResources'
3
+ import {
4
+ searchResources,
5
+ searchResourcesRanked,
6
+ searchWithinApp,
7
+ } from '../searchResources'
8
+
9
+ function makeApp(
10
+ overrides: Partial<SearchableResource> & { slug: string },
11
+ ): SearchableResource {
12
+ return {
13
+ displayName: overrides.slug,
14
+ ...overrides,
15
+ }
16
+ }
17
+
18
+ function makeChildResource(
19
+ overrides: Partial<SearchableResource> & {
20
+ slug: string
21
+ parentSlug: string
22
+ },
23
+ ): SearchableResource {
24
+ return {
25
+ displayName: overrides.slug,
26
+ aliases: [],
27
+ ...overrides,
28
+ }
29
+ }
30
+
31
+ describe('searchResources', () => {
32
+ const apps: SearchableResource[] = [
33
+ makeApp({
34
+ slug: 'taskflow',
35
+ displayName: 'TaskFlow',
36
+ description: 'Issue tracker',
37
+ }),
38
+ makeApp({
39
+ slug: 'aws-console',
40
+ displayName: 'AWS Console',
41
+ description: 'Cloud management',
42
+ }),
43
+ makeApp({
44
+ slug: 'teamchat',
45
+ displayName: 'TeamChat',
46
+ description: 'Messaging',
47
+ }),
48
+ ]
49
+
50
+ it('returns all root apps when query is empty', () => {
51
+ expect(searchResources(apps, '')).toHaveLength(3)
52
+ })
53
+
54
+ it('finds app by displayName', () => {
55
+ const results = searchResources(apps, 'taskflow')
56
+ expect(results).toHaveLength(1)
57
+ expect(results[0]!.slug).toBe('taskflow')
58
+ })
59
+
60
+ it('finds app by nickname and by abbreviation', () => {
61
+ const withAliases = [
62
+ ...apps,
63
+ makeApp({
64
+ slug: 'lab-system',
65
+ displayName: 'Laboratory System',
66
+ abbreviation: 'LS',
67
+ nicknames: ['labsys'],
68
+ }),
69
+ ]
70
+ expect(searchResources(withAliases, 'labsys')[0]!.slug).toBe('lab-system')
71
+ expect(searchResources(withAliases, 'ls')[0]!.slug).toBe('lab-system')
72
+ })
73
+
74
+ describe('child resource search', () => {
75
+ const childResources: SearchableResource[] = [
76
+ makeChildResource({
77
+ slug: 'aws-acme-pipelines-dev',
78
+ displayName: 'acme-pipelines-data-analytics-dev',
79
+ parentSlug: 'aws-console',
80
+ aliases: ['000000000001'],
81
+ }),
82
+ makeChildResource({
83
+ slug: 'aws-acme-infosec-dev',
84
+ displayName: 'acme-infosec-dev',
85
+ parentSlug: 'aws-console',
86
+ aliases: [],
87
+ }),
88
+ ]
89
+
90
+ const allResources = [...apps, ...childResources]
91
+
92
+ it('finds app by child resource displayName', () => {
93
+ const results = searchResources(allResources, 'pipelines data-analytics')
94
+ expect(results).toHaveLength(1)
95
+ expect(results[0]!.slug).toBe('aws-console')
96
+ })
97
+
98
+ it('finds app by child resource alias (numeric account identifier)', () => {
99
+ const results = searchResources(allResources, '000000000001')
100
+ expect(results).toHaveLength(1)
101
+ expect(results[0]!.slug).toBe('aws-console')
102
+ })
103
+
104
+ it('does not match child resources when none provided', () => {
105
+ const results = searchResources(apps, '000000000001')
106
+ expect(results).toHaveLength(0)
107
+ })
108
+
109
+ it('direct app match ranks higher than child resource match', () => {
110
+ const results = searchResources(allResources, 'aws')
111
+ // 'aws-console' matches by displayName, should appear
112
+ expect(results.length).toBeGreaterThanOrEqual(1)
113
+ expect(results[0]!.slug).toBe('aws-console')
114
+ })
115
+
116
+ it('never returns children from the roots-only roll-up', () => {
117
+ const results = searchResources(allResources, '')
118
+ expect(results.map((r) => r.slug)).toEqual([
119
+ 'taskflow',
120
+ 'aws-console',
121
+ 'teamchat',
122
+ ])
123
+ })
124
+ })
125
+ })
126
+
127
+ describe('searchResourcesRanked', () => {
128
+ const apps: SearchableResource[] = [
129
+ makeApp({ slug: 'taskflow', displayName: 'TaskFlow' }),
130
+ makeApp({
131
+ slug: 'teamchat',
132
+ displayName: 'TeamChat',
133
+ description: 'taskflow rival',
134
+ }),
135
+ ]
136
+
137
+ it('reports which field matched and how', () => {
138
+ const results = searchResourcesRanked(apps, 'taskflow')
139
+ expect(
140
+ results.map((r) => [r.app.slug, r.match?.field, r.match?.type]),
141
+ ).toEqual([
142
+ ['taskflow', 'displayName', 'exact'],
143
+ ['teamchat', 'description', 'contains'],
144
+ ])
145
+ })
146
+
147
+ it('returns the same order as searchResources', () => {
148
+ expect(searchResourcesRanked(apps, 'taskflow').map((r) => r.app)).toEqual(
149
+ searchResources(apps, 'taskflow'),
150
+ )
151
+ })
152
+ })
153
+
154
+ describe('searchWithinApp', () => {
155
+ const resources: SearchableResource[] = [
156
+ makeApp({ slug: 'aws-console', displayName: 'AWS Console' }),
157
+ makeApp({ slug: 'taskflow', displayName: 'TaskFlow' }),
158
+ makeChildResource({
159
+ slug: 'aws-acme-biomarkers-prod',
160
+ displayName: 'acme-biomarkers-prod',
161
+ parentSlug: 'aws-console',
162
+ aliases: ['000000000001'],
163
+ description: 'Cloud account: acme-biomarkers-prod',
164
+ }),
165
+ makeChildResource({
166
+ slug: 'aws-acme-biomarkers-dev',
167
+ displayName: 'acme-biomarkers-dev',
168
+ parentSlug: 'aws-console',
169
+ aliases: ['000000000002'],
170
+ }),
171
+ makeChildResource({
172
+ slug: 'aws-acme-infosec-dev',
173
+ displayName: 'acme-infosec-dev',
174
+ parentSlug: 'aws-console',
175
+ aliases: ['000000000003'],
176
+ }),
177
+ makeChildResource({
178
+ slug: 'tf-child',
179
+ displayName: 'biomarkers-board',
180
+ parentSlug: 'taskflow',
181
+ aliases: [],
182
+ }),
183
+ ]
184
+
185
+ it('searches only the named app’s children', () => {
186
+ const results = searchWithinApp(resources, 'aws-console', 'biomarkers')
187
+ expect(results.map((r) => r.app.slug)).toEqual([
188
+ 'aws-acme-biomarkers-dev',
189
+ 'aws-acme-biomarkers-prod',
190
+ ])
191
+ })
192
+
193
+ it('matches a bare numeric alias — the account-number lookup', () => {
194
+ const results = searchWithinApp(resources, 'aws-console', '000000000001')
195
+ expect(results).toHaveLength(1)
196
+ expect(results[0]!.app.slug).toBe('aws-acme-biomarkers-prod')
197
+ expect(results[0]!.match).toEqual({ field: 'aliases', type: 'exact' })
198
+ })
199
+
200
+ it('matches on slug and on description', () => {
201
+ expect(
202
+ searchWithinApp(resources, 'aws-console', 'aws-acme-infosec-dev'),
203
+ ).toHaveLength(1)
204
+ expect(
205
+ searchWithinApp(resources, 'aws-console', 'cloud account'),
206
+ ).toHaveLength(1)
207
+ })
208
+
209
+ it('returns every child for an empty query, alphabetically', () => {
210
+ expect(
211
+ searchWithinApp(resources, 'aws-console', '').map((r) => r.app.slug),
212
+ ).toEqual([
213
+ 'aws-acme-biomarkers-dev',
214
+ 'aws-acme-biomarkers-prod',
215
+ 'aws-acme-infosec-dev',
216
+ ])
217
+ })
218
+
219
+ it('returns nothing for an app without children or a miss', () => {
220
+ expect(searchWithinApp(resources, 'nope', 'biomarkers')).toEqual([])
221
+ expect(searchWithinApp(resources, 'aws-console', 'zzzz')).toEqual([])
222
+ })
223
+ })
package/src/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from './nullToUndefined'
2
+ export * from './searchResources'
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Relevance ranking for catalog resources.
3
+ *
4
+ * Lives in shared-core so that every consumer — the catalog grid and the MCP
5
+ * server — ranks identically and cannot drift. It must NOT import `Resource`
6
+ * from backend-core: backend-core already depends on shared-core, so that would
7
+ * be a cycle. Instead the functions are generic over a structural
8
+ * `SearchableResource`, which `Resource` satisfies; each caller gets its own
9
+ * type back.
10
+ */
11
+
12
+ /** The subset of a resource this engine reads. */
13
+ export interface SearchableResource {
14
+ slug: string
15
+ displayName: string
16
+ abbreviation?: string
17
+ nicknames?: string[]
18
+ aliases?: string[]
19
+ description?: string
20
+ tags?: string[]
21
+ teams?: string[]
22
+ /** Undefined for top-level apps; the parent app's slug for sub-resources. */
23
+ parentSlug?: string
24
+ }
25
+
26
+ export interface SearchMatch {
27
+ /** Field where the match occurred */
28
+ field:
29
+ | 'displayName'
30
+ | 'abbreviation'
31
+ | 'nicknames'
32
+ | 'slug'
33
+ | 'aliases'
34
+ | 'tags'
35
+ | 'teams'
36
+ | 'description'
37
+ | 'subResource'
38
+ /** Type of match */
39
+ type: 'exact' | 'prefix' | 'contains'
40
+ }
41
+
42
+ export interface SearchResult<T extends SearchableResource> {
43
+ app: T
44
+ /**
45
+ * Which field matched and how. Absent only for the empty-query browse case,
46
+ * where nothing was matched against.
47
+ */
48
+ match?: SearchMatch
49
+ }
50
+
51
+ /** All terms appear in the text, order-independent (AND logic). */
52
+ function allTermsMatcher(terms: string[]): (text: string) => boolean {
53
+ return (text: string) => terms.every((term) => text.includes(term))
54
+ }
55
+
56
+ /**
57
+ * Search and rank resources by relevance, keeping the match info.
58
+ *
59
+ * Only root resources (no `parentSlug`) are scored and returned. Child
60
+ * resources contribute to their parent's score — a query matching a child's
61
+ * displayName, alias or description surfaces the parent.
62
+ *
63
+ * Priority order:
64
+ * 0. Exact match in abbreviation
65
+ * 1. Exact match in displayName
66
+ * 2. Exact match in nickname
67
+ * 3. Prefix match in abbreviation
68
+ * 4. Prefix match in displayName
69
+ * 5. Prefix match in nickname
70
+ * 6. Exact match in tags
71
+ * 7. Prefix match in tags
72
+ * 8. Contains match in abbreviation
73
+ * 9. Contains match in displayName
74
+ * 10. Contains match in nickname
75
+ * 11. Contains match in tags
76
+ * 12. Teams
77
+ * 13. Sub-resource
78
+ * 14. Description
79
+ *
80
+ * @param resources - Array of all resources (roots + children)
81
+ * @param searchQuery - Search query string
82
+ * @returns Filtered and sorted root resources with their match info
83
+ */
84
+ export function searchResourcesRanked<T extends SearchableResource>(
85
+ resources: T[],
86
+ searchQuery: string,
87
+ ): SearchResult<T>[] {
88
+ const normalizedQuery = searchQuery.trim().toLowerCase()
89
+
90
+ // Separate root resources from children
91
+ const rootResources = resources.filter((r) => !r.parentSlug)
92
+
93
+ if (normalizedQuery === '') {
94
+ return rootResources.map((app) => ({ app }))
95
+ }
96
+
97
+ // Split query into terms for multi-word matching (AND logic)
98
+ const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
99
+ const allTermsMatch = allTermsMatcher(queryTerms)
100
+
101
+ // Build children lookup: parentSlug -> T[]
102
+ const childrenByParent = new Map<string, T[]>()
103
+ for (const r of resources) {
104
+ if (r.parentSlug) {
105
+ const list = childrenByParent.get(r.parentSlug) ?? []
106
+ list.push(r)
107
+ childrenByParent.set(r.parentSlug, list)
108
+ }
109
+ }
110
+
111
+ // Filter and score root resources
112
+ const scoredApps = rootResources
113
+ .map((app): { result: SearchResult<T>; score: number } | null => {
114
+ const name = app.displayName.toLowerCase()
115
+ const abbreviation = app.abbreviation?.toLowerCase() || ''
116
+ const nicknames = app.nicknames?.map((n) => n.toLowerCase()) || []
117
+ const description = app.description?.toLowerCase() || ''
118
+ const tags = app.tags?.join(' ').toLowerCase() || ''
119
+ const teams = app.teams?.join(' ').toLowerCase() || ''
120
+
121
+ const hit = (
122
+ field: SearchMatch['field'],
123
+ type: SearchMatch['type'],
124
+ score: number,
125
+ ) => ({ result: { app, match: { field, type } }, score })
126
+
127
+ // Check exact matches first - prioritize abbreviation over displayName
128
+ if (abbreviation && abbreviation === normalizedQuery) {
129
+ return hit('abbreviation', 'exact', 0)
130
+ }
131
+ if (name === normalizedQuery) {
132
+ return hit('displayName', 'exact', 1)
133
+ }
134
+ if (nicknames.some((n) => n === normalizedQuery)) {
135
+ return hit('nicknames', 'exact', 2)
136
+ }
137
+
138
+ // Check prefix matches
139
+ if (abbreviation && abbreviation.startsWith(normalizedQuery)) {
140
+ return hit('abbreviation', 'prefix', 3)
141
+ }
142
+ if (name.startsWith(normalizedQuery)) {
143
+ return hit('displayName', 'prefix', 4)
144
+ }
145
+ if (nicknames.some((n) => n.startsWith(normalizedQuery))) {
146
+ return hit('nicknames', 'prefix', 5)
147
+ }
148
+
149
+ // Check exact match in tags (any tag exactly matches query)
150
+ if (app.tags?.some((tag) => tag.toLowerCase() === normalizedQuery)) {
151
+ return hit('tags', 'exact', 6)
152
+ }
153
+
154
+ // Check tags - prefix match (any tag starts with query)
155
+ if (
156
+ app.tags?.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))
157
+ ) {
158
+ return hit('tags', 'prefix', 7)
159
+ }
160
+
161
+ // Check contains matches - prioritize abbreviation over displayName
162
+ if (abbreviation && abbreviation.includes(normalizedQuery)) {
163
+ return hit('abbreviation', 'contains', 8)
164
+ }
165
+ if (name.includes(normalizedQuery)) {
166
+ return hit('displayName', 'contains', 9)
167
+ }
168
+ if (nicknames.some((n) => n.includes(normalizedQuery))) {
169
+ return hit('nicknames', 'contains', 10)
170
+ }
171
+
172
+ // Check tags - contains match
173
+ if (tags.includes(normalizedQuery)) {
174
+ return hit('tags', 'contains', 11)
175
+ }
176
+
177
+ // Check teams (multi-word)
178
+ if (allTermsMatch(teams)) {
179
+ return hit('teams', 'contains', 12)
180
+ }
181
+
182
+ // Check description (multi-word)
183
+ if (allTermsMatch(description)) {
184
+ return hit('description', 'contains', 14)
185
+ }
186
+
187
+ // Check child resources (name, aliases, description) — supports
188
+ // multi-word queries. Alias matching is what makes a bare numeric
189
+ // identifier find its parent app in deployments where sub-resources
190
+ // carry numeric aliases.
191
+ const children = childrenByParent.get(app.slug)
192
+ if (children) {
193
+ const subMatch = children.some(
194
+ (r) =>
195
+ allTermsMatch(r.displayName.toLowerCase()) ||
196
+ (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) ||
197
+ (r.description
198
+ ? allTermsMatch(r.description.toLowerCase())
199
+ : false),
200
+ )
201
+ if (subMatch) {
202
+ return hit('subResource', 'contains', 13)
203
+ }
204
+ }
205
+
206
+ // No match found
207
+ return null
208
+ })
209
+ .filter(
210
+ (item): item is { result: SearchResult<T>; score: number } =>
211
+ item !== null,
212
+ )
213
+
214
+ // Sort by score (ascending - lower score = higher priority)
215
+ scoredApps.sort((a, b) => {
216
+ if (a.score !== b.score) {
217
+ return a.score - b.score
218
+ }
219
+ // If same score, sort alphabetically by display name
220
+ return a.result.app.displayName.localeCompare(b.result.app.displayName)
221
+ })
222
+
223
+ return scoredApps.map((item) => item.result)
224
+ }
225
+
226
+ /**
227
+ * Search and sort resources by relevance — the roots-only roll-up the catalog
228
+ * grid uses. Same ranking as {@link searchResourcesRanked}, without the match
229
+ * info.
230
+ */
231
+ export function searchResources<T extends SearchableResource>(
232
+ resources: T[],
233
+ searchQuery: string,
234
+ ): T[] {
235
+ return searchResourcesRanked(resources, searchQuery).map((r) => r.app)
236
+ }
237
+
238
+ /**
239
+ * Ranked search *within* one app's sub-resources — the `"<app>/<term>"` form.
240
+ *
241
+ * Matches a child's `displayName`, `slug`, `aliases` and `description`. Alias
242
+ * matching is load-bearing: in a deployment where every sub-resource carries a
243
+ * numeric alias, searching that bare number must find the sub-resource.
244
+ *
245
+ * @param resources - Array of all resources (roots + children)
246
+ * @param appSlug - Parent app slug whose children to search
247
+ * @param searchQuery - Search query string; empty returns every child
248
+ */
249
+ export function searchWithinApp<T extends SearchableResource>(
250
+ resources: T[],
251
+ appSlug: string,
252
+ searchQuery: string,
253
+ ): SearchResult<T>[] {
254
+ const children = resources.filter((r) => r.parentSlug === appSlug)
255
+ const normalizedQuery = searchQuery.trim().toLowerCase()
256
+
257
+ const byName = (a: T, b: T) => a.displayName.localeCompare(b.displayName)
258
+
259
+ if (normalizedQuery === '') {
260
+ return [...children].sort(byName).map((app) => ({ app }))
261
+ }
262
+
263
+ const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
264
+ const allTermsMatch = allTermsMatcher(queryTerms)
265
+
266
+ const scored = children
267
+ .map((app): { result: SearchResult<T>; score: number } | null => {
268
+ const name = app.displayName.toLowerCase()
269
+ const slug = app.slug.toLowerCase()
270
+ const aliases = app.aliases?.map((a) => a.toLowerCase()) ?? []
271
+ const description = app.description?.toLowerCase() ?? ''
272
+
273
+ const hit = (
274
+ field: SearchMatch['field'],
275
+ type: SearchMatch['type'],
276
+ score: number,
277
+ ) => ({ result: { app, match: { field, type } }, score })
278
+
279
+ if (name === normalizedQuery) return hit('displayName', 'exact', 0)
280
+ if (slug === normalizedQuery) return hit('slug', 'exact', 1)
281
+ if (aliases.some((a) => a === normalizedQuery)) {
282
+ return hit('aliases', 'exact', 2)
283
+ }
284
+
285
+ if (name.startsWith(normalizedQuery)) {
286
+ return hit('displayName', 'prefix', 3)
287
+ }
288
+ if (slug.startsWith(normalizedQuery)) return hit('slug', 'prefix', 4)
289
+ if (aliases.some((a) => a.startsWith(normalizedQuery))) {
290
+ return hit('aliases', 'prefix', 5)
291
+ }
292
+
293
+ if (allTermsMatch(name)) return hit('displayName', 'contains', 6)
294
+ if (allTermsMatch(slug)) return hit('slug', 'contains', 7)
295
+ if (aliases.some((a) => allTermsMatch(a))) {
296
+ return hit('aliases', 'contains', 8)
297
+ }
298
+ if (description && allTermsMatch(description)) {
299
+ return hit('description', 'contains', 9)
300
+ }
301
+
302
+ return null
303
+ })
304
+ .filter(
305
+ (item): item is { result: SearchResult<T>; score: number } =>
306
+ item !== null,
307
+ )
308
+
309
+ scored.sort((a, b) =>
310
+ a.score !== b.score
311
+ ? a.score - b.score
312
+ : byName(a.result.app, b.result.app),
313
+ )
314
+
315
+ return scored.map((item) => item.result)
316
+ }