@oss-scout/core 0.11.0 → 1.1.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.
Files changed (68) hide show
  1. package/dist/cli.bundle.cjs +89 -66
  2. package/dist/cli.js +302 -436
  3. package/dist/commands/command-scout.d.ts +21 -0
  4. package/dist/commands/command-scout.js +21 -0
  5. package/dist/commands/config.js +10 -128
  6. package/dist/commands/features.js +15 -28
  7. package/dist/commands/results.d.ts +13 -2
  8. package/dist/commands/results.js +29 -2
  9. package/dist/commands/search.d.ts +4 -0
  10. package/dist/commands/search.js +65 -70
  11. package/dist/commands/setup.d.ts +2 -0
  12. package/dist/commands/setup.js +35 -6
  13. package/dist/commands/skip.d.ts +4 -0
  14. package/dist/commands/skip.js +45 -55
  15. package/dist/commands/sync.d.ts +10 -0
  16. package/dist/commands/sync.js +10 -0
  17. package/dist/commands/vet-list.js +3 -19
  18. package/dist/commands/vet.js +18 -25
  19. package/dist/commands/with-scout.d.ts +32 -0
  20. package/dist/commands/with-scout.js +41 -0
  21. package/dist/core/anti-llm-policy.js +5 -33
  22. package/dist/core/bootstrap.d.ts +2 -2
  23. package/dist/core/bootstrap.js +5 -9
  24. package/dist/core/errors.d.ts +10 -0
  25. package/dist/core/errors.js +20 -5
  26. package/dist/core/feature-discovery.d.ts +13 -1
  27. package/dist/core/feature-discovery.js +104 -81
  28. package/dist/core/gist-state-store.d.ts +13 -12
  29. package/dist/core/gist-state-store.js +128 -53
  30. package/dist/core/http-cache.d.ts +32 -2
  31. package/dist/core/http-cache.js +74 -19
  32. package/dist/core/issue-discovery.d.ts +12 -1
  33. package/dist/core/issue-discovery.js +94 -67
  34. package/dist/core/issue-eligibility.d.ts +11 -4
  35. package/dist/core/issue-eligibility.js +124 -69
  36. package/dist/core/issue-graphql.d.ts +58 -0
  37. package/dist/core/issue-graphql.js +108 -0
  38. package/dist/core/issue-vetting.d.ts +115 -9
  39. package/dist/core/issue-vetting.js +246 -109
  40. package/dist/core/local-state.d.ts +6 -2
  41. package/dist/core/local-state.js +23 -5
  42. package/dist/core/logger.d.ts +12 -4
  43. package/dist/core/logger.js +33 -7
  44. package/dist/core/personalization.d.ts +30 -10
  45. package/dist/core/personalization.js +64 -24
  46. package/dist/core/preference-fields.d.ts +47 -0
  47. package/dist/core/preference-fields.js +180 -0
  48. package/dist/core/probe-repo-file.d.ts +47 -0
  49. package/dist/core/probe-repo-file.js +57 -0
  50. package/dist/core/repo-health.js +40 -32
  51. package/dist/core/roadmap.js +26 -22
  52. package/dist/core/schemas.d.ts +148 -26
  53. package/dist/core/schemas.js +83 -17
  54. package/dist/core/search-budget.d.ts +9 -0
  55. package/dist/core/search-budget.js +36 -3
  56. package/dist/core/search-phases.d.ts +4 -21
  57. package/dist/core/search-phases.js +37 -89
  58. package/dist/core/types.d.ts +151 -38
  59. package/dist/core/utils.js +60 -26
  60. package/dist/formatters/human.d.ts +60 -0
  61. package/dist/formatters/human.js +199 -0
  62. package/dist/formatters/markdown.d.ts +10 -0
  63. package/dist/formatters/markdown.js +31 -0
  64. package/dist/index.d.ts +6 -2
  65. package/dist/index.js +8 -0
  66. package/dist/scout.d.ts +75 -12
  67. package/dist/scout.js +265 -26
  68. package/package.json +1 -1
