@expo/code-review-cli 0.12.3 → 0.12.5

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.
@@ -754,25 +754,43 @@ export async function runReview(source, options) {
754
754
  const findingCountBeforeChecks = output.findings.length;
755
755
  const decisionBeforeChecks = output.decision;
756
756
  let verifierDropped = [];
757
+ // Citations the verifier stripped from kept findings, persisted to the run
758
+ // log so the removal reason stays auditable — mirrors verifierDropped.
759
+ let citationStrips = [];
757
760
  // Stripped requalifications (finding + reason), persisted to the run log so the
758
761
  // stack-aware decision trail is auditable after the fact — mirrors verifierDropped.
759
762
  const requalificationStrips = [];
760
763
  if (output.findings.length > 0) {
761
764
  progress("Verifying findings…");
762
- const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
765
+ const verification = await verifyFindings(handle, output.findings, process.cwd(), progress, researchEvidence);
763
766
  agentCosts["verifier"] = verification.cost;
764
767
  trackTokens("verifier", verification.tokens);
765
768
  // Mirrors buildOpencodeConfig, which gives the verifier the first agent's model.
766
769
  trackModel("verifier", config.agents[0]?.model ?? config.coordinator.model, verification.model);
767
770
  verifierDropped = verification.dropped;
768
- if (verification.dropped.length > 0) {
769
- progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
771
+ citationStrips = verification.citationStripped;
772
+ if (verification.dropped.length > 0 || verification.citationStripped.length > 0) {
773
+ if (verification.dropped.length > 0) {
774
+ progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
775
+ }
770
776
  output = {
771
777
  ...output,
772
778
  findings: verification.kept,
773
- decision: decisionAfterVerification(output.decision, verification.kept),
779
+ // Re-derive the decision ONLY when findings were dropped. A citation
780
+ // strip keeps every finding, and decisionAfterVerification would
781
+ // downgrade a criticals-free request_changes to approve_with_comments —
782
+ // a blocking review must not stop blocking because a link was removed.
783
+ decision: verification.dropped.length > 0
784
+ ? decisionAfterVerification(output.decision, verification.kept)
785
+ : output.decision,
774
786
  };
775
787
  }
788
+ // The verifier judged these citations unsupportive. Remove the
789
+ // fingerprint-carried copies too, or the post-coordination merge below
790
+ // would silently restore the stripped sources.
791
+ for (const stripped of verification.citationStripped) {
792
+ sourcesByFp.delete(fingerprintFinding(stripped.finding));
793
+ }
776
794
  }
777
795
  // Stack-aware requalification grounding (deterministic, zero LLM): strip any
778
796
  // `requalifiedBy` the coordinator wrote that is forged, hallucinated, or touches a
@@ -976,6 +994,7 @@ export async function runReview(source, options) {
976
994
  coverageNotes,
977
995
  verifierDropped,
978
996
  requalificationStrips,
997
+ citationStrips,
979
998
  ...(rlTotal > 0
980
999
  ? {
981
1000
  rateLimitEvents: rlTotal,
@@ -90,6 +90,12 @@ export function isOverallRiskHandoff(finding) {
90
90
  export const VerdictSchema = z.object({
91
91
  verified: z.boolean(),
92
92
  reason: z.string().default(""),
93
+ /**
94
+ * Only requested when the finding cites research: whether the cited passages
95
+ * genuinely support the finding's external-behavior claim. `false` strips the
96
+ * citation while the finding itself stands or falls on `verified`.
97
+ */
98
+ citationSupported: z.boolean().optional(),
93
99
  });
94
100
  /**
95
101
  * A stack verifier's verdict on whether a later stacked PR's patch actually
@@ -12,6 +12,17 @@ import { errorMessage, normalizeCode } from "./util.js";
12
12
  const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
13
13
  // Evidence shorter than this (normalized) is too weak to conclude "hallucinated".
14
14
  const MIN_EVIDENCE_LEN = 12;
15
+ /** The audited passages behind a finding's grounded citations, bounded per source. */
16
+ function citedSourcesFor(finding, evidence) {
17
+ if (!finding.sources?.length || evidence.length === 0)
18
+ return undefined;
19
+ const byUrl = new Map(evidence.map((item) => [item.url, item]));
20
+ const cited = finding.sources.flatMap((source) => {
21
+ const match = byUrl.get(source.url);
22
+ return match ? [{ title: match.title, url: match.url, passage: match.passage }] : [];
23
+ });
24
+ return cited.length > 0 ? cited : undefined;
25
+ }
15
26
  // @ref LLP 0005#evidence-grounding-escalate-never-hard-drop [implements] — exact-substring is a good positive but poor negative signal (33a970a revert)
16
27
  /**
17
28
  * Break `evidence` into normalized, substantive fragments for fuzzy matching:
@@ -95,31 +106,43 @@ async function evidencePresence(finding, cwd) {
95
106
  * real findings whose natural evidence (a structural/absence bug, a cross-line
96
107
  * quote, a slightly-wrong location) wasn't a verbatim substring.
97
108
  */
98
- export async function verifyFindings(handle, findings, cwd, onProgress) {
109
+ export async function verifyFindings(handle, findings, cwd, onProgress,
110
+ /** This run's audited research evidence, for findings that cite documentation. */
111
+ researchEvidence = []) {
99
112
  const dropped = [];
113
+ const citationStripped = [];
114
+ // Kept findings the verifier rewrote (currently only citation removal).
115
+ const replacements = new Map();
100
116
  let cost = 0;
101
117
  let model;
102
118
  const tokens = {};
103
119
  // Phase 1 — deterministic quote-grounding for every finding.
104
120
  const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
105
- // Decide which findings need an LLM check vs. can be kept directly.
121
+ // Decide which findings need an LLM check vs. can be kept directly. A finding
122
+ // that cites documentation always gets an LLM check: the repo alone cannot
123
+ // confirm an external-behavior claim, and the verifier must judge whether the
124
+ // cited passages support it rather than fall back to model memory.
106
125
  const verdicts = new Map();
107
126
  const toVerify = [];
108
127
  for (const { finding, presence } of checked) {
109
- if (presence === "absent" || finding.severity === "critical") {
110
- toVerify.push({ finding, presence });
128
+ const citedSources = citedSourcesFor(finding, researchEvidence);
129
+ if (presence === "absent" || finding.severity === "critical" || citedSources) {
130
+ toVerify.push({ finding, presence, ...(citedSources ? { citedSources } : {}) });
111
131
  }
112
132
  else {
113
133
  verdicts.set(finding, "keep"); // grounded (or uncheckable) non-critical
114
134
  }
115
135
  }
116
136
  // Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
117
- await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
137
+ await Promise.all(toVerify.map(async ({ finding, presence, citedSources }, index) => {
118
138
  try {
119
139
  const { value, cost: verifyCost, tokens: verifyTokens, model: verifyModel, } = await promptAndParse(handle, {
120
140
  agent: VERIFIER_AGENT,
121
141
  system: buildVerifierSystem(),
122
- text: buildVerifierTask(finding, { evidenceUngrounded: presence === "absent" }),
142
+ text: buildVerifierTask(finding, {
143
+ evidenceUngrounded: presence === "absent",
144
+ ...(citedSources ? { citedSources } : {}),
145
+ }),
123
146
  title: `verify-${index}`,
124
147
  maxWaitMs: VERIFY_TIMEOUT_MS,
125
148
  finalizeOnTimeout: true,
@@ -130,6 +153,17 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
130
153
  model = verifyModel ?? model;
131
154
  if (value.verified) {
132
155
  verdicts.set(finding, "keep");
156
+ // An explicit false strips the citation; the finding itself stands.
157
+ // Absent or true leaves the grounded sources untouched (fail open).
158
+ if (citedSources && value.citationSupported === false) {
159
+ const { sources: _sources, ...withoutSources } = finding;
160
+ replacements.set(finding, withoutSources);
161
+ citationStripped.push({
162
+ finding,
163
+ reason: "the verifier judged the cited passages unsupportive of the claim",
164
+ });
165
+ onProgress?.(` verify: kept "${finding.title}" but removed its citation — the cited passages do not support the claim`);
166
+ }
133
167
  }
134
168
  else {
135
169
  verdicts.set(finding, "drop");
@@ -144,6 +178,8 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
144
178
  }
145
179
  }));
146
180
  // Preserve original order.
147
- const kept = findings.filter((finding) => verdicts.get(finding) === "keep");
148
- return { kept, dropped, cost, tokens, model };
181
+ const kept = findings
182
+ .filter((finding) => verdicts.get(finding) === "keep")
183
+ .map((finding) => replacements.get(finding) ?? finding);
184
+ return { kept, dropped, citationStripped, cost, tokens, model };
149
185
  }
@@ -1,5 +1,15 @@
1
1
  import { appendFile, mkdir, readFile, rmdir } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
+ /**
4
+ * Why a call was refused before it executed. Recorded WITHOUT the offending
5
+ * input: a rejected query or URL may contain exactly the sensitive material
6
+ * the sanitizer refused to send, so only the reason class is audited.
7
+ */
8
+ export const RESEARCH_REJECTION_REASONS = [
9
+ "query-rejected",
10
+ "url-rejected",
11
+ "budget-exhausted",
12
+ ];
3
13
  const LOCK_RETRIES = 200;
4
14
  const LOCK_DELAY_MS = 10;
5
15
  const MAX_AUDITED_PASSAGE_CHARACTERS = 20_000;
@@ -97,6 +107,14 @@ export class ResearchAudit {
97
107
  await this.withLock(async () => {
98
108
  const used = await this.reservationCount();
99
109
  if (used >= this.maxCalls) {
110
+ // Rejected events do not count as reservations, so recording the refusal
111
+ // cannot itself consume (or extend) the budget.
112
+ await this.append({
113
+ type: "rejected",
114
+ tool,
115
+ reason: "budget-exhausted",
116
+ timestamp: new Date().toISOString(),
117
+ });
100
118
  throw new Error(`Documentation research call budget exhausted (${this.maxCalls})`);
101
119
  }
102
120
  if (!this.path)
@@ -111,7 +129,7 @@ export class ResearchAudit {
111
129
  });
112
130
  return requestId;
113
131
  }
114
- async complete(requestId, tool, input, results, warnings = []) {
132
+ async complete(requestId, tool, input, results, warnings = [], network) {
115
133
  await this.append({
116
134
  type: "completed",
117
135
  requestId,
@@ -119,6 +137,7 @@ export class ResearchAudit {
119
137
  input,
120
138
  results: results.map(boundedResult),
121
139
  warnings: warnings.slice(0, 10).map((warning) => warning.slice(0, 500)),
140
+ ...(network ? { network } : {}),
122
141
  timestamp: new Date().toISOString(),
123
142
  });
124
143
  }
@@ -132,6 +151,15 @@ export class ResearchAudit {
132
151
  timestamp: new Date().toISOString(),
133
152
  });
134
153
  }
154
+ /** Record a call refused before execution — reason class only, never the input. */
155
+ async rejected(tool, reason) {
156
+ await this.append({
157
+ type: "rejected",
158
+ tool,
159
+ reason,
160
+ timestamp: new Date().toISOString(),
161
+ });
162
+ }
135
163
  }
136
164
  export async function readResearchAudit(path) {
137
165
  let contents = "";
@@ -140,10 +168,11 @@ export async function readResearchAudit(path) {
140
168
  }
141
169
  catch (error) {
142
170
  if (error.code === "ENOENT")
143
- return [];
171
+ return { records: [], rejections: [] };
144
172
  throw error;
145
173
  }
146
174
  const records = [];
175
+ const rejections = [];
147
176
  for (const line of contents.split("\n")) {
148
177
  if (!line)
149
178
  continue;
@@ -161,10 +190,14 @@ export async function readResearchAudit(path) {
161
190
  error: event.error,
162
191
  });
163
192
  }
193
+ if (event.type === "rejected" &&
194
+ RESEARCH_REJECTION_REASONS.includes(event.reason)) {
195
+ rejections.push({ tool: event.tool, reason: event.reason });
196
+ }
164
197
  }
165
198
  catch {
166
199
  // Ignore a partial final line from a process that was terminated mid-write.
167
200
  }
168
201
  }
169
- return records;
202
+ return { records, rejections };
170
203
  }
@@ -0,0 +1,74 @@
1
+ // @ref LLP 0013#one-package-two-binaries [implements] — the bounded MCP's environment is constructed, never inherited
2
+ /**
3
+ * The single definition of what the bounded documentation MCP is allowed to see
4
+ * in its environment.
5
+ *
6
+ * Two callers share it, and they need it for different reasons:
7
+ *
8
+ * - `createResearchMcpRuntime` writes `researchChildEnvironment()` into the
9
+ * engine's MCP configuration. That block is a REQUEST, not a guarantee: both
10
+ * Claude Code and OpenCode merge it onto the environment the engine already
11
+ * has rather than replacing it, so the declared allowlist alone never bounds
12
+ * the child.
13
+ * - `wrapper.ts` applies `researchWrapperEnvironment()` when it spawns the real
14
+ * server. That IS the guarantee — the server process is handed a constructed
15
+ * environment, so whatever the engine merged in stops at the wrapper.
16
+ *
17
+ * Nothing here imports a parser, a network client, or the config schema: the
18
+ * wrapper must stay loadable without pulling untrusted-content machinery into
19
+ * the one process that still holds the engine's credentials.
20
+ */
21
+ const PROXY_ENV_KEYS = [
22
+ "HTTP_PROXY",
23
+ "HTTPS_PROXY",
24
+ "NO_PROXY",
25
+ "http_proxy",
26
+ "https_proxy",
27
+ "no_proxy",
28
+ ];
29
+ export const RESEARCH_SEARCH_API_KEY = "BRAVE_SEARCH_API_KEY";
30
+ /**
31
+ * Bounds ECR sets on the child through the config env block. These are inputs to
32
+ * the server, so the wrapper must forward them; every one is a number, a path ECR
33
+ * itself chose, or both, and none is a credential.
34
+ */
35
+ export const RESEARCH_RUNTIME_ENV_KEYS = [
36
+ "REVIEW_RESEARCH_AUDIT_PATH",
37
+ "REVIEW_RESEARCH_INDEX_PATH",
38
+ "REVIEW_RESEARCH_MAX_CALLS",
39
+ "REVIEW_RESEARCH_MAX_RESULTS",
40
+ "REVIEW_RESEARCH_TIMEOUT_MS",
41
+ ];
42
+ /**
43
+ * Locale, proxy configuration, and the search-only credential — the environment
44
+ * the MCP actually needs to do its job.
45
+ *
46
+ * NODE_OPTIONS is deliberately absent: it is arbitrary code injection into the
47
+ * process that parses untrusted remote documents. So are PATH and HOME — the
48
+ * wrapper spawns the server by absolute path and the server writes only to the
49
+ * audit path it is given.
50
+ */
51
+ export function researchChildEnvironment(source = process.env) {
52
+ const environment = {
53
+ LANG: "C.UTF-8",
54
+ LC_ALL: "C.UTF-8",
55
+ ...(process.platform === "win32" && source.SystemRoot ? { SystemRoot: source.SystemRoot } : {}),
56
+ };
57
+ for (const key of PROXY_ENV_KEYS) {
58
+ if (source[key])
59
+ environment[key] = source[key];
60
+ }
61
+ if (source[RESEARCH_SEARCH_API_KEY]) {
62
+ environment[RESEARCH_SEARCH_API_KEY] = source[RESEARCH_SEARCH_API_KEY];
63
+ }
64
+ return environment;
65
+ }
66
+ /** The child environment plus the bounds ECR passes through the config block. */
67
+ export function researchWrapperEnvironment(source = process.env) {
68
+ const environment = researchChildEnvironment(source);
69
+ for (const key of RESEARCH_RUNTIME_ENV_KEYS) {
70
+ if (source[key] !== undefined)
71
+ environment[key] = source[key];
72
+ }
73
+ return environment;
74
+ }
@@ -58,6 +58,7 @@ async function main() {
58
58
  : {}),
59
59
  maxCalls: boundedInteger("REVIEW_RESEARCH_MAX_CALLS", 8, 1, 20),
60
60
  maxResultsPerCall: boundedInteger("REVIEW_RESEARCH_MAX_RESULTS", 3, 1, 3),
61
+ timeoutMs: boundedInteger("REVIEW_RESEARCH_TIMEOUT_MS", 30_000, 1_000, 60_000),
61
62
  ...(process.env.BRAVE_SEARCH_API_KEY
62
63
  ? { braveApiKey: process.env.BRAVE_SEARCH_API_KEY }
63
64
  : {}),
@@ -53,8 +53,8 @@ export function extractExpoAlgoliaDocuments(json) {
53
53
  }
54
54
  return documents;
55
55
  }
56
- async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes) {
57
- const response = await fetch(EXPO_ALGOLIA_ENDPOINT, {
56
+ async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes, fetchImplementation) {
57
+ const response = await fetchImplementation(EXPO_ALGOLIA_ENDPOINT, {
58
58
  method: "POST",
59
59
  redirect: "error",
60
60
  signal: AbortSignal.timeout(timeoutMs),
@@ -83,7 +83,7 @@ async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes) {
83
83
  const body = await readBodyWithLimit(response, maxResponseBytes);
84
84
  return extractExpoAlgoliaDocuments(body);
85
85
  }
86
- export async function searchExpoAlgolia(query, limit) {
86
+ export async function searchExpoAlgolia(query, limit, fetchImplementation = fetch) {
87
87
  const normalized = query.replace(/\s+/g, " ").trim();
88
88
  if (!normalized || normalized.length > 300) {
89
89
  throw new Error("Expo Algolia query must contain between 1 and 300 characters");
@@ -91,5 +91,5 @@ export async function searchExpoAlgolia(query, limit) {
91
91
  if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
92
92
  throw new Error("Expo Algolia result limit must be between 1 and 10");
93
93
  }
94
- return fetchExpoAlgolia(normalized, limit, 5000, 1_000_000);
94
+ return fetchExpoAlgolia(normalized, limit, 5000, 1_000_000, fetchImplementation);
95
95
  }
@@ -0,0 +1,75 @@
1
+ /** Discovery endpoints. Everything else is a documentation page or asset. */
2
+ const SEARCH_ENDPOINTS = [
3
+ "https://api.search.brave.com/",
4
+ "https://qex7pb7d46-dsn.algolia.net/",
5
+ "https://lysine.dev/okhttp/search/search_index.json",
6
+ ];
7
+ export class ResearchDeadlineError extends Error {
8
+ constructor(timeoutMs) {
9
+ super(`Documentation research call exceeded its ${timeoutMs}ms deadline`);
10
+ this.name = "ResearchDeadlineError";
11
+ }
12
+ }
13
+ export function createResearchNetwork(base, timeoutMs) {
14
+ const startedAt = Date.now();
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(new ResearchDeadlineError(timeoutMs)), timeoutMs);
17
+ // The MCP is a short-lived stdio process; never hold the loop open for this.
18
+ timer.unref?.();
19
+ let searchRequests = 0;
20
+ let documentRequests = 0;
21
+ let redirects = 0;
22
+ let totalRequests = 0;
23
+ const wrapped = async (input, init) => {
24
+ if (controller.signal.aborted)
25
+ throw new ResearchDeadlineError(timeoutMs);
26
+ const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
27
+ const isSearch = SEARCH_ENDPOINTS.some((endpoint) => href.startsWith(endpoint));
28
+ totalRequests++;
29
+ if (isSearch)
30
+ searchRequests++;
31
+ else
32
+ documentRequests++;
33
+ // Keep the caller's own per-attempt timeout AND add the call deadline, so a
34
+ // single slow hop still fails fast while the whole call stays bounded.
35
+ const signal = init?.signal
36
+ ? AbortSignal.any([init.signal, controller.signal])
37
+ : controller.signal;
38
+ const response = await base(input, { ...init, signal });
39
+ if (response.status >= 300 && response.status < 400) {
40
+ redirects++;
41
+ // A redirect hop is a round trip, not a distinct resource, so undo the
42
+ // classification count. It has to be the SAME counter that was
43
+ // incremented: the search backends use `redirect: "manual"` too and treat
44
+ // a 3xx as an error, so unconditionally decrementing documentRequests
45
+ // drives it negative the first time a discovery endpoint redirects.
46
+ if (isSearch)
47
+ searchRequests--;
48
+ else
49
+ documentRequests--;
50
+ }
51
+ return response;
52
+ };
53
+ return {
54
+ fetch: wrapped,
55
+ signal: controller.signal,
56
+ expired: () => controller.signal.aborted,
57
+ counts: () => ({
58
+ searchRequests,
59
+ documentRequests,
60
+ redirects,
61
+ totalRequests,
62
+ elapsedMs: Date.now() - startedAt,
63
+ }),
64
+ };
65
+ }
66
+ /** Sum per-call ledgers into one review-wide total. */
67
+ export function totalResearchNetwork(counts) {
68
+ return counts.reduce((total, entry) => ({
69
+ searchRequests: total.searchRequests + entry.searchRequests,
70
+ documentRequests: total.documentRequests + entry.documentRequests,
71
+ redirects: total.redirects + entry.redirects,
72
+ totalRequests: total.totalRequests + entry.totalRequests,
73
+ elapsedMs: total.elapsedMs + entry.elapsedMs,
74
+ }), { searchRequests: 0, documentRequests: 0, redirects: 0, totalRequests: 0, elapsedMs: 0 });
75
+ }
@@ -71,7 +71,20 @@ function isApiAnchor(value) {
71
71
  return (/[a-z][A-Z]/.test(value) ||
72
72
  /[A-Z][A-Za-z0-9_]{1,}/.test(value) ||
73
73
  /[._:$#()]/.test(value) ||
74
- /_[A-Z0-9]/.test(value));
74
+ /_[A-Z0-9]/.test(value) ||
75
+ // Hyphenated package and module names (expo-camera, react-native-screens).
76
+ /[a-z0-9]-[a-z]/.test(value));
77
+ }
78
+ /**
79
+ * A short, all-lowercase concept phrase ("gradle configuration cache",
80
+ * "coroutine cancellation cooperative"). Guide, release-note, and Expo
81
+ * documentation topics frequently have no CamelCase symbol; two or more plain
82
+ * dictionary-shaped words carry no more outbound capacity than a symbol query
83
+ * (same token, length, entropy, and secret checks apply) and are accepted.
84
+ * A single generic word still fails closed.
85
+ */
86
+ function isPlainConceptPhrase(tokens) {
87
+ return tokens.length >= 2 && tokens.every((token) => /^[a-z][a-z0-9]{2,23}$/.test(token));
75
88
  }
76
89
  /**
77
90
  * Convert an agent-authored search into a short API-symbol query. Dangerous shapes
@@ -100,8 +113,8 @@ export function sanitizeDocumentationQuery(rawQuery) {
100
113
  return !PROSE_STOP_WORDS.has(token.toLowerCase());
101
114
  });
102
115
  const unique = [...new Set(candidates)].slice(0, MAX_QUERY_TOKENS);
103
- if (!unique.some(isApiAnchor)) {
104
- throw new Error("Query must include an API-like symbol or member name");
116
+ if (!unique.some(isApiAnchor) && !isPlainConceptPhrase(unique)) {
117
+ throw new Error("Query must include an API-like symbol or a multi-word concept phrase");
105
118
  }
106
119
  const sanitized = unique.join(" ").slice(0, MAX_QUERY_CHARACTERS).trim();
107
120
  if (!sanitized)
@@ -136,7 +136,7 @@ export async function searchRemoteDocumentation(providerId, query, limit, option
136
136
  }
137
137
  const warnings = [];
138
138
  const indexedAt = new Date().toISOString();
139
- const fetched = await Promise.all(candidates.map(async ({ url, position }) => {
139
+ const fetchCandidate = async ({ url, position, }) => {
140
140
  try {
141
141
  const document = await fetchDocumentationDocument(provider, url.href, definition.sourceKind, fetchImplementation);
142
142
  if (!document || (options.language && document.language !== options.language))
@@ -168,12 +168,24 @@ export async function searchRemoteDocumentation(providerId, query, limit, option
168
168
  warnings.push(`${provider.displayName} fetch failed for ${url.href}: ${message}`);
169
169
  return null;
170
170
  }
171
- }));
171
+ };
172
+ // Brave ranking is discovery order. Fetch only enough pages to satisfy the
173
+ // caller, advancing to later candidates when a page is rejected or unavailable.
174
+ // Batching the outstanding result count preserves parallelism without eagerly
175
+ // downloading every discovery candidate.
176
+ const fetched = [];
177
+ let candidateIndex = 0;
178
+ while (fetched.length < limit &&
179
+ candidateIndex < candidates.length &&
180
+ !options.deadline?.aborted) {
181
+ const outstanding = limit - fetched.length;
182
+ const batch = candidates.slice(candidateIndex, candidateIndex + outstanding);
183
+ candidateIndex += batch.length;
184
+ const batchResults = await Promise.all(batch.map(fetchCandidate));
185
+ fetched.push(...batchResults.flatMap((result) => (result ? [result] : [])));
186
+ }
172
187
  return {
173
- results: fetched
174
- .flatMap((result) => (result ? [result] : []))
175
- .sort((left, right) => right.score - left.score)
176
- .slice(0, limit),
188
+ results: fetched.sort((left, right) => right.score - left.score).slice(0, limit),
177
189
  warnings: warnings.slice(0, 5),
178
190
  };
179
191
  }