@expo/code-review-cli 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -3
- package/build/cli.js +5 -0
- package/build/commands/ci.js +5 -1
- package/build/commands/post-review.js +147 -0
- package/build/commands/review.js +46 -14
- package/build/config/load.js +11 -0
- package/build/config/schema.js +34 -1
- package/build/core/deferred-review.js +119 -0
- package/build/core/prompts.js +30 -2
- package/build/core/render.js +4 -1
- package/build/core/research.js +498 -0
- package/build/core/review.js +22 -2
- package/build/research-mcp/apple-docc.js +122 -0
- package/build/research-mcp/cli.js +83 -0
- package/build/research-mcp/crawler.js +194 -0
- package/build/research-mcp/expo-algolia.js +95 -0
- package/build/research-mcp/html.js +132 -0
- package/build/research-mcp/markdown.js +32 -0
- package/build/research-mcp/paths.js +4 -0
- package/build/research-mcp/providers.js +322 -0
- package/build/research-mcp/response.js +24 -0
- package/build/research-mcp/search-index.js +131 -0
- package/build/research-mcp/server.js +118 -0
- package/build/research-mcp/types.js +28 -0
- package/build/research-mcp/youtrack.js +57 -0
- package/package.json +14 -3
- package/research/sources.json +283 -0
- package/templates/config.jsonc +16 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// @ref LLP 0013#query-and-prompt-boundary [implements] — derive identifiers only; validate, bound, and sanitize MCP evidence
|
|
2
|
+
// @ref LLP 0013#one-package-two-binaries [implements] — resolve the package-relative MCP entry instead of PATH/configured commands
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { run } from "./exec.js";
|
|
8
|
+
const APPLE_EXTENSIONS = /\.(?:swift|m|mm)$/i;
|
|
9
|
+
const ANDROID_EXTENSIONS = /\.(?:kt|java|gradle|gradle\.kts)$/i;
|
|
10
|
+
const REACT_NATIVE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
|
|
11
|
+
const QUERY_TOKEN = /[A-Za-z][A-Za-z0-9_.:-]{1,79}/g;
|
|
12
|
+
const MAX_ANALYZED_PATCH_LINE_LENGTH = 4096;
|
|
13
|
+
const TYPE_TOKEN = /\b[A-Z][A-Za-z0-9_]{2,}(?:\.[A-Za-z_][A-Za-z0-9_]*)*/g;
|
|
14
|
+
const MEMBER_TOKEN = /\.([a-z][A-Za-z0-9_]{3,})\s*(?:\(|\b)/g;
|
|
15
|
+
const ECOSYSTEM_CALL_TOKEN = /\b((?:create|enable|make|measure|run|schedule|scrollTo|use|with)[A-Z][A-Za-z0-9_]*)\s*\(/g;
|
|
16
|
+
const IGNORED_TYPES = new Set([
|
|
17
|
+
"Array",
|
|
18
|
+
"Bool",
|
|
19
|
+
"Boolean",
|
|
20
|
+
"Class",
|
|
21
|
+
"Data",
|
|
22
|
+
"Double",
|
|
23
|
+
"Error",
|
|
24
|
+
"Exception",
|
|
25
|
+
"Float",
|
|
26
|
+
"Int",
|
|
27
|
+
"Integer",
|
|
28
|
+
"List",
|
|
29
|
+
"Long",
|
|
30
|
+
"Map",
|
|
31
|
+
"Object",
|
|
32
|
+
"Promise",
|
|
33
|
+
"Set",
|
|
34
|
+
"String",
|
|
35
|
+
"URL",
|
|
36
|
+
"Unit",
|
|
37
|
+
]);
|
|
38
|
+
const IGNORED_MEMBERS = new Set([
|
|
39
|
+
"apply",
|
|
40
|
+
"build",
|
|
41
|
+
"copy",
|
|
42
|
+
"equals",
|
|
43
|
+
"filter",
|
|
44
|
+
"first",
|
|
45
|
+
"get",
|
|
46
|
+
"hashCode",
|
|
47
|
+
"invoke",
|
|
48
|
+
"last",
|
|
49
|
+
"let",
|
|
50
|
+
"map",
|
|
51
|
+
"remove",
|
|
52
|
+
"run",
|
|
53
|
+
"set",
|
|
54
|
+
"toString",
|
|
55
|
+
]);
|
|
56
|
+
function startsModuleSpecifier(code) {
|
|
57
|
+
return /(?:\b(?:from|import)\s*|\brequire\s*\(\s*)$/.test(code);
|
|
58
|
+
}
|
|
59
|
+
function analyzePatchLine(source, state, collectProviderSignals, providerSignals) {
|
|
60
|
+
let code = "";
|
|
61
|
+
const finishModuleSpecifier = () => {
|
|
62
|
+
const value = state.moduleSpecifier;
|
|
63
|
+
if (value && /^[A-Za-z0-9@._/+~-]{1,200}$/.test(value))
|
|
64
|
+
providerSignals.push(value);
|
|
65
|
+
state.moduleSpecifier = null;
|
|
66
|
+
};
|
|
67
|
+
for (let index = 0; index < source.length;) {
|
|
68
|
+
if (state.mode === "block-comment") {
|
|
69
|
+
if (source.startsWith("*/", index)) {
|
|
70
|
+
state.mode = "code";
|
|
71
|
+
code += " ";
|
|
72
|
+
index += 2;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
code += " ";
|
|
76
|
+
index++;
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (state.mode === "triple-single-quote" || state.mode === "triple-double-quote") {
|
|
81
|
+
const delimiter = state.mode === "triple-single-quote" ? "'''" : '"""';
|
|
82
|
+
if (source.startsWith(delimiter, index)) {
|
|
83
|
+
state.mode = "code";
|
|
84
|
+
code += " ";
|
|
85
|
+
index += 3;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
code += " ";
|
|
89
|
+
index++;
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (state.mode === "single-quote" ||
|
|
94
|
+
state.mode === "double-quote" ||
|
|
95
|
+
state.mode === "template") {
|
|
96
|
+
const delimiter = state.mode === "single-quote" ? "'" : state.mode === "double-quote" ? '"' : "`";
|
|
97
|
+
const character = source[index];
|
|
98
|
+
if (state.escaped) {
|
|
99
|
+
if (state.moduleSpecifier !== null)
|
|
100
|
+
state.moduleSpecifier += character;
|
|
101
|
+
state.escaped = false;
|
|
102
|
+
code += " ";
|
|
103
|
+
index++;
|
|
104
|
+
}
|
|
105
|
+
else if (character === "\\") {
|
|
106
|
+
state.escaped = true;
|
|
107
|
+
code += " ";
|
|
108
|
+
index++;
|
|
109
|
+
}
|
|
110
|
+
else if (character === delimiter) {
|
|
111
|
+
finishModuleSpecifier();
|
|
112
|
+
state.mode = "code";
|
|
113
|
+
code += " ";
|
|
114
|
+
index++;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
if (state.moduleSpecifier !== null)
|
|
118
|
+
state.moduleSpecifier += character;
|
|
119
|
+
code += " ";
|
|
120
|
+
index++;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (source.startsWith("//", index)) {
|
|
125
|
+
code += " ".repeat(source.length - index);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (source.startsWith("/*", index)) {
|
|
129
|
+
state.mode = "block-comment";
|
|
130
|
+
code += " ";
|
|
131
|
+
index += 2;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (source.startsWith("'''", index) || source.startsWith('"""', index)) {
|
|
135
|
+
state.mode = source.startsWith("'''", index) ? "triple-single-quote" : "triple-double-quote";
|
|
136
|
+
state.moduleSpecifier = null;
|
|
137
|
+
code += " ";
|
|
138
|
+
index += 3;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const character = source[index];
|
|
142
|
+
if (character === "'" || character === '"' || character === "`") {
|
|
143
|
+
state.mode =
|
|
144
|
+
character === "'" ? "single-quote" : character === '"' ? "double-quote" : "template";
|
|
145
|
+
state.escaped = false;
|
|
146
|
+
state.moduleSpecifier =
|
|
147
|
+
collectProviderSignals && character !== "`" && startsModuleSpecifier(code) ? "" : null;
|
|
148
|
+
code += " ";
|
|
149
|
+
index++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
code += character;
|
|
153
|
+
index++;
|
|
154
|
+
}
|
|
155
|
+
// JavaScript/TypeScript, Swift, and Kotlin single/double-quoted strings do not
|
|
156
|
+
// continue onto the next physical line unless the final character escapes the
|
|
157
|
+
// newline. Reset malformed prose-like quotes (notably JSX apostrophes) here so
|
|
158
|
+
// they cannot invert how later patch lines are classified. Templates, triple
|
|
159
|
+
// quotes, and block comments intentionally retain their multiline state.
|
|
160
|
+
const continuesQuotedLine = (state.mode === "single-quote" || state.mode === "double-quote") && state.escaped;
|
|
161
|
+
if ((state.mode === "single-quote" || state.mode === "double-quote") && !continuesQuotedLine) {
|
|
162
|
+
state.mode = "code";
|
|
163
|
+
state.moduleSpecifier = null;
|
|
164
|
+
}
|
|
165
|
+
state.escaped = false;
|
|
166
|
+
return code.trim();
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Analyze the resulting side of a unified diff while keeping lexical state across
|
|
170
|
+
* lines. Provider routing may retain only import/require module specifiers; those
|
|
171
|
+
* strings are kept separate and can never become outbound query text.
|
|
172
|
+
*/
|
|
173
|
+
function analyzeAddedPatch(patch) {
|
|
174
|
+
const codeLines = [];
|
|
175
|
+
const providerSignals = [];
|
|
176
|
+
const state = { mode: "code", escaped: false, moduleSpecifier: null };
|
|
177
|
+
for (const patchLine of patch.split("\n")) {
|
|
178
|
+
if (patchLine.startsWith("@@")) {
|
|
179
|
+
state.mode = "code";
|
|
180
|
+
state.escaped = false;
|
|
181
|
+
state.moduleSpecifier = null;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (patchLine.startsWith("+++") || patchLine.startsWith("---"))
|
|
185
|
+
continue;
|
|
186
|
+
const isAdded = patchLine.startsWith("+");
|
|
187
|
+
const isContext = patchLine.startsWith(" ");
|
|
188
|
+
if (!isAdded && !isContext)
|
|
189
|
+
continue;
|
|
190
|
+
const source = patchLine.slice(1);
|
|
191
|
+
if (source.length > MAX_ANALYZED_PATCH_LINE_LENGTH) {
|
|
192
|
+
// A quote startsModuleSpecifier check examines the accumulated line prefix.
|
|
193
|
+
// Stop this file before an attacker-controlled giant line can turn that
|
|
194
|
+
// bounded research prepass into quadratic work. Abandoning later lines also
|
|
195
|
+
// avoids guessing whether the skipped input opened a multiline literal.
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
const code = analyzePatchLine(source, state, isAdded, providerSignals);
|
|
199
|
+
if (isAdded && code)
|
|
200
|
+
codeLines.push(code);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
codeLines,
|
|
204
|
+
providerSignals: providerSignals.join("\n").slice(0, 256_000).toLowerCase(),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function normalizeQuery(parts) {
|
|
208
|
+
const tokens = parts.join(" ").match(QUERY_TOKEN) ?? [];
|
|
209
|
+
return [...new Set(tokens)].join(" ").slice(0, 120).trim();
|
|
210
|
+
}
|
|
211
|
+
function platformFor(file) {
|
|
212
|
+
const normalized = file.path.replace(/\\/g, "/");
|
|
213
|
+
if (APPLE_EXTENSIONS.test(normalized) || /(?:^|\/)ios(?:\/|$)/i.test(normalized)) {
|
|
214
|
+
return "apple";
|
|
215
|
+
}
|
|
216
|
+
if (ANDROID_EXTENSIONS.test(normalized) || /(?:^|\/)android(?:\/|$)/i.test(normalized)) {
|
|
217
|
+
return "android";
|
|
218
|
+
}
|
|
219
|
+
if (REACT_NATIVE_EXTENSIONS.test(normalized))
|
|
220
|
+
return "react-native";
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const REACT_NATIVE_PROVIDERS = new Set([
|
|
224
|
+
"expo",
|
|
225
|
+
"react-native",
|
|
226
|
+
"react-native-reanimated",
|
|
227
|
+
"react-native-gesture-handler",
|
|
228
|
+
"react-native-screens",
|
|
229
|
+
"react-native-worklets",
|
|
230
|
+
]);
|
|
231
|
+
function providersFor(file, code, signals) {
|
|
232
|
+
const path = file.path.toLowerCase();
|
|
233
|
+
const text = code.toLowerCase();
|
|
234
|
+
const providers = [];
|
|
235
|
+
const add = (provider, matches) => {
|
|
236
|
+
if (matches && !providers.includes(provider))
|
|
237
|
+
providers.push(provider);
|
|
238
|
+
};
|
|
239
|
+
add("react-native-reanimated", /react-native-reanimated/.test(signals) || path.includes("react-native-reanimated"));
|
|
240
|
+
add("react-native-gesture-handler", /react-native-gesture-handler/.test(signals) || path.includes("react-native-gesture-handler"));
|
|
241
|
+
add("react-native-screens", /react-native-screens/.test(signals) || path.includes("react-native-screens"));
|
|
242
|
+
add("react-native-worklets", /react-native-worklets/.test(signals) || path.includes("react-native-worklets"));
|
|
243
|
+
add("expo", /(?:^|\n)(?:expo|expo-[a-z0-9-]+|@expo\/[a-z0-9-]+)(?:\n|$)/.test(signals) ||
|
|
244
|
+
/(?:^|\/)packages\/expo(?:-[^/]+)?(?:\/|$)/.test(path));
|
|
245
|
+
add("react-native", /(?:^|\n)react-native(?:\/[^\n]+)?(?:\n|$)/.test(signals));
|
|
246
|
+
if (providers.length > 0)
|
|
247
|
+
return providers;
|
|
248
|
+
if (/androidx\.media3|mediasessionservice|\bexoplayer\b/.test(text))
|
|
249
|
+
return ["media3"];
|
|
250
|
+
if (/com\.bumptech\.glide|\bglide\b/.test(text))
|
|
251
|
+
return ["glide"];
|
|
252
|
+
if (/okhttp3|\bokhttpclient\b|\brequest\.builder\b/.test(text))
|
|
253
|
+
return ["okhttp"];
|
|
254
|
+
if (/kotlinx\.coroutines|\bcoroutinescope\b|\bmutable(?:state|shared)flow\b/.test(text)) {
|
|
255
|
+
return ["kotlin-coroutines"];
|
|
256
|
+
}
|
|
257
|
+
if (/\.gradle(?:\.kts)?$/.test(path) || /(?:^|\/)build\.gradle/.test(path)) {
|
|
258
|
+
return /com\.android|android\s*\{|compilesdk|targetsdk/.test(text)
|
|
259
|
+
? ["agp", "gradle"]
|
|
260
|
+
: ["gradle"];
|
|
261
|
+
}
|
|
262
|
+
const platform = platformFor(file);
|
|
263
|
+
if (platform === "apple")
|
|
264
|
+
return ["apple"];
|
|
265
|
+
if (platform === "android")
|
|
266
|
+
return ["android"];
|
|
267
|
+
return ["react-native"];
|
|
268
|
+
}
|
|
269
|
+
function lineQuery(line) {
|
|
270
|
+
const declared = line.match(/\b(?:class|struct|enum|interface|protocol)\s+([A-Z][A-Za-z0-9_]*)/)?.[1];
|
|
271
|
+
const types = [...line.matchAll(TYPE_TOKEN)]
|
|
272
|
+
.map((match) => match[0])
|
|
273
|
+
.filter((value) => value !== declared && !IGNORED_TYPES.has(value.split(".")[0]));
|
|
274
|
+
if (types.length === 0) {
|
|
275
|
+
const call = [...line.matchAll(ECOSYSTEM_CALL_TOKEN)][0]?.[1];
|
|
276
|
+
return call ? normalizeQuery([call]) : null;
|
|
277
|
+
}
|
|
278
|
+
const members = [...line.matchAll(MEMBER_TOKEN)]
|
|
279
|
+
.map((match) => match[1])
|
|
280
|
+
.filter((value) => !IGNORED_MEMBERS.has(value));
|
|
281
|
+
const primary = types[0];
|
|
282
|
+
const member = members.find((value) => !primary.toLowerCase().includes(value.toLowerCase()));
|
|
283
|
+
return normalizeQuery(member ? [primary, member] : [primary]);
|
|
284
|
+
}
|
|
285
|
+
function addQuery(target, seen, platform, providers, query) {
|
|
286
|
+
const normalized = normalizeQuery([query]);
|
|
287
|
+
if (!normalized)
|
|
288
|
+
return;
|
|
289
|
+
const key = `${platform}|${providers.join(",")}|${normalized.toLowerCase()}`;
|
|
290
|
+
if (seen.has(key))
|
|
291
|
+
return;
|
|
292
|
+
seen.add(key);
|
|
293
|
+
target.push({ platform, providers, query: normalized });
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Derive bounded documentation searches from code identifiers only. String literals,
|
|
297
|
+
* comments, removed lines, paths, and raw source snippets never become query text.
|
|
298
|
+
*/
|
|
299
|
+
export function deriveResearchQueries(files, maxQueries = 8) {
|
|
300
|
+
const queries = [];
|
|
301
|
+
const seen = new Set();
|
|
302
|
+
for (const file of files) {
|
|
303
|
+
const platform = platformFor(file);
|
|
304
|
+
if (!platform)
|
|
305
|
+
continue;
|
|
306
|
+
const { codeLines: lines, providerSignals } = analyzeAddedPatch(file.patch);
|
|
307
|
+
const code = lines.join("\n").slice(0, 256_000);
|
|
308
|
+
const providers = providersFor(file, code, providerSignals);
|
|
309
|
+
for (const provider of providers) {
|
|
310
|
+
let addedForProvider = 0;
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
const query = lineQuery(line);
|
|
313
|
+
if (!query)
|
|
314
|
+
continue;
|
|
315
|
+
const before = queries.length;
|
|
316
|
+
addQuery(queries, seen, REACT_NATIVE_PROVIDERS.has(provider) ? "react-native" : platform, [provider], query);
|
|
317
|
+
if (queries.length > before && ++addedForProvider >= 2)
|
|
318
|
+
break;
|
|
319
|
+
if (queries.length >= maxQueries)
|
|
320
|
+
return queries;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (platform === "apple" &&
|
|
324
|
+
/\b(?:actor|MainActor|Sendable|TaskGroup|Task\.sleep)\b/.test(code)) {
|
|
325
|
+
const concept = code.match(/\b(?:MainActor|Sendable|TaskGroup|actor|Task\.sleep)\b/)?.[0];
|
|
326
|
+
if (concept)
|
|
327
|
+
addQuery(queries, seen, "apple", ["swift-evolution"], concept);
|
|
328
|
+
}
|
|
329
|
+
if (platform === "android" && /\b(?:VERSION_CODES|SDK_INT|targetSdk|compileSdk)\b/.test(code)) {
|
|
330
|
+
const api = code.match(/\b(?:VERSION_CODES(?:\.[A-Z_]+)?|SDK_INT|targetSdk|compileSdk)\b/)?.[0];
|
|
331
|
+
if (api)
|
|
332
|
+
addQuery(queries, seen, "android", ["android-releases"], api);
|
|
333
|
+
}
|
|
334
|
+
if (queries.length >= maxQueries)
|
|
335
|
+
return queries.slice(0, maxQueries);
|
|
336
|
+
}
|
|
337
|
+
return queries.slice(0, maxQueries);
|
|
338
|
+
}
|
|
339
|
+
const ToolResultSchema = z.object({
|
|
340
|
+
content: z.array(z.object({
|
|
341
|
+
type: z.string(),
|
|
342
|
+
text: z.string().optional(),
|
|
343
|
+
})),
|
|
344
|
+
});
|
|
345
|
+
const SearchPayloadSchema = z.object({
|
|
346
|
+
warnings: z.array(z.string().max(500)).max(10).optional(),
|
|
347
|
+
results: z.array(z.object({
|
|
348
|
+
provider: z.string(),
|
|
349
|
+
sourceKind: z.string(),
|
|
350
|
+
title: z.string(),
|
|
351
|
+
url: z.string().url(),
|
|
352
|
+
passage: z.string(),
|
|
353
|
+
availability: z.array(z.string()).optional(),
|
|
354
|
+
})),
|
|
355
|
+
});
|
|
356
|
+
const RESEARCH_PROXY_ENV_KEYS = [
|
|
357
|
+
"HTTP_PROXY",
|
|
358
|
+
"HTTPS_PROXY",
|
|
359
|
+
"NO_PROXY",
|
|
360
|
+
"http_proxy",
|
|
361
|
+
"https_proxy",
|
|
362
|
+
"no_proxy",
|
|
363
|
+
];
|
|
364
|
+
export function researchChildEnvironment(source = process.env) {
|
|
365
|
+
const environment = {
|
|
366
|
+
LANG: "C.UTF-8",
|
|
367
|
+
LC_ALL: "C.UTF-8",
|
|
368
|
+
...(process.platform === "win32" && source.SystemRoot ? { SystemRoot: source.SystemRoot } : {}),
|
|
369
|
+
};
|
|
370
|
+
for (const key of RESEARCH_PROXY_ENV_KEYS) {
|
|
371
|
+
if (source[key])
|
|
372
|
+
environment[key] = source[key];
|
|
373
|
+
}
|
|
374
|
+
return environment;
|
|
375
|
+
}
|
|
376
|
+
function bundledResearchServer() {
|
|
377
|
+
const builtEntry = fileURLToPath(new URL("../research-mcp/cli.js", import.meta.url));
|
|
378
|
+
const sourceEntry = fileURLToPath(new URL("../research-mcp/cli.ts", import.meta.url));
|
|
379
|
+
return {
|
|
380
|
+
command: process.execPath,
|
|
381
|
+
args: [existsSync(builtEntry) ? builtEntry : sourceEntry],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function cleanEvidenceText(value, maxLength) {
|
|
385
|
+
return (value
|
|
386
|
+
.replace(/^\s*-{3,}\s*(?:BEGIN|END)\s+PLATFORM RESEARCH.*$/gim, "")
|
|
387
|
+
.replace(/`{3,}/g, "'''")
|
|
388
|
+
.replace(/<\/?\s*(?:system|user|assistant|instructions?|prompt|tool)[^>]*>/gi, "")
|
|
389
|
+
// oxlint-disable-next-line no-control-regex -- intentional prompt-data sanitization
|
|
390
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
|
|
391
|
+
.slice(0, maxLength)
|
|
392
|
+
.trim());
|
|
393
|
+
}
|
|
394
|
+
export function formatResearchEvidence(evidence) {
|
|
395
|
+
if (evidence.length === 0)
|
|
396
|
+
return "";
|
|
397
|
+
const body = evidence
|
|
398
|
+
.map((item) => {
|
|
399
|
+
const availability = item.availability?.length
|
|
400
|
+
? `\nAvailability: ${cleanEvidenceText(item.availability.join(", "), 500)}`
|
|
401
|
+
: "";
|
|
402
|
+
return [
|
|
403
|
+
`Query: ${cleanEvidenceText(item.query.query, 120)}`,
|
|
404
|
+
`Provider: ${cleanEvidenceText(item.provider, 80)} (${cleanEvidenceText(item.sourceKind, 80)})`,
|
|
405
|
+
`Source: ${cleanEvidenceText(item.title, 240)} — ${item.url}${availability}`,
|
|
406
|
+
"Passage:",
|
|
407
|
+
cleanEvidenceText(item.passage, 1200),
|
|
408
|
+
].join("\n");
|
|
409
|
+
})
|
|
410
|
+
.join("\n\n");
|
|
411
|
+
return cleanEvidenceText(body, 16_000);
|
|
412
|
+
}
|
|
413
|
+
export async function collectPlatformResearch(files, config) {
|
|
414
|
+
const queries = deriveResearchQueries(files, config.maxQueries);
|
|
415
|
+
if (!config.enabled || !config.indexPath || queries.length === 0) {
|
|
416
|
+
return { queries, evidence: [], warnings: [], promptText: "" };
|
|
417
|
+
}
|
|
418
|
+
const calls = queries.map((query, index) => ({
|
|
419
|
+
jsonrpc: "2.0",
|
|
420
|
+
id: index + 2,
|
|
421
|
+
method: "tools/call",
|
|
422
|
+
params: {
|
|
423
|
+
name: "search_platform_docs",
|
|
424
|
+
arguments: {
|
|
425
|
+
platform: query.platform,
|
|
426
|
+
providers: query.providers,
|
|
427
|
+
query: query.query,
|
|
428
|
+
limit: config.resultsPerQuery,
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
}));
|
|
432
|
+
const messages = [
|
|
433
|
+
{
|
|
434
|
+
jsonrpc: "2.0",
|
|
435
|
+
id: 1,
|
|
436
|
+
method: "initialize",
|
|
437
|
+
params: {
|
|
438
|
+
protocolVersion: "2025-06-18",
|
|
439
|
+
capabilities: {},
|
|
440
|
+
clientInfo: { name: "expo-code-review-cli", version: "0.0.0" },
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
{ jsonrpc: "2.0", method: "notifications/initialized", params: {} },
|
|
444
|
+
...calls,
|
|
445
|
+
];
|
|
446
|
+
const server = bundledResearchServer();
|
|
447
|
+
const serverArgs = [...server.args, "serve", "--index", config.indexPath];
|
|
448
|
+
const result = await run(server.command, serverArgs, {
|
|
449
|
+
input: `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`,
|
|
450
|
+
cwd: tmpdir(),
|
|
451
|
+
env: researchChildEnvironment(),
|
|
452
|
+
timeout: config.timeoutMs,
|
|
453
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
454
|
+
check: false,
|
|
455
|
+
});
|
|
456
|
+
if (result.timedOut)
|
|
457
|
+
throw new Error("platform research MCP timed out");
|
|
458
|
+
if (result.overflowed)
|
|
459
|
+
throw new Error("platform research MCP output exceeded 2 MB");
|
|
460
|
+
if (result.code !== 0) {
|
|
461
|
+
throw new Error(`platform research MCP exited ${result.code}: ${result.stderr.slice(0, 500)}`);
|
|
462
|
+
}
|
|
463
|
+
const responses = new Map();
|
|
464
|
+
for (const line of result.stdout.split("\n")) {
|
|
465
|
+
if (!line.trim())
|
|
466
|
+
continue;
|
|
467
|
+
const parsed = JSON.parse(line);
|
|
468
|
+
if (typeof parsed.id === "number") {
|
|
469
|
+
if (parsed.error)
|
|
470
|
+
throw new Error(`platform research MCP error for request ${parsed.id}`);
|
|
471
|
+
responses.set(parsed.id, parsed.result);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (!responses.has(1))
|
|
475
|
+
throw new Error("platform research MCP did not initialize");
|
|
476
|
+
const evidence = [];
|
|
477
|
+
const warnings = [];
|
|
478
|
+
for (let index = 0; index < queries.length; index++) {
|
|
479
|
+
const query = queries[index];
|
|
480
|
+
const toolResult = ToolResultSchema.parse(responses.get(index + 2));
|
|
481
|
+
const text = toolResult.content.find((block) => block.type === "text")?.text;
|
|
482
|
+
if (!text)
|
|
483
|
+
continue;
|
|
484
|
+
const payload = SearchPayloadSchema.parse(JSON.parse(text));
|
|
485
|
+
warnings.push(...(payload.warnings ?? []).map((warning) => cleanEvidenceText(warning, 500)).filter(Boolean));
|
|
486
|
+
for (const item of payload.results.slice(0, config.resultsPerQuery)) {
|
|
487
|
+
if (!item.url.startsWith("https://") || !query.providers.includes(item.provider))
|
|
488
|
+
continue;
|
|
489
|
+
evidence.push({ query, ...item });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
queries,
|
|
494
|
+
evidence,
|
|
495
|
+
warnings: [...new Set(warnings)].slice(0, 10),
|
|
496
|
+
promptText: formatResearchEvidence(evidence),
|
|
497
|
+
};
|
|
498
|
+
}
|
package/build/core/review.js
CHANGED
|
@@ -18,6 +18,7 @@ import { errorMessage, sleep } from "./util.js";
|
|
|
18
18
|
import { reviewSetupRefNotes } from "./config-refs.js";
|
|
19
19
|
import { verifyFindings } from "./verify.js";
|
|
20
20
|
import { applyInlineIgnores } from "./suppress.js";
|
|
21
|
+
import { collectPlatformResearch } from "./research.js";
|
|
21
22
|
/**
|
|
22
23
|
* Filter changed files down to an explicit include set (exact-path membership, not
|
|
23
24
|
* globs — scope assignment already happened in resolveScopes). With no include set,
|
|
@@ -132,6 +133,25 @@ export async function runReview(source, options) {
|
|
|
132
133
|
});
|
|
133
134
|
return output;
|
|
134
135
|
}
|
|
136
|
+
let researchText = "";
|
|
137
|
+
if (config.research.enabled) {
|
|
138
|
+
progress("Researching platform documentation from changed API identifiers…");
|
|
139
|
+
try {
|
|
140
|
+
const research = await collectPlatformResearch(kept, config.research);
|
|
141
|
+
researchText = research.promptText;
|
|
142
|
+
progress(research.queries.length === 0
|
|
143
|
+
? " research: no native platform identifiers found"
|
|
144
|
+
: ` research: ${research.evidence.length} passage(s) from ${research.queries.length} bounded query(s)`);
|
|
145
|
+
for (const warning of research.warnings) {
|
|
146
|
+
progress(` research warning: ${warning}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
// Documentation is supporting evidence, not a prerequisite for reviewing the
|
|
151
|
+
// code. Fail open with a visible diagnostic; never weaken or skip the review.
|
|
152
|
+
progress(` research unavailable; continuing without it (${errorMessage(error)})`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
135
155
|
// Materialize the PR-head tree (not the current checkout) when the source can, so
|
|
136
156
|
// the agents' surrounding-source reads and the verifier's re-reads see the versions
|
|
137
157
|
// that match the diff. Config is already fully loaded in memory, so the chdir below
|
|
@@ -460,8 +480,8 @@ export async function runReview(source, options) {
|
|
|
460
480
|
// smaller file set); a fallback task forbids tools and reviews the inlined diff.
|
|
461
481
|
const buildTaskText = (task) => {
|
|
462
482
|
const base = task.kind === "cross-cutting"
|
|
463
|
-
? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText)
|
|
464
|
-
: buildReviewerTask(task.files, workspace.files, filtered, options.contextText);
|
|
483
|
+
? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText, researchText)
|
|
484
|
+
: buildReviewerTask(task.files, workspace.files, filtered, options.contextText, researchText);
|
|
465
485
|
return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
|
|
466
486
|
};
|
|
467
487
|
const filesLabel = (files) => files.length === 1
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
function objectValue(value) {
|
|
2
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
3
|
+
? value
|
|
4
|
+
: null;
|
|
5
|
+
}
|
|
6
|
+
function inlineText(value) {
|
|
7
|
+
if (!Array.isArray(value))
|
|
8
|
+
return "";
|
|
9
|
+
return value
|
|
10
|
+
.map((item) => {
|
|
11
|
+
const object = objectValue(item);
|
|
12
|
+
return object && typeof object.text === "string" ? object.text : "";
|
|
13
|
+
})
|
|
14
|
+
.join("")
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
const readableKeys = new Set(["text", "code", "title", "name"]);
|
|
18
|
+
const skippedKeys = new Set([
|
|
19
|
+
"anchor",
|
|
20
|
+
"checksum",
|
|
21
|
+
"identifier",
|
|
22
|
+
"identifiers",
|
|
23
|
+
"images",
|
|
24
|
+
"kind",
|
|
25
|
+
"role",
|
|
26
|
+
"type",
|
|
27
|
+
"url",
|
|
28
|
+
]);
|
|
29
|
+
function collectReadableText(value, output, key) {
|
|
30
|
+
if (typeof value === "string") {
|
|
31
|
+
if (key && readableKeys.has(key))
|
|
32
|
+
output.push(value);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
for (const item of value)
|
|
37
|
+
collectReadableText(item, output, key);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const object = objectValue(value);
|
|
41
|
+
if (!object)
|
|
42
|
+
return;
|
|
43
|
+
for (const [childKey, child] of Object.entries(object)) {
|
|
44
|
+
if (!skippedKeys.has(childKey))
|
|
45
|
+
collectReadableText(child, output, childKey);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function cleanLines(lines) {
|
|
49
|
+
const output = [];
|
|
50
|
+
let previous = "";
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const cleaned = line.replace(/\s+/g, " ").trim();
|
|
53
|
+
if (cleaned && cleaned !== previous) {
|
|
54
|
+
output.push(cleaned);
|
|
55
|
+
previous = cleaned;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return output.join("\n\n");
|
|
59
|
+
}
|
|
60
|
+
function languageFromIdentifier(identifier) {
|
|
61
|
+
const language = identifier?.interfaceLanguage;
|
|
62
|
+
if (language === "swift")
|
|
63
|
+
return "swift";
|
|
64
|
+
if (language === "occ" || language === "objective-c")
|
|
65
|
+
return "objective-c";
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export function extractAppleDocCPage(json, url, source = {}) {
|
|
69
|
+
const root = objectValue(JSON.parse(json));
|
|
70
|
+
const metadata = objectValue(root?.metadata);
|
|
71
|
+
const identifier = objectValue(root?.identifier);
|
|
72
|
+
const title = typeof metadata?.title === "string" ? metadata.title.trim() : "";
|
|
73
|
+
if (!root || !metadata || !title)
|
|
74
|
+
return null;
|
|
75
|
+
const text = [];
|
|
76
|
+
const abstract = inlineText(root.abstract);
|
|
77
|
+
if (abstract)
|
|
78
|
+
text.push(abstract);
|
|
79
|
+
collectReadableText(root.primaryContentSections, text);
|
|
80
|
+
collectReadableText(root.relationshipsSections, text);
|
|
81
|
+
const links = new Set();
|
|
82
|
+
const references = objectValue(root.references);
|
|
83
|
+
for (const value of Object.values(references ?? {})) {
|
|
84
|
+
const reference = objectValue(value);
|
|
85
|
+
if (!reference || typeof reference.url !== "string")
|
|
86
|
+
continue;
|
|
87
|
+
if (reference.url.startsWith("/documentation/")) {
|
|
88
|
+
links.add(reference.url);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const body = cleanLines(text);
|
|
92
|
+
if (body.length < 40)
|
|
93
|
+
return null;
|
|
94
|
+
const platforms = Array.isArray(metadata.platforms) ? metadata.platforms : [];
|
|
95
|
+
const availability = platforms.flatMap((item) => {
|
|
96
|
+
const platform = objectValue(item);
|
|
97
|
+
if (!platform || typeof platform.name !== "string")
|
|
98
|
+
return [];
|
|
99
|
+
const introduced = typeof platform.introducedAt === "string" ? ` ${platform.introducedAt}` : "";
|
|
100
|
+
const deprecated = typeof platform.deprecatedAt === "string" ? ` (deprecated ${platform.deprecatedAt})` : "";
|
|
101
|
+
return [`${platform.name}${introduced}${deprecated}`];
|
|
102
|
+
});
|
|
103
|
+
const modules = Array.isArray(metadata.modules) ? metadata.modules : [];
|
|
104
|
+
const firstModule = objectValue(modules[0]);
|
|
105
|
+
const isSymbol = metadata.role === "symbol" || typeof metadata.symbolKind === "string";
|
|
106
|
+
return {
|
|
107
|
+
document: {
|
|
108
|
+
platform: "apple",
|
|
109
|
+
...source,
|
|
110
|
+
title,
|
|
111
|
+
url,
|
|
112
|
+
body,
|
|
113
|
+
...(typeof firstModule?.name === "string" ? { framework: firstModule.name } : {}),
|
|
114
|
+
...(isSymbol ? { symbol: title } : {}),
|
|
115
|
+
...(languageFromIdentifier(identifier)
|
|
116
|
+
? { language: languageFromIdentifier(identifier) }
|
|
117
|
+
: {}),
|
|
118
|
+
...(availability.length > 0 ? { availability } : {}),
|
|
119
|
+
},
|
|
120
|
+
links: [...links],
|
|
121
|
+
};
|
|
122
|
+
}
|