@igstack/app-catalog-shared-core 2.0.1-alpha-20260228221600 → 2.0.1-alpha-20260915181451

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 {};
@@ -0,0 +1 @@
1
+ export {};
@@ -20,19 +20,19 @@ export type ApprovalType = 'none' | 'service' | 'person' | 'other' | 'unknown';
20
20
  /**
21
21
  * Approval type options for UI display
22
22
  */
23
- export declare const APPROVAL_TYPES: ReadonlyArray<{
23
+ export declare const APPROVAL_TYPES: readonly {
24
24
  value: ApprovalType;
25
25
  label: string;
26
- }>;
26
+ }[];
27
27
  /**
28
28
  * Common fields for all approval method types
29
29
  */
30
30
  export interface BaseApprovalMethod {
31
31
  comment?: string;
32
- roles?: Array<AppRole>;
32
+ roles?: AppRole[];
33
33
  approvalPolicy?: string;
34
34
  postApprovalInstructions?: string;
35
- seeMoreUrls?: Array<string>;
35
+ seeMoreUrls?: string[];
36
36
  }
37
37
  /**
38
38
  * Service-based approval (bot or ticketing system)
@@ -106,22 +106,22 @@ export interface AppForCatalog {
106
106
  displayName: string;
107
107
  description?: string;
108
108
  approvalMethod?: ApprovalMethod;
109
- teams?: Array<string>;
109
+ teams?: string[];
110
110
  notes?: string;
111
- tags?: Array<string>;
111
+ tags?: string[];
112
112
  appUrl?: string;
113
- links?: Array<{
113
+ links?: {
114
114
  url: string;
115
115
  title?: string;
116
- }>;
116
+ }[];
117
117
  iconName?: string;
118
- screenshotIds?: Array<string>;
118
+ screenshotIds?: string[];
119
119
  }
120
120
  export interface AppCategory {
121
121
  id: string;
122
122
  name: string;
123
123
  }
124
124
  export interface AppCatalogData {
125
- apps: Array<AppForCatalog>;
126
- categories: Array<AppCategory>;
125
+ apps: AppForCatalog[];
126
+ categories: AppCategory[];
127
127
  }
@@ -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":";;"}
@@ -1,8 +1,8 @@
1
1
  import { Primitive } from 'type-fest';
2
- export type NullToUndefined<T> = T extends null ? undefined : T extends Primitive | Function | Date | RegExp ? T : T extends Array<infer U> ? Array<NullToUndefined<U>> : T extends Map<infer K, infer V> ? Map<K, NullToUndefined<V>> : T extends Set<infer U> ? Set<NullToUndefined<U>> : T extends object ? {
2
+ export type NullToUndefined<T> = T extends null ? undefined : T extends Primitive | Function | Date | RegExp ? T : T extends (infer U)[] ? NullToUndefined<U>[] : T extends Map<infer K, infer V> ? Map<K, NullToUndefined<V>> : T extends Set<infer U> ? Set<NullToUndefined<U>> : T extends object ? {
3
3
  [K in keyof T]: NullToUndefined<T[K]>;
4
4
  } : unknown;
5
- export type UndefinedToNull<T> = T extends undefined ? null : T extends Primitive | Function | Date | RegExp ? T : T extends Array<infer U> ? Array<UndefinedToNull<U>> : T extends Map<infer K, infer V> ? Map<K, UndefinedToNull<V>> : T extends Set<infer U> ? Set<NullToUndefined<U>> : T extends object ? {
5
+ export type UndefinedToNull<T> = T extends undefined ? null : T extends Primitive | Function | Date | RegExp ? T : T extends (infer U)[] ? UndefinedToNull<U>[] : T extends Map<infer K, infer V> ? Map<K, UndefinedToNull<V>> : T extends Set<infer U> ? Set<NullToUndefined<U>> : T extends object ? {
6
6
  [K in keyof T]: UndefinedToNull<T[K]>;
7
7
  } : unknown;
8
8
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"nullToUndefined.js","sources":["../../src/nullToUndefined.ts"],"sourcesContent":["import type { Primitive } from 'type-fest'\n\n/*\n Taken from https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0\n * [Generic way to convert all instances of null to undefined in TypeScript](https://stackoverflow.com/q/50374869)\n *\n * This only works with JS objects hence the file name *Object*Values\n *\n * [\"I intend to stop using `null` in my JS code in favor of `undefined`\"](https://github.com/sindresorhus/meta/discussions/7)\n * [Proposal: NullToUndefined and UndefinedToNull](https://github.com/sindresorhus/type-fest/issues/603)\n *\n * Types implementation inspired by:\n * - https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/delimiter-cased-properties-deep.d.ts\n * - https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/readonly-deep.d.ts\n *\n * https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0\n */\nexport type NullToUndefined<T> = T extends null\n ? undefined\n : T extends Primitive | Function | Date | RegExp\n ? T\n : T extends Array<infer U>\n ? Array<NullToUndefined<U>>\n : T extends Map<infer K, infer V>\n ? Map<K, NullToUndefined<V>>\n : T extends Set<infer U>\n ? Set<NullToUndefined<U>>\n : T extends object\n ? { [K in keyof T]: NullToUndefined<T[K]> }\n : unknown\n\nexport type UndefinedToNull<T> = T extends undefined\n ? null\n : T extends Primitive | Function | Date | RegExp\n ? T\n : T extends Array<infer U>\n ? Array<UndefinedToNull<U>>\n : T extends Map<infer K, infer V>\n ? Map<K, UndefinedToNull<V>>\n : T extends Set<infer U>\n ? Set<NullToUndefined<U>>\n : T extends object\n ? { [K in keyof T]: UndefinedToNull<T[K]> }\n : unknown\n\nfunction _nullToUndefined<T>(obj: T): NullToUndefined<T> {\n if (obj === null) {\n return undefined as any\n }\n\n if (typeof obj === 'object') {\n if (obj instanceof Map) {\n obj.forEach((value, key) => obj.set(key, _nullToUndefined(value)))\n } else {\n for (const key in obj) {\n obj[key] = _nullToUndefined(obj[key]) as any\n }\n }\n }\n\n return obj as any\n}\n\n/**\n * Recursively converts all null values to undefined.\n *\n * @param obj object to convert\n * @returns a copy of the object with all its null values converted to undefined\n */\nexport function nullToUndefined<\n T,\n // Can cause: \"Type instantiation is excessively deep and possibly infinite.\"\n // extends Jsonifiable\n>(obj: T) {\n return _nullToUndefined(structuredClone(obj))\n}\n\nfunction _undefinedToNull<T>(obj: T): UndefinedToNull<T> {\n if (obj === undefined) {\n return null as any\n }\n\n if (typeof obj === 'object') {\n if (obj instanceof Map) {\n obj.forEach((value, key) => obj.set(key, _undefinedToNull(value)))\n } else {\n for (const key in obj) {\n obj[key] = _undefinedToNull(obj[key]) as any\n }\n }\n }\n\n return obj as any\n}\n\n/**\n * Recursively converts all undefined values to null.\n *\n * @param obj object to convert\n * @returns a copy of the object with all its undefined values converted to null\n */\nexport function undefinedToNull<\n T,\n // Can cause: \"Type instantiation is excessively deep and possibly infinite.\"\n // extends Jsonifiable\n>(obj: T) {\n return _undefinedToNull(structuredClone(obj))\n}\n"],"names":[],"mappings":"AA6CA,SAAS,iBAAoB,KAA4B;AACvD,MAAI,QAAQ,MAAM;AAChB,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,QAAQ,UAAU;AAC3B,QAAI,eAAe,KAAK;AACtB,UAAI,QAAQ,CAAC,OAAO,QAAQ,IAAI,IAAI,KAAK,iBAAiB,KAAK,CAAC,CAAC;AAAA,IACnE,OAAO;AACL,iBAAW,OAAO,KAAK;AACrB,YAAI,GAAG,IAAI,iBAAiB,IAAI,GAAG,CAAC;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,gBAId,KAAQ;AACR,SAAO,iBAAiB,gBAAgB,GAAG,CAAC;AAC9C;AAEA,SAAS,iBAAoB,KAA4B;AACvD,MAAI,QAAQ,QAAW;AACrB,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,QAAQ,UAAU;AAC3B,QAAI,eAAe,KAAK;AACtB,UAAI,QAAQ,CAAC,OAAO,QAAQ,IAAI,IAAI,KAAK,iBAAiB,KAAK,CAAC,CAAC;AAAA,IACnE,OAAO;AACL,iBAAW,OAAO,KAAK;AACrB,YAAI,GAAG,IAAI,iBAAiB,IAAI,GAAG,CAAC;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,gBAId,KAAQ;AACR,SAAO,iBAAiB,gBAAgB,GAAG,CAAC;AAC9C;"}
1
+ {"version":3,"file":"nullToUndefined.js","sources":["../../src/nullToUndefined.ts"],"sourcesContent":["import type { Primitive } from 'type-fest'\n\n/*\n Taken from https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0\n * [Generic way to convert all instances of null to undefined in TypeScript](https://stackoverflow.com/q/50374869)\n *\n * This only works with JS objects hence the file name *Object*Values\n *\n * [\"I intend to stop using `null` in my JS code in favor of `undefined`\"](https://github.com/sindresorhus/meta/discussions/7)\n * [Proposal: NullToUndefined and UndefinedToNull](https://github.com/sindresorhus/type-fest/issues/603)\n *\n * Types implementation inspired by:\n * - https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/delimiter-cased-properties-deep.d.ts\n * - https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/readonly-deep.d.ts\n *\n * https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0\n */\nexport type NullToUndefined<T> = T extends null\n ? undefined\n : T extends Primitive | Function | Date | RegExp\n ? T\n : T extends (infer U)[]\n ? NullToUndefined<U>[]\n : T extends Map<infer K, infer V>\n ? Map<K, NullToUndefined<V>>\n : T extends Set<infer U>\n ? Set<NullToUndefined<U>>\n : T extends object\n ? { [K in keyof T]: NullToUndefined<T[K]> }\n : unknown\n\nexport type UndefinedToNull<T> = T extends undefined\n ? null\n : T extends Primitive | Function | Date | RegExp\n ? T\n : T extends (infer U)[]\n ? UndefinedToNull<U>[]\n : T extends Map<infer K, infer V>\n ? Map<K, UndefinedToNull<V>>\n : T extends Set<infer U>\n ? Set<NullToUndefined<U>>\n : T extends object\n ? { [K in keyof T]: UndefinedToNull<T[K]> }\n : unknown\n\nfunction _nullToUndefined<T>(obj: T): NullToUndefined<T> {\n if (obj === null) {\n return undefined as any\n }\n\n if (typeof obj === 'object') {\n if (obj instanceof Map) {\n obj.forEach((value, key) => obj.set(key, _nullToUndefined(value)))\n } else {\n for (const key in obj) {\n obj[key] = _nullToUndefined(obj[key]) as any\n }\n }\n }\n\n return obj as any\n}\n\n/**\n * Recursively converts all null values to undefined.\n *\n * @param obj object to convert\n * @returns a copy of the object with all its null values converted to undefined\n */\nexport function nullToUndefined<\n T,\n // Can cause: \"Type instantiation is excessively deep and possibly infinite.\"\n // extends Jsonifiable\n>(obj: T) {\n return _nullToUndefined(structuredClone(obj))\n}\n\nfunction _undefinedToNull<T>(obj: T): UndefinedToNull<T> {\n if (obj === undefined) {\n return null as any\n }\n\n if (typeof obj === 'object') {\n if (obj instanceof Map) {\n obj.forEach((value, key) => obj.set(key, _undefinedToNull(value)))\n } else {\n for (const key in obj) {\n obj[key] = _undefinedToNull(obj[key]) as any\n }\n }\n }\n\n return obj as any\n}\n\n/**\n * Recursively converts all undefined values to null.\n *\n * @param obj object to convert\n * @returns a copy of the object with all its undefined values converted to null\n */\nexport function undefinedToNull<\n T,\n // Can cause: \"Type instantiation is excessively deep and possibly infinite.\"\n // extends Jsonifiable\n>(obj: T) {\n return _undefinedToNull(structuredClone(obj))\n}\n"],"names":[],"mappings":"AA6CA,SAAS,iBAAoB,KAA4B;AACvD,MAAI,QAAQ,MAAM;AAChB,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,QAAQ,UAAU;AAC3B,QAAI,eAAe,KAAK;AACtB,UAAI,QAAQ,CAAC,OAAO,QAAQ,IAAI,IAAI,KAAK,iBAAiB,KAAK,CAAC,CAAC;AAAA,IACnE,OAAO;AACL,iBAAW,OAAO,KAAK;AACrB,YAAI,GAAG,IAAI,iBAAiB,IAAI,GAAG,CAAC;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,gBAId,KAAQ;AACR,SAAO,iBAAiB,gBAAgB,GAAG,CAAC;AAC9C;AAEA,SAAS,iBAAoB,KAA4B;AACvD,MAAI,QAAQ,QAAW;AACrB,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,QAAQ,UAAU;AAC3B,QAAI,eAAe,KAAK;AACtB,UAAI,QAAQ,CAAC,OAAO,QAAQ,IAAI,IAAI,KAAK,iBAAiB,KAAK,CAAC,CAAC;AAAA,IACnE,OAAO;AACL,iBAAW,OAAO,KAAK;AACrB,YAAI,GAAG,IAAI,iBAAiB,IAAI,GAAG,CAAC;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,gBAId,KAAQ;AACR,SAAO,iBAAiB,gBAAgB,GAAG,CAAC;AAC9C;"}
@@ -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": "2.0.1-alpha-20260228221600",
3
+ "version": "2.0.1-alpha-20260915181451",
4
4
  "description": "Shared core utilities for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -37,17 +37,20 @@
