@expo/code-review-cli 0.12.1 → 0.12.3

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,10 +1,22 @@
1
1
  // @ref LLP 0013#query-and-prompt-boundary [implements] — derive identifiers only; validate, bound, and sanitize MCP evidence
2
2
  // @ref LLP 0013#one-package-two-binaries [implements] — resolve the package-relative MCP entry instead of PATH/configured commands
3
+ // @ref LLP 0013#research-provenance-and-citations [implements] — bounded query/result audit records plus exact citation grounding
3
4
  import { existsSync } from "node:fs";
5
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
6
  import { tmpdir } from "node:os";
7
+ import path from "node:path";
5
8
  import { fileURLToPath } from "node:url";
6
9
  import { z } from "zod";
10
+ import { readResearchAudit } from "../research-mcp/audit.js";
7
11
  import { run } from "./exec.js";
12
+ export { OPENCODE_RESEARCH_TOOLS } from "./tools.js";
13
+ export const RESEARCH_DECISION_COUNT_LIMIT = 16;
14
+ export const RESEARCH_DECISION_BYTES_LIMIT = 20_000;
15
+ export const RESEARCH_MCP_SERVER_NAME = "platform_docs";
16
+ export const CLAUDE_RESEARCH_TOOLS = [
17
+ `mcp__${RESEARCH_MCP_SERVER_NAME}__search_platform_docs`,
18
+ `mcp__${RESEARCH_MCP_SERVER_NAME}__fetch_platform_doc`,
19
+ ];
8
20
  const APPLE_EXTENSIONS = /\.(?:swift|m|mm)$/i;
9
21
  const ANDROID_EXTENSIONS = /\.(?:kt|java|gradle|gradle\.kts)$/i;
10
22
  const REACT_NATIVE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
