github-search-cli 9.8.2 → 9.9.0

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
@@ -8,6 +8,7 @@
8
8
  import type {
9
9
  ParsedQuery,
10
10
  Qualifier,
11
+ QueryCorrection,
11
12
  Repo,
12
13
  SearchOptions,
13
14
  SearchProvider,
@@ -15,7 +16,14 @@ import type {
15
16
  SortStrategy,
16
17
  CacheEntry,
17
18
  } from "./types";
18
- import { NetworkError, RateLimitError, ParseError } from "./errors";
19
+ import {
20
+ NetworkError,
21
+ RateLimitError,
22
+ ParseError,
23
+ AuthError,
24
+ ForbiddenError,
25
+ BadQueryError,
26
+ } from "./errors";
19
27
  import { parseTrendingHtml, type RawTrendingRepo } from "./trending-parser";
20
28
 
21
29
  export interface Logger {
@@ -146,9 +154,11 @@ export function normalizeEnvelope(env: GitHubSearchEnvelope): Repo[] {
146
154
  export const KNOWN_QUALIFIERS = [
147
155
  "language",
148
156
  "stars",
157
+ "forks",
149
158
  "fork",
150
159
  "archived",
151
160
  "topic",
161
+ "topics",
152
162
  "user",
153
163
  "org",
154
164
  "repo",
@@ -159,10 +169,123 @@ export const KNOWN_QUALIFIERS = [
159
169
  "size",
160
170
  "license",
161
171
  "created",
172
+ "followers",
162
173
  ] as const;
163
174
 
164
175
  export type KnownQualifier = (typeof KNOWN_QUALIFIERS)[number];
165
176
 
177
+ /**
178
+ * The same set as `KNOWN_QUALIFIERS`, as a lookup for O(1) membership tests
179
+ * during validation.
180
+ */
181
+ const KNOWN_QUALIFIER_SET: ReadonlySet<string> = new Set(KNOWN_QUALIFIERS);
182
+
183
+ /**
184
+ * Common misspellings of the qualifiers ghfind knows, mapped to the correct
185
+ * key. Scoped deliberately to ghfind's own qualifier set: correcting a key we
186
+ * don't recognize could silently rewrite a valid `props.*`, `is:`, or `has:`
187
+ * qualifier into something else entirely.
188
+ */
189
+ export const QUALIFIER_ALIASES: Record<string, string> = {
190
+ // language
191
+ langauge: "language",
192
+ lang: "language",
193
+ languge: "language",
194
+ // stars / forks
195
+ star: "stars",
196
+ strs: "stars",
197
+ forke: "forks",
198
+ // topic
199
+ topc: "topic",
200
+ tpics: "topic",
201
+ // user / org
202
+ usr: "user",
203
+ ogranization: "org",
204
+ orginzation: "org",
205
+ // date qualifiers
206
+ push: "pushed",
207
+ creat: "created",
208
+ updat: "updated",
209
+ // visibility
210
+ visiblity: "visibility",
211
+ visability: "visibility",
212
+ // license
213
+ licence: "license",
214
+ // size
215
+ siz: "size",
216
+ // archived
217
+ archive: "archived",
218
+ };
219
+
220
+ /**
221
+ * Levenshtein edit distance, bounded to `max` so near-miss candidates don't
222
+ * dominate the cost. Returns `max + 1` as soon as the distance provably
223
+ * exceeds `max`.
224
+ */
225
+ function editDistanceWithin(a: string, b: string, max: number): number {
226
+ if (a === b) return 0;
227
+ if (Math.abs(a.length - b.length) > max) return max + 1;
228
+
229
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
230
+ for (let i = 1; i <= a.length; i++) {
231
+ const curr = [i];
232
+ let rowMin = i;
233
+ for (let j = 1; j <= b.length; j++) {
234
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
235
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
236
+ if (curr[j] < rowMin) rowMin = curr[j];
237
+ }
238
+ if (rowMin > max) return max + 1;
239
+ prev = curr;
240
+ }
241
+ return prev[b.length];
242
+ }
243
+
244
+ /**
245
+ * Resolve a qualifier key to the canonical ghfind key, or return it unchanged.
246
+ *
247
+ * Two passes, in order of confidence:
248
+ * 1. An exact hit in `QUALIFIER_ALIASES` (`langauge` → `language`).
249
+ * 2. A near-miss within edit distance 2 of a known key (`starss` → `stars`).
250
+ *
251
+ * Anything else is returned as-is so unknown-but-valid qualifiers survive.
252
+ */
253
+ export function resolveQualifierKey(key: string): string {
254
+ const k = key.toLowerCase();
255
+ // Check the exact set before the alias table: `fork`, `forks`, and `topic`
256
+ // are all real GitHub qualifiers, so a fuzzy pass must never collapse one
257
+ // into another (that would strip the numeric rule from `forks`).
258
+ if (KNOWN_QUALIFIER_SET.has(k)) return k;
259
+
260
+ const alias = QUALIFIER_ALIASES[k];
261
+ if (alias) return alias;
262
+
263
+ // Fuzzy correction is restricted to keys of 4+ characters. Short keys are
264
+ // exactly where a wrong guess does real damage: `is` sits one edit from the
265
+ // known `in`, so without this guard the valid qualifier `is:public` would be
266
+ // rewritten to `in:public` and then rejected as an invalid search field.
267
+ if (k.length < 4) return k;
268
+
269
+ const maxDistance = k.length <= 5 ? 1 : 2;
270
+
271
+ let best: string | undefined;
272
+ let bestDistance = maxDistance + 1;
273
+ let ties = 0;
274
+ for (const known of KNOWN_QUALIFIERS) {
275
+ const d = editDistanceWithin(k, known, maxDistance);
276
+ if (d < bestDistance) {
277
+ bestDistance = d;
278
+ best = known;
279
+ ties = 1;
280
+ if (d === 0) break;
281
+ } else if (d === bestDistance && best !== undefined) {
282
+ // Two candidates equally close — not enough signal to rewrite.
283
+ ties++;
284
+ }
285
+ }
286
+ return best !== undefined && ties === 1 ? best : k;
287
+ }
288
+
166
289
  export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
167
290
  l: ["language:"],
168
291
  s: ["stars:", "size:"],
@@ -179,13 +302,279 @@ export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
179
302
  };
180
303
 
181
304
  export function suggestFor(input: string): string[] {
182
- const trimmed = input.trim().toLowerCase();
305
+ const trimmed = input.trim().toLowerCase().replace(/:$/, "");
183
306
  if (trimmed.length === 0) return [];
184
307
  if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
185
308
  return QUALIFIER_SUGGESTIONS[trimmed];
186
309
  }
187
- const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
188
- return matches.map((k) => `${k}:`);
310
+
311
+ // An exact alias (`langauge`) or a unique near-miss (`starss`) is the
312
+ // highest-confidence completion, so it leads the list.
313
+ const resolved = resolveQualifierKey(trimmed);
314
+ const aliasHit =
315
+ resolved !== trimmed && KNOWN_QUALIFIER_SET.has(resolved)
316
+ ? [`${resolved}:`]
317
+ : [];
318
+
319
+ // Prefix matches, minus the alias hit so it isn't duplicated.
320
+ const matches = KNOWN_QUALIFIERS.filter(
321
+ (k) => k.startsWith(trimmed) && !aliasHit.includes(`${k}:`),
322
+ ).map((k) => `${k}:`);
323
+
324
+ return [...aliasHit, ...matches];
325
+ }
326
+
327
+ // ─── Qualifier value rules ──────────────────────────────────────────
328
+
329
+ /**
330
+ * Qualifier keys whose value is a count. These accept comparison operators,
331
+ * ranges, and `k`/`m` magnitude sugar (`stars:10k` → `stars:>=10000`).
332
+ * Per GitHub docs: forks, size, stars, topics, followers.
333
+ */
334
+ const NUMERIC_QUALIFIERS = new Set([
335
+ "forks",
336
+ "size",
337
+ "stars",
338
+ "topics",
339
+ "followers",
340
+ ]);
341
+
342
+ /**
343
+ * Qualifier keys whose value is `true`/`false`. `fork` is handled separately
344
+ * because GitHub additionally accepts `only` for it (per docs: "add
345
+ * `fork:true` or `fork:only`"). `mirror:` and `template:` are valid GitHub
346
+ * qualifiers but not in `KNOWN_QUALIFIERS`, so they pass through unvalidated
347
+ * by design.
348
+ */
349
+ const BOOLEAN_QUALIFIERS = new Set(["archived"]);
350
+
351
+ /** Accepted values for the `visibility:` qualifier. */
352
+ const VISIBILITY_VALUES = new Set(["public", "private", "internal"]);
353
+
354
+ /**
355
+ * Accepted fields for the `in:` qualifier. GitHub accepts a comma-separated
356
+ * list (documented form: `in:name,description`).
357
+ */
358
+ const IN_FIELDS = new Set([
359
+ "name",
360
+ "description",
361
+ "topics",
362
+ "readme",
363
+ "comments",
364
+ ]);
365
+
366
+ /**
367
+ * Qualifier keys whose value is an ISO8601 date, optionally prefixed by a
368
+ * comparison operator or written as a `a..b` range.
369
+ * Per GitHub docs: created, pushed, updated.
370
+ */
371
+ const DATE_QUALIFIERS = new Set(["created", "pushed", "updated"]);
372
+
373
+ /** Every comparison operator GitHub's search syntax accepts on a value. */
374
+ const COMPARISON_OPERATORS = [">=", "<=", ">", "<"] as const;
375
+
376
+ /**
377
+ * Strip a token out of a URL before logging. Guarded against the empty
378
+ * string: `url.replace("", …)` inserts the replacement between every
379
+ * character, which would mangle the whole URL whenever no token is in play.
380
+ */
381
+ function redactToken(url: string, token: string | undefined): string {
382
+ return token ? url.replace(token, "<token>") : url;
383
+ }
384
+
385
+ /** Strip a leading comparison operator, returning it alongside the rest. */
386
+ function splitComparison(value: string): { op: string; rest: string } {
387
+ for (const op of COMPARISON_OPERATORS) {
388
+ // `>=` must be tested before `>` or the value `>=5` parses as op `>`,
389
+ // rest `=5` — which then fails the numeric rule.
390
+ if (value.startsWith(op)) {
391
+ return { op, rest: value.slice(op.length) };
392
+ }
393
+ }
394
+ return { op: "", rest: value };
395
+ }
396
+
397
+ /**
398
+ * Parse a magnitude-suffixed count: `10`, `10k`, `1.5m`, `2_000`.
399
+ * Returns null when the input is not a well-formed count.
400
+ *
401
+ * Deliberately does not accept a bare float with a fractional part
402
+ * (`stars:1.5`) — only fractional values *with* a k/m suffix, since
403
+ * `stars:1.5` is a user mistake rather than a quantity.
404
+ */
405
+ export function parseCount(raw: string): number | null {
406
+ const trimmed = raw.trim().replace(/_/g, "");
407
+ if (trimmed === "") return null;
408
+
409
+ const match = /^(\d+(?:\.\d+)?)([km])?$/i.exec(trimmed);
410
+ if (!match) return null;
411
+
412
+ // A fraction is only meaningful with a magnitude suffix (`1.5m`); a bare
413
+ // `1.5` is a typo that would otherwise silently floor to 1.
414
+ if (!match[2] && match[1].includes(".")) return null;
415
+
416
+ const base = Number.parseFloat(match[1]);
417
+ if (!Number.isFinite(base)) return null;
418
+
419
+ const suffix = match[2]?.toLowerCase();
420
+ const multiplier = suffix === "k" ? 1_000 : suffix === "m" ? 1_000_000 : 1;
421
+ return Math.floor(base * multiplier);
422
+ }
423
+
424
+ /**
425
+ * Validate an ISO8601 date as GitHub accepts it: `YYYY-MM-DD` optionally
426
+ * followed by `THH:MM:SS` and a timezone offset. Rejects calendar-invalid
427
+ * dates like `2024-13-45`, which `Date.parse` would otherwise roll over.
428
+ */
429
+ export function isValidISODate(raw: string): boolean {
430
+ const trimmed = raw.trim();
431
+ const m =
432
+ /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(
433
+ trimmed,
434
+ );
435
+ if (!m) return false;
436
+
437
+ const [, y, mo, d, h = "0", mi = "0", s = "0"] = m;
438
+ const year = Number(y);
439
+ const month = Number(mo);
440
+ const day = Number(d);
441
+ if (month < 1 || month > 12) return false;
442
+ if (day < 1 || day > 31) return false;
443
+ if (Number(h) > 23 || Number(mi) > 59 || Number(s) > 59) return false;
444
+
445
+ // Reject days past the end of the month (e.g. 2024-02-31, 2023-02-29).
446
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
447
+ return day <= daysInMonth;
448
+ }
449
+
450
+ /**
451
+ * Rewrite magnitude sugar into GitHub's comparison-operator form.
452
+ * `stars:10k` → `stars:>=10000`; `stars:1k..5k` → `stars:1000..5000`.
453
+ *
454
+ * A plain integer is left alone: GitHub reads `stars:100` as *exactly* 100,
455
+ * so rewriting it to `>=100` would silently change the question being asked.
456
+ * Only the `k`/`m` sugar — which GitHub does not understand at all — gets an
457
+ * operator, defaulting to `>=` because "10k" almost always means "10k+".
458
+ */
459
+ export function normalizeQualifierValue(key: string, value: string): string {
460
+ const normalizedKey = key.toLowerCase();
461
+ if (!NUMERIC_QUALIFIERS.has(normalizedKey)) return value;
462
+ if (value === "") return value;
463
+
464
+ const { op, rest } = splitComparison(value);
465
+ const rangeIdx = rest.indexOf("..");
466
+ if (rangeIdx > 0) {
467
+ const lo = parseCount(rest.slice(0, rangeIdx));
468
+ const hi = parseCount(rest.slice(rangeIdx + 2));
469
+ if (lo === null || hi === null) return value;
470
+ return `${op}${lo}..${hi}`;
471
+ }
472
+
473
+ // A bare integer is GitHub's exact-match form — leave it alone. Only the
474
+ // k/m sugar, which GitHub does not understand at all, gets rewritten.
475
+ if (!/[km]$/i.test(rest.trim())) return value;
476
+
477
+ const count = parseCount(rest);
478
+ if (count === null) return value;
479
+ // `>=10k` keeps its operator; a bare `10k` defaults to `>=`, since "10k"
480
+ // almost always means "10k or more".
481
+ return `${op || ">="}${count}`;
482
+ }
483
+
484
+ /**
485
+ * Check one qualifier's value against its key's rule and return a
486
+ * human-readable problem, or null when the value is acceptable.
487
+ *
488
+ * Only keys in `KNOWN_QUALIFIERS` are checked. An unknown key (e.g.
489
+ * `props.environment`, `is:`, `has:`) is passed through untouched — ghfind
490
+ * does not own GitHub's full qualifier set, and rejecting keys it hasn't
491
+ * heard of would break valid queries.
492
+ */
493
+ export function validateQualifierValue(
494
+ key: string,
495
+ value: string,
496
+ ): string | null {
497
+ const k = key.toLowerCase();
498
+ if (!KNOWN_QUALIFIER_SET.has(k)) return null;
499
+
500
+ if (value.trim() === "") {
501
+ return `"${key}:" needs a value (e.g. ${key}:<something>)`;
502
+ }
503
+
504
+ if (NUMERIC_QUALIFIERS.has(k)) {
505
+ const { rest } = splitComparison(value.trim());
506
+ const rangeIdx = rest.indexOf("..");
507
+ if (rangeIdx > 0) {
508
+ const lo = parseCount(rest.slice(0, rangeIdx));
509
+ const hi = parseCount(rest.slice(rangeIdx + 2));
510
+ if (lo === null || hi === null) {
511
+ return `"${key}:${value}" is not a valid range — use ${k}:100..500 or ${k}:>=100`;
512
+ }
513
+ if (lo > hi) {
514
+ return `"${key}:${value}" has a reversed range (${lo} > ${hi})`;
515
+ }
516
+ return null;
517
+ }
518
+ if (parseCount(rest) === null) {
519
+ return `"${key}:${value}" is not a number — use ${k}:100, ${k}:>=100, or ${k}:1k..10k`;
520
+ }
521
+ return null;
522
+ }
523
+
524
+ if (k === "fork") {
525
+ const v = value.trim().toLowerCase();
526
+ if (v !== "true" && v !== "false" && v !== "only") {
527
+ return `"fork:${value}" is not valid — use fork:true, fork:false, or fork:only`;
528
+ }
529
+ return null;
530
+ }
531
+
532
+ if (BOOLEAN_QUALIFIERS.has(k)) {
533
+ const v = value.trim().toLowerCase();
534
+ if (v !== "true" && v !== "false") {
535
+ return `"${key}:${value}" is not valid — use ${k}:true or ${k}:false`;
536
+ }
537
+ return null;
538
+ }
539
+
540
+ if (k === "visibility") {
541
+ const v = value.trim().toLowerCase();
542
+ if (!VISIBILITY_VALUES.has(v)) {
543
+ return `"visibility:${value}" is not valid — use visibility:public, visibility:private, or visibility:internal`;
544
+ }
545
+ return null;
546
+ }
547
+
548
+ if (k === "in") {
549
+ const parts = value.split(",").map((p) => p.trim().toLowerCase());
550
+ if (parts.some((p) => !IN_FIELDS.has(p))) {
551
+ const bad = parts.find((p) => !IN_FIELDS.has(p));
552
+ return `"in:${value}" is not valid — ${bad ? `"${bad}" is not a ` : ""}searchable field. Try in:name, in:description, in:topics, or in:readme`;
553
+ }
554
+ return null;
555
+ }
556
+
557
+ if (DATE_QUALIFIERS.has(k)) {
558
+ const trimmed = value.trim();
559
+ const { rest } = splitComparison(trimmed);
560
+ const rangeIdx = rest.indexOf("..");
561
+ if (rangeIdx > 0) {
562
+ const lo = rest.slice(0, rangeIdx);
563
+ const hi = rest.slice(rangeIdx + 2);
564
+ if (!isValidISODate(lo) || !isValidISODate(hi)) {
565
+ return `"${key}:${value}" is not a valid date range — use ${k}:2020-01-01..2024-01-01`;
566
+ }
567
+ return null;
568
+ }
569
+ if (!isValidISODate(rest)) {
570
+ return `"${key}:${value}" is not a valid date — use ${k}:2024-01-01 (optionally with >, >=, <, <=)`;
571
+ }
572
+ return null;
573
+ }
574
+
575
+ // Keys whose value is an opaque identifier (user, org, repo, topic,
576
+ // license) — GitHub owns what counts as valid, so nothing to check here.
577
+ return null;
189
578
  }
190
579
 
191
580
  export function tokenize(input: string): string[] {
@@ -218,25 +607,62 @@ export function tokenize(input: string): string[] {
218
607
  }
219
608
  return tokens;
220
609
  }
610
+ export type { ParsedQuery, QueryCorrection } from "./types";
611
+
612
+ /**
613
+ * A no-op query, used where a search adapter needs a placeholder (the trending
614
+ * adapter ignores the query entirely and reads `options.trendingSince`).
615
+ */
616
+ export const EMPTY_QUERY: ParsedQuery = {
617
+ keywords: [],
618
+ qualifiers: [],
619
+ raw: "",
620
+ corrections: [],
621
+ };
622
+
623
+ /** The placeholder the trending surfaces pass, preserving the old `raw` value. */
624
+ export function trendingQuery(): ParsedQuery {
625
+ return { ...EMPTY_QUERY, raw: "trending" };
626
+ }
627
+
221
628
  export function parseQuery(raw: string): ParsedQuery {
222
629
  const trimmed = raw.trim();
223
630
  if (trimmed === "") {
224
- return { keywords: [], qualifiers: [], raw };
631
+ return { keywords: [], qualifiers: [], raw, corrections: [] };
225
632
  }
226
633
 
227
634
  const keywords: string[] = [];
228
635
  const qualifiers: Qualifier[] = [];
636
+ const corrections: QueryCorrection[] = [];
229
637
 
230
638
  for (const token of tokenize(trimmed)) {
231
639
  const qualifier = matchQualifier(token);
232
640
  if (qualifier) {
233
- qualifiers.push(qualifier);
641
+ // Correct the key in place (typo → canonical) and normalize the value
642
+ // (magnitude sugar). Rewriting the qualifier object rather than
643
+ // re-parsing a corrected copy of the query string keeps keyword order
644
+ // and `raw` exactly as the user typed them.
645
+ const resolvedKey = resolveQualifierKey(qualifier.key);
646
+ const normalizedValue = normalizeQualifierValue(
647
+ resolvedKey,
648
+ qualifier.value,
649
+ );
650
+ const original = `${qualifier.negated ? "-" : ""}${qualifier.key}:${qualifier.value}`;
651
+ const corrected = `${qualifier.negated ? "-" : ""}${resolvedKey}:${normalizedValue}`;
652
+ if (original !== corrected) {
653
+ corrections.push({ from: original, to: corrected });
654
+ }
655
+ qualifiers.push({
656
+ key: resolvedKey,
657
+ value: normalizedValue,
658
+ negated: qualifier.negated,
659
+ });
234
660
  } else {
235
661
  keywords.push(token);
236
662
  }
237
663
  }
238
664
 
239
- return { keywords, qualifiers, raw: trimmed };
665
+ return { keywords, qualifiers, raw: trimmed, corrections };
240
666
  }
241
667
 
242
668
  function matchQualifier(token: string): Qualifier | null {
@@ -246,8 +672,11 @@ function matchQualifier(token: string): Qualifier | null {
246
672
  if (idx <= 0) return null;
247
673
  const key = body.slice(0, idx).toLowerCase();
248
674
  let value = body.slice(idx + 1);
249
- if (value === "") return null;
250
- if (!/^[a-z]+$/i.test(key)) return null;
675
+ // An empty value (`stars:`) is kept as a qualifier so validation can report
676
+ // "needs a value" instead of silently degrading it into a literal keyword.
677
+ // Custom-property qualifiers (`props.environment:prod`) contain a dot, so
678
+ // the key charset allows one and lets validation ignore what it doesn't own.
679
+ if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/i.test(key)) return null;
251
680
  if (value.startsWith("//")) return null;
252
681
  if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
253
682
  value = value.slice(1, -1);
@@ -297,12 +726,235 @@ export function applyFlagFilters(
297
726
  return { ...query, qualifiers };
298
727
  }
299
728
 
729
+ /**
730
+ * Rebuild a query string with one qualifier removed, for relaxation hints.
731
+ * Returns null when removing it would leave nothing to search for.
732
+ */
733
+ function withoutQualifier(query: ParsedQuery, index: number): string | null {
734
+ const remaining = query.qualifiers.filter((_, i) => i !== index);
735
+ const parts = [...query.keywords];
736
+ for (const q of remaining) {
737
+ const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
738
+ const value = needsQuote ? `"${q.value}"` : q.value;
739
+ parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
740
+ }
741
+ const text = parts.join(" ").trim();
742
+ return text === "" ? null : text;
743
+ }
744
+
745
+ /**
746
+ * Validate a parsed query before it costs an API call.
747
+ *
748
+ * Throws `BadQueryError` with a specific message (and a concrete corrected
749
+ * query where one exists) instead of letting a malformed qualifier reach
750
+ * GitHub, whose 422 surfaces to the user as an opaque error.
751
+ */
300
752
  export function validateQuery(query: ParsedQuery): void {
301
753
  const has = (key: string) =>
302
754
  query.qualifiers.some((q) => q.key === key && !q.negated);
755
+
756
+ // `private:` is GitHub's legacy spelling of `visibility:private`.
303
757
  if (has("visibility") && has("private")) {
304
- throw new Error("Cannot combine `visibility:` and `private:` filters.");
758
+ throw new BadQueryError(
759
+ "Cannot combine `visibility:` and `private:` filters.",
760
+ `${
761
+ query.qualifiers
762
+ .filter((q) => q.key === "private" && !q.negated)
763
+ .map((q) => withoutQualifier(query, query.qualifiers.indexOf(q)))
764
+ .find((v): v is string => v !== null) ?? query.raw
765
+ }`,
766
+ );
767
+ }
768
+
769
+ // Report the first bad qualifier, with the rest of the query as a retry.
770
+ for (let i = 0; i < query.qualifiers.length; i++) {
771
+ const q = query.qualifiers[i];
772
+ const problem = validateQualifierValue(q.key, q.value);
773
+ if (problem) {
774
+ throw new BadQueryError(problem, withoutQualifier(query, i) ?? undefined);
775
+ }
776
+ }
777
+ }
778
+
779
+ // ─── Zero-result relaxation ─────────────────────────────────────────
780
+
781
+ /**
782
+ * How likely a qualifier is to be the reason a search returned nothing.
783
+ * Higher = drop it first.
784
+ *
785
+ * Ordered by how sharply the filter narrows GitHub's corpus:
786
+ * - `stars`/`size` thresholds are absolute, so a high bar empties the result
787
+ * set outright (`stars:>=500000` matches almost nothing).
788
+ * - a narrow date window is similarly absolute.
789
+ * - `user`/`org` restricts to one account's repos.
790
+ * - a plain `language` still matches every repo in that language, so it is
791
+ * the last thing worth dropping.
792
+ */
793
+ const OVER_CONSTRAINT_WEIGHT: Record<string, number> = {
794
+ size: 100,
795
+ stars: 95,
796
+ forks: 80,
797
+ created: 75,
798
+ pushed: 70,
799
+ updated: 70,
800
+ visibility: 65,
801
+ repo: 60,
802
+ user: 55,
803
+ org: 50,
804
+ topic: 45,
805
+ in: 30,
806
+ license: 25,
807
+ language: 20,
808
+ fork: 15,
809
+ archived: 10,
810
+ };
811
+
812
+ /**
813
+ * A concrete way to loosen a query that returned nothing, e.g.
814
+ * `rust stars:>=50000` → `Try: rust stars:>=1000`.
815
+ */
816
+ export interface Relaxation {
817
+ /** What to suggest to the user. */
818
+ suggestion: string;
819
+ /** The qualifier being loosened. */
820
+ qualifier: string;
821
+ /** Higher means "try this one first". */
822
+ confidence: number;
823
+ }
824
+
825
+ /** A looser bound for an over-tight numeric threshold. */
826
+ function loosenNumericBound(value: string): string | null {
827
+ const { op, rest } = splitComparison(value.trim());
828
+ const count = parseCount(rest);
829
+ if (count === null || count === 0) return null;
830
+ // Step down by an order of magnitude, but never below 1.
831
+ const loosened = Math.max(1, Math.floor(count / 10));
832
+ return `${op || ">="}${loosened}`;
833
+ }
834
+
835
+ /**
836
+ * Widen a date bound by two years in the direction that admits more repos.
837
+ * `pushed:>2024-01-01` → `pushed:>2022-01-01`.
838
+ */
839
+ function loosenDateBound(value: string): { op: string; date: string } | null {
840
+ const { op, rest } = splitComparison(value.trim());
841
+ if (!isValidISODate(rest)) return null;
842
+
843
+ const year = Number.parseInt(rest.slice(0, 4), 10);
844
+ if (!Number.isFinite(year)) return null;
845
+
846
+ // A `>` bound moves earlier (more repos qualify); a `<` bound moves later.
847
+ const shifted =
848
+ op === "<" || op === "<=" ? year + 2 : Math.max(1970, year - 2);
849
+ return { op, date: `${shifted}${rest.slice(4)}` };
850
+ }
851
+
852
+ /**
853
+ * Rank concrete relaxations for a query that returned zero results, most
854
+ * promising first.
855
+ *
856
+ * Each suggestion rewrites one qualifier rather than stripping it outright,
857
+ * so a retry is likely to return *some* results instead of a different
858
+ * question. An empty array means nothing sensible can be loosened — the caller
859
+ * should fall back to its generic advice.
860
+ */
861
+ export function suggestRelaxations(
862
+ query: ParsedQuery,
863
+ limit = 3,
864
+ ): Relaxation[] {
865
+ const out: Relaxation[] = [];
866
+
867
+ for (let i = 0; i < query.qualifiers.length; i++) {
868
+ const q = query.qualifiers[i];
869
+ const key = q.key.toLowerCase();
870
+ // A negated filter (`-language:Rust`) only ever widens a search, so it
871
+ // cannot be the reason a query came back empty.
872
+ if (q.negated) continue;
873
+
874
+ const weight = OVER_CONSTRAINT_WEIGHT[key] ?? 40;
875
+ const emit = (relaxed: string, qualifier: string) => {
876
+ const rest = query.qualifiers
877
+ .filter((_, j) => j !== i)
878
+ .map((other) => {
879
+ const needsQuote =
880
+ other.value.includes(" ") && !other.value.startsWith('"');
881
+ const value = needsQuote ? `"${other.value}"` : other.value;
882
+ return `${other.negated ? "-" : ""}${other.key}:${value}`;
883
+ });
884
+ const text = [...query.keywords, ...rest, relaxed].join(" ").trim();
885
+ out.push({ suggestion: text, qualifier, confidence: weight });
886
+ };
887
+
888
+ if (NUMERIC_QUALIFIERS.has(key) || key === "followers") {
889
+ const rangeIdx = q.value.indexOf("..");
890
+ if (rangeIdx > 0) {
891
+ // A range with an unreachable upper bound: widen the ceiling.
892
+ const hi = parseCount(q.value.slice(rangeIdx + 2));
893
+ if (hi !== null) {
894
+ const lo = q.value.slice(0, rangeIdx);
895
+ emit(`${key}:${lo}..${Math.floor(hi / 10) || hi * 2}`, key);
896
+ }
897
+ } else {
898
+ const looser = loosenNumericBound(q.value);
899
+ if (looser) emit(`${key}:${looser}`, key);
900
+ else {
901
+ // Already at the floor (`stars:>=1`) — try widening a range instead.
902
+ const dropped = withoutQualifier(query, i);
903
+ if (dropped) {
904
+ out.push({
905
+ suggestion: dropped,
906
+ qualifier: key,
907
+ confidence: weight - 30,
908
+ });
909
+ }
910
+ }
911
+ }
912
+ continue;
913
+ }
914
+
915
+ if (DATE_QUALIFIERS.has(key)) {
916
+ const loosened = loosenDateBound(q.value);
917
+ if (loosened) {
918
+ emit(`${key}:${loosened.op || ">="}${loosened.date}`, key);
919
+ } else {
920
+ const dropped = withoutQualifier(query, i);
921
+ if (dropped) {
922
+ out.push({
923
+ suggestion: dropped,
924
+ qualifier: key,
925
+ confidence: weight - 30,
926
+ });
927
+ }
928
+ }
929
+ continue;
930
+ }
931
+
932
+ // Everything else (language, org, topic, …): dropping the qualifier is
933
+ // the only meaningful relaxation.
934
+ const dropped = withoutQualifier(query, i);
935
+ if (dropped) {
936
+ out.push({
937
+ suggestion: dropped,
938
+ qualifier: key,
939
+ confidence: weight - 30,
940
+ });
941
+ }
305
942
  }
943
+
944
+ // Deduplicate (two qualifiers can relax to the same text), then rank.
945
+ const seen = new Set<string>();
946
+ const unique = out.filter((r) => {
947
+ if (seen.has(r.suggestion)) return false;
948
+ seen.add(r.suggestion);
949
+ return true;
950
+ });
951
+
952
+ unique.sort((a, b) => b.confidence - a.confidence);
953
+ return unique.slice(0, limit).map((r) => ({
954
+ ...r,
955
+ // Clamp so a "drop the qualifier" hint never reports a negative score.
956
+ confidence: Math.max(0, r.confidence),
957
+ }));
306
958
  }
307
959
 
308
960
  export function buildGitHubQuery(query: ParsedQuery): string {
@@ -434,6 +1086,12 @@ export class GitHubSearchAdapter implements SearchAdapter {
434
1086
  const remainingStr = res.headers.get("x-ratelimit-remaining");
435
1087
  rateLimitRemaining =
436
1088
  remainingStr !== null ? Number(remainingStr) : undefined;
1089
+ if (process.env.GHFIND_LOG) {
1090
+ this.logger.debug(
1091
+ `[github] ${res.status} remaining=${remainingStr ?? "none"} ` +
1092
+ `url=${redactToken(pagedUrl, token)}`,
1093
+ );
1094
+ }
437
1095
 
438
1096
  if (res.status === 403) {
439
1097
  if (remainingStr === "0" || remainingStr === null) {
@@ -445,10 +1103,37 @@ export class GitHubSearchAdapter implements SearchAdapter {
445
1103
  throw new RateLimitError(!!token);
446
1104
  }
447
1105
  const body = await res.text().catch(() => "");
1106
+ // These three 403s are indistinguishable from the status code
1107
+ // alone, and each needs a different fix: SSO enforcement, a
1108
+ // secondary/abuse rate limit, or a token blocked by org policy.
1109
+ // Log the headers that tell them apart.
448
1110
  this.logger.error(
449
- `[github] non-rate-limit 403: ${body.slice(0, 200)}`,
1111
+ `[github] non-rate-limit 403: remaining=${remainingStr} ` +
1112
+ `retry-after=${res.headers.get("retry-after") ?? "none"} ` +
1113
+ `x-ratelimit-reset=${res.headers.get("x-ratelimit-reset") ?? "none"} ` +
1114
+ `authed=${token ? "yes" : "no"} url=${redactToken(pagedUrl, token)}`,
450
1115
  );
451
- throw new NetworkError();
1116
+ this.logger.error(`[github] 403 body: ${body.slice(0, 400)}`);
1117
+ // Surface GitHub's own explanation ("Resource not accessible by
1118
+ // integration", "secondary rate limit", …) rather than asserting
1119
+ // a cause we cannot actually know.
1120
+ let reason = "no reason given";
1121
+ try {
1122
+ const parsed = JSON.parse(body) as { message?: unknown };
1123
+ if (typeof parsed.message === "string" && parsed.message) {
1124
+ reason = parsed.message;
1125
+ }
1126
+ } catch {
1127
+ // Non-JSON body (HTML/CAPTCHA); keep the generic reason.
1128
+ }
1129
+ throw new ForbiddenError(reason);
1130
+ }
1131
+ if (res.status === 401) {
1132
+ const body = await res.text().catch(() => "");
1133
+ this.logger.error(
1134
+ `[github] auth rejected (401): ${body.slice(0, 200)}`,
1135
+ );
1136
+ throw new AuthError();
452
1137
  }
453
1138
  if (!res.ok) {
454
1139
  const body = await res.text().catch(() => "");
@@ -492,6 +1177,10 @@ export class GitHubSearchAdapter implements SearchAdapter {
492
1177
  lastErr = undefined;
493
1178
  break;
494
1179
  } catch (err) {
1180
+ // RateLimitError must propagate: swallowing it turns the failure
1181
+ // into a silent zero-result "success" and the user never learns
1182
+ // why their search died.
1183
+ if (err instanceof RateLimitError) throw err;
495
1184
  lastErr = err as Error;
496
1185
  if (rateLimited) break;
497
1186
  }