github-search-cli 9.8.3 → 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,
@@ -21,6 +22,7 @@ import {
21
22
  ParseError,
22
23
  AuthError,
23
24
  ForbiddenError,
25
+ BadQueryError,
24
26
  } from "./errors";
25
27
  import { parseTrendingHtml, type RawTrendingRepo } from "./trending-parser";
26
28
 
@@ -152,9 +154,11 @@ export function normalizeEnvelope(env: GitHubSearchEnvelope): Repo[] {
152
154
  export const KNOWN_QUALIFIERS = [
153
155
  "language",
154
156
  "stars",
157
+ "forks",
155
158
  "fork",
156
159
  "archived",
157
160
  "topic",
161
+ "topics",
158
162
  "user",
159
163
  "org",
160
164
  "repo",
@@ -165,10 +169,123 @@ export const KNOWN_QUALIFIERS = [
165
169
  "size",
166
170
  "license",
167
171
  "created",
172
+ "followers",
168
173
  ] as const;
169
174
 
170
175
  export type KnownQualifier = (typeof KNOWN_QUALIFIERS)[number];
171
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
+
172
289
  export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
173
290
  l: ["language:"],
174
291
  s: ["stars:", "size:"],
@@ -185,13 +302,279 @@ export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
185
302
  };
186
303
 
187
304
  export function suggestFor(input: string): string[] {
188
- const trimmed = input.trim().toLowerCase();
305
+ const trimmed = input.trim().toLowerCase().replace(/:$/, "");
189
306
  if (trimmed.length === 0) return [];
190
307
  if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
191
308
  return QUALIFIER_SUGGESTIONS[trimmed];
192
309
  }
193
- const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
194
- 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;
195
578
  }
196
579
 
197
580
  export function tokenize(input: string): string[] {
@@ -224,25 +607,62 @@ export function tokenize(input: string): string[] {
224
607
  }
225
608
  return tokens;
226
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
+
227
628
  export function parseQuery(raw: string): ParsedQuery {
228
629
  const trimmed = raw.trim();
229
630
  if (trimmed === "") {
230
- return { keywords: [], qualifiers: [], raw };
631
+ return { keywords: [], qualifiers: [], raw, corrections: [] };
231
632
  }
232
633
 
233
634
  const keywords: string[] = [];
234
635
  const qualifiers: Qualifier[] = [];
636
+ const corrections: QueryCorrection[] = [];
235
637
 
236
638
  for (const token of tokenize(trimmed)) {
237
639
  const qualifier = matchQualifier(token);
238
640
  if (qualifier) {
239
- 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
+ });
240
660
  } else {
241
661
  keywords.push(token);
242
662
  }
243
663
  }
244
664
 
245
- return { keywords, qualifiers, raw: trimmed };
665
+ return { keywords, qualifiers, raw: trimmed, corrections };
246
666
  }
247
667
 
248
668
  function matchQualifier(token: string): Qualifier | null {
@@ -252,8 +672,11 @@ function matchQualifier(token: string): Qualifier | null {
252
672
  if (idx <= 0) return null;
253
673
  const key = body.slice(0, idx).toLowerCase();
254
674
  let value = body.slice(idx + 1);
255
- if (value === "") return null;
256
- 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;
257
680
  if (value.startsWith("//")) return null;
258
681
  if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
259
682
  value = value.slice(1, -1);
@@ -303,14 +726,237 @@ export function applyFlagFilters(
303
726
  return { ...query, qualifiers };
304
727
  }
305
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
+ */
306
752
  export function validateQuery(query: ParsedQuery): void {
307
753
  const has = (key: string) =>
308
754
  query.qualifiers.some((q) => q.key === key && !q.negated);
755
+
756
+ // `private:` is GitHub's legacy spelling of `visibility:private`.
309
757
  if (has("visibility") && has("private")) {
310
- 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
+ }
311
776
  }
312
777
  }
313
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
+ }
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
+ }));
958
+ }
959
+
314
960
  export function buildGitHubQuery(query: ParsedQuery): string {
315
961
  const parts: string[] = [];
316
962
 
@@ -440,6 +1086,12 @@ export class GitHubSearchAdapter implements SearchAdapter {
440
1086
  const remainingStr = res.headers.get("x-ratelimit-remaining");
441
1087
  rateLimitRemaining =
442
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
+ }
443
1095
 
444
1096
  if (res.status === 403) {
445
1097
  if (remainingStr === "0" || remainingStr === null) {
@@ -451,10 +1103,30 @@ export class GitHubSearchAdapter implements SearchAdapter {
451
1103
  throw new RateLimitError(!!token);
452
1104
  }
453
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.
454
1110
  this.logger.error(
455
- `[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)}`,
456
1115
  );
457
- throw new ForbiddenError();
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);
458
1130
  }
459
1131
  if (res.status === 401) {
460
1132
  const body = await res.text().catch(() => "");