@@ -231,40 +243,46 @@ const REACT_NATIVE_PROVIDERS = new Set([
231
243
  function providersFor(file, code, signals) {
232
244
  const path = file.path.toLowerCase();
233
245
  const text = code.toLowerCase();
246
+ const platform = platformFor(file);
234
247
  const providers = [];
235
248
  const add = (provider, matches) => {
236
249
  if (matches && !providers.includes(provider))
237
250
  providers.push(provider);
238
251
  };
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);
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.
263
255
  if (platform === "apple")
264
- return ["apple"];
256
+ add("apple", true);
265
257
  if (platform === "android")
266
- return ["android"];
267
- return ["react-native"];
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"];
268
286
  }
269
287
  function lineQuery(line) {
270
288
  const declared = line.match(/\b(?:class|struct|enum|interface|protocol)\s+([A-Z][A-Za-z0-9_]*)/)?.[1];
@@ -345,12 +363,13 @@ const ToolResultSchema = z.object({
345
363
  const SearchPayloadSchema = z.object({
346
364
  warnings: z.array(z.string().max(500)).max(10).optional(),
347
365
  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(),
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(),
354
373
  })),
355
374
  });
356
375
  const RESEARCH_PROXY_ENV_KEYS = [
@@ -377,7 +396,7 @@ export function researchChildEnvironment(source = process.env) {
377
396
  }
378
397
  return environment;
379
398
  }
380
- function bundledResearchServer() {
399
+ export function bundledResearchServer() {
381
400
  const builtEntry = fileURLToPath(new URL("../research-mcp/cli.js", import.meta.url));
382
401
  const sourceEntry = fileURLToPath(new URL("../research-mcp/cli.ts", import.meta.url));
383
402
  return {
@@ -385,6 +404,92 @@ function bundledResearchServer() {
385
404
  args: [existsSync(builtEntry) ? builtEntry : sourceEntry],
386
405
  };
387
406
  }
407
+ /**
408
+ * 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.
411
+ */
412
+ export async function createResearchMcpRuntime(config) {
413
+ if (!config.enabled)
414
+ return undefined;
415
+ const directory = await mkdtemp(path.join(tmpdir(), "ecr-research-"));
416
+ const auditPath = path.join(directory, "audit.jsonl");
417
+ const claudeConfigPath = path.join(directory, "mcp.json");
418
+ const server = bundledResearchServer();
419
+ const args = [
420
+ ...server.args,
421
+ "serve",
422
+ ...(config.indexPath ? ["--index", config.indexPath] : []),
423
+ ];
424
+ const child = researchChildEnvironment();
425
+ const environment = Object.fromEntries(Object.entries({
426
+ ...child,
427
+ REVIEW_RESEARCH_AUDIT_PATH: auditPath,
428
+ REVIEW_RESEARCH_MAX_CALLS: String(config.maxQueries),
429
+ REVIEW_RESEARCH_MAX_RESULTS: String(config.resultsPerQuery),
430
+ }).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])));
431
+ await writeFile(claudeConfigPath, `${JSON.stringify({
432
+ mcpServers: {
433
+ [RESEARCH_MCP_SERVER_NAME]: {
434
+ type: "stdio",
435
+ command: server.command,
436
+ args,
437
+ env: environment,
438
+ },
439
+ },
440
+ })}\n`, { encoding: "utf8", mode: 0o600 });
441
+ return {
442
+ auditPath,
443
+ claudeConfigPath,
444
+ command: server.command,
445
+ args,
446
+ environment,
447
+ cleanup: () => rm(directory, { recursive: true, force: true }),
448
+ };
449
+ }
450
+ export async function researchProvenanceFromAudit(auditPath) {
451
+ const records = await readResearchAudit(auditPath);
452
+ const queries = [];
453
+ const evidence = [];
454
+ const warnings = [];
455
+ for (const record of records) {
456
+ const firstResult = record.results[0];
457
+ const platformValue = record.input.platform ?? firstResult?.platform ?? "react-native";
458
+ const platform = platformValue === "apple" || platformValue === "android" ? platformValue : "react-native";
459
+ const providers = record.input.providers ??
460
+ record.results.flatMap((result) => (result.provider ? [result.provider] : []));
461
+ const query = {
462
+ platform,
463
+ providers: [...new Set(providers)],
464
+ query: record.input.query ?? record.input.url ?? record.tool,
465
+ };
466
+ queries.push(query);
467
+ warnings.push(...record.warnings);
468
+ if (record.error)
469
+ warnings.push(`${record.tool}: ${record.error}`);
470
+ for (const result of record.results) {
471
+ if (!result.provider || !result.sourceKind)
472
+ continue;
473
+ evidence.push({
474
+ id: result.id,
475
+ query,
476
+ provider: result.provider,
477
+ sourceKind: result.sourceKind,
478
+ title: result.title,
479
+ url: result.url,
480
+ passage: result.passage,
481
+ ...(result.availability ? { availability: result.availability } : {}),
482
+ });
483
+ }
484
+ }
485
+ const run = {
486
+ queries,
487
+ evidence,
488
+ warnings: [...new Set(warnings)].slice(0, 10),
489
+ promptText: "",
490
+ };
491
+ return { provenance: toResearchProvenance(run), evidence };
492
+ }
388
493
  function cleanEvidenceText(value, maxLength) {
389
494
  return (value
390
495
  .replace(/^\s*-{3,}\s*(?:BEGIN|END)\s+PLATFORM RESEARCH.*$/gim, "")
@@ -414,6 +519,219 @@ export function formatResearchEvidence(evidence) {
414
519
  .join("\n\n");
415
520
  return cleanEvidenceText(body, 16_000);
416
521
  }
522
+ function researchQueryKey(query) {
523
+ return `${query.platform}\0${query.providers.join(",")}\0${query.query}`;
524
+ }
525
+ export function toResearchProvenance(run) {
526
+ return {
527
+ queries: run.queries,
528
+ results: run.evidence.map((item) => ({
529
+ ...(item.id ? { id: cleanEvidenceText(item.id, 240) } : {}),
530
+ query: item.query,
531
+ provider: cleanEvidenceText(item.provider, 80),
532
+ sourceKind: cleanEvidenceText(item.sourceKind, 80),
533
+ title: cleanEvidenceText(item.title, 240),
534
+ url: item.url,
535
+ passage: cleanEvidenceText(item.passage, 20_000),
536
+ ...(item.availability?.length
537
+ ? { availability: item.availability.map((value) => cleanEvidenceText(value, 240)) }
538
+ : {}),
539
+ })),
540
+ warnings: run.warnings.map((warning) => cleanEvidenceText(warning, 500)),
541
+ };
542
+ }
543
+ export function formatResearchProgress(provenance) {
544
+ const lines = [
545
+ ` research: ${provenance.results.length} result(s) from ${provenance.queries.length} bounded query(s)`,
546
+ ];
547
+ const byQuery = new Map();
548
+ for (const result of provenance.results) {
549
+ const key = researchQueryKey(result.query);
550
+ const bucket = byQuery.get(key) ?? [];
551
+ bucket.push(result);
552
+ byQuery.set(key, bucket);
553
+ }
554
+ for (const [index, query] of provenance.queries.entries()) {
555
+ lines.push(` research query ${index + 1}/${provenance.queries.length} — ${query.platform} [${query.providers.join(", ")}]: ${cleanEvidenceText(query.query, 120)}`);
556
+ const results = byQuery.get(researchQueryKey(query)) ?? [];
557
+ if (results.length === 0) {
558
+ lines.push(" result: none");
559
+ continue;
560
+ }
561
+ for (const result of results) {
562
+ lines.push(` result: ${cleanEvidenceText(result.title, 160)} (${result.provider}/${result.sourceKind}) — ${result.url}`);
563
+ }
564
+ }
565
+ for (const warning of provenance.warnings) {
566
+ lines.push(` research warning: ${cleanEvidenceText(warning, 500)}`);
567
+ }
568
+ if (provenance.error) {
569
+ lines.push(` research error: ${cleanEvidenceText(provenance.error, 500)}`);
570
+ }
571
+ return lines;
572
+ }
573
+ function escapeMarkdownLabel(value) {
574
+ return cleanEvidenceText(value, 240)
575
+ .replace(/&/g, "&")
576
+ .replace(/</g, "&lt;")
577
+ .replace(/>/g, "&gt;")
578
+ .replace(/([\\[\]])/g, "\\$1");
579
+ }
580
+ export function renderResearchMarkdown(provenance) {
581
+ const lines = [
582
+ "### 🔎 Documentation research",
583
+ "",
584
+ `${provenance.results.length} result(s) from ${provenance.queries.length} bounded query(s).`,
585
+ "",
586
+ ];
587
+ const byQuery = new Map();
588
+ for (const result of provenance.results) {
589
+ const key = researchQueryKey(result.query);
590
+ const bucket = byQuery.get(key) ?? [];
591
+ bucket.push(result);
592
+ byQuery.set(key, bucket);
593
+ }
594
+ for (const query of provenance.queries) {
595
+ lines.push(`- \`${cleanEvidenceText(query.query, 120)}\` — ${query.platform}; ${query.providers.join(", ")}`);
596
+ const results = byQuery.get(researchQueryKey(query)) ?? [];
597
+ if (results.length === 0) {
598
+ lines.push(" - _No allowlisted result._");
599
+ continue;
600
+ }
601
+ for (const result of results) {
602
+ lines.push(` - [${escapeMarkdownLabel(result.title)}](<${result.url}>) — ${escapeMarkdownLabel(result.provider)}/${escapeMarkdownLabel(result.sourceKind)}`);
603
+ }
604
+ }
605
+ for (const warning of provenance.warnings) {
606
+ lines.push(`- ⚠️ ${escapeMarkdownLabel(warning)}`);
607
+ }
608
+ if (provenance.error) {
609
+ lines.push(`- ⚠️ Research failed: ${escapeMarkdownLabel(provenance.error)}`);
610
+ }
611
+ return lines.join("\n");
612
+ }
613
+ export function mergeResearchSources(...groups) {
614
+ const seen = new Set();
615
+ return groups
616
+ .flatMap((group) => group ?? [])
617
+ .filter((source) => {
618
+ if (seen.has(source.url))
619
+ return false;
620
+ seen.add(source.url);
621
+ return true;
622
+ })
623
+ .slice(0, 5);
624
+ }
625
+ /** Keep only exact URLs returned by this review's MCP calls and restore canonical titles. */
626
+ 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
+ ]));
631
+ return findings.map((finding) => {
632
+ const { sources: claimed, ...withoutSources } = finding;
633
+ const sources = mergeResearchSources(claimed?.flatMap((source) => {
634
+ const canonical = allowed.get(source.url);
635
+ return canonical ? [canonical] : [];
636
+ }));
637
+ return sources.length > 0 ? { ...withoutSources, sources } : withoutSources;
638
+ });
639
+ }
640
+ /**
641
+ * Keep only reviewer decisions backed by an exact URL from this run's MCP audit.
642
+ * An ungrounded declaration is discarded so model output cannot inflate usefulness.
643
+ */
644
+ 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
+ ]));
649
+ return decisions.flatMap((decision) => {
650
+ const sources = mergeResearchSources(decision.sources.flatMap((source) => {
651
+ const canonical = allowed.get(source.url);
652
+ return canonical ? [canonical] : [];
653
+ }));
654
+ if (sources.length === 0)
655
+ return [];
656
+ return [
657
+ {
658
+ outcome: decision.outcome,
659
+ summary: cleanEvidenceText(decision.summary, 240),
660
+ sources,
661
+ agent: cleanEvidenceText(agent, 120),
662
+ },
663
+ ];
664
+ });
665
+ }
666
+ /**
667
+ * Bound the cross-agent decision channel after grounding. Reviewer tasks finish
668
+ * concurrently, so sort before applying limits to keep the retained set stable.
669
+ */
670
+ export function boundResearchDecisions(decisions) {
671
+ const sorted = [...decisions].sort((left, right) => {
672
+ const leftKey = `${left.agent}\0${left.outcome}\0${left.summary}\0${left.sources[0]?.url ?? ""}`;
673
+ const rightKey = `${right.agent}\0${right.outcome}\0${right.summary}\0${right.sources[0]?.url ?? ""}`;
674
+ return leftKey.localeCompare(rightKey);
675
+ });
676
+ const kept = sorted.slice(0, RESEARCH_DECISION_COUNT_LIMIT);
677
+ let omitted = sorted.length - kept.length;
678
+ while (kept.length > 0 &&
679
+ Buffer.byteLength(JSON.stringify(kept), "utf8") > RESEARCH_DECISION_BYTES_LIMIT) {
680
+ kept.pop();
681
+ omitted++;
682
+ }
683
+ return { decisions: kept, omitted };
684
+ }
685
+ /** Count unique audited results that materially affected the final review. */
686
+ export function summarizeResearchUsefulness(provenance, findings) {
687
+ const resultUrls = new Set(provenance.results.map((result) => result.url));
688
+ const citedUrls = new Set(findings.flatMap((finding) => (finding.sources ?? []).flatMap((source) => (resultUrls.has(source.url) ? [source.url] : []))));
689
+ const decisions = provenance.decisions ?? [];
690
+ const decisionUrls = new Set(decisions.flatMap((decision) => decision.sources.flatMap((source) => (resultUrls.has(source.url) ? [source.url] : []))));
691
+ const utilizedUrls = new Set([...citedUrls, ...decisionUrls]);
692
+ return {
693
+ finalFindingsWithSources: findings.filter((finding) => (finding.sources ?? []).some((source) => resultUrls.has(source.url))).length,
694
+ citedResultCount: citedUrls.size,
695
+ supportedFindingCandidates: decisions.filter((decision) => decision.outcome === "supported-finding").length,
696
+ dismissedCandidates: decisions.filter((decision) => decision.outcome === "dismissed-candidate")
697
+ .length,
698
+ decisionResultCount: decisionUrls.size,
699
+ utilizedResultCount: utilizedUrls.size,
700
+ unusedResultCount: Math.max(0, resultUrls.size - utilizedUrls.size),
701
+ };
702
+ }
703
+ export function formatResearchUsefulness(usefulness) {
704
+ return (` research usefulness: ${usefulness.finalFindingsWithSources} final finding(s) cited ` +
705
+ `${usefulness.citedResultCount} unique result(s); ` +
706
+ `${usefulness.supportedFindingCandidates} supported and ` +
707
+ `${usefulness.dismissedCandidates} dismissed candidate(s); ` +
708
+ `${usefulness.utilizedResultCount} result(s) materially used, ` +
709
+ `${usefulness.unusedResultCount} unused`);
710
+ }
711
+ export function renderResearchUsefulnessMarkdown(provenance) {
712
+ const usefulness = provenance.usefulness;
713
+ if (!usefulness)
714
+ return "";
715
+ const totalUniqueResults = usefulness.utilizedResultCount + usefulness.unusedResultCount;
716
+ const lines = [
717
+ "### 📚 Documentation research usefulness",
718
+ "",
719
+ `- Final findings with grounded citations: **${usefulness.finalFindingsWithSources}**`,
720
+ `- Unique results cited by final findings: **${usefulness.citedResultCount}**`,
721
+ `- Candidate decisions: **${usefulness.supportedFindingCandidates} supported**, **${usefulness.dismissedCandidates} dismissed**`,
722
+ `- Unique results materially used: **${usefulness.utilizedResultCount}/${totalUniqueResults}**`,
723
+ ];
724
+ if (provenance.decisions?.length) {
725
+ lines.push("", "Grounded candidate decisions:");
726
+ for (const decision of provenance.decisions) {
727
+ const sources = decision.sources
728
+ .map((source) => `[${escapeMarkdownLabel(source.title)}](<${source.url}>)`)
729
+ .join(", ");
730
+ lines.push(`- **${decision.outcome === "supported-finding" ? "Supported finding" : "Dismissed candidate"}** (${escapeMarkdownLabel(decision.agent)}): ${escapeMarkdownLabel(decision.summary)} — ${sources}`);
731
+ }
732
+ }
733
+ return lines.join("\n");
734
+ }
417
735
  export async function collectPlatformResearch(files, config) {
418
736
  const queries = deriveResearchQueries(files, config.maxQueries);
419
737
  if (!config.enabled || queries.length === 0) {