@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/research.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ref LLP 0013#query-and-prompt-boundary [implements] —
|
|
1
|
+
// @ref LLP 0013#query-and-prompt-boundary [implements] — validate, bound, and sanitize reviewer-requested MCP evidence
|
|
2
2
|
// @ref LLP 0013#one-package-two-binaries [implements] — resolve the package-relative MCP entry instead of PATH/configured commands
|
|
3
3
|
// @ref LLP 0013#research-provenance-and-citations [implements] — bounded query/result audit records plus exact citation grounding
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
@@ -6,9 +6,9 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { z } from "zod";
|
|
10
9
|
import { readResearchAudit } from "../research-mcp/audit.js";
|
|
11
|
-
import {
|
|
10
|
+
import { researchChildEnvironment } from "../research-mcp/child-env.js";
|
|
11
|
+
import { totalResearchNetwork } from "../research-mcp/network.js";
|
|
12
12
|
export { OPENCODE_RESEARCH_TOOLS } from "./tools.js";
|
|
13
13
|
export const RESEARCH_DECISION_COUNT_LIMIT = 16;
|
|
14
14
|
export const RESEARCH_DECISION_BYTES_LIMIT = 20_000;
|
|
@@ -17,388 +17,17 @@ export const CLAUDE_RESEARCH_TOOLS = [
|
|
|
17
17
|
`mcp__${RESEARCH_MCP_SERVER_NAME}__search_platform_docs`,
|
|
18
18
|
`mcp__${RESEARCH_MCP_SERVER_NAME}__fetch_platform_doc`,
|
|
19
19
|
];
|
|
20
|
-
|
|
21
|
-
const ANDROID_EXTENSIONS = /\.(?:kt|java|gradle|gradle\.kts)$/i;
|
|
22
|
-
const REACT_NATIVE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
|
|
23
|
-
const QUERY_TOKEN = /[A-Za-z][A-Za-z0-9_.:-]{1,79}/g;
|
|
24
|
-
const MAX_ANALYZED_PATCH_LINE_LENGTH = 4096;
|
|
25
|
-
const TYPE_TOKEN = /\b[A-Z][A-Za-z0-9_]{2,}(?:\.[A-Za-z_][A-Za-z0-9_]*)*/g;
|
|
26
|
-
const MEMBER_TOKEN = /\.([a-z][A-Za-z0-9_]{3,})\s*(?:\(|\b)/g;
|
|
27
|
-
const ECOSYSTEM_CALL_TOKEN = /\b((?:create|enable|make|measure|run|schedule|scrollTo|use|with)[A-Z][A-Za-z0-9_]*)\s*\(/g;
|
|
28
|
-
const IGNORED_TYPES = new Set([
|
|
29
|
-
"Array",
|
|
30
|
-
"Bool",
|
|
31
|
-
"Boolean",
|
|
32
|
-
"Class",
|
|
33
|
-
"Data",
|
|
34
|
-
"Double",
|
|
35
|
-
"Error",
|
|
36
|
-
"Exception",
|
|
37
|
-
"Float",
|
|
38
|
-
"Int",
|
|
39
|
-
"Integer",
|
|
40
|
-
"List",
|
|
41
|
-
"Long",
|
|
42
|
-
"Map",
|
|
43
|
-
"Object",
|
|
44
|
-
"Promise",
|
|
45
|
-
"Set",
|
|
46
|
-
"String",
|
|
47
|
-
"URL",
|
|
48
|
-
"Unit",
|
|
49
|
-
]);
|
|
50
|
-
const IGNORED_MEMBERS = new Set([
|
|
51
|
-
"apply",
|
|
52
|
-
"build",
|
|
53
|
-
"copy",
|
|
54
|
-
"equals",
|
|
55
|
-
"filter",
|
|
56
|
-
"first",
|
|
57
|
-
"get",
|
|
58
|
-
"hashCode",
|
|
59
|
-
"invoke",
|
|
60
|
-
"last",
|
|
61
|
-
"let",
|
|
62
|
-
"map",
|
|
63
|
-
"remove",
|
|
64
|
-
"run",
|
|
65
|
-
"set",
|
|
66
|
-
"toString",
|
|
67
|
-
]);
|
|
68
|
-
function startsModuleSpecifier(code) {
|
|
69
|
-
return /(?:\b(?:from|import)\s*|\brequire\s*\(\s*)$/.test(code);
|
|
70
|
-
}
|
|
71
|
-
function analyzePatchLine(source, state, collectProviderSignals, providerSignals) {
|
|
72
|
-
let code = "";
|
|
73
|
-
const finishModuleSpecifier = () => {
|
|
74
|
-
const value = state.moduleSpecifier;
|
|
75
|
-
if (value && /^[A-Za-z0-9@._/+~-]{1,200}$/.test(value))
|
|
76
|
-
providerSignals.push(value);
|
|
77
|
-
state.moduleSpecifier = null;
|
|
78
|
-
};
|
|
79
|
-
for (let index = 0; index < source.length;) {
|
|
80
|
-
if (state.mode === "block-comment") {
|
|
81
|
-
if (source.startsWith("*/", index)) {
|
|
82
|
-
state.mode = "code";
|
|
83
|
-
code += " ";
|
|
84
|
-
index += 2;
|
|
85
|
-
}
|
|
86
|
-
else {
|
|
87
|
-
code += " ";
|
|
88
|
-
index++;
|
|
89
|
-
}
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (state.mode === "triple-single-quote" || state.mode === "triple-double-quote") {
|
|
93
|
-
const delimiter = state.mode === "triple-single-quote" ? "'''" : '"""';
|
|
94
|
-
if (source.startsWith(delimiter, index)) {
|
|
95
|
-
state.mode = "code";
|
|
96
|
-
code += " ";
|
|
97
|
-
index += 3;
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
code += " ";
|
|
101
|
-
index++;
|
|
102
|
-
}
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (state.mode === "single-quote" ||
|
|
106
|
-
state.mode === "double-quote" ||
|
|
107
|
-
state.mode === "template") {
|
|
108
|
-
const delimiter = state.mode === "single-quote" ? "'" : state.mode === "double-quote" ? '"' : "`";
|
|
109
|
-
const character = source[index];
|
|
110
|
-
if (state.escaped) {
|
|
111
|
-
if (state.moduleSpecifier !== null)
|
|
112
|
-
state.moduleSpecifier += character;
|
|
113
|
-
state.escaped = false;
|
|
114
|
-
code += " ";
|
|
115
|
-
index++;
|
|
116
|
-
}
|
|
117
|
-
else if (character === "\\") {
|
|
118
|
-
state.escaped = true;
|
|
119
|
-
code += " ";
|
|
120
|
-
index++;
|
|
121
|
-
}
|
|
122
|
-
else if (character === delimiter) {
|
|
123
|
-
finishModuleSpecifier();
|
|
124
|
-
state.mode = "code";
|
|
125
|
-
code += " ";
|
|
126
|
-
index++;
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
if (state.moduleSpecifier !== null)
|
|
130
|
-
state.moduleSpecifier += character;
|
|
131
|
-
code += " ";
|
|
132
|
-
index++;
|
|
133
|
-
}
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (source.startsWith("//", index)) {
|
|
137
|
-
code += " ".repeat(source.length - index);
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
if (source.startsWith("/*", index)) {
|
|
141
|
-
state.mode = "block-comment";
|
|
142
|
-
code += " ";
|
|
143
|
-
index += 2;
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
if (source.startsWith("'''", index) || source.startsWith('"""', index)) {
|
|
147
|
-
state.mode = source.startsWith("'''", index) ? "triple-single-quote" : "triple-double-quote";
|
|
148
|
-
state.moduleSpecifier = null;
|
|
149
|
-
code += " ";
|
|
150
|
-
index += 3;
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
const character = source[index];
|
|
154
|
-
if (character === "'" || character === '"' || character === "`") {
|
|
155
|
-
state.mode =
|
|
156
|
-
character === "'" ? "single-quote" : character === '"' ? "double-quote" : "template";
|
|
157
|
-
state.escaped = false;
|
|
158
|
-
state.moduleSpecifier =
|
|
159
|
-
collectProviderSignals && character !== "`" && startsModuleSpecifier(code) ? "" : null;
|
|
160
|
-
code += " ";
|
|
161
|
-
index++;
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
code += character;
|
|
165
|
-
index++;
|
|
166
|
-
}
|
|
167
|
-
// JavaScript/TypeScript, Swift, and Kotlin single/double-quoted strings do not
|
|
168
|
-
// continue onto the next physical line unless the final character escapes the
|
|
169
|
-
// newline. Reset malformed prose-like quotes (notably JSX apostrophes) here so
|
|
170
|
-
// they cannot invert how later patch lines are classified. Templates, triple
|
|
171
|
-
// quotes, and block comments intentionally retain their multiline state.
|
|
172
|
-
const continuesQuotedLine = (state.mode === "single-quote" || state.mode === "double-quote") && state.escaped;
|
|
173
|
-
if ((state.mode === "single-quote" || state.mode === "double-quote") && !continuesQuotedLine) {
|
|
174
|
-
state.mode = "code";
|
|
175
|
-
state.moduleSpecifier = null;
|
|
176
|
-
}
|
|
177
|
-
state.escaped = false;
|
|
178
|
-
return code.trim();
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Analyze the resulting side of a unified diff while keeping lexical state across
|
|
182
|
-
* lines. Provider routing may retain only import/require module specifiers; those
|
|
183
|
-
* strings are kept separate and can never become outbound query text.
|
|
184
|
-
*/
|
|
185
|
-
function analyzeAddedPatch(patch) {
|
|
186
|
-
const codeLines = [];
|
|
187
|
-
const providerSignals = [];
|
|
188
|
-
const state = { mode: "code", escaped: false, moduleSpecifier: null };
|
|
189
|
-
for (const patchLine of patch.split("\n")) {
|
|
190
|
-
if (patchLine.startsWith("@@")) {
|
|
191
|
-
state.mode = "code";
|
|
192
|
-
state.escaped = false;
|
|
193
|
-
state.moduleSpecifier = null;
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
if (patchLine.startsWith("+++") || patchLine.startsWith("---"))
|
|
197
|
-
continue;
|
|
198
|
-
const isAdded = patchLine.startsWith("+");
|
|
199
|
-
const isContext = patchLine.startsWith(" ");
|
|
200
|
-
if (!isAdded && !isContext)
|
|
201
|
-
continue;
|
|
202
|
-
const source = patchLine.slice(1);
|
|
203
|
-
if (source.length > MAX_ANALYZED_PATCH_LINE_LENGTH) {
|
|
204
|
-
// A quote startsModuleSpecifier check examines the accumulated line prefix.
|
|
205
|
-
// Stop this file before an attacker-controlled giant line can turn that
|
|
206
|
-
// bounded research prepass into quadratic work. Abandoning later lines also
|
|
207
|
-
// avoids guessing whether the skipped input opened a multiline literal.
|
|
208
|
-
break;
|
|
209
|
-
}
|
|
210
|
-
const code = analyzePatchLine(source, state, isAdded, providerSignals);
|
|
211
|
-
if (isAdded && code)
|
|
212
|
-
codeLines.push(code);
|
|
213
|
-
}
|
|
214
|
-
return {
|
|
215
|
-
codeLines,
|
|
216
|
-
providerSignals: providerSignals.join("\n").slice(0, 256_000).toLowerCase(),
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
function normalizeQuery(parts) {
|
|
220
|
-
const tokens = parts.join(" ").match(QUERY_TOKEN) ?? [];
|
|
221
|
-
return [...new Set(tokens)].join(" ").slice(0, 120).trim();
|
|
222
|
-
}
|
|
223
|
-
function platformFor(file) {
|
|
224
|
-
const normalized = file.path.replace(/\\/g, "/");
|
|
225
|
-
if (APPLE_EXTENSIONS.test(normalized) || /(?:^|\/)ios(?:\/|$)/i.test(normalized)) {
|
|
226
|
-
return "apple";
|
|
227
|
-
}
|
|
228
|
-
if (ANDROID_EXTENSIONS.test(normalized) || /(?:^|\/)android(?:\/|$)/i.test(normalized)) {
|
|
229
|
-
return "android";
|
|
230
|
-
}
|
|
231
|
-
if (REACT_NATIVE_EXTENSIONS.test(normalized))
|
|
232
|
-
return "react-native";
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
const REACT_NATIVE_PROVIDERS = new Set([
|
|
236
|
-
"expo",
|
|
237
|
-
"react-native",
|
|
238
|
-
"react-native-reanimated",
|
|
239
|
-
"react-native-gesture-handler",
|
|
240
|
-
"react-native-screens",
|
|
241
|
-
"react-native-worklets",
|
|
242
|
-
]);
|
|
243
|
-
function providersFor(file, code, signals) {
|
|
244
|
-
const path = file.path.toLowerCase();
|
|
245
|
-
const text = code.toLowerCase();
|
|
246
|
-
const platform = platformFor(file);
|
|
247
|
-
const providers = [];
|
|
248
|
-
const add = (provider, matches) => {
|
|
249
|
-
if (matches && !providers.includes(provider))
|
|
250
|
-
providers.push(provider);
|
|
251
|
-
};
|
|
252
|
-
// Native source is owned by the native platform first. An Expo package path is
|
|
253
|
-
// repository ownership, not documentation ownership: packages/expo-image/ios
|
|
254
|
-
// must search Apple and SDWebImage contracts rather than Expo's JavaScript docs.
|
|
255
|
-
if (platform === "apple")
|
|
256
|
-
add("apple", true);
|
|
257
|
-
if (platform === "android")
|
|
258
|
-
add("android", true);
|
|
259
|
-
if (platform === "react-native") {
|
|
260
|
-
add("react-native-reanimated", /react-native-reanimated/.test(signals) || path.includes("react-native-reanimated"));
|
|
261
|
-
add("react-native-gesture-handler", /react-native-gesture-handler/.test(signals) || path.includes("react-native-gesture-handler"));
|
|
262
|
-
add("react-native-screens", /react-native-screens/.test(signals) || path.includes("react-native-screens"));
|
|
263
|
-
add("react-native-worklets", /react-native-worklets/.test(signals) || path.includes("react-native-worklets"));
|
|
264
|
-
add("expo", /(?:^|\n)(?:expo|expo-[a-z0-9-]+|@expo\/[a-z0-9-]+)(?:\n|$)/.test(signals) ||
|
|
265
|
-
/(?:^|\/)packages\/expo(?:-[^/]+)?(?:\/|$)/.test(path));
|
|
266
|
-
add("react-native", /(?:^|\n)react-native(?:\/[^\n]+)?(?:\n|$)/.test(signals));
|
|
267
|
-
}
|
|
268
|
-
// Explicit framework/dependency signals are additive. Keeping the platform
|
|
269
|
-
// provider alongside the dependency lets one pass check both the OS contract and
|
|
270
|
-
// the wrapper/library behavior without a package-path heuristic hiding either.
|
|
271
|
-
if (platform === "apple") {
|
|
272
|
-
add("sdwebimage", /\bsdwebimage(?:manager|options|context|cache|loader)?\b/.test(text));
|
|
273
|
-
add("expo", /\bexpomodulescore\b/.test(text));
|
|
274
|
-
}
|
|
275
|
-
if (platform === "android") {
|
|
276
|
-
add("media3", /androidx\.media3|mediasessionservice|\bexoplayer\b/.test(text));
|
|
277
|
-
add("glide", /com\.bumptech\.glide|\bglide\b/.test(text));
|
|
278
|
-
add("okhttp", /okhttp3|\bokhttpclient\b|\brequest\.builder\b/.test(text));
|
|
279
|
-
add("kotlin-coroutines", /kotlinx\.coroutines|\bcoroutinescope\b|\bmutable(?:state|shared)flow\b/.test(text));
|
|
280
|
-
const isGradle = /\.gradle(?:\.kts)?$/.test(path) || /(?:^|\/)build\.gradle/.test(path);
|
|
281
|
-
add("agp", isGradle && /com\.android|android\s*\{|compilesdk|targetsdk/.test(text));
|
|
282
|
-
add("gradle", isGradle);
|
|
283
|
-
add("expo", /\bexpo\.modules\.kotlin\b/.test(text));
|
|
284
|
-
}
|
|
285
|
-
return providers.length > 0 ? providers : ["react-native"];
|
|
286
|
-
}
|
|
287
|
-
function lineQuery(line) {
|
|
288
|
-
const declared = line.match(/\b(?:class|struct|enum|interface|protocol)\s+([A-Z][A-Za-z0-9_]*)/)?.[1];
|
|
289
|
-
const types = [...line.matchAll(TYPE_TOKEN)]
|
|
290
|
-
.map((match) => match[0])
|
|
291
|
-
.filter((value) => value !== declared && !IGNORED_TYPES.has(value.split(".")[0]));
|
|
292
|
-
if (types.length === 0) {
|
|
293
|
-
const call = [...line.matchAll(ECOSYSTEM_CALL_TOKEN)][0]?.[1];
|
|
294
|
-
return call ? normalizeQuery([call]) : null;
|
|
295
|
-
}
|
|
296
|
-
const members = [...line.matchAll(MEMBER_TOKEN)]
|
|
297
|
-
.map((match) => match[1])
|
|
298
|
-
.filter((value) => !IGNORED_MEMBERS.has(value));
|
|
299
|
-
const primary = types[0];
|
|
300
|
-
const member = members.find((value) => !primary.toLowerCase().includes(value.toLowerCase()));
|
|
301
|
-
return normalizeQuery(member ? [primary, member] : [primary]);
|
|
302
|
-
}
|
|
303
|
-
function addQuery(target, seen, platform, providers, query) {
|
|
304
|
-
const normalized = normalizeQuery([query]);
|
|
305
|
-
if (!normalized)
|
|
306
|
-
return;
|
|
307
|
-
const key = `${platform}|${providers.join(",")}|${normalized.toLowerCase()}`;
|
|
308
|
-
if (seen.has(key))
|
|
309
|
-
return;
|
|
310
|
-
seen.add(key);
|
|
311
|
-
target.push({ platform, providers, query: normalized });
|
|
312
|
-
}
|
|
20
|
+
export { researchChildEnvironment };
|
|
313
21
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
22
|
+
* The engine spawns the WRAPPER, not the server. Both Claude Code and OpenCode
|
|
23
|
+
* merge the config's `env` block onto their own environment instead of replacing
|
|
24
|
+
* it, so the block below cannot bound the child on its own; the wrapper rebuilds
|
|
25
|
+
* the environment from an explicit allowlist before the real server starts. See
|
|
26
|
+
* research-mcp/wrapper.ts.
|
|
316
27
|
*/
|
|
317
|
-
export function deriveResearchQueries(files, maxQueries = 8) {
|
|
318
|
-
const queries = [];
|
|
319
|
-
const seen = new Set();
|
|
320
|
-
for (const file of files) {
|
|
321
|
-
const platform = platformFor(file);
|
|
322
|
-
if (!platform)
|
|
323
|
-
continue;
|
|
324
|
-
const { codeLines: lines, providerSignals } = analyzeAddedPatch(file.patch);
|
|
325
|
-
const code = lines.join("\n").slice(0, 256_000);
|
|
326
|
-
const providers = providersFor(file, code, providerSignals);
|
|
327
|
-
for (const provider of providers) {
|
|
328
|
-
let addedForProvider = 0;
|
|
329
|
-
for (const line of lines) {
|
|
330
|
-
const query = lineQuery(line);
|
|
331
|
-
if (!query)
|
|
332
|
-
continue;
|
|
333
|
-
const before = queries.length;
|
|
334
|
-
addQuery(queries, seen, REACT_NATIVE_PROVIDERS.has(provider) ? "react-native" : platform, [provider], query);
|
|
335
|
-
if (queries.length > before && ++addedForProvider >= 2)
|
|
336
|
-
break;
|
|
337
|
-
if (queries.length >= maxQueries)
|
|
338
|
-
return queries;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
if (platform === "apple" &&
|
|
342
|
-
/\b(?:actor|MainActor|Sendable|TaskGroup|Task\.sleep)\b/.test(code)) {
|
|
343
|
-
const concept = code.match(/\b(?:MainActor|Sendable|TaskGroup|actor|Task\.sleep)\b/)?.[0];
|
|
344
|
-
if (concept)
|
|
345
|
-
addQuery(queries, seen, "apple", ["swift-evolution"], concept);
|
|
346
|
-
}
|
|
347
|
-
if (platform === "android" && /\b(?:VERSION_CODES|SDK_INT|targetSdk|compileSdk)\b/.test(code)) {
|
|
348
|
-
const api = code.match(/\b(?:VERSION_CODES(?:\.[A-Z_]+)?|SDK_INT|targetSdk|compileSdk)\b/)?.[0];
|
|
349
|
-
if (api)
|
|
350
|
-
addQuery(queries, seen, "android", ["android-releases"], api);
|
|
351
|
-
}
|
|
352
|
-
if (queries.length >= maxQueries)
|
|
353
|
-
return queries.slice(0, maxQueries);
|
|
354
|
-
}
|
|
355
|
-
return queries.slice(0, maxQueries);
|
|
356
|
-
}
|
|
357
|
-
const ToolResultSchema = z.object({
|
|
358
|
-
content: z.array(z.object({
|
|
359
|
-
type: z.string(),
|
|
360
|
-
text: z.string().optional(),
|
|
361
|
-
})),
|
|
362
|
-
});
|
|
363
|
-
const SearchPayloadSchema = z.object({
|
|
364
|
-
warnings: z.array(z.string().max(500)).max(10).optional(),
|
|
365
|
-
results: z.array(z.object({
|
|
366
|
-
id: z.string().min(1).max(240).optional(),
|
|
367
|
-
provider: z.string().min(1).max(80),
|
|
368
|
-
sourceKind: z.string().min(1).max(80),
|
|
369
|
-
title: z.string().min(1).max(500),
|
|
370
|
-
url: z.string().url().max(2_000),
|
|
371
|
-
passage: z.string().max(5_000),
|
|
372
|
-
availability: z.array(z.string().max(240)).max(20).optional(),
|
|
373
|
-
})),
|
|
374
|
-
});
|
|
375
|
-
const RESEARCH_PROXY_ENV_KEYS = [
|
|
376
|
-
"HTTP_PROXY",
|
|
377
|
-
"HTTPS_PROXY",
|
|
378
|
-
"NO_PROXY",
|
|
379
|
-
"http_proxy",
|
|
380
|
-
"https_proxy",
|
|
381
|
-
"no_proxy",
|
|
382
|
-
];
|
|
383
|
-
const RESEARCH_SEARCH_API_KEY = "BRAVE_SEARCH_API_KEY";
|
|
384
|
-
export function researchChildEnvironment(source = process.env) {
|
|
385
|
-
const environment = {
|
|
386
|
-
LANG: "C.UTF-8",
|
|
387
|
-
LC_ALL: "C.UTF-8",
|
|
388
|
-
...(process.platform === "win32" && source.SystemRoot ? { SystemRoot: source.SystemRoot } : {}),
|
|
389
|
-
};
|
|
390
|
-
for (const key of RESEARCH_PROXY_ENV_KEYS) {
|
|
391
|
-
if (source[key])
|
|
392
|
-
environment[key] = source[key];
|
|
393
|
-
}
|
|
394
|
-
if (source[RESEARCH_SEARCH_API_KEY]) {
|
|
395
|
-
environment[RESEARCH_SEARCH_API_KEY] = source[RESEARCH_SEARCH_API_KEY];
|
|
396
|
-
}
|
|
397
|
-
return environment;
|
|
398
|
-
}
|
|
399
28
|
export function bundledResearchServer() {
|
|
400
|
-
const builtEntry = fileURLToPath(new URL("../research-mcp/
|
|
401
|
-
const sourceEntry = fileURLToPath(new URL("../research-mcp/
|
|
29
|
+
const builtEntry = fileURLToPath(new URL("../research-mcp/wrapper.js", import.meta.url));
|
|
30
|
+
const sourceEntry = fileURLToPath(new URL("../research-mcp/wrapper.ts", import.meta.url));
|
|
402
31
|
return {
|
|
403
32
|
command: process.execPath,
|
|
404
33
|
args: [existsSync(builtEntry) ? builtEntry : sourceEntry],
|
|
@@ -406,8 +35,12 @@ export function bundledResearchServer() {
|
|
|
406
35
|
}
|
|
407
36
|
/**
|
|
408
37
|
* Create one owner-only MCP configuration and append-only audit for a review run.
|
|
409
|
-
* The model process receives only the config path
|
|
410
|
-
*
|
|
38
|
+
* The model process receives only the config path.
|
|
39
|
+
*
|
|
40
|
+
* The `env` block below is a request the engine merges rather than applies — see
|
|
41
|
+
* bundledResearchServer. The wrapper it names is what actually bounds the server's
|
|
42
|
+
* environment, so the Brave credential and these limits are the only things the
|
|
43
|
+
* server can see, whatever the engine passed down.
|
|
411
44
|
*/
|
|
412
45
|
export async function createResearchMcpRuntime(config) {
|
|
413
46
|
if (!config.enabled)
|
|
@@ -427,6 +60,10 @@ export async function createResearchMcpRuntime(config) {
|
|
|
427
60
|
REVIEW_RESEARCH_AUDIT_PATH: auditPath,
|
|
428
61
|
REVIEW_RESEARCH_MAX_CALLS: String(config.maxQueries),
|
|
429
62
|
REVIEW_RESEARCH_MAX_RESULTS: String(config.resultsPerQuery),
|
|
63
|
+
// The MCP enforces this itself as a per-call deadline. OpenCode's `timeout`
|
|
64
|
+
// only bounds tool DISCOVERY and Claude has no equivalent, so neither engine
|
|
65
|
+
// can bound how long a call actually runs — the server has to.
|
|
66
|
+
REVIEW_RESEARCH_TIMEOUT_MS: String(config.timeoutMs),
|
|
430
67
|
}).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])));
|
|
431
68
|
await writeFile(claudeConfigPath, `${JSON.stringify({
|
|
432
69
|
mcpServers: {
|
|
@@ -448,7 +85,7 @@ export async function createResearchMcpRuntime(config) {
|
|
|
448
85
|
};
|
|
449
86
|
}
|
|
450
87
|
export async function researchProvenanceFromAudit(auditPath) {
|
|
451
|
-
const records = await readResearchAudit(auditPath);
|
|
88
|
+
const { records, rejections } = await readResearchAudit(auditPath);
|
|
452
89
|
const queries = [];
|
|
453
90
|
const evidence = [];
|
|
454
91
|
const warnings = [];
|
|
@@ -486,9 +123,25 @@ export async function researchProvenanceFromAudit(auditPath) {
|
|
|
486
123
|
queries,
|
|
487
124
|
evidence,
|
|
488
125
|
warnings: [...new Set(warnings)].slice(0, 10),
|
|
489
|
-
promptText: "",
|
|
490
126
|
};
|
|
491
|
-
|
|
127
|
+
const provenance = toResearchProvenance(run);
|
|
128
|
+
const ledgers = records.flatMap((record) => (record.network ? [record.network] : []));
|
|
129
|
+
if (ledgers.length > 0)
|
|
130
|
+
provenance.network = totalResearchNetwork(ledgers);
|
|
131
|
+
if (rejections.length > 0) {
|
|
132
|
+
const counts = new Map();
|
|
133
|
+
for (const rejection of rejections) {
|
|
134
|
+
const key = `${rejection.tool}\0${rejection.reason}`;
|
|
135
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
136
|
+
}
|
|
137
|
+
provenance.rejections = [...counts.entries()]
|
|
138
|
+
.map(([key, count]) => {
|
|
139
|
+
const [tool = "", reason = ""] = key.split("\0");
|
|
140
|
+
return { tool: cleanEvidenceText(tool, 80), reason: cleanEvidenceText(reason, 80), count };
|
|
141
|
+
})
|
|
142
|
+
.sort((left, right) => `${left.tool}${left.reason}`.localeCompare(`${right.tool}${right.reason}`));
|
|
143
|
+
}
|
|
144
|
+
return { provenance, evidence };
|
|
492
145
|
}
|
|
493
146
|
function cleanEvidenceText(value, maxLength) {
|
|
494
147
|
return (value
|
|
@@ -500,25 +153,6 @@ function cleanEvidenceText(value, maxLength) {
|
|
|
500
153
|
.slice(0, maxLength)
|
|
501
154
|
.trim());
|
|
502
155
|
}
|
|
503
|
-
export function formatResearchEvidence(evidence) {
|
|
504
|
-
if (evidence.length === 0)
|
|
505
|
-
return "";
|
|
506
|
-
const body = evidence
|
|
507
|
-
.map((item) => {
|
|
508
|
-
const availability = item.availability?.length
|
|
509
|
-
? `\nAvailability: ${cleanEvidenceText(item.availability.join(", "), 500)}`
|
|
510
|
-
: "";
|
|
511
|
-
return [
|
|
512
|
-
`Query: ${cleanEvidenceText(item.query.query, 120)}`,
|
|
513
|
-
`Provider: ${cleanEvidenceText(item.provider, 80)} (${cleanEvidenceText(item.sourceKind, 80)})`,
|
|
514
|
-
`Source: ${cleanEvidenceText(item.title, 240)} — ${item.url}${availability}`,
|
|
515
|
-
"Passage:",
|
|
516
|
-
cleanEvidenceText(item.passage, 1200),
|
|
517
|
-
].join("\n");
|
|
518
|
-
})
|
|
519
|
-
.join("\n\n");
|
|
520
|
-
return cleanEvidenceText(body, 16_000);
|
|
521
|
-
}
|
|
522
156
|
function researchQueryKey(query) {
|
|
523
157
|
return `${query.platform}\0${query.providers.join(",")}\0${query.query}`;
|
|
524
158
|
}
|
|
@@ -562,6 +196,14 @@ export function formatResearchProgress(provenance) {
|
|
|
562
196
|
lines.push(` result: ${cleanEvidenceText(result.title, 160)} (${result.provider}/${result.sourceKind}) — ${result.url}`);
|
|
563
197
|
}
|
|
564
198
|
}
|
|
199
|
+
if (provenance.network) {
|
|
200
|
+
const { searchRequests, documentRequests, redirects, totalRequests } = provenance.network;
|
|
201
|
+
lines.push(` research network: ${searchRequests} search request(s), ${documentRequests} page fetch(es), ` +
|
|
202
|
+
`${redirects} redirect(s) — ${totalRequests} HTTP request(s) total`);
|
|
203
|
+
}
|
|
204
|
+
for (const rejection of provenance.rejections ?? []) {
|
|
205
|
+
lines.push(` research: ${rejection.count} ${rejection.tool} call(s) rejected before execution (${rejection.reason})`);
|
|
206
|
+
}
|
|
565
207
|
for (const warning of provenance.warnings) {
|
|
566
208
|
lines.push(` research warning: ${cleanEvidenceText(warning, 500)}`);
|
|
567
209
|
}
|
|
@@ -602,6 +244,15 @@ export function renderResearchMarkdown(provenance) {
|
|
|
602
244
|
lines.push(` - [${escapeMarkdownLabel(result.title)}](<${result.url}>) — ${escapeMarkdownLabel(result.provider)}/${escapeMarkdownLabel(result.sourceKind)}`);
|
|
603
245
|
}
|
|
604
246
|
}
|
|
247
|
+
if (provenance.network) {
|
|
248
|
+
const { searchRequests, documentRequests, redirects, totalRequests, elapsedMs } = provenance.network;
|
|
249
|
+
lines.push("", `Outbound: **${searchRequests}** search request(s), **${documentRequests}** page fetch(es), ` +
|
|
250
|
+
`**${redirects}** redirect(s) — **${totalRequests}** HTTP request(s) across ` +
|
|
251
|
+
`${(elapsedMs / 1000).toFixed(1)}s of call time.`);
|
|
252
|
+
}
|
|
253
|
+
for (const rejection of provenance.rejections ?? []) {
|
|
254
|
+
lines.push(`- ⚠️ ${rejection.count} \`${escapeMarkdownLabel(rejection.tool)}\` call(s) rejected before execution (${escapeMarkdownLabel(rejection.reason)}).`);
|
|
255
|
+
}
|
|
605
256
|
for (const warning of provenance.warnings) {
|
|
606
257
|
lines.push(`- ⚠️ ${escapeMarkdownLabel(warning)}`);
|
|
607
258
|
}
|
|
@@ -622,16 +273,46 @@ export function mergeResearchSources(...groups) {
|
|
|
622
273
|
})
|
|
623
274
|
.slice(0, 5);
|
|
624
275
|
}
|
|
625
|
-
/**
|
|
276
|
+
/**
|
|
277
|
+
* Fragment-insensitive lookup key. Models frequently normalize a copied URL by
|
|
278
|
+
* dropping its `#fragment`; the page identity is unchanged, so a fragment
|
|
279
|
+
* mismatch must not silently discard an otherwise exact citation. The audited
|
|
280
|
+
* URL (never the model's variant) is always what gets restored.
|
|
281
|
+
*/
|
|
282
|
+
function citationKey(url) {
|
|
283
|
+
try {
|
|
284
|
+
const parsed = new URL(url);
|
|
285
|
+
parsed.hash = "";
|
|
286
|
+
return parsed.href;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return url;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Exact-URL map plus a fragment-insensitive fallback; first audited entry wins. */
|
|
293
|
+
function allowedSourceMaps(evidence) {
|
|
294
|
+
const exact = new Map();
|
|
295
|
+
const byKey = new Map();
|
|
296
|
+
for (const item of evidence) {
|
|
297
|
+
const canonical = { title: cleanEvidenceText(item.title, 240), url: item.url };
|
|
298
|
+
if (!exact.has(item.url))
|
|
299
|
+
exact.set(item.url, canonical);
|
|
300
|
+
const key = citationKey(item.url);
|
|
301
|
+
if (!byKey.has(key))
|
|
302
|
+
byKey.set(key, canonical);
|
|
303
|
+
}
|
|
304
|
+
return { exact, byKey };
|
|
305
|
+
}
|
|
306
|
+
function resolveClaimedSource(source, maps) {
|
|
307
|
+
return maps.exact.get(source.url) ?? maps.byKey.get(citationKey(source.url));
|
|
308
|
+
}
|
|
309
|
+
/** Keep only audited URLs from this review's MCP calls and restore canonical titles. */
|
|
626
310
|
export function groundResearchSources(findings, evidence) {
|
|
627
|
-
const
|
|
628
|
-
item.url,
|
|
629
|
-
{ title: cleanEvidenceText(item.title, 240), url: item.url },
|
|
630
|
-
]));
|
|
311
|
+
const maps = allowedSourceMaps(evidence);
|
|
631
312
|
return findings.map((finding) => {
|
|
632
313
|
const { sources: claimed, ...withoutSources } = finding;
|
|
633
314
|
const sources = mergeResearchSources(claimed?.flatMap((source) => {
|
|
634
|
-
const canonical =
|
|
315
|
+
const canonical = resolveClaimedSource(source, maps);
|
|
635
316
|
return canonical ? [canonical] : [];
|
|
636
317
|
}));
|
|
637
318
|
return sources.length > 0 ? { ...withoutSources, sources } : withoutSources;
|
|
@@ -642,13 +323,10 @@ export function groundResearchSources(findings, evidence) {
|
|
|
642
323
|
* An ungrounded declaration is discarded so model output cannot inflate usefulness.
|
|
643
324
|
*/
|
|
644
325
|
export function groundResearchDecisions(decisions, evidence, agent) {
|
|
645
|
-
const
|
|
646
|
-
item.url,
|
|
647
|
-
{ title: cleanEvidenceText(item.title, 240), url: item.url },
|
|
648
|
-
]));
|
|
326
|
+
const maps = allowedSourceMaps(evidence);
|
|
649
327
|
return decisions.flatMap((decision) => {
|
|
650
328
|
const sources = mergeResearchSources(decision.sources.flatMap((source) => {
|
|
651
|
-
const canonical =
|
|
329
|
+
const canonical = resolveClaimedSource(source, maps);
|
|
652
330
|
return canonical ? [canonical] : [];
|
|
653
331
|
}));
|
|
654
332
|
if (sources.length === 0)
|
|
@@ -682,6 +360,17 @@ export function boundResearchDecisions(decisions) {
|
|
|
682
360
|
}
|
|
683
361
|
return { decisions: kept, omitted };
|
|
684
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* Heuristic for external-behavior assertions: an OS-version or API-level claim,
|
|
365
|
+
* or documented-lifecycle wording. Deliberately narrow — it flags the claim
|
|
366
|
+
* shapes that most need documentation, not every mention of a platform.
|
|
367
|
+
*/
|
|
368
|
+
const EXTERNAL_CLAIM_PATTERN = /\b(?:iOS|iPadOS|macOS|watchOS|tvOS|visionOS|Android)\s?\d+(?:\.\d+)?\b|\bAPI level\s?\d+\b|\b(?:deprecated|introduced|available|removed)\s+(?:in|since|from|as of)\b|\brequires\s+(?:iOS|iPadOS|macOS|watchOS|tvOS|visionOS|Android|API level|SDK)\b/i;
|
|
369
|
+
/** Final findings asserting external platform behavior with no grounded citation. */
|
|
370
|
+
export function countUngroundedExternalClaims(findings) {
|
|
371
|
+
return findings.filter((finding) => !finding.sources?.length &&
|
|
372
|
+
EXTERNAL_CLAIM_PATTERN.test(`${finding.title}\n${finding.rationale}`)).length;
|
|
373
|
+
}
|
|
685
374
|
/** Count unique audited results that materially affected the final review. */
|
|
686
375
|
export function summarizeResearchUsefulness(provenance, findings) {
|
|
687
376
|
const resultUrls = new Set(provenance.results.map((result) => result.url));
|
|
@@ -698,15 +387,19 @@ export function summarizeResearchUsefulness(provenance, findings) {
|
|
|
698
387
|
decisionResultCount: decisionUrls.size,
|
|
699
388
|
utilizedResultCount: utilizedUrls.size,
|
|
700
389
|
unusedResultCount: Math.max(0, resultUrls.size - utilizedUrls.size),
|
|
390
|
+
externalClaimFindingsWithoutSources: countUngroundedExternalClaims(findings),
|
|
701
391
|
};
|
|
702
392
|
}
|
|
703
393
|
export function formatResearchUsefulness(usefulness) {
|
|
394
|
+
const ungrounded = usefulness.externalClaimFindingsWithoutSources > 0
|
|
395
|
+
? `; ${usefulness.externalClaimFindingsWithoutSources} finding(s) assert external platform behavior without a citation`
|
|
396
|
+
: "";
|
|
704
397
|
return (` research usefulness: ${usefulness.finalFindingsWithSources} final finding(s) cited ` +
|
|
705
398
|
`${usefulness.citedResultCount} unique result(s); ` +
|
|
706
399
|
`${usefulness.supportedFindingCandidates} supported and ` +
|
|
707
400
|
`${usefulness.dismissedCandidates} dismissed candidate(s); ` +
|
|
708
401
|
`${usefulness.utilizedResultCount} result(s) materially used, ` +
|
|
709
|
-
`${usefulness.unusedResultCount} unused`);
|
|
402
|
+
`${usefulness.unusedResultCount} unused${ungrounded}`);
|
|
710
403
|
}
|
|
711
404
|
export function renderResearchUsefulnessMarkdown(provenance) {
|
|
712
405
|
const usefulness = provenance.usefulness;
|
|
@@ -721,6 +414,9 @@ export function renderResearchUsefulnessMarkdown(provenance) {
|
|
|
721
414
|
`- Candidate decisions: **${usefulness.supportedFindingCandidates} supported**, **${usefulness.dismissedCandidates} dismissed**`,
|
|
722
415
|
`- Unique results materially used: **${usefulness.utilizedResultCount}/${totalUniqueResults}**`,
|
|
723
416
|
];
|
|
417
|
+
if (usefulness.externalClaimFindingsWithoutSources > 0) {
|
|
418
|
+
lines.push(`- ⚠️ Findings asserting external platform behavior without a citation: **${usefulness.externalClaimFindingsWithoutSources}**`);
|
|
419
|
+
}
|
|
724
420
|
if (provenance.decisions?.length) {
|
|
725
421
|
lines.push("", "Grounded candidate decisions:");
|
|
726
422
|
for (const decision of provenance.decisions) {
|
|
@@ -732,93 +428,3 @@ export function renderResearchUsefulnessMarkdown(provenance) {
|
|
|
732
428
|
}
|
|
733
429
|
return lines.join("\n");
|
|
734
430
|
}
|
|
735
|
-
export async function collectPlatformResearch(files, config) {
|
|
736
|
-
const queries = deriveResearchQueries(files, config.maxQueries);
|
|
737
|
-
if (!config.enabled || queries.length === 0) {
|
|
738
|
-
return { queries, evidence: [], warnings: [], promptText: "" };
|
|
739
|
-
}
|
|
740
|
-
const calls = queries.map((query, index) => ({
|
|
741
|
-
jsonrpc: "2.0",
|
|
742
|
-
id: index + 2,
|
|
743
|
-
method: "tools/call",
|
|
744
|
-
params: {
|
|
745
|
-
name: "search_platform_docs",
|
|
746
|
-
arguments: {
|
|
747
|
-
platform: query.platform,
|
|
748
|
-
providers: query.providers,
|
|
749
|
-
query: query.query,
|
|
750
|
-
limit: config.resultsPerQuery,
|
|
751
|
-
},
|
|
752
|
-
},
|
|
753
|
-
}));
|
|
754
|
-
const messages = [
|
|
755
|
-
{
|
|
756
|
-
jsonrpc: "2.0",
|
|
757
|
-
id: 1,
|
|
758
|
-
method: "initialize",
|
|
759
|
-
params: {
|
|
760
|
-
protocolVersion: "2025-06-18",
|
|
761
|
-
capabilities: {},
|
|
762
|
-
clientInfo: { name: "expo-code-review-cli", version: "0.0.0" },
|
|
763
|
-
},
|
|
764
|
-
},
|
|
765
|
-
{ jsonrpc: "2.0", method: "notifications/initialized", params: {} },
|
|
766
|
-
...calls,
|
|
767
|
-
];
|
|
768
|
-
const server = bundledResearchServer();
|
|
769
|
-
const serverArgs = [
|
|
770
|
-
...server.args,
|
|
771
|
-
"serve",
|
|
772
|
-
...(config.indexPath ? ["--index", config.indexPath] : []),
|
|
773
|
-
];
|
|
774
|
-
const result = await run(server.command, serverArgs, {
|
|
775
|
-
input: `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`,
|
|
776
|
-
cwd: tmpdir(),
|
|
777
|
-
env: researchChildEnvironment(),
|
|
778
|
-
timeout: config.timeoutMs,
|
|
779
|
-
maxBuffer: 2 * 1024 * 1024,
|
|
780
|
-
check: false,
|
|
781
|
-
});
|
|
782
|
-
if (result.timedOut)
|
|
783
|
-
throw new Error("platform research MCP timed out");
|
|
784
|
-
if (result.overflowed)
|
|
785
|
-
throw new Error("platform research MCP output exceeded 2 MB");
|
|
786
|
-
if (result.code !== 0) {
|
|
787
|
-
throw new Error(`platform research MCP exited ${result.code}: ${result.stderr.slice(0, 500)}`);
|
|
788
|
-
}
|
|
789
|
-
const responses = new Map();
|
|
790
|
-
for (const line of result.stdout.split("\n")) {
|
|
791
|
-
if (!line.trim())
|
|
792
|
-
continue;
|
|
793
|
-
const parsed = JSON.parse(line);
|
|
794
|
-
if (typeof parsed.id === "number") {
|
|
795
|
-
if (parsed.error)
|
|
796
|
-
throw new Error(`platform research MCP error for request ${parsed.id}`);
|
|
797
|
-
responses.set(parsed.id, parsed.result);
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
if (!responses.has(1))
|
|
801
|
-
throw new Error("platform research MCP did not initialize");
|
|
802
|
-
const evidence = [];
|
|
803
|
-
const warnings = [];
|
|
804
|
-
for (let index = 0; index < queries.length; index++) {
|
|
805
|
-
const query = queries[index];
|
|
806
|
-
const toolResult = ToolResultSchema.parse(responses.get(index + 2));
|
|
807
|
-
const text = toolResult.content.find((block) => block.type === "text")?.text;
|
|
808
|
-
if (!text)
|
|
809
|
-
continue;
|
|
810
|
-
const payload = SearchPayloadSchema.parse(JSON.parse(text));
|
|
811
|
-
warnings.push(...(payload.warnings ?? []).map((warning) => cleanEvidenceText(warning, 500)).filter(Boolean));
|
|
812
|
-
for (const item of payload.results.slice(0, config.resultsPerQuery)) {
|
|
813
|
-
if (!item.url.startsWith("https://") || !query.providers.includes(item.provider))
|
|
814
|
-
continue;
|
|
815
|
-
evidence.push({ query, ...item });
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
return {
|
|
819
|
-
queries,
|
|
820
|
-
evidence,
|
|
821
|
-
warnings: [...new Set(warnings)].slice(0, 10),
|
|
822
|
-
promptText: formatResearchEvidence(evidence),
|
|
823
|
-
};
|
|
824
|
-
}
|