@@ -1,52 +1,41 @@
1
1
  /**
2
2
  * Skip command — manage the skip list for excluding issues from future searches.
3
3
  */
4
- import { loadLocalState, saveLocalState } from "../core/local-state.js";
5
- import { createScout } from "../scout.js";
6
- import { getGitHubToken } from "../core/utils.js";
7
- /**
8
- * Create an OssScout instance for skip operations.
9
- * Uses gist persistence when a token is available, otherwise provided-state mode.
10
- */
11
- async function createSkipScout(state) {
12
- const token = getGitHubToken() ?? "";
13
- if (token) {
14
- return createScout({
15
- githubToken: token,
16
- persistence: "provided",
17
- initialState: state,
18
- });
19
- }
20
- return createScout({
21
- githubToken: "",
22
- persistence: "provided",
23
- initialState: state,
24
- });
25
- }
4
+ import { loadLocalState } from "../core/local-state.js";
5
+ import { withScout, persistScout } from "./with-scout.js";
6
+ import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl, } from "./validation.js";
7
+ // Skip operations are local-only, so they don't require a GitHub token.
8
+ const SKIP_SCOUT_OPTIONS = { requireToken: false };
26
9
  /**
27
10
  * Skip an issue by URL — adds it to the skip list and removes it from saved results.
28
11
  * Tries to enrich metadata from saved results if available.
29
12
  */
30
13
  export async function runSkip(options) {
31
- const state = options.state ?? loadLocalState();
32
- const scout = await createSkipScout(state);
33
- const alreadySkipped = scout
34
- .getSkippedIssues()
35
- .some((s) => s.url === options.issueUrl);
36
- if (alreadySkipped) {
37
- return { skipped: false, alreadySkipped: true };
38
- }
39
- // Try to enrich metadata from saved results
40
- const saved = scout
41
- .getSavedResults()
42
- .find((r) => r.issueUrl === options.issueUrl);
43
- const metadata = saved
44
- ? { repo: saved.repo, number: saved.number, title: saved.title }
45
- : parseIssueUrl(options.issueUrl);
46
- scout.skipIssue(options.issueUrl, metadata);
47
- saveLocalState(scout.getState());
48
- await scout.checkpoint();
49
- return { skipped: true, alreadySkipped: false };
14
+ // Validate up front: skip matching is exact-URL, so a junk or near-miss
15
+ // URL (trailing slash, query string) would be stored but never exclude
16
+ // anything a silent no-op. Reject it with the expected format instead.
17
+ validateUrl(options.issueUrl);
18
+ validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, "issue");
19
+ return withScout(options.state, async (scout) => {
20
+ const alreadySkipped = scout
21
+ .getSkippedIssues()
22
+ .some((s) => s.url === options.issueUrl);
23
+ if (alreadySkipped) {
24
+ return { skipped: false, alreadySkipped: true };
25
+ }
26
+ // Try to enrich metadata from saved results
27
+ const saved = scout
28
+ .getSavedResults()
29
+ .find((r) => r.issueUrl === options.issueUrl);
30
+ const metadata = saved
31
+ ? { repo: saved.repo, number: saved.number, title: saved.title }
32
+ : parseIssueUrl(options.issueUrl);
33
+ scout.skipIssue(options.issueUrl, metadata);
34
+ // Persist only on an actual change so an already-skipped no-op doesn't
35
+ // trigger a needless gist push.
36
+ await persistScout(scout);
37
+ return { skipped: true, alreadySkipped: false };
38
+ }, SKIP_SCOUT_OPTIONS);
50
39
  }
51
40
  /**
52
41
  * List all skipped issues.
@@ -59,30 +48,31 @@ export function runSkipList(options) {
59
48
  * Clear all skipped issues.
60
49
  */
61
50
  export async function runSkipClear() {
62
- const state = loadLocalState();
63
- const scout = await createSkipScout(state);
64
- scout.clearSkippedIssues();
65
- saveLocalState(scout.getState());
66
- await scout.checkpoint();
51
+ await withScout(undefined, (scout) => {
52
+ scout.clearSkippedIssues();
53
+ }, { ...SKIP_SCOUT_OPTIONS, persist: true });
67
54
  }
