@expo/code-review-cli 0.12.1 → 0.12.2

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 { collectPlatformResearch } from "./research.js";
21
+ import { createResearchMcpRuntime, formatResearchProgress, groundResearchSources, mergeResearchSources, researchProvenanceFromAudit, renderResearchMarkdown, } 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,
@@ -133,25 +133,8 @@ export async function runReview(source, options) {
133
133
  });
134
134
  return output;
135
135
  }
136
- let researchText = "";
137
- if (config.research.enabled) {
138
- progress("Researching platform documentation from changed API identifiers…");
139
- try {
140
- const research = await collectPlatformResearch(kept, config.research);
141
- researchText = research.promptText;
142
- progress(research.queries.length === 0
143
- ? " research: no native platform identifiers found"
144
- : ` research: ${research.evidence.length} passage(s) from ${research.queries.length} bounded query(s)`);
145
- for (const warning of research.warnings) {
146
- progress(` research warning: ${warning}`);
147
- }
148
- }
149
- catch (error) {
150
- // Documentation is supporting evidence, not a prerequisite for reviewing the
151
- // code. Fail open with a visible diagnostic; never weaken or skip the review.
152
- progress(` research unavailable; continuing without it (${errorMessage(error)})`);
153
- }
154
- }
136
+ let researchEvidence = [];
137
+ let researchRecord;
155
138
  // Materialize the PR-head tree (not the current checkout) when the source can, so
156
139
  // the agents' surrounding-source reads and the verifier's re-reads see the versions
157
140
  // that match the diff. Config is already fully loaded in memory, so the chdir below