37
37
  "@types/node": "^24.3.0",
38
38
  "esbuild": "^0.25.5",
39
39
  "tsx": "^4.19.4",
40
- "typescript": "5.9.2"
40
+ "typescript": "5.9.3"
41
41
  },
42
42
  "engines": {
43
43
  "node": ">=16"
44
44
  },
45
+ "gitHead": "7723c1685f4b6736d5dce12d51c7bb5f2d8a338d",
45
46
  "scripts": {
46
47
  "build": "vite build",
47
48
  "clean": "premove ./dist ./coverage ./dist-ts",
48
49
  "compile": "tsc --build",
49
50
  "dev": "tsx watch src/index.ts",
50
- "test:eslint": "eslint ./src",
51
+ "fix": "prettier --write ./src && eslint --fix ./src",
52
+ "test:eslint": "eslint ./src --max-warnings=0",
53
+ "test:types": "tsc --noEmit",
51
54
  "test:unit": "vitest",
52
55
  "test:unit:dev": "pnpm run test:unit --watch"
53
56
  }
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ // The @ai-sdk packages publish many times a day and hard-pin each other to exact
6
+ // versions. A floating range therefore resolves to a release that may be minutes
7
+ // old, and npm's registry metadata is not consistent that fast — a fresh install
8
+ // intermittently fails with ERR_PNPM_NO_MATCHING_VERSION on a transitive @ai-sdk
9
+ // dependency. Downstream consumers install from the registry without a lockfile,
10
+ // so an exact version in a *published* dependency field is what protects them.
11
+
12
+ // Walk up from the vitest cwd rather than resolving import.meta.url, which vitest
13
+ // serves through its /@fs/ prefix.
14
+ const findRepoRoot = () => {
15
+ let dir = process.cwd()
16
+ while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
17
+ const parent = dirname(dir)
18
+ if (parent === dir) throw new Error('workspace root not found above cwd')
19
+ dir = parent
20
+ }
21
+ return dir
22
+ }
23
+
24
+ const repoRoot = findRepoRoot()
25
+ const workspaceDirs = ['packages', 'examples']
26
+ const dependencyFields = [
27
+ 'dependencies',
28
+ 'devDependencies',
29
+ 'peerDependencies',
30
+ 'optionalDependencies',
31
+ ] as const
32
+
33
+ const isAiSdkPackage = (name: string) =>
34
+ name === 'ai' || name.startsWith('@ai-sdk/')
35
+
36
+ const isExactVersion = (range: string) => /^\d+\.\d+\.\d+/.test(range)
37
+
38
+ const collectPackageJsonPaths = () => {
39
+ const paths = [join(repoRoot, 'package.json')]
40
+ for (const dir of workspaceDirs) {
41
+ for (const entry of readdirSync(join(repoRoot, dir), {
42
+ withFileTypes: true,
43
+ })) {
44
+ if (entry.isDirectory()) {
45
+ paths.push(join(repoRoot, dir, entry.name, 'package.json'))
46
+ }
47
+ }
48
+ }
49
+ return paths
50
+ }
51
+
52
+ const collectAiSdkRanges = () => {
53
+ const found: { where: string; name: string; range: string }[] = []
54
+ for (const path of collectPackageJsonPaths()) {
55
+ let raw: string
56
+ try {
57
+ raw = readFileSync(path, 'utf8')
58
+ } catch {
59
+ continue
60
+ }
61
+ const pkg = JSON.parse(raw) as Record<string, Record<string, string>>
62
+ for (const field of dependencyFields) {
63
+ for (const [name, range] of Object.entries(pkg[field] ?? {})) {
64
+ if (isAiSdkPackage(name)) {
65
+ found.push({
66
+ where: `${path.slice(repoRoot.length + 1)} (${field})`,
67
+ name,
68
+ range,
69
+ })
70
+ }
71
+ }
72
+ }
73
+ }
74
+ return found
75
+ }
76
+
77
+ describe('@ai-sdk dependency pins', () => {
78
+ it('finds the @ai-sdk dependencies it is meant to guard', () => {
79
+ expect(collectAiSdkRanges().length).toBeGreaterThan(0)
80
+ })
81
+
82
+ it('declares every ai / @ai-sdk dependency as an exact version', () => {
83
+ const floating = collectAiSdkRanges().filter(
84
+ ({ range }) => !isExactVersion(range),
85
+ )
86
+ expect(
87
+ floating.map(({ where, name, range }) => `${where}: ${name}@${range}`),
88
+ ).toEqual([])
89
+ })
90
+ })
@@ -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
+ })
@@ -31,10 +31,10 @@ export type ApprovalType = 'none' | 'service' | 'person' | 'other' | 'unknown'
31
31
  /**
32
32
  * Approval type options for UI display
33
33
  */
