@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.
@@ -1,4 +1,4 @@
1
- // @ref LLP 0013#query-and-prompt-boundary [implements] — derive identifiers only; validate, bound, and sanitize MCP evidence
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 { run } from "./exec.js";
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
- const APPLE_EXTENSIONS = /\.(?:swift|m|mm)$/i;
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
- * Derive bounded documentation searches from code identifiers only. String literals,
315
- * comments, removed lines, paths, and raw source snippets never become query text.
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/cli.js", import.meta.url));
401
- const sourceEntry = fileURLToPath(new URL("../research-mcp/cli.ts", import.meta.url));
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; the Brave credential is passed
410
- * directly to the bounded MCP child and never added to the model process env.
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
- return { provenance: toResearchProvenance(run), evidence };
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
- /** Keep only exact URLs returned by this review's MCP calls and restore canonical titles. */
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 allowed = new Map(evidence.map((item) => [
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 = allowed.get(source.url);
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 allowed = new Map(evidence.map((item) => [
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 = allowed.get(source.url);
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
- }