@@ -208,6 +191,18 @@ export async function runReview(source, options) {
208
191
  for (const note of setupNotes) {
209
192
  progress(` setup: ${note}`);
210
193
  }
194
+ let researchRuntime;
195
+ try {
196
+ researchRuntime = await createResearchMcpRuntime(config.research);
197
+ if (researchRuntime) {
198
+ progress(`Documentation MCP enabled for reviewer passes (${config.research.maxQueries} calls max; queries and results will be reported).`);
199
+ }
200
+ }
201
+ catch (error) {
202
+ await auth.cleanup();
203
+ await restoreCwd();
204
+ throw new Error(`Failed to prepare the documentation MCP: ${errorMessage(error)}`);
205
+ }
211
206
  const starting = [
212
207
  usesClaude ? "Claude Code engine" : null,
213
208
  usesOpencode ? "OpenCode server" : null,
@@ -221,23 +216,25 @@ export async function runReview(source, options) {
221
216
  let claudeHandle = null;
222
217
  try {
223
218
  if (usesOpencode) {
224
- opencodeHandle = await startOpencode(buildOpencodeConfig(config));
219
+ opencodeHandle = await startOpencode(buildOpencodeConfig(config, researchRuntime));
225
220
  }
226
221
  }
227
222
  catch (error) {
228
223
  await auth.cleanup();
224
+ await researchRuntime?.cleanup();
229
225
  await restoreCwd();
230
226
  throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
231
227
  `model credentials are configured (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
232
228
  }
233
229
  try {
234
230
  if (usesClaude) {
235
- claudeHandle = await startClaudeCode(config);
231
+ claudeHandle = await startClaudeCode(config, researchRuntime);
236
232
  }
237
233
  }
238
234
  catch (error) {
239
235
  opencodeHandle?.close();
240
236
  await auth.cleanup();
237
+ await researchRuntime?.cleanup();
241
238
  await restoreCwd();
242
239
  throw new Error(`Failed to start the Claude Code engine. Ensure the \`claude\` CLI is installed and ` +
243
240
  `logged into a Max/Team subscription (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
@@ -299,6 +296,7 @@ export async function runReview(source, options) {
299
296
  catch (error) {
300
297
  handle.close();
301
298
  await auth.cleanup();
299
+ await researchRuntime?.cleanup();
302
300
  await restoreCwd();
303
301
  throw error;
304
302
  }
@@ -319,6 +317,9 @@ export async function runReview(source, options) {
319
317
  // run log stay byte-identical (attribution is engine metadata, never sent to a model).
320
318
  // @ref LLP 0011#attribution-and-identity [constrained-by] — engine-set, excluded from fingerprintFinding, so attribution never re-keys a dismissal
321
319
  const agentByFp = new Map();
320
+ // Grounded source citations ride through coordinator rewrites by the same stable
321
+ // fingerprint. The model may select an injected source, but cannot invent its URL.
322
+ const sourcesByFp = new Map();
322
323
  // Every model request's usage lands in the run total AND its bucket, so the run
323
324
  // log can show cache effectiveness per pass and not just run-wide.
324
325
  const trackTokens = (bucket, tokens) => {
@@ -480,8 +481,8 @@ export async function runReview(source, options) {
480
481
  // smaller file set); a fallback task forbids tools and reviews the inlined diff.
481
482
  const buildTaskText = (task) => {
482
483
  const base = task.kind === "cross-cutting"
483
- ? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText, researchText)
484
- : buildReviewerTask(task.files, workspace.files, filtered, options.contextText, researchText);
484
+ ? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText, Boolean(researchRuntime) && !task.fallback)
485
+ : buildReviewerTask(task.files, workspace.files, filtered, options.contextText, Boolean(researchRuntime) && !task.fallback);
485
486
  return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
486
487
  };
487
488
  const filesLabel = (files) => files.length === 1
@@ -616,6 +617,33 @@ export async function runReview(source, options) {
616
617
  }
617
618
  }
618
619
  });
620
+ if (researchRuntime) {
621
+ try {
622
+ const audited = await researchProvenanceFromAudit(researchRuntime.auditPath);
623
+ researchRecord = audited.provenance;
624
+ researchEvidence = audited.evidence;
625
+ for (const line of formatResearchProgress(researchRecord))
626
+ progress(line);
627
+ await appendStepSummary(renderResearchMarkdown(researchRecord));
628
+ }
629
+ catch (error) {
630
+ researchRecord = { queries: [], results: [], warnings: [], error: errorMessage(error) };
631
+ progress(` research audit unavailable: ${researchRecord.error}`);
632
+ }
633
+ }
634
+ // Citations are accepted only when their exact canonical URL appeared in this
635
+ // run's MCP audit. This strips invented URLs even if a model copied a plausible
636
+ // official-looking address into its structured output.
637
+ for (const [bucket, findings] of Object.entries(agentFindings)) {
638
+ const grounded = groundResearchSources(findings, researchEvidence);
639
+ agentFindings[bucket] = grounded;
640
+ for (const finding of grounded) {
641
+ if (!finding.sources?.length)
642
+ continue;
643
+ const fp = fingerprintFinding(finding);
644
+ sourcesByFp.set(fp, mergeResearchSources(sourcesByFp.get(fp), finding.sources));
645
+ }
646
+ }
619
647
  // A substituted model means the review did not run on the model this repo
620
648
  // configured — the findings may be from a weaker (or free-tier) model entirely.
621
649
  // Never silent: it goes to the log, the coverage notes, and the run log.
@@ -693,6 +721,12 @@ export async function runReview(source, options) {
693
721
  : consolidated.decision;
694
722
  output = { ...consolidated, decision, incomplete: [...new Set(coverageNotes)] };
695
723
  }
724
+ // The coordinator remains model output. Revalidate every citation against the
725
+ // allowlisted prepass before verification, persistence, or rendering.
726
+ output = {
727
+ ...output,
728
+ findings: groundResearchSources(output.findings, researchEvidence),
729
+ };
696
730
  // Guard against hallucinated findings before surfacing: quote-ground every
697
731
  // finding against the real file, and adversarially verify criticals. This is
698
732
  // what stops a confident but wrong critical from shipping.
@@ -796,6 +830,17 @@ export async function runReview(source, options) {
796
830
  else if (output.decision !== decisionBeforeChecks) {
797
831
  output = { ...output, summary: reconcileRequalifiedSummary(output.summary) };
798
832
  }
833
+ // Carry a reviewer's grounded citations through a coordinator rewrite. A changed
834
+ // fingerprint fails closed, so the engine never guesses which source applies.
835
+ if (output.findings.length > 0) {
836
+ output = {
837
+ ...output,
838
+ findings: output.findings.map((finding) => {
839
+ const sources = mergeResearchSources(finding.sources, sourcesByFp.get(fingerprintFinding(finding)));
840
+ return sources.length > 0 ? { ...finding, sources } : finding;
841
+ }),
842
+ };
843
+ }
799
844
  // Attribution: carry each surviving finding's originating agent onto the output. The
800
845
  // coordinator merges and rewrites findings, so match by fingerprint and keep the
801
846
  // first agent that produced it; a finding the coordinator changed enough to break the
@@ -891,6 +936,7 @@ export async function runReview(source, options) {
891
936
  const reviewTrace = buildReviewTrace(agentTrace);
892
937
  await safeLog(logPath, {
893
938
  ...baseRecord,
939
+ ...(researchRecord ? { research: researchRecord } : {}),
894
940
  agentCosts,
895
941
  totalCost: sum(agentCosts),
896
942
  tokens: tokenTotals,
@@ -927,6 +973,7 @@ export async function runReview(source, options) {
927
973
  catch (error) {
928
974
  await safeLog(logPath, {
929
975
  ...baseRecord,
976
+ ...(researchRecord ? { research: researchRecord } : {}),
930
977
  agentCosts,
931
978
  totalCost: sum(agentCosts),
932
979
  tokens: tokenTotals,
@@ -944,6 +991,7 @@ export async function runReview(source, options) {
944
991
  finally {
945
992
  handle.close();
946
993
  await auth.cleanup();
994
+ await researchRuntime?.cleanup();
947
995
  await restoreCwd();
948
996
  }
949
997
  }
@@ -1,4 +1,5 @@
1
1
  // @ref LLP 0005#finding-identity-fingerprints
2
+ // @ref LLP 0013#research-provenance-and-citations [implements] — optional citations are annotations, not finding identity or decision inputs
2
3
  import { createHash } from "node:crypto";
3
4
  import { z } from "zod";
4
5
  import { normalizeCode } from "./util.js";
@@ -8,6 +9,14 @@ export const SEVERITIES = ["critical", "warning", "suggestion"];
8
9
  export const SEVERITY_RANK = { critical: 0, warning: 1, suggestion: 2 };
9
10
  export const CATEGORIES = ["correctness", "quality", "security", "secrets"];
10
11
  export const DECISIONS = ["approve", "approve_with_comments", "request_changes"];
12
+ export const FindingSourceSchema = z.object({
13
+ title: z.string().min(1).max(240),
14
+ url: z
15
+ .string()
16
+ .url()
17
+ .max(2_000)
18
+ .refine((value) => new URL(value).protocol === "https:", "source URL must use HTTPS"),
19
+ });
11
20
  export const FindingSchema = z.object({
12
21
  severity: z.enum(SEVERITIES),
13
22
  category: z.enum(CATEGORIES),
@@ -16,6 +25,11 @@ export const FindingSchema = z.object({
16
25
  title: z.string(),
17
26
  rationale: z.string(),
18
27
  suggestion: z.string().optional(),
28
+ sources: z
29
+ .array(FindingSourceSchema)
30
+ .max(5)
31
+ .optional()
32
+ .describe("Exact documentation sources used to support this finding; copy title and URL from the injected research evidence and omit when unused"),
19
33
  /**
20
34
  * Verbatim snippet of the flagged code, copied from the file. Used to
21
35
  * quote-ground the finding: if this text isn't actually present in the file,
@@ -1,5 +1,9 @@
1
1
  /** The OpenCode tool names the reviewer toggles. Single source of truth so the
2
2
  * agent and coordinator tool maps can't drift apart. */
3
+ export const OPENCODE_RESEARCH_TOOLS = [
4
+ "platform_docs_search_platform_docs",
5
+ "platform_docs_fetch_platform_doc",
6
+ ];
3
7
  export const TOOL_NAMES = [
4
8
  "read",
5
9
  "grep",
@@ -9,6 +13,7 @@ export const TOOL_NAMES = [
9
13
  "write",
10
14
  "edit",
11
15
  "patch",
16
+ ...OPENCODE_RESEARCH_TOOLS,
12
17
  ];
13
18
  /** Build a full tool map with only the listed tools enabled. */
14
19
  export function toolMap(enabled) {
@@ -0,0 +1,163 @@
1
+ import { appendFile, mkdir, readFile, rmdir } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ const LOCK_RETRIES = 200;
4
+ const LOCK_DELAY_MS = 10;
5
+ function delay(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+ function boundedError(error) {
9
+ const message = error instanceof Error ? error.message : String(error);
10
+ // oxlint-disable-next-line no-control-regex -- audit lines must stay single-line JSONL
11
+ return message.replace(/[\r\n\u0000-\u001f\u007f]+/g, " ").slice(0, 500);
12
+ }
13
+ function boundedResult(result) {
14
+ return {
15
+ id: result.id.slice(0, 240),
16
+ platform: result.platform,
17
+ provider: result.provider,
18
+ sourceKind: result.sourceKind,
19
+ title: result.title.slice(0, 240),
20
+ url: result.url.slice(0, 2_000),
21
+ passage: result.passage.slice(0, 1_400),
22
+ ...(result.availability?.length
23
+ ? { availability: result.availability.slice(0, 20).map((value) => value.slice(0, 240)) }
24
+ : {}),
25
+ ...(result.framework ? { framework: result.framework.slice(0, 240) } : {}),
26
+ ...(result.language ? { language: result.language } : {}),
27
+ ...(result.symbol ? { symbol: result.symbol.slice(0, 240) } : {}),
28
+ };
29
+ }
30
+ /** Shared append-only audit and global request budget for all MCP processes in one review. */
31
+ export class ResearchAudit {
32
+ path;
33
+ maxCalls;
34
+ localReservations = 0;
35
+ constructor(path, maxCalls) {
36
+ this.path = path;
37
+ this.maxCalls = maxCalls;
38
+ }
39
+ async append(event) {
40
+ if (!this.path)
41
+ return;
42
+ await appendFile(this.path, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
43
+ }
44
+ async withLock(callback) {
45
+ if (!this.path)
46
+ return callback();
47
+ const lockPath = `${this.path}.lock`;
48
+ for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
49
+ try {
50
+ await mkdir(lockPath, { mode: 0o700 });
51
+ try {
52
+ return await callback();
53
+ }
54
+ finally {
55
+ await rmdir(lockPath).catch(() => { });
56
+ }
57
+ }
58
+ catch (error) {
59
+ if (error.code !== "EEXIST")
60
+ throw error;
61
+ await delay(LOCK_DELAY_MS);
62
+ }
63
+ }
64
+ throw new Error("Documentation research audit lock timed out");
65
+ }
66
+ async reservationCount() {
67
+ if (!this.path)
68
+ return this.localReservations;
69
+ let contents = "";
70
+ try {
71
+ contents = await readFile(this.path, "utf8");
72
+ }
73
+ catch (error) {
74
+ if (error.code !== "ENOENT")
75
+ throw error;
76
+ }
77
+ return contents.split("\n").reduce((count, line) => {
78
+ if (!line)
79
+ return count;
80
+ try {
81
+ return JSON.parse(line).type === "reserved" ? count + 1 : count;
82
+ }
83
+ catch {
84
+ return count;
85
+ }
86
+ }, 0);
87
+ }
88
+ async reserve(tool, input) {
89
+ const requestId = randomUUID();
90
+ await this.withLock(async () => {
91
+ const used = await this.reservationCount();
92
+ if (used >= this.maxCalls) {
93
+ throw new Error(`Documentation research call budget exhausted (${this.maxCalls})`);
94
+ }
95
+ if (!this.path)
96
+ this.localReservations++;
97
+ await this.append({
98
+ type: "reserved",
99
+ requestId,
100
+ tool,
101
+ input,
102
+ timestamp: new Date().toISOString(),
103
+ });
104
+ });
105
+ return requestId;
106
+ }
107
+ async complete(requestId, tool, input, results, warnings = []) {
108
+ await this.append({
109
+ type: "completed",
110
+ requestId,
111
+ tool,
112
+ input,
113
+ results: results.map(boundedResult),
114
+ warnings: warnings.slice(0, 10).map((warning) => warning.slice(0, 500)),
115
+ timestamp: new Date().toISOString(),
116
+ });
117
+ }
118
+ async fail(requestId, tool, input, error) {
119
+ await this.append({
120
+ type: "failed",
121
+ requestId,
122
+ tool,
123
+ input,
124
+ error: boundedError(error),
125
+ timestamp: new Date().toISOString(),
126
+ });
127
+ }
128
+ }
129
+ export async function readResearchAudit(path) {
130
+ let contents = "";
131
+ try {
132
+ contents = await readFile(path, "utf8");
133
+ }
134
+ catch (error) {
135
+ if (error.code === "ENOENT")
136
+ return [];
137
+ throw error;
138
+ }
139
+ const records = [];
140
+ for (const line of contents.split("\n")) {
141
+ if (!line)
142
+ continue;
143
+ try {
144
+ const event = JSON.parse(line);
145
+ if (event.type === "completed")
146
+ records.push(event);
147
+ if (event.type === "failed") {
148
+ records.push({
149
+ requestId: event.requestId,
150
+ tool: event.tool,
151
+ input: event.input,
152
+ results: [],
153
+ warnings: [],
154
+ error: event.error,
155
+ });
156
+ }
157
+ }
158
+ catch {
159
+ // Ignore a partial final line from a process that was terminated mid-write.
160
+ }
161
+ }
162
+ return records;
163
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { sanitizeDocumentationQuery } from "./query-sanitizer.js";
2
3
  import { readBodyWithLimit } from "./response.js";
3
4
  const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
4
5
  const BRAVE_RESPONSE_LIMIT_BYTES = 1_000_000;
@@ -16,18 +17,8 @@ const braveResponseSchema = z.object({
16
17
  })
17
18
  .optional(),
18
19
  });
19
- function normalizeSearchText(value) {
20
- return (value
21
- // oxlint-disable-next-line no-control-regex -- outbound query sanitization
22
- .replace(/[\u0000-\u001f\u007f]/g, " ")
23
- .replace(/\s+/g, " ")
24
- .trim());
25
- }
26
20
  export function buildScopedSearchQuery(query, scopes) {
27
- const normalized = normalizeSearchText(query);
28
- if (!normalized || normalized.length > 300) {
29
- throw new Error("Query must contain between 1 and 300 visible characters");
30
- }
21
+ const normalized = sanitizeDocumentationQuery(query);
31
22
  if (scopes.length === 0 || scopes.length > 8) {
32
23
  throw new Error("A documentation search requires between 1 and 8 fixed scopes");
33
24
  }
@@ -16,10 +16,21 @@ Usage:
16
16
 
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
- searches use Expo's public documentation index. The update command is an optional
20
- offline crawler for operator-managed fallback indexes.
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
21
+ an optional offline crawler for operator-managed fallback indexes.
21
22
  `);
22
23
  }
24
+ function boundedInteger(name, fallback, minimum, maximum) {
25
+ const raw = process.env[name];
26
+ if (!raw)
27
+ return fallback;
28
+ const value = Number(raw);
29
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
30
+ throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`);
31
+ }
32
+ return value;
33
+ }
23
34
  async function main() {
24
35
  const [command = "serve", ...rest] = process.argv.slice(2);
25
36
  if (command === "--help" || command === "-h" || command === "help") {
@@ -41,6 +52,11 @@ async function main() {
41
52
  const indexPath = values.index ?? process.env.REVIEW_RESEARCH_INDEX_PATH;
42
53
  await runStdioServer({
43
54
  ...(indexPath ? { indexPath } : {}),
55
+ ...(process.env.REVIEW_RESEARCH_AUDIT_PATH
56
+ ? { auditPath: process.env.REVIEW_RESEARCH_AUDIT_PATH }
57
+ : {}),
58
+ maxCalls: boundedInteger("REVIEW_RESEARCH_MAX_CALLS", 8, 1, 20),
59
+ maxResultsPerCall: boundedInteger("REVIEW_RESEARCH_MAX_RESULTS", 3, 1, 3),
44
60
  ...(process.env.BRAVE_SEARCH_API_KEY
45
61
  ? { braveApiKey: process.env.BRAVE_SEARCH_API_KEY }
46
62
  : {}),
@@ -0,0 +1,100 @@
1
+ import { fetchDocumentationDocument } from "./fetch-document.js";
2
+ import { chunkDocument } from "./html.js";
3
+ import { getProvider, resolveAllowedUrl } from "./providers.js";
4
+ import { assertSafeDocumentationUrlShape } from "./query-sanitizer.js";
5
+ import { buildSearchIndex, searchDocumentation } from "./search-index.js";
6
+ /** Specific corpora precede their broader host/path parents during URL inference. */
7
+ const DIRECT_PROVIDER_ORDER = [
8
+ "apple-releases",
9
+ "apple",
10
+ "swift-evolution",
11
+ "android-releases",
12
+ "media3",
13
+ "agp",
14
+ "android",
15
+ "glide",
16
+ "okhttp",
17
+ "kotlin-coroutines",
18
+ "gradle",
19
+ "jetbrains-issues",
20
+ "react-native-reanimated",
21
+ "react-native-gesture-handler",
22
+ "react-native-screens",
23
+ "react-native-worklets",
24
+ "react-native",
25
+ "expo",
26
+ ];
27
+ const DIRECT_SOURCE_KIND = {
28
+ apple: "official-api",
29
+ "apple-releases": "release-notes",
30
+ "swift-evolution": "official-guide",
31
+ android: "official-api",
32
+ "android-releases": "release-notes",
33
+ media3: "official-guide",
34
+ glide: "official-guide",
35
+ okhttp: "official-guide",
36
+ "kotlin-coroutines": "official-guide",
37
+ gradle: "official-guide",
38
+ agp: "release-notes",
39
+ "jetbrains-issues": "issue-tracker",
40
+ expo: "official-api",
41
+ "react-native": "official-api",
42
+ "react-native-reanimated": "official-guide",
43
+ "react-native-gesture-handler": "official-guide",
44
+ "react-native-screens": "official-guide",
45
+ "react-native-worklets": "official-guide",
46
+ };
47
+ /** Resolve a caller-supplied URL against the fixed provider allowlist. */
48
+ export function resolveDirectDocumentationTarget(rawUrl, providerHint) {
49
+ assertSafeDocumentationUrlShape(rawUrl);
50
+ const candidates = providerHint ? [providerHint] : DIRECT_PROVIDER_ORDER;
51
+ for (const providerId of candidates) {
52
+ const provider = getProvider(providerId);
53
+ try {
54
+ return {
55
+ provider: providerId,
56
+ sourceKind: DIRECT_SOURCE_KIND[providerId],
57
+ url: resolveAllowedUrl(provider, rawUrl),
58
+ };
59
+ }
60
+ catch {
61
+ // Try the next fixed provider. No caller-controlled host is ever admitted.
62
+ }
63
+ }
64
+ throw new Error(providerHint
65
+ ? `URL is outside the ${providerHint} documentation allowlist`
66
+ : "URL is outside the supported documentation allowlist");
67
+ }
68
+ /** Fetch one allowlisted documentation URL and return bounded extracted passages. */
69
+ export async function fetchDocumentationUrl(rawUrl, options = {}) {
70
+ const target = resolveDirectDocumentationTarget(rawUrl, options.provider);
71
+ const provider = getProvider(target.provider);
72
+ const document = await fetchDocumentationDocument(provider, target.url.href, target.sourceKind, options.fetchImplementation ?? fetch);
73
+ if (!document) {
74
+ throw new Error(`No readable documentation content at ${target.url.href}`);
75
+ }
76
+ const indexedAt = new Date().toISOString();
77
+ const chunks = chunkDocument(document, indexedAt);
78
+ 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
+ }
90
+ }
91
+ else {
92
+ results = chunks.slice(0, limit).map((chunk) => ({ ...chunk, score: 0 }));
93
+ }
94
+ return {
95
+ provider: target.provider,
96
+ sourceKind: target.sourceKind,
97
+ canonicalUrl: target.url.href,
98
+ results,
99
+ };
100
+ }