34
- export const APPROVAL_TYPES: ReadonlyArray<{
34
+ export const APPROVAL_TYPES: readonly {
35
35
  value: ApprovalType
36
36
  label: string
37
- }> = [
37
+ }[] = [
38
38
  { value: 'none', label: 'None' },
39
39
  { value: 'service', label: 'Service' },
40
40
  { value: 'person', label: 'Person' },
@@ -47,10 +47,10 @@ export const APPROVAL_TYPES: ReadonlyArray<{
47
47
  */
48
48
  export interface BaseApprovalMethod {
49
49
  comment?: string
50
- roles?: Array<AppRole>
50
+ roles?: AppRole[]
51
51
  approvalPolicy?: string
52
52
  postApprovalInstructions?: string
53
- seeMoreUrls?: Array<string>
53
+ seeMoreUrls?: string[]
54
54
  }
55
55
 
56
56
  /**
@@ -152,13 +152,13 @@ export interface AppForCatalog {
152
152
  displayName: string
153
153
  description?: string
154
154
  approvalMethod?: ApprovalMethod
155
- teams?: Array<string> // Teams that use this app
155
+ teams?: string[] // Teams that use this app
156
156
  notes?: string
157
- tags?: Array<string>
157
+ tags?: string[]
158
158
  appUrl?: string
159
- links?: Array<{ url: string; title?: string }>
159
+ links?: { url: string; title?: string }[]
160
160
  iconName?: string
161
- screenshotIds?: Array<string>
161
+ screenshotIds?: string[]
162
162
  }
163
163
 
164
164
  // Derived catalog data returned by backend
@@ -168,6 +168,6 @@ export interface AppCategory {
168
168
  }
169
169
 
170
170
  export interface AppCatalogData {
171
- apps: Array<AppForCatalog>
172
- categories: Array<AppCategory>
171
+ apps: AppForCatalog[]
172
+ categories: AppCategory[]
173
173
  }
package/src/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from './nullToUndefined'
2
+ export * from './searchResources'
@@ -19,8 +19,8 @@ export type NullToUndefined<T> = T extends null
19
19
  ? undefined
20
20
  : T extends Primitive | Function | Date | RegExp
21
21
  ? T
22
- : T extends Array<infer U>
23
- ? Array<NullToUndefined<U>>
22
+ : T extends (infer U)[]
23
+ ? NullToUndefined<U>[]
24
24
  : T extends Map<infer K, infer V>
25
25
  ? Map<K, NullToUndefined<V>>
26
26
  : T extends Set<infer U>
@@ -33,8 +33,8 @@ export type UndefinedToNull<T> = T extends undefined
33
33
  ? null
34
34
  : T extends Primitive | Function | Date | RegExp
35
35
  ? T
36
- : T extends Array<infer U>
37
- ? Array<UndefinedToNull<U>>
36
+ : T extends (infer U)[]
37
+ ? UndefinedToNull<U>[]
38
38
  : T extends Map<infer K, infer V>
39
39
  ? Map<K, UndefinedToNull<V>>
40
40
  : T extends Set<infer U>
@@ -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
+ }