github-search-cli 0.8.0 → 0.8.1

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.
package/src/search.ts CHANGED
@@ -1,555 +1,555 @@
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
- // ─── In-memory adapter (for tests) ────────────────────────────────────
471
-
472
- /**
473
- * In-memory adapter that returns canned responses.
474
- * Use in tests to avoid network calls.
475
- */
476
- export class InMemoryAdapter implements SearchAdapter {
477
- readonly name = "in-memory";
478
- private responses: Map<string, SearchResponse> = new Map();
479
- private defaultResponse: SearchResponse = { totalCount: 0, repos: [], rateLimited: false };
480
-
481
- /** Set a canned response keyed by the raw query string. */
482
- setResponse(queryRaw: string, response: SearchResponse): void {
483
- this.responses.set(queryRaw.toLowerCase(), response);
484
- }
485
-
486
- /** Set the default response returned when no canned response matches. */
487
- setDefault(response: SearchResponse): void {
488
- this.defaultResponse = response;
489
- }
490
-
491
- async search(query: ParsedQuery, _options: SearchOptions): Promise<SearchResponse> {
492
- const key = query.raw.toLowerCase();
493
- return this.responses.get(key) ?? { ...this.defaultResponse };
494
- }
495
-
496
- clear(): void {
497
- this.responses.clear();
498
- }
499
- }
500
-
501
- // ─── Search module (orchestrator) ─────────────────────────────────────
502
-
503
- /**
504
- * Deep Search module — owns the cache, delegates network I/O to an adapter,
505
- * and orchestrates the full pipeline: cache → adapter → rank.
506
- */
507
- export class SearchModule implements SearchProvider {
508
- readonly name = "search";
509
- private readonly adapter: SearchAdapter;
510
- private readonly logger: Logger;
511
- /** Shared cache across instances. */
512
- static cache = new MemoryCache<SearchResponse>(300_000);
513
-
514
- constructor(adapter: SearchAdapter, logger: Logger = noopLogger) {
515
- this.adapter = adapter;
516
- this.logger = logger;
517
- }
518
-
519
- async search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
520
- const cacheKey = MemoryCache.key(
521
- query.raw,
522
- options.sort,
523
- String(options.limit),
524
- String(options.page ?? 1),
525
- );
526
- const cached = SearchModule.cache.get(cacheKey);
527
- if (cached) {
528
- this.logger.debug("[search] cache hit");
529
- return cached;
530
- }
531
- this.logger.debug("[search] cache miss");
532
-
533
- const response = await this.adapter.search(query, options);
534
- if (!response.rateLimited) {
535
- SearchModule.cache.set(cacheKey, response);
536
- }
537
- return response;
538
- }
539
- }
540
-
541
- // ─── Convenience: create a GitHub-backed SearchModule ──────────────────
542
-
543
- /**
544
- * Create a SearchModule backed by the GitHub adapter.
545
- * This is the primary entry point for production code.
546
- */
547
- export function createGitHubSearch(
548
- logger: Logger = noopLogger,
549
- tokens: string[] = [],
550
- ): SearchModule {
551
- return new SearchModule(new GitHubSearchAdapter(logger, tokens), logger);
552
- }
553
-
554
- // Re-export types that callers need
555
- export type { GitHubApiItem, GitHubSearchEnvelope };
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
+ // ─── In-memory adapter (for tests) ────────────────────────────────────
471
+
472
+ /**
473
+ * In-memory adapter that returns canned responses.
474
+ * Use in tests to avoid network calls.
475
+ */
476
+ export class InMemoryAdapter implements SearchAdapter {
477
+ readonly name = "in-memory";
478
+ private responses: Map<string, SearchResponse> = new Map();
479
+ private defaultResponse: SearchResponse = { totalCount: 0, repos: [], rateLimited: false };
480
+
481
+ /** Set a canned response keyed by the raw query string. */
482
+ setResponse(queryRaw: string, response: SearchResponse): void {
483
+ this.responses.set(queryRaw.toLowerCase(), response);
484
+ }
485
+
486
+ /** Set the default response returned when no canned response matches. */
487
+ setDefault(response: SearchResponse): void {
488
+ this.defaultResponse = response;
489
+ }
490
+
491
+ async search(query: ParsedQuery, _options: SearchOptions): Promise<SearchResponse> {
492
+ const key = query.raw.toLowerCase();
493
+ return this.responses.get(key) ?? { ...this.defaultResponse };
494
+ }
495
+
496
+ clear(): void {
497
+ this.responses.clear();
498
+ }
499
+ }
500
+
501
+ // ─── Search module (orchestrator) ─────────────────────────────────────
502
+
503
+ /**
504
+ * Deep Search module — owns the cache, delegates network I/O to an adapter,
505
+ * and orchestrates the full pipeline: cache → adapter → rank.
506
+ */
507
+ export class SearchModule implements SearchProvider {
508
+ readonly name = "search";
509
+ private readonly adapter: SearchAdapter;
510
+ private readonly logger: Logger;
511
+ /** Shared cache across instances. */
512
+ static cache = new MemoryCache<SearchResponse>(300_000);
513
+
514
+ constructor(adapter: SearchAdapter, logger: Logger = noopLogger) {
515
+ this.adapter = adapter;
516
+ this.logger = logger;
517
+ }
518
+
519
+ async search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
520
+ const cacheKey = MemoryCache.key(
521
+ query.raw,
522
+ options.sort,
523
+ String(options.limit),
524
+ String(options.page ?? 1),
525
+ );
526
+ const cached = SearchModule.cache.get(cacheKey);
527
+ if (cached) {
528
+ this.logger.debug("[search] cache hit");
529
+ return cached;
530
+ }
531
+ this.logger.debug("[search] cache miss");
532
+
533
+ const response = await this.adapter.search(query, options);
534
+ if (!response.rateLimited) {
535
+ SearchModule.cache.set(cacheKey, response);
536
+ }
537
+ return response;
538
+ }
539
+ }
540
+
541
+ // ─── Convenience: create a GitHub-backed SearchModule ──────────────────
542
+
543
+ /**
544
+ * Create a SearchModule backed by the GitHub adapter.
545
+ * This is the primary entry point for production code.
546
+ */
547
+ export function createGitHubSearch(
548
+ logger: Logger = noopLogger,
549
+ tokens: string[] = [],
550
+ ): SearchModule {
551
+ return new SearchModule(new GitHubSearchAdapter(logger, tokens), logger);
552
+ }
553
+
554
+ // Re-export types that callers need
555
+ export type { GitHubApiItem, GitHubSearchEnvelope };