@expo/code-review-cli 0.12.2 → 0.12.4

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,364 +6,15 @@ 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";
12
10
  export { OPENCODE_RESEARCH_TOOLS } from "./tools.js";
11
+ export const RESEARCH_DECISION_COUNT_LIMIT = 16;
12
+ export const RESEARCH_DECISION_BYTES_LIMIT = 20_000;
13
13
  export const RESEARCH_MCP_SERVER_NAME = "platform_docs";
14
14
  export const CLAUDE_RESEARCH_TOOLS = [
15
15
  `mcp__${RESEARCH_MCP_SERVER_NAME}__search_platform_docs`,
16
16
  `mcp__${RESEARCH_MCP_SERVER_NAME}__fetch_platform_doc`,
17
17
  ];
18
- const APPLE_EXTENSIONS = /\.(?:swift|m|mm)$/i;
19
- const ANDROID_EXTENSIONS = /\.(?:kt|java|gradle|gradle\.kts)$/i;
20
- const REACT_NATIVE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
21
- const QUERY_TOKEN = /[A-Za-z][A-Za-z0-9_.:-]{1,79}/g;
22
- const MAX_ANALYZED_PATCH_LINE_LENGTH = 4096;
23
- const TYPE_TOKEN = /\b[A-Z][A-Za-z0-9_]{2,}(?:\.[A-Za-z_][A-Za-z0-9_]*)*/g;
24
- const MEMBER_TOKEN = /\.([a-z][A-Za-z0-9_]{3,})\s*(?:\(|\b)/g;
25
- const ECOSYSTEM_CALL_TOKEN = /\b((?:create|enable|make|measure|run|schedule|scrollTo|use|with)[A-Z][A-Za-z0-9_]*)\s*\(/g;
26
- const IGNORED_TYPES = new Set([
27
- "Array",
28
- "Bool",
29
- "Boolean",
30
- "Class",
31
- "Data",
32
- "Double",
33
- "Error",
34
- "Exception",
35
- "Float",
36
- "Int",
37
- "Integer",
38
- "List",
39
- "Long",
40
- "Map",
41
- "Object",
42
- "Promise",
43
- "Set",
44
- "String",
45
- "URL",
46
- "Unit",
47
- ]);
48
- const IGNORED_MEMBERS = new Set([
49
- "apply",
50
- "build",
51
- "copy",
52
- "equals",
53
- "filter",
54
- "first",
55
- "get",
56
- "hashCode",
57
- "invoke",
58
- "last",
59
- "let",
60
- "map",
61
- "remove",
62
- "run",
63
- "set",
64
- "toString",
65
- ]);
66
- function startsModuleSpecifier(code) {
67
- return /(?:\b(?:from|import)\s*|\brequire\s*\(\s*)$/.test(code);
68
- }
69
- function analyzePatchLine(source, state, collectProviderSignals, providerSignals) {
70
- let code = "";
71
- const finishModuleSpecifier = () => {
72
- const value = state.moduleSpecifier;
73
- if (value && /^[A-Za-z0-9@._/+~-]{1,200}$/.test(value))
74
- providerSignals.push(value);
75
- state.moduleSpecifier = null;
76
- };
77
- for (let index = 0; index < source.length;) {
78
- if (state.mode === "block-comment") {
79
- if (source.startsWith("*/", index)) {
80
- state.mode = "code";
81
- code += " ";
82
- index += 2;
83
- }
84
- else {
85
- code += " ";
86
- index++;
87
- }
88
- continue;
89
- }
90
- if (state.mode === "triple-single-quote" || state.mode === "triple-double-quote") {
91
- const delimiter = state.mode === "triple-single-quote" ? "'''" : '"""';
92
- if (source.startsWith(delimiter, index)) {
93
- state.mode = "code";
94
- code += " ";
95
- index += 3;
96
- }
97
- else {
98
- code += " ";
99
- index++;
100
- }
101
- continue;
102
- }
103
- if (state.mode === "single-quote" ||
104
- state.mode === "double-quote" ||
105
- state.mode === "template") {
106
- const delimiter = state.mode === "single-quote" ? "'" : state.mode === "double-quote" ? '"' : "`";
107
- const character = source[index];
108
- if (state.escaped) {
109
- if (state.moduleSpecifier !== null)
110
- state.moduleSpecifier += character;
111
- state.escaped = false;
112
- code += " ";
113
- index++;
114
- }
115
- else if (character === "\\") {
116
- state.escaped = true;
117
- code += " ";
118
- index++;
119
- }
120
- else if (character === delimiter) {
121
- finishModuleSpecifier();
122
- state.mode = "code";
123
- code += " ";
124
- index++;
125
- }
126
- else {
127
- if (state.moduleSpecifier !== null)
128
- state.moduleSpecifier += character;
129
- code += " ";
130
- index++;
131
- }
132
- continue;
133
- }
134
- if (source.startsWith("//", index)) {
135
- code += " ".repeat(source.length - index);
136
- break;
137
- }
138
- if (source.startsWith("/*", index)) {
139
- state.mode = "block-comment";
140
- code += " ";
141
- index += 2;
142
- continue;
143
- }
144
- if (source.startsWith("'''", index) || source.startsWith('"""', index)) {
145
- state.mode = source.startsWith("'''", index) ? "triple-single-quote" : "triple-double-quote";
146
- state.moduleSpecifier = null;
147
- code += " ";
148
- index += 3;
149
- continue;
150
- }
151
- const character = source[index];
152
- if (character === "'" || character === '"' || character === "`") {
153
- state.mode =
154
- character === "'" ? "single-quote" : character === '"' ? "double-quote" : "template";
155
- state.escaped = false;
156
- state.moduleSpecifier =
157
- collectProviderSignals && character !== "`" && startsModuleSpecifier(code) ? "" : null;
158
- code += " ";
159
- index++;
160
- continue;
161
- }
162
- code += character;
163
- index++;
164
- }
165
- // JavaScript/TypeScript, Swift, and Kotlin single/double-quoted strings do not
166
- // continue onto the next physical line unless the final character escapes the
167
- // newline. Reset malformed prose-like quotes (notably JSX apostrophes) here so
168
- // they cannot invert how later patch lines are classified. Templates, triple
169
- // quotes, and block comments intentionally retain their multiline state.
170
- const continuesQuotedLine = (state.mode === "single-quote" || state.mode === "double-quote") && state.escaped;
171
- if ((state.mode === "single-quote" || state.mode === "double-quote") && !continuesQuotedLine) {
172
- state.mode = "code";
173
- state.moduleSpecifier = null;
174
- }
175
- state.escaped = false;
176
- return code.trim();
177
- }
178
- /**
179
- * Analyze the resulting side of a unified diff while keeping lexical state across
180
- * lines. Provider routing may retain only import/require module specifiers; those
181
- * strings are kept separate and can never become outbound query text.
182
- */
183
- function analyzeAddedPatch(patch) {
184
- const codeLines = [];
185
- const providerSignals = [];
186
- const state = { mode: "code", escaped: false, moduleSpecifier: null };
187
- for (const patchLine of patch.split("\n")) {
188
- if (patchLine.startsWith("@@")) {
189
- state.mode = "code";
190
- state.escaped = false;
191
- state.moduleSpecifier = null;
192
- continue;
193
- }
194
- if (patchLine.startsWith("+++") || patchLine.startsWith("---"))
195
- continue;
196
- const isAdded = patchLine.startsWith("+");
197
- const isContext = patchLine.startsWith(" ");
198
- if (!isAdded && !isContext)
199
- continue;
200
- const source = patchLine.slice(1);
201
- if (source.length > MAX_ANALYZED_PATCH_LINE_LENGTH) {
202
- // A quote startsModuleSpecifier check examines the accumulated line prefix.
203
- // Stop this file before an attacker-controlled giant line can turn that
204
- // bounded research prepass into quadratic work. Abandoning later lines also
205
- // avoids guessing whether the skipped input opened a multiline literal.
206
- break;
207
- }
208
- const code = analyzePatchLine(source, state, isAdded, providerSignals);
209
- if (isAdded && code)
210
- codeLines.push(code);
211
- }
212
- return {
213
- codeLines,
214
- providerSignals: providerSignals.join("\n").slice(0, 256_000).toLowerCase(),
215
- };
216
- }
217
- function normalizeQuery(parts) {
218
- const tokens = parts.join(" ").match(QUERY_TOKEN) ?? [];
219
- return [...new Set(tokens)].join(" ").slice(0, 120).trim();
220
- }
221
- function platformFor(file) {
222
- const normalized = file.path.replace(/\\/g, "/");
223
- if (APPLE_EXTENSIONS.test(normalized) || /(?:^|\/)ios(?:\/|$)/i.test(normalized)) {
224
- return "apple";
225
- }
226
- if (ANDROID_EXTENSIONS.test(normalized) || /(?:^|\/)android(?:\/|$)/i.test(normalized)) {
227
- return "android";
228
- }
229
- if (REACT_NATIVE_EXTENSIONS.test(normalized))
230
- return "react-native";
231
- return null;
232
- }
233
- const REACT_NATIVE_PROVIDERS = new Set([
234
- "expo",
235
- "react-native",
236
- "react-native-reanimated",
237
- "react-native-gesture-handler",
238
- "react-native-screens",
239
- "react-native-worklets",
240
- ]);
241
- function providersFor(file, code, signals) {
242
- const path = file.path.toLowerCase();
243
- const text = code.toLowerCase();
244
- const providers = [];
245
- const add = (provider, matches) => {
246
- if (matches && !providers.includes(provider))
247
- providers.push(provider);
248
- };
249
- add("react-native-reanimated", /react-native-reanimated/.test(signals) || path.includes("react-native-reanimated"));
250
- add("react-native-gesture-handler", /react-native-gesture-handler/.test(signals) || path.includes("react-native-gesture-handler"));
251
- add("react-native-screens", /react-native-screens/.test(signals) || path.includes("react-native-screens"));
252
- add("react-native-worklets", /react-native-worklets/.test(signals) || path.includes("react-native-worklets"));
253
- add("expo", /(?:^|\n)(?:expo|expo-[a-z0-9-]+|@expo\/[a-z0-9-]+)(?:\n|$)/.test(signals) ||
254
- /(?:^|\/)packages\/expo(?:-[^/]+)?(?:\/|$)/.test(path));
255
- add("react-native", /(?:^|\n)react-native(?:\/[^\n]+)?(?:\n|$)/.test(signals));
256
- if (providers.length > 0)
257
- return providers;
258
- if (/androidx\.media3|mediasessionservice|\bexoplayer\b/.test(text))
259
- return ["media3"];
260
- if (/com\.bumptech\.glide|\bglide\b/.test(text))
261
- return ["glide"];
262
- if (/okhttp3|\bokhttpclient\b|\brequest\.builder\b/.test(text))
263
- return ["okhttp"];
264
- if (/kotlinx\.coroutines|\bcoroutinescope\b|\bmutable(?:state|shared)flow\b/.test(text)) {
265
- return ["kotlin-coroutines"];
266
- }
267
- if (/\.gradle(?:\.kts)?$/.test(path) || /(?:^|\/)build\.gradle/.test(path)) {
268
- return /com\.android|android\s*\{|compilesdk|targetsdk/.test(text)
269
- ? ["agp", "gradle"]
270
- : ["gradle"];
271
- }
272
- const platform = platformFor(file);
273
- if (platform === "apple")
274
- return ["apple"];
275
- if (platform === "android")
276
- return ["android"];
277
- return ["react-native"];
278
- }
279
- function lineQuery(line) {
280
- const declared = line.match(/\b(?:class|struct|enum|interface|protocol)\s+([A-Z][A-Za-z0-9_]*)/)?.[1];
281
- const types = [...line.matchAll(TYPE_TOKEN)]
282
- .map((match) => match[0])
283
- .filter((value) => value !== declared && !IGNORED_TYPES.has(value.split(".")[0]));
284
- if (types.length === 0) {
285
- const call = [...line.matchAll(ECOSYSTEM_CALL_TOKEN)][0]?.[1];
286
- return call ? normalizeQuery([call]) : null;
287
- }
288
- const members = [...line.matchAll(MEMBER_TOKEN)]
289
- .map((match) => match[1])
290
- .filter((value) => !IGNORED_MEMBERS.has(value));
291
- const primary = types[0];
292
- const member = members.find((value) => !primary.toLowerCase().includes(value.toLowerCase()));
293
- return normalizeQuery(member ? [primary, member] : [primary]);
294
- }
295
- function addQuery(target, seen, platform, providers, query) {
296
- const normalized = normalizeQuery([query]);
297
- if (!normalized)
298
- return;
299
- const key = `${platform}|${providers.join(",")}|${normalized.toLowerCase()}`;
300
- if (seen.has(key))
301
- return;
302
- seen.add(key);
303
- target.push({ platform, providers, query: normalized });
304
- }
305
- /**
306
- * Derive bounded documentation searches from code identifiers only. String literals,
307
- * comments, removed lines, paths, and raw source snippets never become query text.
308
- */
309
- export function deriveResearchQueries(files, maxQueries = 8) {
310
- const queries = [];
311
- const seen = new Set();
312
- for (const file of files) {
313
- const platform = platformFor(file);
314
- if (!platform)
315
- continue;
316
- const { codeLines: lines, providerSignals } = analyzeAddedPatch(file.patch);
317
- const code = lines.join("\n").slice(0, 256_000);
318
- const providers = providersFor(file, code, providerSignals);
319
- for (const provider of providers) {
320
- let addedForProvider = 0;
321
- for (const line of lines) {
322
- const query = lineQuery(line);
323
- if (!query)
324
- continue;
325
- const before = queries.length;
326
- addQuery(queries, seen, REACT_NATIVE_PROVIDERS.has(provider) ? "react-native" : platform, [provider], query);
327
- if (queries.length > before && ++addedForProvider >= 2)
328
- break;
329
- if (queries.length >= maxQueries)
330
- return queries;
331
- }
332
- }
333
- if (platform === "apple" &&
334
- /\b(?:actor|MainActor|Sendable|TaskGroup|Task\.sleep)\b/.test(code)) {
335
- const concept = code.match(/\b(?:MainActor|Sendable|TaskGroup|actor|Task\.sleep)\b/)?.[0];
336
- if (concept)
337
- addQuery(queries, seen, "apple", ["swift-evolution"], concept);
338
- }
339
- if (platform === "android" && /\b(?:VERSION_CODES|SDK_INT|targetSdk|compileSdk)\b/.test(code)) {
340
- const api = code.match(/\b(?:VERSION_CODES(?:\.[A-Z_]+)?|SDK_INT|targetSdk|compileSdk)\b/)?.[0];
341
- if (api)
342
- addQuery(queries, seen, "android", ["android-releases"], api);
343
- }
344
- if (queries.length >= maxQueries)
345
- return queries.slice(0, maxQueries);
346
- }
347
- return queries.slice(0, maxQueries);
348
- }
349
- const ToolResultSchema = z.object({
350
- content: z.array(z.object({
351
- type: z.string(),
352
- text: z.string().optional(),
353
- })),
354
- });
355
- const SearchPayloadSchema = z.object({
356
- warnings: z.array(z.string().max(500)).max(10).optional(),
357
- results: z.array(z.object({
358
- id: z.string().min(1).max(240).optional(),
359
- provider: z.string().min(1).max(80),
360
- sourceKind: z.string().min(1).max(80),
361
- title: z.string().min(1).max(500),
362
- url: z.string().url().max(2_000),
363
- passage: z.string().max(5_000),
364
- availability: z.array(z.string().max(240)).max(20).optional(),
365
- })),
366
- });
367
18
  const RESEARCH_PROXY_ENV_KEYS = [
368
19
  "HTTP_PROXY",
369
20
  "HTTPS_PROXY",
@@ -440,7 +91,7 @@ export async function createResearchMcpRuntime(config) {
440
91
  };
441
92
  }
442
93
  export async function researchProvenanceFromAudit(auditPath) {
443
- const records = await readResearchAudit(auditPath);
94
+ const { records, rejections } = await readResearchAudit(auditPath);
444
95
  const queries = [];
445
96
  const evidence = [];
446
97
  const warnings = [];
@@ -478,9 +129,22 @@ export async function researchProvenanceFromAudit(auditPath) {
478
129
  queries,
479
130
  evidence,
480
131
  warnings: [...new Set(warnings)].slice(0, 10),
481
- promptText: "",
482
132
  };
483
- return { provenance: toResearchProvenance(run), evidence };
133
+ const provenance = toResearchProvenance(run);
134
+ if (rejections.length > 0) {
135
+ const counts = new Map();
136
+ for (const rejection of rejections) {
137
+ const key = `${rejection.tool}\0${rejection.reason}`;
138
+ counts.set(key, (counts.get(key) ?? 0) + 1);
139
+ }
140
+ provenance.rejections = [...counts.entries()]
141
+ .map(([key, count]) => {
142
+ const [tool = "", reason = ""] = key.split("\0");
143
+ return { tool: cleanEvidenceText(tool, 80), reason: cleanEvidenceText(reason, 80), count };
144
+ })
145
+ .sort((left, right) => `${left.tool}${left.reason}`.localeCompare(`${right.tool}${right.reason}`));
146
+ }
147
+ return { provenance, evidence };
484
148
  }
485
149
  function cleanEvidenceText(value, maxLength) {
486
150
  return (value
@@ -492,25 +156,6 @@ function cleanEvidenceText(value, maxLength) {
492
156
  .slice(0, maxLength)
493
157
  .trim());
494
158
  }
495
- export function formatResearchEvidence(evidence) {
496
- if (evidence.length === 0)
497
- return "";
498
- const body = evidence
499
- .map((item) => {
500
- const availability = item.availability?.length
501
- ? `\nAvailability: ${cleanEvidenceText(item.availability.join(", "), 500)}`
502
- : "";
503
- return [
504
- `Query: ${cleanEvidenceText(item.query.query, 120)}`,
505
- `Provider: ${cleanEvidenceText(item.provider, 80)} (${cleanEvidenceText(item.sourceKind, 80)})`,
506
- `Source: ${cleanEvidenceText(item.title, 240)} — ${item.url}${availability}`,
507
- "Passage:",
508
- cleanEvidenceText(item.passage, 1200),
509
- ].join("\n");
510
- })
511
- .join("\n\n");
512
- return cleanEvidenceText(body, 16_000);
513
- }
514
159
  function researchQueryKey(query) {
515
160
  return `${query.platform}\0${query.providers.join(",")}\0${query.query}`;
516
161
  }
@@ -524,7 +169,7 @@ export function toResearchProvenance(run) {
524
169
  sourceKind: cleanEvidenceText(item.sourceKind, 80),
525
170
  title: cleanEvidenceText(item.title, 240),
526
171
  url: item.url,
527
- passage: cleanEvidenceText(item.passage, 1_200),
172
+ passage: cleanEvidenceText(item.passage, 20_000),
528
173
  ...(item.availability?.length
529
174
  ? { availability: item.availability.map((value) => cleanEvidenceText(value, 240)) }
530
175
  : {}),
@@ -554,6 +199,9 @@ export function formatResearchProgress(provenance) {
554
199
  lines.push(` result: ${cleanEvidenceText(result.title, 160)} (${result.provider}/${result.sourceKind}) — ${result.url}`);
555
200
  }
556
201
  }
202
+ for (const rejection of provenance.rejections ?? []) {
203
+ lines.push(` research: ${rejection.count} ${rejection.tool} call(s) rejected before execution (${rejection.reason})`);
204
+ }
557
205
  for (const warning of provenance.warnings) {
558
206
  lines.push(` research warning: ${cleanEvidenceText(warning, 500)}`);
559
207
  }
@@ -594,6 +242,9 @@ export function renderResearchMarkdown(provenance) {
594
242
  lines.push(` - [${escapeMarkdownLabel(result.title)}](<${result.url}>) — ${escapeMarkdownLabel(result.provider)}/${escapeMarkdownLabel(result.sourceKind)}`);
595
243
  }
596
244
  }
245
+ for (const rejection of provenance.rejections ?? []) {
246
+ lines.push(`- ⚠️ ${rejection.count} \`${escapeMarkdownLabel(rejection.tool)}\` call(s) rejected before execution (${escapeMarkdownLabel(rejection.reason)}).`);
247
+ }
597
248
  for (const warning of provenance.warnings) {
598
249
  lines.push(`- ⚠️ ${escapeMarkdownLabel(warning)}`);
599
250
  }
@@ -614,108 +265,158 @@ export function mergeResearchSources(...groups) {
614
265
  })
615
266
  .slice(0, 5);
616
267
  }
617
- /** Keep only exact URLs returned by the trusted research prepass and restore their canonical titles. */
268
+ /**
269
+ * Fragment-insensitive lookup key. Models frequently normalize a copied URL by
270
+ * dropping its `#fragment`; the page identity is unchanged, so a fragment
271
+ * mismatch must not silently discard an otherwise exact citation. The audited
272
+ * URL (never the model's variant) is always what gets restored.
273
+ */
274
+ function citationKey(url) {
275
+ try {
276
+ const parsed = new URL(url);
277
+ parsed.hash = "";
278
+ return parsed.href;
279
+ }
280
+ catch {
281
+ return url;
282
+ }
283
+ }
284
+ /** Exact-URL map plus a fragment-insensitive fallback; first audited entry wins. */
285
+ function allowedSourceMaps(evidence) {
286
+ const exact = new Map();
287
+ const byKey = new Map();
288
+ for (const item of evidence) {
289
+ const canonical = { title: cleanEvidenceText(item.title, 240), url: item.url };
290
+ if (!exact.has(item.url))
291
+ exact.set(item.url, canonical);
292
+ const key = citationKey(item.url);
293
+ if (!byKey.has(key))
294
+ byKey.set(key, canonical);
295
+ }
296
+ return { exact, byKey };
297
+ }
298
+ function resolveClaimedSource(source, maps) {
299
+ return maps.exact.get(source.url) ?? maps.byKey.get(citationKey(source.url));
300
+ }
301
+ /** Keep only audited URLs from this review's MCP calls and restore canonical titles. */
618
302
  export function groundResearchSources(findings, evidence) {
619
- const allowed = new Map(evidence.map((item) => [
620
- item.url,
621
- { title: cleanEvidenceText(item.title, 240), url: item.url },
622
- ]));
303
+ const maps = allowedSourceMaps(evidence);
623
304
  return findings.map((finding) => {
624
305
  const { sources: claimed, ...withoutSources } = finding;
625
306
  const sources = mergeResearchSources(claimed?.flatMap((source) => {
626
- const canonical = allowed.get(source.url);
307
+ const canonical = resolveClaimedSource(source, maps);
627
308
  return canonical ? [canonical] : [];
628
309
  }));
629
310
  return sources.length > 0 ? { ...withoutSources, sources } : withoutSources;
630
311
  });
631
312
  }
632
- export async function collectPlatformResearch(files, config) {
633
- const queries = deriveResearchQueries(files, config.maxQueries);
634
- if (!config.enabled || queries.length === 0) {
635
- return { queries, evidence: [], warnings: [], promptText: "" };
636
- }
637
- const calls = queries.map((query, index) => ({
638
- jsonrpc: "2.0",
639
- id: index + 2,
640
- method: "tools/call",
641
- params: {
642
- name: "search_platform_docs",
643
- arguments: {
644
- platform: query.platform,
645
- providers: query.providers,
646
- query: query.query,
647
- limit: config.resultsPerQuery,
648
- },
649
- },
650
- }));
651
- const messages = [
652
- {
653
- jsonrpc: "2.0",
654
- id: 1,
655
- method: "initialize",
656
- params: {
657
- protocolVersion: "2025-06-18",
658
- capabilities: {},
659
- clientInfo: { name: "expo-code-review-cli", version: "0.0.0" },
313
+ /**
314
+ * Keep only reviewer decisions backed by an exact URL from this run's MCP audit.
315
+ * An ungrounded declaration is discarded so model output cannot inflate usefulness.
316
+ */
317
+ export function groundResearchDecisions(decisions, evidence, agent) {
318
+ const maps = allowedSourceMaps(evidence);
319
+ return decisions.flatMap((decision) => {
320
+ const sources = mergeResearchSources(decision.sources.flatMap((source) => {
321
+ const canonical = resolveClaimedSource(source, maps);
322
+ return canonical ? [canonical] : [];
323
+ }));
324
+ if (sources.length === 0)
325
+ return [];
326
+ return [
327
+ {
328
+ outcome: decision.outcome,
329
+ summary: cleanEvidenceText(decision.summary, 240),
330
+ sources,
331
+ agent: cleanEvidenceText(agent, 120),
660
332
  },
661
- },
662
- { jsonrpc: "2.0", method: "notifications/initialized", params: {} },
663
- ...calls,
664
- ];
665
- const server = bundledResearchServer();
666
- const serverArgs = [
667
- ...server.args,
668
- "serve",
669
- ...(config.indexPath ? ["--index", config.indexPath] : []),
670
- ];
671
- const result = await run(server.command, serverArgs, {
672
- input: `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`,
673
- cwd: tmpdir(),
674
- env: researchChildEnvironment(),
675
- timeout: config.timeoutMs,
676
- maxBuffer: 2 * 1024 * 1024,
677
- check: false,
333
+ ];
334
+ });
335
+ }
336
+ /**
337
+ * Bound the cross-agent decision channel after grounding. Reviewer tasks finish
338
+ * concurrently, so sort before applying limits to keep the retained set stable.
339
+ */
340
+ export function boundResearchDecisions(decisions) {
341
+ const sorted = [...decisions].sort((left, right) => {
342
+ const leftKey = `${left.agent}\0${left.outcome}\0${left.summary}\0${left.sources[0]?.url ?? ""}`;
343
+ const rightKey = `${right.agent}\0${right.outcome}\0${right.summary}\0${right.sources[0]?.url ?? ""}`;
344
+ return leftKey.localeCompare(rightKey);
678
345
  });
679
- if (result.timedOut)
680
- throw new Error("platform research MCP timed out");
681
- if (result.overflowed)
682
- throw new Error("platform research MCP output exceeded 2 MB");
683
- if (result.code !== 0) {
684
- throw new Error(`platform research MCP exited ${result.code}: ${result.stderr.slice(0, 500)}`);
346
+ const kept = sorted.slice(0, RESEARCH_DECISION_COUNT_LIMIT);
347
+ let omitted = sorted.length - kept.length;
348
+ while (kept.length > 0 &&
349
+ Buffer.byteLength(JSON.stringify(kept), "utf8") > RESEARCH_DECISION_BYTES_LIMIT) {
350
+ kept.pop();
351
+ omitted++;
685
352
  }
686
- const responses = new Map();
687
- for (const line of result.stdout.split("\n")) {
688
- if (!line.trim())
689
- continue;
690
- const parsed = JSON.parse(line);
691
- if (typeof parsed.id === "number") {
692
- if (parsed.error)
693
- throw new Error(`platform research MCP error for request ${parsed.id}`);
694
- responses.set(parsed.id, parsed.result);
695
- }
353
+ return { decisions: kept, omitted };
354
+ }
355
+ /**
356
+ * Heuristic for external-behavior assertions: an OS-version or API-level claim,
357
+ * or documented-lifecycle wording. Deliberately narrow — it flags the claim
358
+ * shapes that most need documentation, not every mention of a platform.
359
+ */
360
+ 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;
361
+ /** Final findings asserting external platform behavior with no grounded citation. */
362
+ export function countUngroundedExternalClaims(findings) {
363
+ return findings.filter((finding) => !finding.sources?.length &&
364
+ EXTERNAL_CLAIM_PATTERN.test(`${finding.title}\n${finding.rationale}`)).length;
365
+ }
366
+ /** Count unique audited results that materially affected the final review. */
367
+ export function summarizeResearchUsefulness(provenance, findings) {
368
+ const resultUrls = new Set(provenance.results.map((result) => result.url));
369
+ const citedUrls = new Set(findings.flatMap((finding) => (finding.sources ?? []).flatMap((source) => (resultUrls.has(source.url) ? [source.url] : []))));
370
+ const decisions = provenance.decisions ?? [];
371
+ const decisionUrls = new Set(decisions.flatMap((decision) => decision.sources.flatMap((source) => (resultUrls.has(source.url) ? [source.url] : []))));
372
+ const utilizedUrls = new Set([...citedUrls, ...decisionUrls]);
373
+ return {
374
+ finalFindingsWithSources: findings.filter((finding) => (finding.sources ?? []).some((source) => resultUrls.has(source.url))).length,
375
+ citedResultCount: citedUrls.size,
376
+ supportedFindingCandidates: decisions.filter((decision) => decision.outcome === "supported-finding").length,
377
+ dismissedCandidates: decisions.filter((decision) => decision.outcome === "dismissed-candidate")
378
+ .length,
379
+ decisionResultCount: decisionUrls.size,
380
+ utilizedResultCount: utilizedUrls.size,
381
+ unusedResultCount: Math.max(0, resultUrls.size - utilizedUrls.size),
382
+ externalClaimFindingsWithoutSources: countUngroundedExternalClaims(findings),
383
+ };
384
+ }
385
+ export function formatResearchUsefulness(usefulness) {
386
+ const ungrounded = usefulness.externalClaimFindingsWithoutSources > 0
387
+ ? `; ${usefulness.externalClaimFindingsWithoutSources} finding(s) assert external platform behavior without a citation`
388
+ : "";
389
+ return (` research usefulness: ${usefulness.finalFindingsWithSources} final finding(s) cited ` +
390
+ `${usefulness.citedResultCount} unique result(s); ` +
391
+ `${usefulness.supportedFindingCandidates} supported and ` +
392
+ `${usefulness.dismissedCandidates} dismissed candidate(s); ` +
393
+ `${usefulness.utilizedResultCount} result(s) materially used, ` +
394
+ `${usefulness.unusedResultCount} unused${ungrounded}`);
395
+ }
396
+ export function renderResearchUsefulnessMarkdown(provenance) {
397
+ const usefulness = provenance.usefulness;
398
+ if (!usefulness)
399
+ return "";
400
+ const totalUniqueResults = usefulness.utilizedResultCount + usefulness.unusedResultCount;
401
+ const lines = [
402
+ "### 📚 Documentation research usefulness",
403
+ "",
404
+ `- Final findings with grounded citations: **${usefulness.finalFindingsWithSources}**`,
405
+ `- Unique results cited by final findings: **${usefulness.citedResultCount}**`,
406
+ `- Candidate decisions: **${usefulness.supportedFindingCandidates} supported**, **${usefulness.dismissedCandidates} dismissed**`,
407
+ `- Unique results materially used: **${usefulness.utilizedResultCount}/${totalUniqueResults}**`,
408
+ ];
409
+ if (usefulness.externalClaimFindingsWithoutSources > 0) {
410
+ lines.push(`- ⚠️ Findings asserting external platform behavior without a citation: **${usefulness.externalClaimFindingsWithoutSources}**`);
696
411
  }
697
- if (!responses.has(1))
698
- throw new Error("platform research MCP did not initialize");
699
- const evidence = [];
700
- const warnings = [];
701
- for (let index = 0; index < queries.length; index++) {
702
- const query = queries[index];
703
- const toolResult = ToolResultSchema.parse(responses.get(index + 2));
704
- const text = toolResult.content.find((block) => block.type === "text")?.text;
705
- if (!text)
706
- continue;
707
- const payload = SearchPayloadSchema.parse(JSON.parse(text));
708
- warnings.push(...(payload.warnings ?? []).map((warning) => cleanEvidenceText(warning, 500)).filter(Boolean));
709
- for (const item of payload.results.slice(0, config.resultsPerQuery)) {
710
- if (!item.url.startsWith("https://") || !query.providers.includes(item.provider))
711
- continue;
712
- evidence.push({ query, ...item });
412
+ if (provenance.decisions?.length) {
413
+ lines.push("", "Grounded candidate decisions:");
414
+ for (const decision of provenance.decisions) {
415
+ const sources = decision.sources
416
+ .map((source) => `[${escapeMarkdownLabel(source.title)}](<${source.url}>)`)
417
+ .join(", ");
418
+ lines.push(`- **${decision.outcome === "supported-finding" ? "Supported finding" : "Dismissed candidate"}** (${escapeMarkdownLabel(decision.agent)}): ${escapeMarkdownLabel(decision.summary)} ${sources}`);
713
419
  }
714
420
  }
715
- return {
716
- queries,
717
- evidence,
718
- warnings: [...new Set(warnings)].slice(0, 10),
719
- promptText: formatResearchEvidence(evidence),
720
- };
421
+ return lines.join("\n");
721
422
  }