68
55
  /**
69
56
  * Remove a specific issue from the skip list (unskip).
57
+ *
58
+ * Deliberately does NOT validate the URL: entries stored before skip-add
59
+ * validation existed may be junk, and exact-match removal is the only way
60
+ * to clean them up short of `skip clear`.
70
61
  */
71
62
  export async function runSkipRemove(options) {
72
- const state = loadLocalState();
73
- const scout = await createSkipScout(state);
74
- const before = scout.getSkippedIssues().length;
75
- scout.unskipIssue(options.issueUrl);
76
- const removed = before !== scout.getSkippedIssues().length;
77
- saveLocalState(scout.getState());
78
- await scout.checkpoint();
79
- return { removed };
63
+ return withScout(undefined, async (scout) => {
64
+ const before = scout.getSkippedIssues().length;
65
+ scout.unskipIssue(options.issueUrl);
66
+ const removed = before !== scout.getSkippedIssues().length;
67
+ await persistScout(scout);
68
+ return { removed };
69
+ }, SKIP_SCOUT_OPTIONS);
80
70
  }
81
71
  /**
82
72
  * Parse a GitHub issue URL to extract repo and number.
83
73
  */
84
74
  function parseIssueUrl(url) {
85
- const match = url.match(/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/);
75
+ const match = url.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)$/);
86
76
  if (!match)
87
77
  return undefined;
88
78
  return { repo: match[1], number: parseInt(match[2], 10), title: "" };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Sync command — reconcile tracked open PRs against their current GitHub state
