github-search-cli 9.4.2 → 9.4.4

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +38 -4
  2. package/README.md +8 -3
  3. package/bin/ghfind.js +98 -89
  4. package/completions/ghfind.bash +1 -1
  5. package/completions/ghfind.fish +3 -1
  6. package/completions/ghfind.zsh +6 -0
  7. package/dist/{chunk-IVUU2KGR.js → chunk-GYIL4S5X.js} +4 -1
  8. package/dist/cli.js +761 -342
  9. package/dist/{config-GNZOQRTH.js → config-OCIPF2IF.js} +1 -1
  10. package/package.json +6 -3
  11. package/release-notes/9.3.0.md +32 -29
  12. package/scripts/build.ts +34 -19
  13. package/scripts/generate-svg-demo.js +218 -168
  14. package/scripts/postinstall.js +176 -176
  15. package/src/bookmarks.ts +69 -69
  16. package/src/cli.ts +17 -1
  17. package/src/compare.ts +69 -47
  18. package/src/config.ts +70 -65
  19. package/src/deepdive.ts +149 -118
  20. package/src/doctor.ts +287 -287
  21. package/src/errors.ts +62 -59
  22. package/src/explore.ts +34 -29
  23. package/src/export.ts +73 -63
  24. package/src/help.ts +157 -157
  25. package/src/history.ts +69 -55
  26. package/src/init.ts +60 -55
  27. package/src/landing.ts +106 -104
  28. package/src/login.ts +186 -0
  29. package/src/markdown-render.ts +1 -6
  30. package/src/notifications.ts +75 -59
  31. package/src/open-url.ts +16 -16
  32. package/src/org.ts +43 -15
  33. package/src/output.ts +153 -137
  34. package/src/package-browser.ts +317 -305
  35. package/src/package.ts +149 -137
  36. package/src/pipe.ts +60 -54
  37. package/src/releases.ts +182 -144
  38. package/src/saved-searches.ts +72 -50
  39. package/src/search-state.ts +283 -229
  40. package/src/search.ts +678 -653
  41. package/src/session.ts +26 -24
  42. package/src/share.ts +61 -54
  43. package/src/status.ts +68 -61
  44. package/src/storage.ts +136 -106
  45. package/src/themes.ts +259 -116
  46. package/src/tips.ts +42 -42
  47. package/src/toast.ts +70 -69
  48. package/src/trending-parser.ts +104 -0
  49. package/src/trending.ts +481 -462
  50. package/src/tui.ts +149 -153
  51. package/src/types.ts +160 -158
  52. package/src/watch.ts +95 -74
