@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.
- package/README.md +34 -7
- package/build/config/load.js +1 -1
- package/build/config/schema.js +5 -2
- package/build/core/prompts.js +22 -25
- package/build/core/research.js +114 -508
- package/build/core/review.js +23 -4
- package/build/core/schema.js +6 -0
- package/build/core/verify.js +44 -8
- package/build/research-mcp/audit.js +36 -3
- package/build/research-mcp/child-env.js +74 -0
- package/build/research-mcp/cli.js +1 -0
- package/build/research-mcp/expo-algolia.js +4 -4
- package/build/research-mcp/network.js +75 -0
- package/build/research-mcp/query-sanitizer.js +16 -3
- package/build/research-mcp/remote-search.js +18 -6
- package/build/research-mcp/server.js +64 -19
- package/build/research-mcp/wrapper.js +54 -0
- package/package.json +1 -1
- package/templates/config.jsonc +15 -5
package/build/core/review.js
CHANGED
|
@@ -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
|
-
|
|
769
|
-
|
|
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
|
|
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,
|
package/build/core/schema.js
CHANGED
|
@@ -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
|
package/build/core/verify.js
CHANGED
|
@@ -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
|
-
|
|
110
|
-
|
|
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, {
|
|
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
|
|
148
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|