3
+ * (#164). Records merges/closures, prunes resolved entries, and recomputes repo
4
+ * scores. Cheaper than a full bootstrap; meant for periodic / daily runs.
5
+ */
6
+ import type { ScoutState } from "../core/schemas.js";
7
+ import type { SyncResult } from "../core/types.js";
8
+ export declare function runSync(options?: {
9
+ state?: ScoutState;
10
+ }): Promise<SyncResult>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Sync command — reconcile tracked open PRs against their current GitHub state
3
+ * (#164). Records merges/closures, prunes resolved entries, and recomputes repo
4
+ * scores. Cheaper than a full bootstrap; meant for periodic / daily runs.
5
+ */
6
+ import { withScout } from "./with-scout.js";
7
+ export async function runSync(options) {
8
+ // syncOpenPRs checkpoints itself, so withScout doesn't need to persist.
9
+ return withScout(options?.state, (scout) => scout.syncOpenPRs());
10
+ }
@@ -1,23 +1,7 @@
1
- import { createScout } from "../scout.js";
2
- import { requireGitHubToken } from "../core/utils.js";
3
- import { saveLocalState } from "../core/local-state.js";
1
+ import { withScout } from "./with-scout.js";
4
2
  export async function runVetList(options) {
5
- const token = requireGitHubToken();
6
- const scout = options.state
7
- ? await createScout({
8
- githubToken: token,
9
- persistence: "provided",
10
- initialState: options.state,
11
- })
12
- : await createScout({ githubToken: token });
13
- const result = await scout.vetList({
3
+ return withScout(options.state, (scout) => scout.vetList({
14
4
  concurrency: options.concurrency,
15
5
  prune: options.prune,
16
- });
17
- saveLocalState(scout.getState());
18
- const persisted = await scout.checkpoint();
19
- if (!persisted) {
20
- console.error("Warning: changes saved locally but gist sync failed.");
21
- }
22
- return result;
6
+ }), { persist: true });
23
7
  }
@@ -1,33 +1,26 @@
1
1
  /**
2
2
  * Vet command — vets a specific issue for claimability.
3
3
  */
4
- import { createScout } from "../scout.js";
5
- import { requireGitHubToken } from "../core/utils.js";
4
+ import { withScout } from "./with-scout.js";
6
5
  import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl, } from "./validation.js";
7
6
  export async function runVet(options) {
8
7
  validateUrl(options.issueUrl);
9
8
  validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, "issue");
10
- const token = requireGitHubToken();
11
- const scout = options.state
12
- ? await createScout({
13
- githubToken: token,
14
- persistence: "provided",
15
- initialState: options.state,
16
- })
17
- : await createScout({ githubToken: token });
18
- const candidate = await scout.vetIssue(options.issueUrl);
19
- return {
20
- issue: {
21
- repo: candidate.issue.repo,
22
- number: candidate.issue.number,
23
- title: candidate.issue.title,
24
- url: candidate.issue.url,
25
- labels: candidate.issue.labels,
26
- },
27
- recommendation: candidate.recommendation,
28
- reasonsToApprove: candidate.reasonsToApprove,
29
- reasonsToSkip: candidate.reasonsToSkip,
30
- projectHealth: candidate.projectHealth,
31
- vettingResult: candidate.vettingResult,
32
- };
9
+ return withScout(options.state, async (scout) => {
10
+ const candidate = await scout.vetIssue(options.issueUrl);
11
+ return {
12
+ issue: {
13
+ repo: candidate.issue.repo,
14
+ number: candidate.issue.number,
15
+ title: candidate.issue.title,
16
+ url: candidate.issue.url,
17
+ labels: candidate.issue.labels,
18
+ },
19
+ recommendation: candidate.recommendation,
20
+ reasonsToApprove: candidate.reasonsToApprove,
21
+ reasonsToSkip: candidate.reasonsToSkip,
22
+ projectHealth: candidate.projectHealth,
23
+ vettingResult: candidate.vettingResult,
24
+ };
25
+ });
33
26
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared command scaffolding (#154).
3
+ *
4
+ * Every command repeated the same prologue (require token, load state, build
5
+ * the scout) and most repeated the same epilogue (write local state, gist
6
+ * checkpoint, warn on gist failure). The warning text was byte-identical in
7
+ * three files and inconsistently omitted in skip.ts. `withScout` owns the
8
+ * prologue plus an optional persist epilogue; `persistScout` is the epilogue
9
+ * on its own for commands that persist only on some code paths.
10
+ */
11
+ import type { OssScout } from "../scout.js";
12
+ import type { ScoutState } from "../core/schemas.js";
13
+ /**
14
+ * Write the scout's state to disk and push the gist checkpoint, warning to
15
+ * stderr if the gist push failed (local save still succeeded).
16
+ */
17
+ export declare function persistScout(scout: OssScout): Promise<void>;
18
+ export interface WithScoutOptions {
19
+ /** Persist via persistScout after fn resolves. Default false. */
20
+ persist?: boolean;
21
+ /**
22
+ * Require a GitHub token (throws if absent). Default true. Skip-list
23
+ * operations are local-only and pass false so they work without auth.
24
+ */
25
+ requireToken?: boolean;
26
+ }
27
+ /**
28
+ * Build a scout for the given (or loaded) state, run fn against it, and
29
+ * optionally persist afterward. Centralizes the create/persist/warn boilerplate
30
+ * shared by the search, vet, vet-list, features, and skip commands.
31
+ */
32
+ export declare function withScout<T>(state: ScoutState | undefined, fn: (scout: OssScout) => Promise<T> | T, options?: WithScoutOptions): Promise<T>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared command scaffolding (#154).
3
+ *
4
+ * Every command repeated the same prologue (require token, load state, build
5
+ * the scout) and most repeated the same epilogue (write local state, gist
6
+ * checkpoint, warn on gist failure). The warning text was byte-identical in
7
+ * three files and inconsistently omitted in skip.ts. `withScout` owns the
8
+ * prologue plus an optional persist epilogue; `persistScout` is the epilogue
9
+ * on its own for commands that persist only on some code paths.
10
+ */
11
+ import { buildCommandScout } from "./command-scout.js";
12
+ import { requireGitHubToken, getGitHubToken } from "../core/utils.js";
13
+ import { loadLocalState, saveLocalState } from "../core/local-state.js";
14
+ const GIST_SYNC_WARNING = "Warning: changes saved locally but gist sync failed.";
15
+ /**
16
+ * Write the scout's state to disk and push the gist checkpoint, warning to
17
+ * stderr if the gist push failed (local save still succeeded).
18
+ */
19
+ export async function persistScout(scout) {
20
+ saveLocalState(scout.getState());
21
+ const persisted = await scout.checkpoint();
22
+ if (!persisted) {
23
+ console.error(GIST_SYNC_WARNING);
24
+ }
25
+ }
26
+ /**
27
+ * Build a scout for the given (or loaded) state, run fn against it, and
28
+ * optionally persist afterward. Centralizes the create/persist/warn boilerplate
29
+ * shared by the search, vet, vet-list, features, and skip commands.
30
+ */
31
+ export async function withScout(state, fn, options = {}) {
32
+ const { persist = false, requireToken = true } = options;
33
+ const token = requireToken ? requireGitHubToken() : (getGitHubToken() ?? "");
34
+ const resolvedState = state ?? loadLocalState();
35
+ const scout = await buildCommandScout(resolvedState, token);
36
+ const result = await fn(scout);
37
+ if (persist) {
38
+ await persistScout(scout);
39
+ }
40
+ return result;
41
+ }
@@ -7,10 +7,9 @@
7
7
  * can rely on a structured `AntiLLMPolicyResult` rather than re-implementing
8
8
  * the scan in agent prose.
9
9
  */
10
- import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
11
- import { warn } from "./logger.js";
12
- import { getHttpCache } from "./http-cache.js";
13
- const MODULE = "anti-llm-policy";
10
+ import { getHttpStatusCode, isRateLimitError } from "./errors.js";
11
+ import { getHttpCache, versionedCacheKey } from "./http-cache.js";
12
+ import { probeRepoFile } from "./probe-repo-file.js";
14
13
  /** TTL for cached anti-LLM policy scan results (1 hour). Policy docs change rarely. */
15
14
  const POLICY_SCAN_CACHE_TTL_MS = 60 * 60 * 1000;
16
15
  /**
@@ -85,40 +84,13 @@ const SOURCE_FILE_FAMILIES = [
85
84
  paths: ["README.md", "readme.md", "Readme.md"],
86
85
  },
87
86
  ];
88
- /**
89
- * Fetch one path's raw text content. The `transient` flag distinguishes a
90
- * clean miss (404 — file absent) from a degraded miss (5xx, network) so the
91
- * caller can decide whether to cache "no policy" or retry. Throws on
92
- * 401/auth and rate-limit per documented project error strategy.
93
- */
94
- async function fetchFileText(octokit, owner, repo, path) {
95
- try {
96
- const { data } = await octokit.repos.getContent({ owner, repo, path });
97
- if ("content" in data && typeof data.content === "string") {
98
- return {
99
- text: Buffer.from(data.content, "base64").toString("utf-8"),
100
- transient: false,
101
- };
102
- }
103
- return { text: null, transient: false };
104
- }
105
- catch (error) {
106
- const status = getHttpStatusCode(error);
107
- if (status === 404)
108
- return { text: null, transient: false };
109
- if (status === 401 || isRateLimitError(error))
110
- throw error;
111
- warn(MODULE, `Unexpected error fetching ${path} from ${owner}/${repo}: ${errorMessage(error)}`);
112
- return { text: null, transient: true };
113
- }
114
- }
115
87
  /**
116
88
  * Fetch the first available file from a family. Probes are issued in parallel,
117
89
  * but auth/rate-limit rejections re-throw so the IssueVetter's existing
118
90
  * rate-limit handling kicks in instead of silently caching a wrong answer.
119
91
  */
120
92
  async function fetchFamilyText(octokit, owner, repo, paths) {
121
- const results = await Promise.allSettled(paths.map((p) => fetchFileText(octokit, owner, repo, p)));
93
+ const results = await Promise.allSettled(paths.map((p) => probeRepoFile(octokit, owner, repo, p)));
122
94
  let hadTransientFailure = false;
123
95
  for (const result of results) {
124
96
  if (result.status === "fulfilled") {
@@ -162,7 +134,7 @@ function isAntiLLMPolicyResult(value) {
162
134
  */
163
135
  export async function fetchAndScanAntiLLMPolicy(octokit, owner, repo, options) {
164
136
  const cache = getHttpCache();
165
- const cacheKey = `anti-llm-policy:${owner}/${repo}`;
137
+ const cacheKey = versionedCacheKey(`anti-llm-policy:${owner}/${repo}`);
166
138
  const cached = cache.getIfFresh(cacheKey, POLICY_SCAN_CACHE_TTL_MS);
167
139
  if (isAntiLLMPolicyResult(cached))
168
140
  return cached;
@@ -2,7 +2,7 @@
2
2
  * First-run bootstrap — fetches starred repos and PR history from GitHub
3
3
  * to seed the scout's state with the user's contribution context.
4
4
  */
5
- import type { OssScout } from "../scout.js";
5
+ import type { ScoutStateWriter } from "./issue-vetting.js";
6
6
  export interface BootstrapResult {
7
7
  starredRepoCount: number;
8
8
  mergedPRCount: number;
@@ -12,4 +12,4 @@ export interface BootstrapResult {
12
12
  skippedDueToRateLimit: boolean;
13
13
  errors: string[];
14
14
  }
15
- export declare function bootstrapScout(scout: OssScout, token: string): Promise<BootstrapResult>;
15
+ export declare function bootstrapScout(scout: ScoutStateWriter, token: string): Promise<BootstrapResult>;
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { getOctokit, checkRateLimit } from "./github.js";
6
6
  import { debug, warn } from "./logger.js";
7
- import { ConfigurationError, errorMessage, getHttpStatusCode, isRateLimitError, } from "./errors.js";
7
+ import { ConfigurationError, errorMessage, rethrowIfFatal } from "./errors.js";
8
8
  import { extractRepoFromUrl } from "./utils.js";
9
9
  const MODULE = "bootstrap";
10
10
  const STARRED_MAX_PAGES = 5;
@@ -51,8 +51,7 @@ export async function bootstrapScout(scout, token) {
51
51
  scout.setStarredRepos(starredRepos);
52
52
  }
53
53
  catch (err) {
54
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
55
- throw err;
54
+ rethrowIfFatal(err);
56
55
  warn(MODULE, `Failed to fetch starred repos: ${errorMessage(err)}`);
57
56
  errors.push("starred repos fetch failed");
58
57
  }
@@ -83,8 +82,7 @@ export async function bootstrapScout(scout, token) {
83
82
  debug(MODULE, `Imported ${mergedPRCount} merged PRs`);
84
83
  }
85
84
  catch (err) {
86
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
87
- throw err;
85
+ rethrowIfFatal(err);
88
86
  warn(MODULE, `Failed to fetch merged PRs: ${errorMessage(err)}`);
89
87
  errors.push("merged PR fetch failed");
90
88
  }
@@ -115,8 +113,7 @@ export async function bootstrapScout(scout, token) {
115
113
  debug(MODULE, `Imported ${closedPRCount} closed PRs`);
116
114
  }
117
115
  catch (err) {
118
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
119
- throw err;
116
+ rethrowIfFatal(err);
120
117
  warn(MODULE, `Failed to fetch closed PRs: ${errorMessage(err)}`);
121
118
  errors.push("closed PR fetch failed");
122
119
  }
@@ -147,8 +144,7 @@ export async function bootstrapScout(scout, token) {
147
144
  debug(MODULE, `Imported ${openPRCount} open PRs`);
148
145
  }
149
146
  catch (err) {
150
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
151
- throw err;
147
+ rethrowIfFatal(err);
152
148
  warn(MODULE, `Failed to fetch open PRs: ${errorMessage(err)}`);
153
149
  errors.push("open PR fetch failed");
154
150
  }
@@ -21,6 +21,16 @@ export declare class ValidationError extends OssScoutError {
21
21
  export declare function errorMessage(e: unknown): string;
22
22
  export declare function getHttpStatusCode(error: unknown): number | undefined;
23
23
  export declare function isRateLimitError(error: unknown): boolean;
24
+ /**
25
+ * Re-throw an error if it is one that must always propagate: a 401 (auth) or
26
+ * any rate-limit condition (429, 403 + rate-limit/abuse). Otherwise return so
27
+ * the caller can degrade gracefully. Centralizes the guard that was
28
+ * copy-pasted across ~16 catch blocks (#154).
29
+ *
30
+ * Note: catch sites that deliberately treat a rate limit as degradable use a
31
+ * bare `getHttpStatusCode(err) === 401` check instead and must NOT call this.
32
+ */
33
+ export declare function rethrowIfFatal(error: unknown): void;
24
34
  /** Error codes for JSON output. */
25
35
  export type ErrorCode = "AUTH_REQUIRED" | "CONFIGURATION" | "NETWORK" | "NOT_FOUND" | "RATE_LIMITED" | "STATE_CORRUPTED" | "UNKNOWN" | "VALIDATION";
26
36
  /**
@@ -46,10 +46,27 @@ export function isRateLimitError(error) {
46
46
  return true;
47
47
  if (status === 403) {
48
48
  const msg = errorMessage(error).toLowerCase();
49
- return msg.includes("rate limit");
49
+ // "rate limit" also covers GitHub's "secondary rate limit" wording;
50
+ // abuse-detection 403s carry neither substring but are the same
51
+ // back-off-and-retry condition, so they must propagate too (#138).
52
+ return msg.includes("rate limit") || msg.includes("abuse detection");
50
53
  }
51
54
  return false;
52
55
  }
56
+ /**
57
+ * Re-throw an error if it is one that must always propagate: a 401 (auth) or
58
+ * any rate-limit condition (429, 403 + rate-limit/abuse). Otherwise return so
59
+ * the caller can degrade gracefully. Centralizes the guard that was
60
+ * copy-pasted across ~16 catch blocks (#154).
61
+ *
62
+ * Note: catch sites that deliberately treat a rate limit as degradable use a
63
+ * bare `getHttpStatusCode(err) === 401` check instead and must NOT call this.
64
+ */
65
+ export function rethrowIfFatal(error) {
66
+ if (getHttpStatusCode(error) === 401 || isRateLimitError(error)) {
67
+ throw error;
68
+ }
69
+ }
53
70
  /**
54
71
  * Map an unknown error to a structured ErrorCode for JSON output.
55
72
  */
@@ -62,10 +79,8 @@ export function resolveErrorCode(err) {
62
79
  if (status === 401)
63
80
  return "AUTH_REQUIRED";
64
81
  if (status === 403) {
65
- const msg = errorMessage(err).toLowerCase();
66
- if (msg.includes("rate limit") || msg.includes("abuse detection"))
67
- return "RATE_LIMITED";
68
- return "AUTH_REQUIRED";
82
+ // Single source of truth for the 403 rate-limit/abuse classification
83
+ return isRateLimitError(err) ? "RATE_LIMITED" : "AUTH_REQUIRED";
69
84
  }
70
85
  if (status === 404)
71
86
  return "NOT_FOUND";
@@ -138,10 +138,22 @@ export interface DiscoverFeaturesBroadOptions {
138
138
  * is independently testable without mocking the Search API.
139
139
  */
140
140
  export declare function buildBroadFeatureSearchQuery(opts: {
141
- languages?: string[];
141
+ /** Single language per query; see buildBroadFeatureSearchQueries. */
142
+ language?: string;
142
143
  excludeRepos?: string[];
143
144
  excludeOrgs?: string[];
144
145
  }): string;
146
+ /**
147
+ * One query per language. A combined `(language:a OR language:b)` clause
148
+ * pushed the query past GitHub's 5-operator limit (the label ORs already
149
+ * spend all five), so every 2+ language config drew a 422 that the caller
150
+ * swallowed into "no results" (#121). "any" disables the filter.
151
+ */
152
+ export declare function buildBroadFeatureSearchQueries(opts: {
153
+ languages?: string[];
154
+ excludeRepos?: string[];
155
+ excludeOrgs?: string[];
156
+ }): string[];
145
157
  /**
146
158
  * Orchestrate broad / cross-repo feature discovery (#100). Bypasses anchor
147
159
  * resolution; runs a single GitHub Search API query for feature-labeled