package/src/search.ts CHANGED
@@ -1,653 +1,678 @@
1
- /**
2
- * Search module — deep module that owns the full search pipeline:
3
- * query parsing → URL building → API fetch (via adapter) → normalize → cache → rank.
4
- *
5
- * The adapter seam (`SearchAdapter`) is the only point of contact with the
6
- * network. Two implementations justify the seam: GitHub in prod, in-memory in tests.
7
- */
8
- import type {
9
- ParsedQuery,
10
- Qualifier,
11
- Repo,
12
- SearchOptions,
13
- SearchProvider,
14
- SearchResponse,
15
- SortStrategy,
16
- CacheEntry,
17
- } from "./types";
18
- import { NetworkError, RateLimitError, ParseError } from "./errors";
19
-
20
- export interface Logger {
21
- debug(msg: string): void;
22
- info(msg: string): void;
23
- warn(msg: string): void;
24
- error(msg: string): void;
25
- }
26
-
27
- const noopLogger: Logger = {
28
- debug: () => {},
29
- info: () => {},
30
- warn: () => {},
31
- error: () => {},
32
- };
33
-
34
- export interface SearchAdapter {
35
- search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse>;
36
- }
37
-
38
- export class MemoryCache<T> {
39
- private cache = new Map<string, CacheEntry<T>>();
40
-
41
- constructor(private defaultTtlMs: number = 300_000) {}
42
-
43
- static key(...parts: string[]): string {
44
- return parts.join("::").toLowerCase().replace(/\s+/g, " ");
45
- }
46
-
47
- get(key: string): T | null {
48
- const entry = this.cache.get(key);
49
- if (!entry) return null;
50
- if (Date.now() - entry.cachedAt > entry.ttlMs) {
51
- this.cache.delete(key);
52
- return null;
53
- }
54
- this.cache.delete(key);
55
- this.cache.set(key, entry);
56
- return entry.data;
57
- }
58
-
59
- set(key: string, data: T, ttlMs?: number): void {
60
- if (this.cache.size >= MAX_CACHE_SIZE) {
61
- const firstKey = this.cache.keys().next().value;
62
- if (firstKey !== undefined) this.cache.delete(firstKey);
63
- }
64
- this.cache.set(key, {
65
- data,
66
- cachedAt: Date.now(),
67
- ttlMs: ttlMs ?? this.defaultTtlMs,
68
- });
69
- }
70
-
71
- delete(key: string): void {
72
- this.cache.delete(key);
73
- }
74
-
75
- clear(): void {
76
- this.cache.clear();
77
- }
78
-
79
- get size(): number {
80
- return this.cache.size;
81
- }
82
- }
83
-
84
- const MAX_CACHE_SIZE = 50;
85
-
86
- export interface GitHubApiItem {
87
- id: number;
88
- name: string;
89
- full_name: string;
90
- owner?: { login?: string };
91
- description: string | null;
92
- html_url: string;
93
- stargazers_count: number;
94
- forks_count: number;
95
- watchers_count: number;
96
- language: string | null;
97
- topics?: string[];
98
- archived: boolean;
99
- fork: boolean;
100
- private: boolean;
101
- created_at: string;
102
- updated_at: string;
103
- pushed_at: string;
104
- score: number;
105
- }
106
-
107
- export interface GitHubSearchEnvelope {
108
- total_count: number;
109
- incomplete_results: boolean;
110
- items: GitHubApiItem[];
111
- }
112
-
113
- export function normalizeRepo(item: GitHubApiItem): Repo {
114
- const owner = item.owner?.login ?? item.full_name?.split("/")[0] ?? "";
115
- return {
116
- id: item.id ?? 0,
117
- fullName: item.full_name ?? "",
118
- name: item.name ?? "",
119
- owner,
120
- description: item.description ?? null,
121
- url: item.html_url ?? "",
122
- stars: item.stargazers_count ?? 0,
123
- forks: item.forks_count ?? 0,
124
- watchers: item.watchers_count ?? 0,
125
- language: item.language ?? null,
126
- topics: item.topics ?? [],
127
- archived: item.archived ?? false,
128
- isFork: item.fork ?? false,
129
- private: item.private ?? false,
130
- createdAt: item.created_at ?? "",
131
- updatedAt: item.updated_at ?? "",
132
- pushedAt: item.pushed_at ?? "",
133
- score: item.score ?? 0,
134
- };
135
- }
136
-
137
- export function normalizeEnvelope(env: GitHubSearchEnvelope): Repo[] {
138
- return (env.items ?? []).map(normalizeRepo);
139
- }
140
-
141
- export const KNOWN_QUALIFIERS = [
142
- "language",
143
- "stars",
144
- "fork",
145
- "archived",
146
- "topic",
147
- "user",
148
- "org",
149
- "repo",
150
- "updated",
151
- "pushed",
152
- "visibility",
153
- "in",
154
- "size",
155
- "license",
156
- "created",
157
- ] as const;
158
-
159
- export type KnownQualifier = (typeof KNOWN_QUALIFIERS)[number];
160
-
161
- export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
162
- l: ["language:"],
163
- s: ["stars:", "size:"],
164
- t: ["topic:"],
165
- u: ["user:"],
166
- o: ["org:"],
167
- r: ["repo:"],
168
- c: ["created:"],
169
- p: ["pushed:"],
170
- v: ["visibility:"],
171
- i: ["in:"],
172
- f: ["fork:"],
173
- a: ["archived:"],
174
- };
175
-
176
-
177
- export function suggestFor(input: string): string[] {
178
- const trimmed = input.trim().toLowerCase();
179
- if (trimmed.length === 0) return [];
180
- if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
181
- return QUALIFIER_SUGGESTIONS[trimmed];
182
- }
183
- const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
184
- return matches.map((k) => k + ":");
185
- }
186
-
187
- export function tokenize(input: string): string[] {
188
- const tokens: string[] = [];
189
- let current = "";
190
- let inQuotes = false;
191
- for (let i = 0; i < input.length; i++) {
192
- const ch = input[i];
193
- if (ch === '"') {
194
- inQuotes = !inQuotes;
195
- current += ch;
196
- } else if (/\s/.test(ch) && !inQuotes) {
197
- if (current) {
198
- tokens.push(current);
199
- current = "";
200
- }
201
- } else {
202
- current += ch;
203
- }
204
- }
205
- if (current) {
206
- if (current.startsWith('"') && current.endsWith('"') && current.length >= 2) {
207
- current = current.slice(1, -1);
208
- }
209
- tokens.push(current);
210
- }
211
- return tokens;
212
- }
213
- export function parseQuery(raw: string): ParsedQuery {
214
- const trimmed = raw.trim();
215
- if (trimmed === "") {
216
- return { keywords: [], qualifiers: [], raw };
217
- }
218
-
219
- const keywords: string[] = [];
220
- const qualifiers: Qualifier[] = [];
221
-
222
- for (const token of tokenize(trimmed)) {
223
- const qualifier = matchQualifier(token);
224
- if (qualifier) {
225
- qualifiers.push(qualifier);
226
- } else {
227
- keywords.push(token);
228
- }
229
- }
230
-
231
- return { keywords, qualifiers, raw: trimmed };
232
- }
233
-
234
- function matchQualifier(token: string): Qualifier | null {
235
- const negated = token.startsWith("-");
236
- const body = negated ? token.slice(1) : token;
237
- const idx = body.indexOf(":");
238
- if (idx <= 0) return null;
239
- const key = body.slice(0, idx).toLowerCase();
240
- let value = body.slice(idx + 1);
241
- if (value === "") return null;
242
- if (!/^[a-z]+$/i.test(key)) return null;
243
- if (value.startsWith("//")) return null;
244
- if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
245
- value = value.slice(1, -1);
246
- }
247
- return { key, value, negated };
248
- }
249
-
250
- export interface FlagFilters {
251
- language?: string;
252
- stars?: string;
253
- org?: string;
254
- user?: string;
255
- topic?: string;
256
- archived?: boolean;
257
- fork?: boolean;
258
- }
259
-
260
- export function applyFlagFilters(query: ParsedQuery, flags: FlagFilters): ParsedQuery {
261
- const qualifiers = [...query.qualifiers];
262
-
263
- if (flags.language) qualifiers.push({ key: "language", value: flags.language, negated: false });
264
- if (flags.stars) qualifiers.push({ key: "stars", value: flags.stars, negated: false });
265
- if (flags.org) qualifiers.push({ key: "org", value: flags.org, negated: false });
266
- if (flags.user) qualifiers.push({ key: "user", value: flags.user, negated: false });
267
- if (flags.topic) qualifiers.push({ key: "topic", value: flags.topic, negated: false });
268
- if (flags.archived !== undefined)
269
- qualifiers.push({ key: "archived", value: flags.archived ? "true" : "false", negated: false });
270
- if (flags.fork !== undefined)
271
- qualifiers.push({ key: "fork", value: flags.fork ? "true" : "false", negated: false });
272
-
273
- return { ...query, qualifiers };
274
- }
275
-
276
- export function validateQuery(query: ParsedQuery): void {
277
- const has = (key: string) => query.qualifiers.some((q) => q.key === key && !q.negated);
278
- if (has("visibility") && has("private")) {
279
- throw new Error("Cannot combine `visibility:` and `private:` filters.");
280
- }
281
- }
282
-
283
- export function buildGitHubQuery(query: ParsedQuery): string {
284
- const parts: string[] = [];
285
-
286
- for (const kw of query.keywords) {
287
- const alreadyQuoted = kw.startsWith('"') && kw.endsWith('"');
288
- parts.push(kw.includes(" ") && !alreadyQuoted ? `"${kw}"` : kw);
289
- }
290
-
291
- for (const q of query.qualifiers) {
292
- const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
293
- const value = needsQuote ? `"${q.value}"` : q.value;
294
- parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
295
- }
296
-
297
- return parts.join(" ");
298
- }
299
-
300
- export function githubSortParam(sort: SortStrategy): { sort?: string; order?: string } {
301
- switch (sort) {
302
- case "stars":
303
- return { sort: "stars", order: "desc" };
304
- case "forks":
305
- return { sort: "forks", order: "desc" };
306
- case "updated":
307
- return { sort: "updated", order: "desc" };
308
- case "best-match":
309
- default:
310
- return {};
311
- }
312
- }
313
-
314
- export function buildSearchUrl(query: ParsedQuery, options: SearchOptions): string {
315
- const q = buildGitHubQuery(query);
316
- const { sort, order } = githubSortParam(options.sort);
317
- const params = new URLSearchParams({ q: q || " " });
318
- if (sort) params.set("sort", sort);
319
- if (order) params.set("order", order);
320
- return `https://api.github.com/search/repositories?${params.toString()}`;
321
- }
322
-
323
- export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
324
- const copy = [...repos];
325
- switch (strategy) {
326
- case "stars":
327
- copy.sort(byKey((r) => r.stars));
328
- break;
329
- case "forks":
330
- copy.sort(byKey((r) => r.forks));
331
- break;
332
- case "updated":
333
- copy.sort(byKey((r) => Date.parse(r.updatedAt)));
334
- break;
335
- case "best-match":
336
- default:
337
- break;
338
- }
339
- return copy;
340
- }
341
-
342
- function byKey(key: (r: Repo) => number): (a: Repo, b: Repo) => number {
343
- return (a, b) => key(b) - key(a) || b.stars - a.stars || a.id - b.id;
344
- }
345
-
346
- export function compositeScore(r: Repo): number {
347
- const starScore = Math.log10(r.stars + 1);
348
- const recencyMs = Date.now() - Date.parse(r.pushedAt);
349
- const recencyScore = Math.max(0, 1 - recencyMs / (1000 * 60 * 60 * 24 * 365));
350
- return starScore * 0.7 + recencyScore * 0.3;
351
- }
352
-
353
- export class GitHubSearchAdapter implements SearchAdapter {
354
- readonly name = "github";
355
- private readonly logger: Logger;
356
- private tokens: string[];
357
- private tokenIndex = 0;
358
-
359
- constructor(logger: Logger = noopLogger, tokens: string[] = []) {
360
- this.logger = logger;
361
- this.tokens = tokens.filter(Boolean);
362
- }
363
-
364
- private nextToken(): string | undefined {
365
- if (this.tokens.length === 0) return undefined;
366
- const token = this.tokens[this.tokenIndex % this.tokens.length];
367
- this.tokenIndex++;
368
- return token;
369
- }
370
-
371
- async search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
372
- const url = buildSearchUrl(query, options);
373
- this.logger.debug(`[github] outgoing query: ${query.raw}`);
374
- this.logger.debug(`[github] request url: ${url}`);
375
-
376
- const all: Repo[] = [];
377
- let totalCount = 0;
378
- let rateLimited = false;
379
- let rateLimitRemaining: number | undefined;
380
-
381
- const perPage = Math.min(Math.max(options.limit, 1), 100);
382
- const startPage = options.page ?? 1;
383
- const maxPages = startPage > 1 ? startPage : Math.ceil(options.limit / perPage);
384
-
385
- for (let page = startPage > 1 ? startPage : 1; page <= maxPages; page++) {
386
- const pagedUrl = appendPage(url, page, perPage);
387
- let lastErr: Error | undefined;
388
-
389
- for (let attempt = 0; attempt <= this.tokens.length; attempt++) {
390
- const token = this.tokens.length > 0 ? this.nextToken() : options.token;
391
- try {
392
- const res = await fetch(pagedUrl, {
393
- headers: {
394
- Accept: "application/vnd.github+json",
395
- "X-GitHub-Api-Version": "2022-11-28",
396
- "User-Agent": "ghfind",
397
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
398
- },
399
- });
400
-
401
- const remainingStr = res.headers.get("x-ratelimit-remaining");
402
- rateLimitRemaining = remainingStr !== null ? Number(remainingStr) : undefined;
403
-
404
- if (res.status === 403) {
405
- if (remainingStr === "0" || remainingStr === null) {
406
- if (this.tokens.length > 0 && attempt < this.tokens.length) {
407
- continue;
408
- }
409
- this.logger.warn("[github] rate limit exceeded");
410
- rateLimited = true;
411
- throw new RateLimitError(!!token);
412
- }
413
- const body = await res.text().catch(() => "");
414
- this.logger.error(`[github] non-rate-limit 403: ${body.slice(0, 200)}`);
415
- throw new NetworkError();
416
- }
417
- if (!res.ok) {
418
- const body = await res.text().catch(() => "");
419
- this.logger.error(`[github] API error ${res.status}: ${body.slice(0, 200)}`);
420
- throw new NetworkError();
421
- }
422
-
423
- const raw = await res.text();
424
- let parsed: unknown;
425
- try {
426
- parsed = JSON.parse(raw);
427
- } catch {
428
- const snippet = raw.slice(0, 200);
429
- this.logger.error(`[github] invalid JSON response: ${snippet}`);
430
- throw new ParseError("GitHub API", snippet.includes("<!DOCTYPE")
431
- ? "GitHub returned an HTML page (maintenance or CAPTCHA?)"
432
- : "Invalid JSON response");
433
- }
434
- if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as any).items)) {
435
- this.logger.error(`[github] unexpected response shape: ${JSON.stringify(parsed).slice(0, 200)}`);
436
- throw new ParseError("GitHub API", "Unexpected response shape — missing items array");
437
- }
438
- const env = parsed as GitHubSearchEnvelope;
439
- totalCount = env.total_count;
440
- const repos = normalizeEnvelope(env);
441
- all.push(...repos);
442
- lastErr = undefined;
443
- break;
444
- } catch (err) {
445
- lastErr = err as Error;
446
- if (rateLimited) break;
447
- }
448
- }
449
-
450
- if (lastErr && !rateLimited) throw lastErr;
451
- if (rateLimited) break;
452
- if (all.length >= options.limit) break;
453
- }
454
-
455
- const repos = all.slice(0, options.limit);
456
- if (repos.length === 0) {
457
- this.logger.debug("[github] empty results");
458
- }
459
- return { totalCount, repos, rateLimited, rateLimitRemaining };
460
- }
461
- }
462
-
463
- function appendPage(url: string, page: number, perPage: number): string {
464
- const u = new URL(url);
465
- u.searchParams.set("page", String(page));
466
- u.searchParams.set("per_page", String(perPage));
467
- return u.toString();
468
- }
469
-
470
- // ─── Trending adapter ─────────────────────────────────────────────────
471
-
472
- const TRENDING_URL = "https://github.com/trending";
473
-
474
- interface RawTrendingRepo {
475
- rank: number;
476
- owner: string;
477
- name: string;
478
- stars: number;
479
- starsToday: number;
480
- language: string;
481
- description: string;
482
- }
483
-
484
- function parseTrendingHtml(html: string): RawTrendingRepo[] {
485
- const flat = html.replace(/>\s+</g, "><").replace(/\s+/g, " ");
486
- const repos: RawTrendingRepo[] = [];
487
- const articleRe = /<article class="Box-row">(.*?)<\/article>/gi;
488
- let match: RegExpExecArray | null;
489
- let rank = 0;
490
- while ((match = articleRe.exec(flat)) !== null) {
491
- rank++;
492
- const block = match[1];
493
- const repoMatch = block.match(/href="\/([^\/\"]+)\/([^\"?#]+)"[^>]*class="Link"/);
494
- if (!repoMatch) continue;
495
- const owner = repoMatch[1];
496
- const name = repoMatch[2];
497
- const starsMatch = block.match(/href="\/[^\/\"]+\/[^\/\"]+\/stargazers"[^>]*>.*?<\/svg>\s*(\d[\d,]*)/);
498
- const stars = starsMatch ? parseInt(starsMatch[1].replace(/,/g, ""), 10) : 0;
499
- const growthMatch = block.match(/(\d[\d,]*)\s+stars\s+(today|this\s+\w+)/);
500
- const starsToday = growthMatch ? parseInt(growthMatch[1].replace(/,/g, ""), 10) : 0;
501
- const langMatch = block.match(/itemprop="programmingLanguage">([^<]+)</);
502
- const language = langMatch ? langMatch[1].trim() : "";
503
- const descMatch = block.match(/<p[^>]*class="[^"]*color-fg-muted[^"]*"[^>]*>([^<]+)</);
504
- const description = descMatch ? descMatch[1].trim() : "";
505
- repos.push({ rank, owner, name, stars, starsToday, language, description });
506
- }
507
- return repos;
508
- }
509
-
510
- function trendingRepoToRepo(r: RawTrendingRepo): Repo {
511
- return {
512
- id: 0,
513
- fullName: `${r.owner}/${r.name}`,
514
- name: r.name,
515
- owner: r.owner,
516
- description: r.description,
517
- url: `https://github.com/${r.owner}/${r.name}`,
518
- stars: r.stars,
519
- forks: 0,
520
- watchers: 0,
521
- language: r.language,
522
- topics: [],
523
- archived: false,
524
- isFork: false,
525
- private: false,
526
- createdAt: "",
527
- updatedAt: "",
528
- pushedAt: "",
529
- score: r.starsToday,
530
- };
531
- }
532
-
533
- /**
534
- * Trending adapter — scrapes github.com/trending HTML and returns SearchResponse.
535
- */
536
- export class TrendingAdapter implements SearchAdapter {
537
- readonly name = "trending";
538
- private readonly logger: Logger;
539
-
540
- constructor(logger: Logger = noopLogger) {
541
- this.logger = logger;
542
- }
543
-
544
- async search(_query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
545
- const since = options.trendingSince ?? "daily";
546
- const url = since === "daily" ? TRENDING_URL : `${TRENDING_URL}?since=${since}`;
547
- let res: Response;
548
- try {
549
- res = await fetch(url, { headers: { "User-Agent": "ghfind" } });
550
- } catch {
551
- throw new NetworkError();
552
- }
553
- if (!res.ok) throw new NetworkError();
554
- const html = await res.text();
555
- const repos = parseTrendingHtml(html).map(trendingRepoToRepo);
556
- return { totalCount: repos.length, repos, rateLimited: false };
557
- }
558
- }
559
-
560
- // ─── In-memory adapter (for tests) ────────────────────────────────────
561
-
562
- /**
563
- * In-memory adapter that returns canned responses.
564
- * Use in tests to avoid network calls.
565
- */
566
- export class InMemoryAdapter implements SearchAdapter {
567
- readonly name = "in-memory";
568
- private responses: Map<string, SearchResponse> = new Map();
569
- private defaultResponse: SearchResponse = { totalCount: 0, repos: [], rateLimited: false };
570
-
571
- /** Set a canned response keyed by the raw query string. */
572
- setResponse(queryRaw: string, response: SearchResponse): void {
573
- this.responses.set(queryRaw.toLowerCase(), response);
574
- }
575
-
576
- /** Set the default response returned when no canned response matches. */
577
- setDefault(response: SearchResponse): void {
578
- this.defaultResponse = response;
579
- }
580
-
581
- async search(query: ParsedQuery, _options: SearchOptions): Promise<SearchResponse> {
582
- const key = query.raw.toLowerCase();
583
- return this.responses.get(key) ?? { ...this.defaultResponse };
584
- }
585
-
586
- clear(): void {
587
- this.responses.clear();
588
- }
589
- }
590
-
591
- // ─── Search module (orchestrator) ─────────────────────────────────────
592
-
593
- /**
594
- * Deep Search module — owns the cache, delegates network I/O to an adapter,
595
- * and orchestrates the full pipeline: cache → adapter → rank.
596
- */
597
- export class SearchModule implements SearchProvider {
598
- readonly name = "search";
599
- private readonly adapter: SearchAdapter;
600
- private readonly logger: Logger;
601
- /** Shared cache across instances. */
602
- static cache = new MemoryCache<SearchResponse>(300_000);
603
-
604
- constructor(adapter: SearchAdapter, logger: Logger = noopLogger) {
605
- this.adapter = adapter;
606
- this.logger = logger;
607
- }
608
-
609
- async search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
610
- const cacheKey = MemoryCache.key(
611
- query.raw,
612
- options.sort,
613
- String(options.limit),
614
- String(options.page ?? 1),
615
- options.trendingSince ?? "none",
616
- );
617
- const cached = SearchModule.cache.get(cacheKey);
618
- if (cached) {
619
- this.logger.debug("[search] cache hit");
620
- return cached;
621
- }
622
- this.logger.debug("[search] cache miss");
623
-
624
- const response = await this.adapter.search(query, options);
625
- if (!response.rateLimited) {
626
- SearchModule.cache.set(cacheKey, response);
627
- }
628
- return response;
629
- }
630
- }
631
-
632
- // ─── Convenience: create a GitHub-backed SearchModule ──────────────────
633
-
634
- /**
635
- * Create a SearchModule backed by the GitHub adapter.
636
- * This is the primary entry point for production code.
637
- */
638
- export function createGitHubSearch(
639
- logger: Logger = noopLogger,
640
- tokens: string[] = [],
641
- ): SearchModule {
642
- return new SearchModule(new GitHubSearchAdapter(logger, tokens), logger);
643
- }
644
-
645
- /**
646
- * Create a SearchModule backed by the Trending adapter.
647
- */
648
- export function createTrendingSearch(
649
- logger: Logger = noopLogger,
650
- ): SearchModule {
651
- return new SearchModule(new TrendingAdapter(logger), logger);
652
- }
653
-
1
+ /**
2
+ * Search module — deep module that owns the full search pipeline:
3
+ * query parsing → URL building → API fetch (via adapter) → normalize → cache → rank.
4
+ *
5
+ * The adapter seam (`SearchAdapter`) is the only point of contact with the
6
+ * network. Two implementations justify the seam: GitHub in prod, in-memory in tests.
7
+ */
8
+ import type {
9
+ ParsedQuery,
10
+ Qualifier,
11
+ Repo,
12
+ SearchOptions,
13
+ SearchProvider,
14
+ SearchResponse,
15
+ SortStrategy,
16
+ CacheEntry,
17
+ } from "./types";
18
+ import { NetworkError, RateLimitError, ParseError } from "./errors";
19
+ import { parseTrendingHtml, type RawTrendingRepo } from "./trending-parser";
20
+
21
+ export interface Logger {
22
+ debug(msg: string): void;
23
+ info(msg: string): void;
24
+ warn(msg: string): void;
25
+ error(msg: string): void;
26
+ }
27
+
28
+ const noopLogger: Logger = {
29
+ debug: () => {},
30
+ info: () => {},
31
+ warn: () => {},
32
+ error: () => {},
33
+ };
34
+
35
+ export interface SearchAdapter {
36
+ search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse>;
37
+ }
38
+
39
+ export class MemoryCache<T> {
40
+ private cache = new Map<string, CacheEntry<T>>();
41
+
42
+ constructor(private defaultTtlMs: number = 300_000) {}
43
+
44
+ static key(...parts: string[]): string {
45
+ return parts.join("::").toLowerCase().replace(/\s+/g, " ");
46
+ }
47
+
48
+ get(key: string): T | null {
49
+ const entry = this.cache.get(key);
50
+ if (!entry) return null;
51
+ if (Date.now() - entry.cachedAt > entry.ttlMs) {
52
+ this.cache.delete(key);
53
+ return null;
54
+ }
55
+ this.cache.delete(key);
56
+ this.cache.set(key, entry);
57
+ return entry.data;
58
+ }
59
+
60
+ set(key: string, data: T, ttlMs?: number): void {
61
+ if (this.cache.size >= MAX_CACHE_SIZE) {
62
+ const firstKey = this.cache.keys().next().value;
63
+ if (firstKey !== undefined) this.cache.delete(firstKey);
64
+ }
65
+ this.cache.set(key, {
66
+ data,
67
+ cachedAt: Date.now(),
68
+ ttlMs: ttlMs ?? this.defaultTtlMs,
69
+ });
70
+ }
71
+
72
+ delete(key: string): void {
73
+ this.cache.delete(key);
74
+ }
75
+
76
+ clear(): void {
77
+ this.cache.clear();
78
+ }
79
+
80
+ get size(): number {
81
+ return this.cache.size;
82
+ }
83
+ }
84
+
85
+ const MAX_CACHE_SIZE = 50;
86
+
87
+ export interface GitHubApiItem {
88
+ id: number;
89
+ name: string;
90
+ full_name: string;
91
+ owner?: { login?: string };
92
+ description: string | null;
93
+ html_url: string;
94
+ stargazers_count: number;
95
+ forks_count: number;
96
+ watchers_count: number;
97
+ language: string | null;
98
+ topics?: string[];
99
+ archived: boolean;
100
+ fork: boolean;
101
+ private: boolean;
102
+ created_at: string;
103
+ updated_at: string;
104
+ pushed_at: string;
105
+ score: number;
106
+ }
107
+
108
+ export interface GitHubSearchEnvelope {
109
+ total_count: number;
110
+ incomplete_results: boolean;
111
+ items: GitHubApiItem[];
112
+ }
113
+
114
+ export function normalizeRepo(item: GitHubApiItem): Repo {
115
+ const owner = item.owner?.login ?? item.full_name?.split("/")[0] ?? "";
116
+ return {
117
+ id: item.id ?? 0,
118
+ fullName: item.full_name ?? "",
119
+ name: item.name ?? "",
120
+ owner,
121
+ description: item.description ?? null,
122
+ url: item.html_url ?? "",
123
+ stars: item.stargazers_count ?? 0,
124
+ forks: item.forks_count ?? 0,
125
+ watchers: item.watchers_count ?? 0,
126
+ language: item.language ?? null,
127
+ topics: item.topics ?? [],
128
+ archived: item.archived ?? false,
129
+ isFork: item.fork ?? false,
130
+ private: item.private ?? false,
131
+ createdAt: item.created_at ?? "",
132
+ updatedAt: item.updated_at ?? "",
133
+ pushedAt: item.pushed_at ?? "",
134
+ score: item.score ?? 0,
135
+ };
136
+ }
137
+
138
+ export function normalizeEnvelope(env: GitHubSearchEnvelope): Repo[] {
139
+ return (env.items ?? []).map(normalizeRepo);
140
+ }
141
+
142
+ export const KNOWN_QUALIFIERS = [
143
+ "language",
144
+ "stars",
145
+ "fork",
146
+ "archived",
147
+ "topic",
148
+ "user",
149
+ "org",
150
+ "repo",
151
+ "updated",
152
+ "pushed",
153
+ "visibility",
154
+ "in",
155
+ "size",
156
+ "license",
157
+ "created",
158
+ ] as const;
159
+
160
+ export type KnownQualifier = (typeof KNOWN_QUALIFIERS)[number];
161
+
162
+ export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
163
+ l: ["language:"],
164
+ s: ["stars:", "size:"],
165
+ t: ["topic:"],
166
+ u: ["user:"],
167
+ o: ["org:"],
168
+ r: ["repo:"],
169
+ c: ["created:"],
170
+ p: ["pushed:"],
171
+ v: ["visibility:"],
172
+ i: ["in:"],
173
+ f: ["fork:"],
174
+ a: ["archived:"],
175
+ };
176
+
177
+ export function suggestFor(input: string): string[] {
178
+ const trimmed = input.trim().toLowerCase();
179
+ if (trimmed.length === 0) return [];
180
+ if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
181
+ return QUALIFIER_SUGGESTIONS[trimmed];
182
+ }
183
+ const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
184
+ return matches.map((k) => k + ":");
185
+ }
186
+
187
+ export function tokenize(input: string): string[] {
188
+ const tokens: string[] = [];
189
+ let current = "";
190
+ let inQuotes = false;
191
+ for (let i = 0; i < input.length; i++) {
192
+ const ch = input[i];
193
+ if (ch === '"') {
194
+ inQuotes = !inQuotes;
195
+ current += ch;
196
+ } else if (/\s/.test(ch) && !inQuotes) {
197
+ if (current) {
198
+ tokens.push(current);
199
+ current = "";
200
+ }
201
+ } else {
202
+ current += ch;
203
+ }
204
+ }
205
+ if (current) {
206
+ if (
207
+ current.startsWith('"') &&
208
+ current.endsWith('"') &&
209
+ current.length >= 2
210
+ ) {
211
+ current = current.slice(1, -1);
212
+ }
213
+ tokens.push(current);
214
+ }
215
+ return tokens;
216
+ }
217
+ export function parseQuery(raw: string): ParsedQuery {
218
+ const trimmed = raw.trim();
219
+ if (trimmed === "") {
220
+ return { keywords: [], qualifiers: [], raw };
221
+ }
222
+
223
+ const keywords: string[] = [];
224
+ const qualifiers: Qualifier[] = [];
225
+
226
+ for (const token of tokenize(trimmed)) {
227
+ const qualifier = matchQualifier(token);
228
+ if (qualifier) {
229
+ qualifiers.push(qualifier);
230
+ } else {
231
+ keywords.push(token);
232
+ }
233
+ }
234
+
235
+ return { keywords, qualifiers, raw: trimmed };
236
+ }
237
+
238
+ function matchQualifier(token: string): Qualifier | null {
239
+ const negated = token.startsWith("-");
240
+ const body = negated ? token.slice(1) : token;
241
+ const idx = body.indexOf(":");
242
+ if (idx <= 0) return null;
243
+ const key = body.slice(0, idx).toLowerCase();
244
+ let value = body.slice(idx + 1);
245
+ if (value === "") return null;
246
+ if (!/^[a-z]+$/i.test(key)) return null;
247
+ if (value.startsWith("//")) return null;
248
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
249
+ value = value.slice(1, -1);
250
+ }
251
+ return { key, value, negated };
252
+ }
253
+
254
+ export interface FlagFilters {
255
+ language?: string;
256
+ stars?: string;
257
+ org?: string;
258
+ user?: string;
259
+ topic?: string;
260
+ archived?: boolean;
261
+ fork?: boolean;
262
+ }
263
+
264
+ export function applyFlagFilters(
265
+ query: ParsedQuery,
266
+ flags: FlagFilters,
267
+ ): ParsedQuery {
268
+ const qualifiers = [...query.qualifiers];
269
+
270
+ if (flags.language)
271
+ qualifiers.push({ key: "language", value: flags.language, negated: false });
272
+ if (flags.stars)
273
+ qualifiers.push({ key: "stars", value: flags.stars, negated: false });
274
+ if (flags.org)
275
+ qualifiers.push({ key: "org", value: flags.org, negated: false });
276
+ if (flags.user)
277
+ qualifiers.push({ key: "user", value: flags.user, negated: false });
278
+ if (flags.topic)
279
+ qualifiers.push({ key: "topic", value: flags.topic, negated: false });
280
+ if (flags.archived !== undefined)
281
+ qualifiers.push({
282
+ key: "archived",
283
+ value: flags.archived ? "true" : "false",
284
+ negated: false,
285
+ });
286
+ if (flags.fork !== undefined)
287
+ qualifiers.push({
288
+ key: "fork",
289
+ value: flags.fork ? "true" : "false",
290
+ negated: false,
291
+ });
292
+
293
+ return { ...query, qualifiers };
294
+ }
295
+
296
+ export function validateQuery(query: ParsedQuery): void {
297
+ const has = (key: string) =>
298
+ query.qualifiers.some((q) => q.key === key && !q.negated);
299
+ if (has("visibility") && has("private")) {
300
+ throw new Error("Cannot combine `visibility:` and `private:` filters.");
301
+ }
302
+ }
303
+
304
+ export function buildGitHubQuery(query: ParsedQuery): string {
305
+ const parts: string[] = [];
306
+
307
+ for (const kw of query.keywords) {
308
+ const alreadyQuoted = kw.startsWith('"') && kw.endsWith('"');
309
+ parts.push(kw.includes(" ") && !alreadyQuoted ? `"${kw}"` : kw);
310
+ }
311
+
312
+ for (const q of query.qualifiers) {
313
+ const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
314
+ const value = needsQuote ? `"${q.value}"` : q.value;
315
+ parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
316
+ }
317
+
318
+ return parts.join(" ");
319
+ }
320
+
321
+ export function githubSortParam(sort: SortStrategy): {
322
+ sort?: string;
323
+ order?: string;
324
+ } {
325
+ switch (sort) {
326
+ case "stars":
327
+ return { sort: "stars", order: "desc" };
328
+ case "forks":
329
+ return { sort: "forks", order: "desc" };
330
+ case "updated":
331
+ return { sort: "updated", order: "desc" };
332
+ case "best-match":
333
+ default:
334
+ return {};
335
+ }
336
+ }
337
+
338
+ export function buildSearchUrl(
339
+ query: ParsedQuery,
340
+ options: SearchOptions,
341
+ ): string {
342
+ const q = buildGitHubQuery(query);
343
+ const { sort, order } = githubSortParam(options.sort);
344
+ const params = new URLSearchParams({ q: q || " " });
345
+ if (sort) params.set("sort", sort);
346
+ if (order) params.set("order", order);
347
+ return `https://api.github.com/search/repositories?${params.toString()}`;
348
+ }
349
+
350
+ export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
351
+ const copy = [...repos];
352
+ switch (strategy) {
353
+ case "stars":
354
+ copy.sort(byKey((r) => r.stars));
355
+ break;
356
+ case "forks":
357
+ copy.sort(byKey((r) => r.forks));
358
+ break;
359
+ case "updated":
360
+ copy.sort(byKey((r) => Date.parse(r.updatedAt)));
361
+ break;
362
+ case "best-match":
363
+ default:
364
+ break;
365
+ }
366
+ return copy;
367
+ }
368
+
369
+ function byKey(key: (r: Repo) => number): (a: Repo, b: Repo) => number {
370
+ return (a, b) => key(b) - key(a) || b.stars - a.stars || a.id - b.id;
371
+ }
372
+
373
+ export function compositeScore(r: Repo): number {
374
+ const starScore = Math.log10(r.stars + 1);
375
+ const recencyMs = Date.now() - Date.parse(r.pushedAt);
376
+ const recencyScore = Math.max(0, 1 - recencyMs / (1000 * 60 * 60 * 24 * 365));
377
+ return starScore * 0.7 + recencyScore * 0.3;
378
+ }
379
+
380
+ export class GitHubSearchAdapter implements SearchAdapter {
381
+ readonly name = "github";
382
+ private readonly logger: Logger;
383
+ private tokens: string[];
384
+ private tokenIndex = 0;
385
+
386
+ constructor(logger: Logger = noopLogger, tokens: string[] = []) {
387
+ this.logger = logger;
388
+ this.tokens = tokens.filter(Boolean);
389
+ }
390
+
391
+ private nextToken(): string | undefined {
392
+ if (this.tokens.length === 0) return undefined;
393
+ const token = this.tokens[this.tokenIndex % this.tokens.length];
394
+ this.tokenIndex++;
395
+ return token;
396
+ }
397
+
398
+ async search(
399
+ query: ParsedQuery,
400
+ options: SearchOptions,
401
+ ): Promise<SearchResponse> {
402
+ const url = buildSearchUrl(query, options);
403
+ this.logger.debug(`[github] outgoing query: ${query.raw}`);
404
+ this.logger.debug(`[github] request url: ${url}`);
405
+
406
+ const all: Repo[] = [];
407
+ let totalCount = 0;
408
+ let rateLimited = false;
409
+ let rateLimitRemaining: number | undefined;
410
+
411
+ const perPage = Math.min(Math.max(options.limit, 1), 100);
412
+ const startPage = options.page ?? 1;
413
+ const maxPages =
414
+ startPage > 1 ? startPage : Math.ceil(options.limit / perPage);
415
+
416
+ for (let page = startPage > 1 ? startPage : 1; page <= maxPages; page++) {
417
+ const pagedUrl = appendPage(url, page, perPage);
418
+ let lastErr: Error | undefined;
419
+
420
+ for (let attempt = 0; attempt <= this.tokens.length; attempt++) {
421
+ const token = this.tokens.length > 0 ? this.nextToken() : options.token;
422
+ try {
423
+ const res = await fetch(pagedUrl, {
424
+ headers: {
425
+ Accept: "application/vnd.github+json",
426
+ "X-GitHub-Api-Version": "2022-11-28",
427
+ "User-Agent": "ghfind",
428
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
429
+ },
430
+ });
431
+
432
+ const remainingStr = res.headers.get("x-ratelimit-remaining");
433
+ rateLimitRemaining =
434
+ remainingStr !== null ? Number(remainingStr) : undefined;
435
+
436
+ if (res.status === 403) {
437
+ if (remainingStr === "0" || remainingStr === null) {
438
+ if (this.tokens.length > 0 && attempt < this.tokens.length) {
439
+ continue;
440
+ }
441
+ this.logger.warn("[github] rate limit exceeded");
442
+ rateLimited = true;
443
+ throw new RateLimitError(!!token);
444
+ }
445
+ const body = await res.text().catch(() => "");
446
+ this.logger.error(
447
+ `[github] non-rate-limit 403: ${body.slice(0, 200)}`,
448
+ );
449
+ throw new NetworkError();
450
+ }
451
+ if (!res.ok) {
452
+ const body = await res.text().catch(() => "");
453
+ this.logger.error(
454
+ `[github] API error ${res.status}: ${body.slice(0, 200)}`,
455
+ );
456
+ throw new NetworkError();
457
+ }
458
+
459
+ const raw = await res.text();
460
+ let parsed: unknown;
461
+ try {
462
+ parsed = JSON.parse(raw);
463
+ } catch {
464
+ const snippet = raw.slice(0, 200);
465
+ this.logger.error(`[github] invalid JSON response: ${snippet}`);
466
+ throw new ParseError(
467
+ "GitHub API",
468
+ snippet.includes("<!DOCTYPE")
469
+ ? "GitHub returned an HTML page (maintenance or CAPTCHA?)"
470
+ : "Invalid JSON response",
471
+ );
472
+ }
473
+ if (
474
+ !parsed ||
475
+ typeof parsed !== "object" ||
476
+ !Array.isArray((parsed as any).items)
477
+ ) {
478
+ this.logger.error(
479
+ `[github] unexpected response shape: ${JSON.stringify(parsed).slice(0, 200)}`,
480
+ );
481
+ throw new ParseError(
482
+ "GitHub API",
483
+ "Unexpected response shape — missing items array",
484
+ );
485
+ }
486
+ const env = parsed as GitHubSearchEnvelope;
487
+ totalCount = env.total_count;
488
+ const repos = normalizeEnvelope(env);
489
+ all.push(...repos);
490
+ lastErr = undefined;
491
+ break;
492
+ } catch (err) {
493
+ lastErr = err as Error;
494
+ if (rateLimited) break;
495
+ }
496
+ }
497
+
498
+ if (lastErr && !rateLimited) throw lastErr;
499
+ if (rateLimited) break;
500
+ if (all.length >= options.limit) break;
501
+ }
502
+
503
+ const repos = all.slice(0, options.limit);
504
+ if (repos.length === 0) {
505
+ this.logger.debug("[github] empty results");
506
+ }
507
+ return { totalCount, repos, rateLimited, rateLimitRemaining };
508
+ }
509
+ }
510
+
511
+ function appendPage(url: string, page: number, perPage: number): string {
512
+ const u = new URL(url);
513
+ u.searchParams.set("page", String(page));
514
+ u.searchParams.set("per_page", String(perPage));
515
+ return u.toString();
516
+ }
517
+
518
+ // ─── Trending adapter ─────────────────────────────────────────────────
519
+
520
+ const TRENDING_URL = "https://github.com/trending";
521
+
522
+ function trendingRepoToRepo(r: RawTrendingRepo): Repo {
523
+ return {
524
+ id: 0,
525
+ fullName: `${r.owner}/${r.name}`,
526
+ name: r.name,
527
+ owner: r.owner,
528
+ description: r.description,
529
+ url: `https://github.com/${r.owner}/${r.name}`,
530
+ stars: r.stars,
531
+ forks: 0,
532
+ watchers: 0,
533
+ language: r.language,
534
+ topics: [],
535
+ archived: false,
536
+ isFork: false,
537
+ private: false,
538
+ createdAt: "",
539
+ updatedAt: "",
540
+ pushedAt: "",
541
+ score: r.starsToday,
542
+ };
543
+ }
544
+
545
+ /**
546
+ * Trending adapter — scrapes github.com/trending HTML and returns SearchResponse.
547
+ */
548
+ export class TrendingAdapter implements SearchAdapter {
549
+ readonly name = "trending";
550
+ private readonly logger: Logger;
551
+
552
+ constructor(logger: Logger = noopLogger) {
553
+ this.logger = logger;
554
+ }
555
+
556
+ async search(
557
+ _query: ParsedQuery,
558
+ options: SearchOptions,
559
+ ): Promise<SearchResponse> {
560
+ const since = options.trendingSince ?? "daily";
561
+ const url =
562
+ since === "daily" ? TRENDING_URL : `${TRENDING_URL}?since=${since}`;
563
+ let res: Response;
564
+ try {
565
+ res = await fetch(url, { headers: { "User-Agent": "ghfind" } });
566
+ } catch {
567
+ throw new NetworkError();
568
+ }
569
+ if (!res.ok) throw new NetworkError();
570
+ const html = await res.text();
571
+ const repos = parseTrendingHtml(html).map(trendingRepoToRepo);
572
+ return { totalCount: repos.length, repos, rateLimited: false };
573
+ }
574
+ }
575
+
576
+ // ─── In-memory adapter (for tests) ────────────────────────────────────
577
+
578
+ /**
579
+ * In-memory adapter that returns canned responses.
580
+ * Use in tests to avoid network calls.
581
+ */
582
+ export class InMemoryAdapter implements SearchAdapter {
583
+ readonly name = "in-memory";
584
+ private responses: Map<string, SearchResponse> = new Map();
585
+ private defaultResponse: SearchResponse = {
586
+ totalCount: 0,
587
+ repos: [],
588
+ rateLimited: false,
589
+ };
590
+
591
+ /** Set a canned response keyed by the raw query string. */
592
+ setResponse(queryRaw: string, response: SearchResponse): void {
593
+ this.responses.set(queryRaw.toLowerCase(), response);
594
+ }
595
+
596
+ /** Set the default response returned when no canned response matches. */
597
+ setDefault(response: SearchResponse): void {
598
+ this.defaultResponse = response;
599
+ }
600
+
601
+ async search(
602
+ query: ParsedQuery,
603
+ _options: SearchOptions,
604
+ ): Promise<SearchResponse> {
605
+ const key = query.raw.toLowerCase();
606
+ return this.responses.get(key) ?? { ...this.defaultResponse };
607
+ }
608
+
609
+ clear(): void {
610
+ this.responses.clear();
611
+ }
612
+ }
613
+
614
+ // ─── Search module (orchestrator) ─────────────────────────────────────
615
+
616
+ /**
617
+ * Deep Search module — owns the cache, delegates network I/O to an adapter,
618
+ * and orchestrates the full pipeline: cache → adapter → rank.
619
+ */
620
+ export class SearchModule implements SearchProvider {
621
+ readonly name = "search";
622
+ private readonly adapter: SearchAdapter;
623
+ private readonly logger: Logger;
624
+ /** Shared cache across instances. */
625
+ static cache = new MemoryCache<SearchResponse>(300_000);
626
+
627
+ constructor(adapter: SearchAdapter, logger: Logger = noopLogger) {
628
+ this.adapter = adapter;
629
+ this.logger = logger;
630
+ }
631
+
632
+ async search(
633
+ query: ParsedQuery,
634
+ options: SearchOptions,
635
+ ): Promise<SearchResponse> {
636
+ const cacheKey = MemoryCache.key(
637
+ query.raw,
638
+ options.sort,
639
+ String(options.limit),
640
+ String(options.page ?? 1),
641
+ options.trendingSince ?? "none",
642
+ );
643
+ const cached = SearchModule.cache.get(cacheKey);
644
+ if (cached) {
645
+ this.logger.debug("[search] cache hit");
646
+ return cached;
647
+ }
648
+ this.logger.debug("[search] cache miss");
649
+
650
+ const response = await this.adapter.search(query, options);
651
+ if (!response.rateLimited) {
652
+ SearchModule.cache.set(cacheKey, response);
653
+ }
654
+ return response;
655
+ }
656
+ }
657
+
658
+ // ─── Convenience: create a GitHub-backed SearchModule ──────────────────
659
+
660
+ /**
661
+ * Create a SearchModule backed by the GitHub adapter.
662
+ * This is the primary entry point for production code.
663
+ */
664
+ export function createGitHubSearch(
665
+ logger: Logger = noopLogger,
666
+ tokens: string[] = [],
667
+ ): SearchModule {
668
+ return new SearchModule(new GitHubSearchAdapter(logger, tokens), logger);
669
+ }
670
+
671
+ /**
672
+ * Create a SearchModule backed by the Trending adapter.
673
+ */
674
+ export function createTrendingSearch(
675
+ logger: Logger = noopLogger,
676
+ ): SearchModule {
677
+ return new SearchModule(new TrendingAdapter(logger), logger);
678
+ }