@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.
@@ -18,7 +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 { createResearchMcpRuntime, formatResearchProgress, groundResearchSources, mergeResearchSources, researchProvenanceFromAudit, renderResearchMarkdown, } from "./research.js";
21
+ import { boundResearchDecisions, createResearchMcpRuntime, formatResearchProgress, formatResearchUsefulness, groundResearchDecisions, groundResearchSources, mergeResearchSources, researchProvenanceFromAudit, renderResearchMarkdown, renderResearchUsefulnessMarkdown, summarizeResearchUsefulness, } from "./research.js";
22
22
  /**
23
23
  * Filter changed files down to an explicit include set (exact-path membership, not
24
24
  * globs — scope assignment already happened in resolveScopes). With no include set,
@@ -307,6 +307,9 @@ export async function runReview(source, options) {
307
307
  // reviewers produced before the failure — partial findings are exactly what's
308
308
  // needed to debug a run that died mid-way.
309
309
  const agentFindings = {};
310
+ // Reviewer-declared, conclusion-only records of cases where documentation changed
311
+ // a concrete candidate decision. They remain inert until exact-source grounding.
312
+ const agentResearchDecisions = {};
310
313
  // Bounded, conclusion-only diagnostics for machine consumers of the hidden
311
314
  // comment state. These are deliberately separate from findings: they never reach
312
315
  // the coordinator, verification, policy, or decision paths.
@@ -521,6 +524,9 @@ export async function runReview(source, options) {
521
524
  trackTokens(task.bucket, tokens);
522
525
  trackModel(task.bucket, taskModel(task), model);
523
526
  (agentFindings[task.bucket] ??= []).push(...value.findings);
527
+ if (value.researchDecisions) {
528
+ (agentResearchDecisions[task.bucket] ??= []).push(...value.researchDecisions);
529
+ }
524
530
  if (value.trace) {
525
531
  mergeTraceNotes(agentTrace, task.bucket, value.trace);
526
532
  }
@@ -644,6 +650,20 @@ export async function runReview(source, options) {
644
650
  sourcesByFp.set(fp, mergeResearchSources(sourcesByFp.get(fp), finding.sources));
645
651
  }
646
652
  }
653
+ if (researchRecord) {
654
+ const groundedDecisions = Object.entries(agentResearchDecisions).flatMap(([agent, records]) => groundResearchDecisions(records, researchEvidence, agent));
655
+ const { decisions, omitted } = boundResearchDecisions(groundedDecisions);
656
+ if (decisions.length > 0)
657
+ researchRecord = { ...researchRecord, decisions };
658
+ if (omitted > 0) {
659
+ const warning = `${omitted} grounded research decision(s) omitted by output bounds`;
660
+ researchRecord = {
661
+ ...researchRecord,
662
+ warnings: [...researchRecord.warnings, warning],
663
+ };
664
+ progress(` research: ${warning}`);
665
+ }
666
+ }
647
667
  // A substituted model means the review did not run on the model this repo
648
668
  // configured — the findings may be from a weaker (or free-tier) model entirely.
649
669
  // Never silent: it goes to the log, the coverage notes, and the run log.
@@ -734,25 +754,43 @@ export async function runReview(source, options) {
734
754
  const findingCountBeforeChecks = output.findings.length;
735
755
  const decisionBeforeChecks = output.decision;
736
756
  let verifierDropped = [];
757
+ // Citations the verifier stripped from kept findings, persisted to the run
758
+ // log so the removal reason stays auditable — mirrors verifierDropped.
759
+ let citationStrips = [];
737
760
  // Stripped requalifications (finding + reason), persisted to the run log so the
738
761
  // stack-aware decision trail is auditable after the fact — mirrors verifierDropped.
739
762
  const requalificationStrips = [];
740
763
  if (output.findings.length > 0) {
741
764
  progress("Verifying findings…");
742
- const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
765
+ const verification = await verifyFindings(handle, output.findings, process.cwd(), progress, researchEvidence);
743
766
  agentCosts["verifier"] = verification.cost;
744
767
  trackTokens("verifier", verification.tokens);
745
768
  // Mirrors buildOpencodeConfig, which gives the verifier the first agent's model.
746
769
  trackModel("verifier", config.agents[0]?.model ?? config.coordinator.model, verification.model);
747
770
  verifierDropped = verification.dropped;
748
- if (verification.dropped.length > 0) {
749
- progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
771
+ citationStrips = verification.citationStripped;
772
+ if (verification.dropped.length > 0 || verification.citationStripped.length > 0) {
773
+ if (verification.dropped.length > 0) {
774
+ progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
775
+ }
750
776
  output = {
751
777
  ...output,
752
778
  findings: verification.kept,
753
- decision: decisionAfterVerification(output.decision, verification.kept),
779
+ // Re-derive the decision ONLY when findings were dropped. A citation
780
+ // strip keeps every finding, and decisionAfterVerification would
781
+ // downgrade a criticals-free request_changes to approve_with_comments —
782
+ // a blocking review must not stop blocking because a link was removed.
783
+ decision: verification.dropped.length > 0
784
+ ? decisionAfterVerification(output.decision, verification.kept)
785
+ : output.decision,
754
786
  };
755
787
  }
788
+ // The verifier judged these citations unsupportive. Remove the
789
+ // fingerprint-carried copies too, or the post-coordination merge below
790
+ // would silently restore the stripped sources.
791
+ for (const stripped of verification.citationStripped) {
792
+ sourcesByFp.delete(fingerprintFinding(stripped.finding));
793
+ }
756
794
  }
757
795
  // Stack-aware requalification grounding (deterministic, zero LLM): strip any
758
796
  // `requalifiedBy` the coordinator wrote that is forged, hallucinated, or touches a
@@ -903,6 +941,15 @@ export async function runReview(source, options) {
903
941
  progress(`Author-reply adjudication failed (${errorMessage(error)}); continuing without it.`);
904
942
  }
905
943
  }
944
+ // Measure utility only after verification, suppression, citation carry-through,
945
+ // and feedback adjudication have produced the final finding set. Counts use
946
+ // unique audited URLs, not passages, so duplicate search hits cannot inflate them.
947
+ if (researchRecord) {
948
+ const usefulness = summarizeResearchUsefulness(researchRecord, output.findings);
949
+ researchRecord = { ...researchRecord, usefulness };
950
+ progress(formatResearchUsefulness(usefulness));
951
+ await appendStepSummary(renderResearchUsefulnessMarkdown(researchRecord));
952
+ }
906
953
  // Surface provider throttling as a fact about the run: passes already waited or
907
954
  // backed off, but the operator should still SEE that it happened (a run that
908
955
  // was rate-limited is slower and may carry partial passes — that's the cause).
@@ -947,6 +994,7 @@ export async function runReview(source, options) {
947
994
  coverageNotes,
948
995
  verifierDropped,
949
996
  requalificationStrips,
997
+ citationStrips,
950
998
  ...(rlTotal > 0
951
999
  ? {
952
1000
  rateLimitEvents: rlTotal,
@@ -17,6 +17,17 @@ export const FindingSourceSchema = z.object({
17
17
  .max(2_000)
18
18
  .refine((value) => new URL(value).protocol === "https:", "source URL must use HTTPS"),
19
19
  });
20
+ export const RESEARCH_DECISION_OUTCOMES = ["supported-finding", "dismissed-candidate"];
21
+ /**
22
+ * A reviewer-declared decision that documentation materially changed. Sources are
23
+ * later grounded against the MCP audit exactly like finding citations; an ungrounded
24
+ * record is discarded and can never inflate usefulness metrics.
25
+ */
26
+ export const ResearchDecisionSchema = z.object({
27
+ outcome: z.enum(RESEARCH_DECISION_OUTCOMES),
28
+ summary: z.string().min(1).max(240),
29
+ sources: z.array(FindingSourceSchema).min(1).max(5),
30
+ });
20
31
  export const FindingSchema = z.object({
21
32
  severity: z.enum(SEVERITIES),
22
33
  category: z.enum(CATEGORIES),
@@ -29,7 +40,7 @@ export const FindingSchema = z.object({
29
40
  .array(FindingSourceSchema)
30
41
  .max(5)
31
42
  .optional()
32
- .describe("Exact documentation sources used to support this finding; copy title and URL from the injected research evidence and omit when unused"),
43
+ .describe("Exact documentation sources used to support this finding; copy the returned title and canonical URL from this review's MCP results and omit when unused"),
33
44
  /**
34
45
  * Verbatim snippet of the flagged code, copied from the file. Used to
35
46
  * quote-ground the finding: if this text isn't actually present in the file,
@@ -79,6 +90,12 @@ export function isOverallRiskHandoff(finding) {
79
90
  export const VerdictSchema = z.object({
80
91
  verified: z.boolean(),
81
92
  reason: z.string().default(""),
93
+ /**
94
+ * Only requested when the finding cites research: whether the cited passages
95
+ * genuinely support the finding's external-behavior claim. `false` strips the
96
+ * citation while the finding itself stands or falls on `verified`.
97
+ */
98
+ citationSupported: z.boolean().optional(),
82
99
  });
83
100
  /**
84
101
  * A stack verifier's verdict on whether a later stacked PR's patch actually
@@ -125,6 +142,7 @@ export const ReviewerTraceNotesSchema = z.object({
125
142
  const ReviewerModelOutputSchema = z.object({
126
143
  findings: z.array(ModelFindingSchema).default([]),
127
144
  trace: ReviewerTraceNotesSchema.optional(),
145
+ researchDecisions: z.array(ResearchDecisionSchema).max(8).optional(),
128
146
  });
129
147
  /**
130
148
  * Local trust boundary for reviewer output. Findings stay strict, while diagnostics
@@ -135,12 +153,18 @@ export const ReviewerOutputSchema = z
135
153
  .object({
136
154
  findings: z.array(ModelFindingSchema).default([]),
137
155
  trace: z.unknown().optional(),
156
+ researchDecisions: z.unknown().optional(),
138
157
  })
139
158
  .transform((output) => {
140
159
  const trace = ReviewerTraceNotesSchema.safeParse(output.trace);
160
+ const researchDecisions = z
161
+ .array(ResearchDecisionSchema)
162
+ .max(8)
163
+ .safeParse(output.researchDecisions);
141
164
  return {
142
165
  findings: output.findings,
143
166
  ...(trace.success ? { trace: trace.data } : {}),
167
+ ...(researchDecisions.success ? { researchDecisions: researchDecisions.data } : {}),
144
168
  };
145
169
  });
146
170
  export const ReviewTraceSchema = z.object({
@@ -12,6 +12,17 @@ import { errorMessage, normalizeCode } from "./util.js";
12
12
  const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
13
13
  // Evidence shorter than this (normalized) is too weak to conclude "hallucinated".
14
14
  const MIN_EVIDENCE_LEN = 12;
15
+ /** The audited passages behind a finding's grounded citations, bounded per source. */
16
+ function citedSourcesFor(finding, evidence) {
17
+ if (!finding.sources?.length || evidence.length === 0)
18
+ return undefined;
19
+ const byUrl = new Map(evidence.map((item) => [item.url, item]));
20
+ const cited = finding.sources.flatMap((source) => {
21
+ const match = byUrl.get(source.url);
22
+ return match ? [{ title: match.title, url: match.url, passage: match.passage }] : [];
23
+ });
24
+ return cited.length > 0 ? cited : undefined;
25
+ }
15
26
  // @ref LLP 0005#evidence-grounding-escalate-never-hard-drop [implements] — exact-substring is a good positive but poor negative signal (33a970a revert)
16
27
  /**
17
28
  * Break `evidence` into normalized, substantive fragments for fuzzy matching:
@@ -95,31 +106,43 @@ async function evidencePresence(finding, cwd) {
95
106
  * real findings whose natural evidence (a structural/absence bug, a cross-line
96
107
  * quote, a slightly-wrong location) wasn't a verbatim substring.
97
108
  */
98
- export async function verifyFindings(handle, findings, cwd, onProgress) {
109
+ export async function verifyFindings(handle, findings, cwd, onProgress,
110
+ /** This run's audited research evidence, for findings that cite documentation. */
111
+ researchEvidence = []) {
99
112
  const dropped = [];
113
+ const citationStripped = [];
114
+ // Kept findings the verifier rewrote (currently only citation removal).
115
+ const replacements = new Map();
100
116
  let cost = 0;
101
117
  let model;
102
118
  const tokens = {};
103
119
  // Phase 1 — deterministic quote-grounding for every finding.
104
120
  const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
105
- // Decide which findings need an LLM check vs. can be kept directly.
121
+ // Decide which findings need an LLM check vs. can be kept directly. A finding
122
+ // that cites documentation always gets an LLM check: the repo alone cannot
123
+ // confirm an external-behavior claim, and the verifier must judge whether the
124
+ // cited passages support it rather than fall back to model memory.
106
125
  const verdicts = new Map();
107
126
  const toVerify = [];
108
127
  for (const { finding, presence } of checked) {
109
- if (presence === "absent" || finding.severity === "critical") {
110
- toVerify.push({ finding, presence });
128
+ const citedSources = citedSourcesFor(finding, researchEvidence);
129
+ if (presence === "absent" || finding.severity === "critical" || citedSources) {
130
+ toVerify.push({ finding, presence, ...(citedSources ? { citedSources } : {}) });
111
131
  }
112
132
  else {
113
133
  verdicts.set(finding, "keep"); // grounded (or uncheckable) non-critical
114
134
  }
115
135
  }
116
136
  // Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
117
- await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
137
+ await Promise.all(toVerify.map(async ({ finding, presence, citedSources }, index) => {
118
138
  try {
119
139
  const { value, cost: verifyCost, tokens: verifyTokens, model: verifyModel, } = await promptAndParse(handle, {
120
140
  agent: VERIFIER_AGENT,
121
141
  system: buildVerifierSystem(),
122
- text: buildVerifierTask(finding, { evidenceUngrounded: presence === "absent" }),
142
+ text: buildVerifierTask(finding, {
143
+ evidenceUngrounded: presence === "absent",
144
+ ...(citedSources ? { citedSources } : {}),
145
+ }),
123
146
  title: `verify-${index}`,
124
147
  maxWaitMs: VERIFY_TIMEOUT_MS,
125
148
  finalizeOnTimeout: true,
@@ -130,6 +153,17 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
130
153
  model = verifyModel ?? model;
131
154
  if (value.verified) {
132
155
  verdicts.set(finding, "keep");
156
+ // An explicit false strips the citation; the finding itself stands.
157
+ // Absent or true leaves the grounded sources untouched (fail open).
158
+ if (citedSources && value.citationSupported === false) {
159
+ const { sources: _sources, ...withoutSources } = finding;
160
+ replacements.set(finding, withoutSources);
161
+ citationStripped.push({
162
+ finding,
163
+ reason: "the verifier judged the cited passages unsupportive of the claim",
164
+ });
165
+ onProgress?.(` verify: kept "${finding.title}" but removed its citation — the cited passages do not support the claim`);
166
+ }
133
167
  }
134
168
  else {
135
169
  verdicts.set(finding, "drop");
@@ -144,6 +178,8 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
144
178
  }
145
179
  }));
146
180
  // Preserve original order.
147
- const kept = findings.filter((finding) => verdicts.get(finding) === "keep");
148
- return { kept, dropped, cost, tokens, model };
181
+ const kept = findings
182
+ .filter((finding) => verdicts.get(finding) === "keep")
183
+ .map((finding) => replacements.get(finding) ?? finding);
184
+ return { kept, dropped, citationStripped, cost, tokens, model };
149
185
  }
@@ -1,7 +1,18 @@
1
1
  import { appendFile, mkdir, readFile, rmdir } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
+ /**
4
+ * Why a call was refused before it executed. Recorded WITHOUT the offending
5
+ * input: a rejected query or URL may contain exactly the sensitive material
6
+ * the sanitizer refused to send, so only the reason class is audited.
7
+ */
8
+ export const RESEARCH_REJECTION_REASONS = [
9
+ "query-rejected",
10
+ "url-rejected",
11
+ "budget-exhausted",
12
+ ];
3
13
  const LOCK_RETRIES = 200;
4
14
  const LOCK_DELAY_MS = 10;
15
+ const MAX_AUDITED_PASSAGE_CHARACTERS = 20_000;
5
16
  function delay(ms) {
6
17
  return new Promise((resolve) => setTimeout(resolve, ms));
7
18
  }
@@ -18,13 +29,19 @@ function boundedResult(result) {
18
29
  sourceKind: result.sourceKind,
19
30
  title: result.title.slice(0, 240),
20
31
  url: result.url.slice(0, 2_000),
21
- passage: result.passage.slice(0, 1_400),
32
+ // This equals the direct-fetch document ceiling, so the artifact preserves the
33
+ // exact bounded text shown to the reviewer without ever storing the raw page.
34
+ passage: result.passage.slice(0, MAX_AUDITED_PASSAGE_CHARACTERS),
22
35
  ...(result.availability?.length
23
36
  ? { availability: result.availability.slice(0, 20).map((value) => value.slice(0, 240)) }
24
37
  : {}),
25
38
  ...(result.framework ? { framework: result.framework.slice(0, 240) } : {}),
26
39
  ...(result.language ? { language: result.language } : {}),
27
40
  ...(result.symbol ? { symbol: result.symbol.slice(0, 240) } : {}),
41
+ ...(result.previousPassageId
42
+ ? { previousPassageId: result.previousPassageId.slice(0, 240) }
43
+ : {}),
44
+ ...(result.nextPassageId ? { nextPassageId: result.nextPassageId.slice(0, 240) } : {}),
28
45
  };
29
46
  }
30
47
  /** Shared append-only audit and global request budget for all MCP processes in one review. */
@@ -90,6 +107,14 @@ export class ResearchAudit {
90
107
  await this.withLock(async () => {
91
108
  const used = await this.reservationCount();
92
109
  if (used >= this.maxCalls) {
110
+ // Rejected events do not count as reservations, so recording the refusal
111
+ // cannot itself consume (or extend) the budget.
112
+ await this.append({
113
+ type: "rejected",
114
+ tool,
115
+ reason: "budget-exhausted",
116
+ timestamp: new Date().toISOString(),
117
+ });
93
118
  throw new Error(`Documentation research call budget exhausted (${this.maxCalls})`);
94
119
  }
95
120
  if (!this.path)
@@ -125,6 +150,15 @@ export class ResearchAudit {
125
150
  timestamp: new Date().toISOString(),
126
151
  });
127
152
  }
153
+ /** Record a call refused before execution — reason class only, never the input. */
154
+ async rejected(tool, reason) {
155
+ await this.append({
156
+ type: "rejected",
157
+ tool,
158
+ reason,
159
+ timestamp: new Date().toISOString(),
160
+ });
161
+ }
128
162
  }
129
163
  export async function readResearchAudit(path) {
130
164
  let contents = "";
@@ -133,10 +167,11 @@ export async function readResearchAudit(path) {
133
167
  }
134
168
  catch (error) {
135
169
  if (error.code === "ENOENT")
136
- return [];
170
+ return { records: [], rejections: [] };
137
171
  throw error;
138
172
  }
139
173
  const records = [];
174
+ const rejections = [];
140
175
  for (const line of contents.split("\n")) {
141
176
  if (!line)
142
177
  continue;
@@ -154,10 +189,14 @@ export async function readResearchAudit(path) {
154
189
  error: event.error,
155
190
  });
156
191
  }
192
+ if (event.type === "rejected" &&
193
+ RESEARCH_REJECTION_REASONS.includes(event.reason)) {
194
+ rejections.push({ tool: event.tool, reason: event.reason });
195
+ }
157
196
  }
158
197
  catch {
159
198
  // Ignore a partial final line from a process that was terminated mid-write.
160
199
  }
161
200
  }
162
- return records;
201
+ return { records, rejections };
163
202
  }
@@ -17,7 +17,8 @@ Usage:
17
17
  The serve command uses BRAVE_SEARCH_API_KEY for scoped web discovery, fetches only
18
18
  allowlisted official pages, and optionally falls back to a local index. Expo-provider
19
19
  searches use Expo's public documentation index. Its fetch_platform_doc tool can fetch
20
- one exact allowlisted documentation URL without a search key. The update command is
20
+ one exact allowlisted documentation URL without a search key and return focused,
21
+ section, or bounded-document extracted context. The update command is
21
22
  an optional offline crawler for operator-managed fallback indexes.
22
23
  `);
23
24
  }
@@ -6,6 +6,7 @@ import { buildSearchIndex, searchDocumentation } from "./search-index.js";
6
6
  /** Specific corpora precede their broader host/path parents during URL inference. */
7
7
  const DIRECT_PROVIDER_ORDER = [
8
8
  "apple-releases",
9
+ "sdwebimage",
9
10
  "apple",
10
11
  "swift-evolution",
11
12
  "android-releases",
@@ -28,6 +29,7 @@ const DIRECT_SOURCE_KIND = {
28
29
  apple: "official-api",
29
30
  "apple-releases": "release-notes",
30
31
  "swift-evolution": "official-guide",
32
+ sdwebimage: "official-api",
31
33
  android: "official-api",
32
34
  "android-releases": "release-notes",
33
35
  media3: "official-guide",
@@ -44,6 +46,48 @@ const DIRECT_SOURCE_KIND = {
44
46
  "react-native-screens": "official-guide",
45
47
  "react-native-worklets": "official-guide",
46
48
  };
49
+ export const DIRECT_DOCUMENT_CONTEXT_MODES = ["focused", "section", "document"];
50
+ const SECTION_CONTEXT_CHARACTERS = 12_000;
51
+ const DOCUMENT_CONTEXT_CHARACTERS = 20_000;
52
+ function withScore(chunk, score = 0) {
53
+ return { ...chunk, score };
54
+ }
55
+ function rankedAnchor(chunks, document, provider, query) {
56
+ if (!query?.trim())
57
+ return chunks[0] ? withScore(chunks[0]) : undefined;
58
+ const index = buildSearchIndex(chunks, 1);
59
+ return (searchDocumentation(index, query, {
60
+ platform: document.platform,
61
+ providers: [provider],
62
+ limit: 1,
63
+ })[0] ?? (chunks[0] ? withScore(chunks[0]) : undefined));
64
+ }
65
+ function contiguousWindow(body, anchor, maxCharacters) {
66
+ if (body.length <= maxCharacters)
67
+ return body;
68
+ const exactIndex = body.indexOf(anchor);
69
+ const prefixIndex = exactIndex >= 0 ? exactIndex : body.indexOf(anchor.slice(0, 160));
70
+ const anchorIndex = prefixIndex >= 0 ? prefixIndex : 0;
71
+ const centeredStart = Math.max(0, anchorIndex - Math.floor(maxCharacters / 3));
72
+ let start = centeredStart;
73
+ const previousBoundary = body.lastIndexOf("\n\n", centeredStart);
74
+ if (previousBoundary >= Math.max(0, centeredStart - 300))
75
+ start = previousBoundary + 2;
76
+ let end = Math.min(body.length, start + maxCharacters);
77
+ const nextBoundary = body.indexOf("\n\n", end - 300);
78
+ if (nextBoundary >= 0 && nextBoundary <= start + maxCharacters)
79
+ end = nextBoundary;
80
+ return body.slice(start, end).trim();
81
+ }
82
+ function focusedResults(chunks, anchor, limit) {
83
+ if (!anchor)
84
+ return [];
85
+ const anchorIndex = Math.max(0, chunks.findIndex((chunk) => chunk.id === anchor.id));
86
+ const start = Math.max(0, Math.min(anchorIndex - Math.floor(limit / 2), chunks.length - limit));
87
+ return chunks
88
+ .slice(start, start + limit)
89
+ .map((chunk) => withScore(chunk, chunk.id === anchor.id ? anchor.score : 0));
90
+ }
47
91
  /** Resolve a caller-supplied URL against the fixed provider allowlist. */
48
92
  export function resolveDirectDocumentationTarget(rawUrl, providerHint) {
49
93
  assertSafeDocumentationUrlShape(rawUrl);
@@ -76,25 +120,41 @@ export async function fetchDocumentationUrl(rawUrl, options = {}) {
76
120
  const indexedAt = new Date().toISOString();
77
121
  const chunks = chunkDocument(document, indexedAt);
78
122
  const limit = Math.min(5, Math.max(1, options.limit ?? 3));
79
- let results;
80
- if (options.query?.trim()) {
81
- const index = buildSearchIndex(chunks, 1, indexedAt);
82
- results = searchDocumentation(index, options.query, {
83
- platform: document.platform,
84
- providers: [target.provider],
85
- limit,
86
- });
87
- if (results.length === 0) {
88
- results = chunks.slice(0, limit).map((chunk) => ({ ...chunk, score: 0 }));
89
- }
123
+ const mode = options.context ?? "section";
124
+ const anchor = rankedAnchor(chunks, document, target.provider, options.query);
125
+ let results = [];
126
+ if (mode === "focused") {
127
+ results = focusedResults(chunks, anchor, limit);
90
128
  }
91
- else {
92
- results = chunks.slice(0, limit).map((chunk) => ({ ...chunk, score: 0 }));
129
+ else if (anchor) {
130
+ const maxCharacters = mode === "document" ? DOCUMENT_CONTEXT_CHARACTERS : SECTION_CONTEXT_CHARACTERS;
131
+ const passage = mode === "document"
132
+ ? document.body.slice(0, maxCharacters).trim()
133
+ : contiguousWindow(document.body, anchor.passage, maxCharacters);
134
+ const { previousPassageId: _previous, nextPassageId: _next, ...anchorWithoutNeighbors } = anchor;
135
+ results = [
136
+ {
137
+ ...anchorWithoutNeighbors,
138
+ id: `${anchor.id.replace(/#\d+$/, "")}#${mode}`,
139
+ passage,
140
+ },
141
+ ];
93
142
  }
143
+ const returnedCharacters = results.reduce((total, result) => total + result.passage.length, 0);
94
144
  return {
95
145
  provider: target.provider,
96
146
  sourceKind: target.sourceKind,
97
147
  canonicalUrl: target.url.href,
148
+ context: {
149
+ mode,
150
+ returnedCharacters,
151
+ documentCharacters: document.body.length,
152
+ truncated: returnedCharacters < document.body.length,
153
+ ...(anchor ? { anchorPassageId: anchor.id } : {}),
154
+ availablePassageCount: chunks.length,
155
+ availablePassageIds: chunks.slice(0, 100).map((chunk) => chunk.id),
156
+ ...(chunks.length > 100 ? { passageIdsTruncated: true } : {}),
157
+ },
98
158
  results,
99
159
  };
100
160
  }
@@ -53,8 +53,8 @@ export function extractExpoAlgoliaDocuments(json) {
53
53
  }
54
54
  return documents;
55
55
  }
