@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.
@@ -0,0 +1,160 @@
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
+ "sdwebimage",
10
+ "apple",
11
+ "swift-evolution",
12
+ "android-releases",
13
+ "media3",
14
+ "agp",
15
+ "android",
16
+ "glide",
17
+ "okhttp",
18
+ "kotlin-coroutines",
19
+ "gradle",
20
+ "jetbrains-issues",
21
+ "react-native-reanimated",
22
+ "react-native-gesture-handler",
23
+ "react-native-screens",
24
+ "react-native-worklets",
25
+ "react-native",
26
+ "expo",
27
+ ];
28
+ const DIRECT_SOURCE_KIND = {
29
+ apple: "official-api",
30
+ "apple-releases": "release-notes",
31
+ "swift-evolution": "official-guide",
32
+ sdwebimage: "official-api",
33
+ android: "official-api",
34
+ "android-releases": "release-notes",
35
+ media3: "official-guide",
36
+ glide: "official-guide",
37
+ okhttp: "official-guide",
38
+ "kotlin-coroutines": "official-guide",
39
+ gradle: "official-guide",
40
+ agp: "release-notes",
41
+ "jetbrains-issues": "issue-tracker",
42
+ expo: "official-api",
43
+ "react-native": "official-api",
44
+ "react-native-reanimated": "official-guide",
45
+ "react-native-gesture-handler": "official-guide",
46
+ "react-native-screens": "official-guide",
47
+ "react-native-worklets": "official-guide",
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
+ }
91
+ /** Resolve a caller-supplied URL against the fixed provider allowlist. */
92
+ export function resolveDirectDocumentationTarget(rawUrl, providerHint) {
93
+ assertSafeDocumentationUrlShape(rawUrl);
94
+ const candidates = providerHint ? [providerHint] : DIRECT_PROVIDER_ORDER;
95
+ for (const providerId of candidates) {
96
+ const provider = getProvider(providerId);
97
+ try {
98
+ return {
99
+ provider: providerId,
100
+ sourceKind: DIRECT_SOURCE_KIND[providerId],
101
+ url: resolveAllowedUrl(provider, rawUrl),
102
+ };
103
+ }
104
+ catch {
105
+ // Try the next fixed provider. No caller-controlled host is ever admitted.
106
+ }
107
+ }
108
+ throw new Error(providerHint
109
+ ? `URL is outside the ${providerHint} documentation allowlist`
110
+ : "URL is outside the supported documentation allowlist");
111
+ }
112
+ /** Fetch one allowlisted documentation URL and return bounded extracted passages. */
113
+ export async function fetchDocumentationUrl(rawUrl, options = {}) {
114
+ const target = resolveDirectDocumentationTarget(rawUrl, options.provider);
115
+ const provider = getProvider(target.provider);
116
+ const document = await fetchDocumentationDocument(provider, target.url.href, target.sourceKind, options.fetchImplementation ?? fetch);
117
+ if (!document) {
118
+ throw new Error(`No readable documentation content at ${target.url.href}`);
119
+ }
120
+ const indexedAt = new Date().toISOString();
121
+ const chunks = chunkDocument(document, indexedAt);
122
+ const limit = Math.min(5, Math.max(1, options.limit ?? 3));
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);
128
+ }
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
+ ];
142
+ }
143
+ const returnedCharacters = results.reduce((total, result) => total + result.passage.length, 0);
144
+ return {
145
+ provider: target.provider,
146
+ sourceKind: target.sourceKind,
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
+ },
158
+ results,
159
+ };
160
+ }
@@ -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,
@@ -0,0 +1,146 @@
1
+ const MAX_QUERY_CHARACTERS = 160;
2
+ const MAX_QUERY_TOKENS = 8;
3
+ const MAX_TOKEN_CHARACTERS = 64;
4
+ const SECRET_SHAPE = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\bAKIA[0-9A-Z]{16}\b|\b(?:sk|xox[baprs]|gh[opusr])[-_][A-Za-z0-9_-]{12,}\b|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b)/i;
5
+ const NAMED_SECRET = /\b(?:api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|password|passwd|secret|credential)\b\s*(?::|=|is)?\s*\S+/i;
6
+ const QUOTED_LITERAL = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g;
7
+ const URL_OR_EMAIL = /\b(?:https?:\/\/|www\.)\S+|\b[^\s@]+@[^\s@]+\.[^\s@]+\b/gi;
8
+ const PATH_LIKE = /(?:^|\s)(?:\.{0,2}[\\/]|[A-Za-z]:[\\/]|~[\\/])\S+/g;
9
+ const QUERY_TOKEN = /[A-Za-z_][A-Za-z0-9_.:$#<>()[\]-]{0,63}|\b\d{1,4}(?:\.\d{1,3}){0,2}\b/g;
10
+ const PROSE_STOP_WORDS = new Set([
11
+ "a",
12
+ "about",
13
+ "an",
14
+ "and",
15
+ "are",
16
+ "can",
17
+ "could",
18
+ "do",
19
+ "does",
20
+ "documentation",
21
+ "explain",
22
+ "find",
23
+ "for",
24
+ "from",
25
+ "how",
26
+ "i",
27
+ "in",
28
+ "is",
29
+ "it",
30
+ "of",
31
+ "on",
32
+ "or",
33
+ "please",
34
+ "search",
35
+ "show",
36
+ "tell",
37
+ "the",
38
+ "this",
39
+ "to",
40
+ "what",
41
+ "when",
42
+ "where",
43
+ "why",
44
+ "with",
45
+ "work",
46
+ "works",
47
+ ]);
48
+ function shannonEntropy(value) {
49
+ const counts = new Map();
50
+ for (const character of value)
51
+ counts.set(character, (counts.get(character) ?? 0) + 1);
52
+ let entropy = 0;
53
+ for (const count of counts.values()) {
54
+ const probability = count / value.length;
55
+ entropy -= probability * Math.log2(probability);
56
+ }
57
+ return entropy;
58
+ }
59
+ function looksHighEntropy(value) {
60
+ if (/^[A-Fa-f0-9]{24,}$/.test(value))
61
+ return true;
62
+ if (/^[A-Za-z0-9+/]{20,}={0,2}$/.test(value) && /[+/=]/.test(value))
63
+ return true;
64
+ return (value.length >= 24 &&
65
+ /[a-z]/.test(value) &&
66
+ /[A-Z]/.test(value) &&
67
+ /\d/.test(value) &&
68
+ shannonEntropy(value) >= 4.2);
69
+ }
70
+ function isApiAnchor(value) {
71
+ return (/[a-z][A-Z]/.test(value) ||
72
+ /[A-Z][A-Za-z0-9_]{1,}/.test(value) ||
73
+ /[._:$#()]/.test(value) ||
74
+ /_[A-Z0-9]/.test(value));
75
+ }
76
+ /**
77
+ * Convert an agent-authored search into a short API-symbol query. Dangerous shapes
78
+ * fail closed; prose, literals, URLs, paths, and unsupported punctuation are removed.
79
+ */
80
+ export function sanitizeDocumentationQuery(rawQuery) {
81
+ const visible = rawQuery
82
+ .normalize("NFKC")
83
+ // oxlint-disable-next-line no-control-regex -- outbound query containment
84
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
85
+ .replace(/\s+/g, " ")
86
+ .trim();
87
+ if (!visible || visible.length > 300) {
88
+ throw new Error("Query must contain between 1 and 300 visible characters");
89
+ }
90
+ if (SECRET_SHAPE.test(visible) || NAMED_SECRET.test(visible)) {
91
+ throw new Error("Query contains credential-shaped or secret-labeled material");
92
+ }
93
+ const candidates = (visible
94
+ .replace(QUOTED_LITERAL, " ")
95
+ .replace(URL_OR_EMAIL, " ")
96
+ .replace(PATH_LIKE, " ")
97
+ .match(QUERY_TOKEN) ?? []).filter((token) => {
98
+ if (token.length > MAX_TOKEN_CHARACTERS || looksHighEntropy(token))
99
+ return false;
100
+ return !PROSE_STOP_WORDS.has(token.toLowerCase());
101
+ });
102
+ 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");
105
+ }
106
+ const sanitized = unique.join(" ").slice(0, MAX_QUERY_CHARACTERS).trim();
107
+ if (!sanitized)
108
+ throw new Error("Query contained no safe documentation terms");
109
+ return sanitized;
110
+ }
111
+ /** Reject URL decorations and path segments that could encode arbitrary outbound data. */
112
+ export function assertSafeDocumentationUrlShape(rawUrl) {
113
+ let url;
114
+ try {
115
+ url = new URL(rawUrl);
116
+ }
117
+ catch {
118
+ throw new Error("Invalid documentation URL");
119
+ }
120
+ if (url.protocol !== "https:" || url.username || url.password || url.port) {
121
+ throw new Error("Documentation URL must use plain HTTPS without credentials or a port");
122
+ }
123
+ if (url.search || url.hash) {
124
+ throw new Error("Documentation URL must not contain a query string or fragment");
125
+ }
126
+ const segments = url.pathname.split("/").filter(Boolean);
127
+ for (const segment of segments) {
128
+ let decoded;
129
+ try {
130
+ decoded = decodeURIComponent(segment);
131
+ }
132
+ catch {
133
+ throw new Error("Documentation URL contains an invalid encoded path segment");
134
+ }
135
+ // oxlint-disable-next-line no-control-regex -- URL path is an outbound data boundary
136
+ const containsControlCharacter = /[\\/\u0000-\u001f\u007f]/.test(decoded);
137
+ if (decoded.length > 120 ||
138
+ containsControlCharacter ||
139
+ SECRET_SHAPE.test(decoded) ||
140
+ NAMED_SECRET.test(decoded) ||
141
+ looksHighEntropy(decoded)) {
142
+ throw new Error("Documentation URL contains a suspicious path segment");
143
+ }
144
+ }
145
+ return url;
146
+ }
@@ -17,6 +17,10 @@ const providerSearchDefinitions = {
17
17
  scopes: ["github.com/swiftlang/swift-evolution/blob/main/proposals"],
18
18
  sourceKind: "official-guide",
19
19
  },
20
+ sdwebimage: {
21
+ scopes: ["sdwebimage.github.io/documentation/sdwebimage"],
22
+ sourceKind: "official-api",
23
+ },
20
24
  android: {
21
25
  scopes: ["developer.android.com/reference"],
22
26
  sourceKind: "official-api",
@@ -15,6 +15,8 @@ const miniSearchOptions = {
15
15
  "symbol",
16
16
  "language",
17
17
  "availability",
18
+ "previousPassageId",
19
+ "nextPassageId",
18
20
  "indexedAt",
19
21
  ],
20
22
  };
@@ -125,6 +127,8 @@ export function searchDocumentation(index, query, options) {
125
127
  ...(match.symbol ? { symbol: String(match.symbol) } : {}),
126
128
  ...(match.language ? { language: match.language } : {}),
127
129
  ...(Array.isArray(match.availability) ? { availability: match.availability.map(String) } : {}),
130
+ ...(match.previousPassageId ? { previousPassageId: String(match.previousPassageId) } : {}),
131
+ ...(match.nextPassageId ? { nextPassageId: String(match.nextPassageId) } : {}),
128
132
  indexedAt: String(match.indexedAt),
129
133
  score: match.score,
130
134
  }));