56
- async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes) {
57
- const response = await fetch(EXPO_ALGOLIA_ENDPOINT, {
56
+ async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes, fetchImplementation) {
57
+ const response = await fetchImplementation(EXPO_ALGOLIA_ENDPOINT, {
58
58
  method: "POST",
59
59
  redirect: "error",
60
60
  signal: AbortSignal.timeout(timeoutMs),
@@ -83,7 +83,7 @@ async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes) {
83
83
  const body = await readBodyWithLimit(response, maxResponseBytes);
84
84
  return extractExpoAlgoliaDocuments(body);
85
85
  }
86
- export async function searchExpoAlgolia(query, limit) {
86
+ export async function searchExpoAlgolia(query, limit, fetchImplementation = fetch) {
87
87
  const normalized = query.replace(/\s+/g, " ").trim();
88
88
  if (!normalized || normalized.length > 300) {
89
89
  throw new Error("Expo Algolia query must contain between 1 and 300 characters");
@@ -91,5 +91,5 @@ export async function searchExpoAlgolia(query, limit) {
91
91
  if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
92
92
  throw new Error("Expo Algolia result limit must be between 1 and 10");
93
93
  }
94
- return fetchExpoAlgolia(normalized, limit, 5000, 1_000_000);
94
+ return fetchExpoAlgolia(normalized, limit, 5000, 1_000_000, fetchImplementation);
95
95
  }
@@ -128,11 +128,14 @@ export function chunkDocument(document, indexedAt, targetCharacters = 1400, over
128
128
  .update(`${document.provider ?? document.platform}\0${document.url}\0${document.title}`)
129
129
  .digest("hex")
130
130
  .slice(0, 16);
131
+ const passageId = (chunkIndex) => `${document.provider ?? document.platform}:${documentId}#${chunkIndex}`;
131
132
  return passages.map((passage, chunkIndex) => ({
132
133
  ...document,
133
134
  body: undefined,
134
- id: `${document.provider ?? document.platform}:${documentId}#${chunkIndex}`,
135
+ id: passageId(chunkIndex),
135
136
  passage,
137
+ ...(chunkIndex > 0 ? { previousPassageId: passageId(chunkIndex - 1) } : {}),
138
+ ...(chunkIndex + 1 < passages.length ? { nextPassageId: passageId(chunkIndex + 1) } : {}),
136
139
  indexedAt,
137
140
  }));
138
141
  }
@@ -130,6 +130,43 @@ export const swiftEvolutionProvider = {
130
130
  return documentUrl.hostname === "github.com" ? "markdown" : "html";
131
131
  },
132
132
  };
133
+ const sdWebImageDocumentPrefix = "/documentation/sdwebimage";
134
+ /** SDWebImage publishes a static DocC archive whose visible routes are JS shells. */
135
+ export const sdWebImageProvider = {
136
+ id: "sdwebimage",
137
+ platform: "apple",
138
+ displayName: "SDWebImage documentation",
139
+ accepts(url) {
140
+ return (isSecurePublicUrl(url, "sdwebimage.github.io") &&
141
+ hasAllowedPath(url, [sdWebImageDocumentPrefix]));
142
+ },
143
+ acceptsRequest(url) {
144
+ if (this.accepts(url))
145
+ return true;
146
+ if (!isSecurePublicUrl(url, "sdwebimage.github.io") ||
147
+ !url.pathname.startsWith(`/data${sdWebImageDocumentPrefix}`) ||
148
+ !url.pathname.endsWith(".json")) {
149
+ return false;
150
+ }
151
+ const correspondingDocumentUrl = new URL(url.href);
152
+ correspondingDocumentUrl.pathname = url.pathname.slice("/data".length).replace(/\.json$/, "");
153
+ return hasAllowedPath(correspondingDocumentUrl, [sdWebImageDocumentPrefix]);
154
+ },
155
+ canonicalize(url) {
156
+ const hadTrailingSlash = url.pathname.endsWith("/");
157
+ const canonical = canonicalizeDocumentationUrl(url);
158
+ if (hadTrailingSlash && !canonical.pathname.endsWith("/"))
159
+ canonical.pathname += "/";
160
+ return canonical;
161
+ },
162
+ requestUrl(documentUrl) {
163
+ const documentPath = documentUrl.pathname.replace(/\/$/, "").toLowerCase();
164
+ return new URL(`/data${documentPath}.json`, documentUrl.origin);
165
+ },
166
+ responseFormat() {
167
+ return "docc-json";
168
+ },
169
+ };
133
170
  const androidPrefixes = [
134
171
  "/build",
135
172
  "/develop",
@@ -272,6 +309,7 @@ const providers = {
272
309
  apple: appleProvider,
273
310
  "apple-releases": appleReleasesProvider,
274
311
  "swift-evolution": swiftEvolutionProvider,
312
+ sdwebimage: sdWebImageProvider,
275
313
  android: androidProvider,
276
314
  "android-releases": androidReleasesProvider,
277
315
  media3: media3Provider,
@@ -71,7 +71,20 @@ function isApiAnchor(value) {
71
71
  return (/[a-z][A-Z]/.test(value) ||
72
72
  /[A-Z][A-Za-z0-9_]{1,}/.test(value) ||
73
73
  /[._:$#()]/.test(value) ||
74
- /_[A-Z0-9]/.test(value));
74
+ /_[A-Z0-9]/.test(value) ||
75
+ // Hyphenated package and module names (expo-camera, react-native-screens).
76
+ /[a-z0-9]-[a-z]/.test(value));
77
+ }
78
+ /**
79
+ * A short, all-lowercase concept phrase ("gradle configuration cache",
80
+ * "coroutine cancellation cooperative"). Guide, release-note, and Expo
81
+ * documentation topics frequently have no CamelCase symbol; two or more plain
82
+ * dictionary-shaped words carry no more outbound capacity than a symbol query
83
+ * (same token, length, entropy, and secret checks apply) and are accepted.
84
+ * A single generic word still fails closed.
85
+ */
86
+ function isPlainConceptPhrase(tokens) {
87
+ return tokens.length >= 2 && tokens.every((token) => /^[a-z][a-z0-9]{2,23}$/.test(token));
75
88
  }
76
89
  /**
77
90
  * Convert an agent-authored search into a short API-symbol query. Dangerous shapes
@@ -100,8 +113,8 @@ export function sanitizeDocumentationQuery(rawQuery) {
100
113
  return !PROSE_STOP_WORDS.has(token.toLowerCase());
101
114
  });
102
115
  const unique = [...new Set(candidates)].slice(0, MAX_QUERY_TOKENS);
103
- if (!unique.some(isApiAnchor)) {
104
- throw new Error("Query must include an API-like symbol or member name");
116
+ if (!unique.some(isApiAnchor) && !isPlainConceptPhrase(unique)) {
117
+ throw new Error("Query must include an API-like symbol or a multi-word concept phrase");
105
118
  }
106
119
  const sanitized = unique.join(" ").slice(0, MAX_QUERY_CHARACTERS).trim();
107
120
  if (!sanitized)