@expo/code-review-cli 0.12.0 → 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.
- package/README.md +79 -43
- package/build/commands/init.js +3 -2
- package/build/config/schema.js +0 -9
- package/build/core/auth.js +1 -0
- package/build/core/claude-code.js +23 -3
- package/build/core/opencode.js +23 -4
- package/build/core/prompts.js +35 -6
- package/build/core/render.js +13 -0
- package/build/core/research.js +232 -9
- package/build/core/review.js +72 -24
- package/build/core/schema.js +14 -0
- package/build/core/tools.js +5 -0
- package/build/research-mcp/apple-docc.js +75 -36
- package/build/research-mcp/audit.js +163 -0
- package/build/research-mcp/brave-search.js +68 -0
- package/build/research-mcp/cli.js +29 -7
- package/build/research-mcp/crawler.js +2 -48
- package/build/research-mcp/direct-fetch.js +100 -0
- package/build/research-mcp/fetch-document.js +76 -0
- package/build/research-mcp/html.js +7 -1
- package/build/research-mcp/okhttp-search.js +94 -0
- package/build/research-mcp/query-sanitizer.js +146 -0
- package/build/research-mcp/remote-search.js +175 -0
- package/build/research-mcp/server.js +236 -65
- package/package.json +1 -1
- package/templates/atlantis.yml +2 -0
- package/templates/command.yml +2 -0
- package/templates/config.jsonc +5 -8
- package/templates/coordinator.md +2 -0
- package/templates/shared.md +7 -1
- package/templates/workflow.yml +2 -0
|
@@ -14,48 +14,87 @@ function inlineText(value) {
|
|
|
14
14
|
.join("")
|
|
15
15
|
.trim();
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
function collectReadableText(value, output, key) {
|
|
30
|
-
if (typeof value === "string") {
|
|
31
|
-
if (key && readableKeys.has(key))
|
|
32
|
-
output.push(value);
|
|
33
|
-
return;
|
|
17
|
+
function cleanBlock(value) {
|
|
18
|
+
return value.replace(/\s+/g, " ").trim();
|
|
19
|
+
}
|
|
20
|
+
function referenceTitle(identifier, references) {
|
|
21
|
+
if (typeof identifier !== "string")
|
|
22
|
+
return "";
|
|
23
|
+
const reference = objectValue(references?.[identifier]);
|
|
24
|
+
return typeof reference?.title === "string" ? reference.title : "";
|
|
25
|
+
}
|
|
26
|
+
function inlineContentText(value, references) {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.map((item) => inlineContentText(item, references)).join("");
|
|
34
29
|
}
|
|
30
|
+
const object = objectValue(value);
|
|
31
|
+
if (!object)
|
|
32
|
+
return "";
|
|
33
|
+
if (typeof object.text === "string")
|
|
34
|
+
return object.text;
|
|
35
|
+
if (typeof object.code === "string")
|
|
36
|
+
return object.code;
|
|
37
|
+
if (object.type === "reference")
|
|
38
|
+
return referenceTitle(object.identifier, references);
|
|
39
|
+
return Object.values(object)
|
|
40
|
+
.map((child) => inlineContentText(child, references))
|
|
41
|
+
.join("");
|
|
42
|
+
}
|
|
43
|
+
function collectReadableBlocks(value, references, output) {
|
|
35
44
|
if (Array.isArray(value)) {
|
|
36
45
|
for (const item of value)
|
|
37
|
-
|
|
46
|
+
collectReadableBlocks(item, references, output);
|
|
38
47
|
return;
|
|
39
48
|
}
|
|
40
49
|
const object = objectValue(value);
|
|
41
50
|
if (!object)
|
|
42
51
|
return;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
if (Array.isArray(object.tokens)) {
|
|
53
|
+
const declaration = cleanBlock(inlineContentText(object.tokens, references));
|
|
54
|
+
if (declaration)
|
|
55
|
+
output.push(declaration);
|
|
56
|
+
return;
|
|
46
57
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
if (object.type === "paragraph") {
|
|
59
|
+
const paragraph = cleanBlock(inlineContentText(object.inlineContent, references));
|
|
60
|
+
if (paragraph)
|
|
61
|
+
output.push(paragraph);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (object.type === "heading" && typeof object.text === "string") {
|
|
65
|
+
const heading = cleanBlock(object.text);
|
|
66
|
+
if (heading)
|
|
67
|
+
output.push(heading);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (object.type === "codeListing" && Array.isArray(object.code)) {
|
|
71
|
+
const code = object.code.filter((line) => typeof line === "string").join("\n");
|
|
72
|
+
if (code.trim())
|
|
73
|
+
output.push(code.trim());
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (typeof object.name === "string" && Array.isArray(object.content)) {
|
|
77
|
+
const content = [];
|
|
78
|
+
collectReadableBlocks(object.content, references, content);
|
|
79
|
+
const body = content.join("\n\n");
|
|
80
|
+
output.push(body ? `${object.name}: ${body}` : object.name);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (object.type === "aside" && Array.isArray(object.content)) {
|
|
84
|
+
const content = [];
|
|
85
|
+
collectReadableBlocks(object.content, references, content);
|
|
86
|
+
const label = typeof object.name === "string"
|
|
87
|
+
? object.name
|
|
88
|
+
: typeof object.style === "string"
|
|
89
|
+
? object.style
|
|
90
|
+
: "Note";
|
|
91
|
+
if (content.length > 0)
|
|
92
|
+
output.push(`${label}: ${content.join("\n\n")}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const child of Object.values(object)) {
|
|
96
|
+
collectReadableBlocks(child, references, output);
|
|
57
97
|
}
|
|
58
|
-
return output.join("\n\n");
|
|
59
98
|
}
|
|
60
99
|
function languageFromIdentifier(identifier) {
|
|
61
100
|
const language = identifier?.interfaceLanguage;
|
|
@@ -76,10 +115,10 @@ export function extractAppleDocCPage(json, url, source = {}) {
|
|
|
76
115
|
const abstract = inlineText(root.abstract);
|
|
77
116
|
if (abstract)
|
|
78
117
|
text.push(abstract);
|
|
79
|
-
collectReadableText(root.primaryContentSections, text);
|
|
80
|
-
collectReadableText(root.relationshipsSections, text);
|
|
81
|
-
const links = new Set();
|
|
82
118
|
const references = objectValue(root.references);
|
|
119
|
+
collectReadableBlocks(root.primaryContentSections, references, text);
|
|
120
|
+
collectReadableBlocks(root.relationshipsSections, references, text);
|
|
121
|
+
const links = new Set();
|
|
83
122
|
for (const value of Object.values(references ?? {})) {
|
|
84
123
|
const reference = objectValue(value);
|
|
85
124
|
if (!reference || typeof reference.url !== "string")
|
|
@@ -88,7 +127,7 @@ export function extractAppleDocCPage(json, url, source = {}) {
|
|
|
88
127
|
links.add(reference.url);
|
|
89
128
|
}
|
|
90
129
|
}
|
|
91
|
-
const body =
|
|
130
|
+
const body = [...new Set(text.map(cleanBlock).filter(Boolean))].join("\n\n");
|
|
92
131
|
if (body.length < 40)
|
|
93
132
|
return null;
|
|
94
133
|
const platforms = Array.isArray(metadata.platforms) ? metadata.platforms : [];
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { sanitizeDocumentationQuery } from "./query-sanitizer.js";
|
|
3
|
+
import { readBodyWithLimit } from "./response.js";
|
|
4
|
+
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
|
5
|
+
const BRAVE_RESPONSE_LIMIT_BYTES = 1_000_000;
|
|
6
|
+
const BRAVE_TIMEOUT_MS = 10_000;
|
|
7
|
+
const braveResponseSchema = z.object({
|
|
8
|
+
web: z
|
|
9
|
+
.object({
|
|
10
|
+
results: z
|
|
11
|
+
.array(z.object({
|
|
12
|
+
title: z.string().min(1).max(500),
|
|
13
|
+
url: z.string().url().max(2_000),
|
|
14
|
+
description: z.string().max(2_000).optional(),
|
|
15
|
+
}))
|
|
16
|
+
.max(50),
|
|
17
|
+
})
|
|
18
|
+
.optional(),
|
|
19
|
+
});
|
|
20
|
+
export function buildScopedSearchQuery(query, scopes) {
|
|
21
|
+
const normalized = sanitizeDocumentationQuery(query);
|
|
22
|
+
if (scopes.length === 0 || scopes.length > 8) {
|
|
23
|
+
throw new Error("A documentation search requires between 1 and 8 fixed scopes");
|
|
24
|
+
}
|
|
25
|
+
const scopeExpression = scopes.length === 1
|
|
26
|
+
? `site:${scopes[0]}`
|
|
27
|
+
: `(${scopes.map((scope) => `site:${scope}`).join(" OR ")})`;
|
|
28
|
+
const scopedQuery = `${scopeExpression} ${normalized}`;
|
|
29
|
+
if (scopedQuery.length > 400 || scopedQuery.split(/\s+/).length > 50) {
|
|
30
|
+
throw new Error("Scoped query exceeds Brave Search limits");
|
|
31
|
+
}
|
|
32
|
+
return scopedQuery;
|
|
33
|
+
}
|
|
34
|
+
export async function searchBrave(query, scopes, limit, apiKey, fetchImplementation = fetch) {
|
|
35
|
+
if (!apiKey.trim()) {
|
|
36
|
+
throw new Error("BRAVE_SEARCH_API_KEY is not set");
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
|
|
39
|
+
throw new Error("Search result limit must be between 1 and 10");
|
|
40
|
+
}
|
|
41
|
+
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
|
42
|
+
url.searchParams.set("q", buildScopedSearchQuery(query, scopes));
|
|
43
|
+
url.searchParams.set("count", String(limit));
|
|
44
|
+
url.searchParams.set("search_lang", "en");
|
|
45
|
+
url.searchParams.set("safesearch", "moderate");
|
|
46
|
+
const response = await fetchImplementation(url, {
|
|
47
|
+
redirect: "manual",
|
|
48
|
+
signal: AbortSignal.timeout(BRAVE_TIMEOUT_MS),
|
|
49
|
+
headers: {
|
|
50
|
+
accept: "application/json",
|
|
51
|
+
"accept-encoding": "gzip",
|
|
52
|
+
"x-subscription-token": apiKey,
|
|
53
|
+
"user-agent": "review-research-mcp/0.2 (+scoped documentation search)",
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
if (response.status >= 300 && response.status < 400) {
|
|
57
|
+
throw new Error(`Brave Search unexpectedly redirected with HTTP ${response.status}`);
|
|
58
|
+
}
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
throw new Error(`Brave Search returned HTTP ${response.status}`);
|
|
61
|
+
}
|
|
62
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
63
|
+
if (!contentType.includes("application/json")) {
|
|
64
|
+
throw new Error(`Brave Search returned unsupported content type: ${contentType || "missing"}`);
|
|
65
|
+
}
|
|
66
|
+
const parsed = braveResponseSchema.parse(JSON.parse(await readBodyWithLimit(response, BRAVE_RESPONSE_LIMIT_BYTES)));
|
|
67
|
+
return parsed.web?.results ?? [];
|
|
68
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ref LLP 0013#one-package-two-binaries [implements] — the package's second binary owns serve/update dispatch
|
|
3
|
-
// @ref LLP 0013#
|
|
3
|
+
// @ref LLP 0013#search-fetch-and-optional-index-boundary [implements] — review-facing serve and operator-only update stay separate
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { defaultConfigPath
|
|
5
|
+
import { defaultConfigPath } from "./paths.js";
|
|
6
6
|
import { runStdioServer } from "./server.js";
|
|
7
7
|
import { PLATFORMS } from "./types.js";
|
|
8
8
|
function printHelp() {
|
|
@@ -14,11 +14,23 @@ Usage:
|
|
|
14
14
|
review-research-mcp update [--config PATH] [--output PATH]
|
|
15
15
|
[--platform apple|android|react-native] [--max-pages NUMBER]
|
|
16
16
|
|
|
17
|
-
The serve command uses
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
The serve command uses BRAVE_SEARCH_API_KEY for scoped web discovery, fetches only
|
|
18
|
+
allowlisted official pages, and optionally falls back to a local index. Expo-provider
|
|
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.
|
|
20
22
|
`);
|
|
21
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
|
+
}
|
|
22
34
|
async function main() {
|
|
23
35
|
const [command = "serve", ...rest] = process.argv.slice(2);
|
|
24
36
|
if (command === "--help" || command === "-h" || command === "help") {
|
|
@@ -37,8 +49,18 @@ async function main() {
|
|
|
37
49
|
},
|
|
38
50
|
strict: true,
|
|
39
51
|
});
|
|
40
|
-
const indexPath = values.index ?? process.env.REVIEW_RESEARCH_INDEX_PATH
|
|
41
|
-
await runStdioServer(
|
|
52
|
+
const indexPath = values.index ?? process.env.REVIEW_RESEARCH_INDEX_PATH;
|
|
53
|
+
await runStdioServer({
|
|
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),
|
|
60
|
+
...(process.env.BRAVE_SEARCH_API_KEY
|
|
61
|
+
? { braveApiKey: process.env.BRAVE_SEARCH_API_KEY }
|
|
62
|
+
: {}),
|
|
63
|
+
});
|
|
42
64
|
return;
|
|
43
65
|
}
|
|
44
66
|
if (command === "update") {
|
|
@@ -2,10 +2,10 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { extractAppleDocCPage } from "./apple-docc.js";
|
|
5
|
+
import { fetchAllowedContent } from "./fetch-document.js";
|
|
5
6
|
import { chunkDocument, extractDocumentationPage } from "./html.js";
|
|
6
7
|
import { extractMarkdownDocumentationPage } from "./markdown.js";
|
|
7
|
-
import { getProvider,
|
|
8
|
-
import { readBodyWithLimit } from "./response.js";
|
|
8
|
+
import { getProvider, resolveAllowedUrl } from "./providers.js";
|
|
9
9
|
import { buildSearchIndex, writeSearchIndex } from "./search-index.js";
|
|
10
10
|
import { extractYouTrackIssue } from "./youtrack.js";
|
|
11
11
|
import { PLATFORMS, PROVIDERS, SOURCE_KINDS, } from "./types.js";
|
|
@@ -31,52 +31,6 @@ const sourcesConfigSchema = z.object({
|
|
|
31
31
|
function sleep(milliseconds) {
|
|
32
32
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
33
33
|
}
|
|
34
|
-
async function fetchAllowedContent(provider, documentUrl, limits) {
|
|
35
|
-
let currentUrl = resolveAllowedRequestUrl(provider, provider.requestUrl(documentUrl).href);
|
|
36
|
-
for (let redirectCount = 0; redirectCount <= 5; redirectCount++) {
|
|
37
|
-
const response = await fetch(currentUrl, {
|
|
38
|
-
redirect: "manual",
|
|
39
|
-
signal: AbortSignal.timeout(limits.timeoutMs),
|
|
40
|
-
headers: {
|
|
41
|
-
"accept-language": "en-US,en;q=0.9",
|
|
42
|
-
accept: (() => {
|
|
43
|
-
const format = provider.responseFormat(documentUrl);
|
|
44
|
-
if (format === "docc-json" || format === "youtrack-json") {
|
|
45
|
-
return "application/json";
|
|
46
|
-
}
|
|
47
|
-
if (format === "markdown") {
|
|
48
|
-
return "text/markdown,text/plain;q=0.9";
|
|
49
|
-
}
|
|
50
|
-
return "text/html,application/xhtml+xml;q=0.9";
|
|
51
|
-
})(),
|
|
52
|
-
"user-agent": "review-research-mcp/0.1 (+local documentation indexer)",
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
if (response.status >= 300 && response.status < 400) {
|
|
56
|
-
const location = response.headers.get("location");
|
|
57
|
-
if (!location) {
|
|
58
|
-
throw new Error(`redirect ${response.status} did not include Location`);
|
|
59
|
-
}
|
|
60
|
-
currentUrl = resolveAllowedRequestUrl(provider, location, currentUrl.href);
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (!response.ok) {
|
|
64
|
-
throw new Error(`HTTP ${response.status}`);
|
|
65
|
-
}
|
|
66
|
-
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
67
|
-
const expectedFormat = provider.responseFormat(documentUrl);
|
|
68
|
-
const isExpectedType = expectedFormat === "docc-json" || expectedFormat === "youtrack-json"
|
|
69
|
-
? contentType.includes("json")
|
|
70
|
-
: expectedFormat === "markdown"
|
|
71
|
-
? contentType.includes("text/plain") || contentType.includes("text/markdown")
|
|
72
|
-
: contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
|
|
73
|
-
if (!isExpectedType) {
|
|
74
|
-
throw new Error(`unsupported content type: ${contentType || "missing"}`);
|
|
75
|
-
}
|
|
76
|
-
return readBodyWithLimit(response, limits.maxResponseBytes);
|
|
77
|
-
}
|
|
78
|
-
throw new Error("too many redirects");
|
|
79
|
-
}
|
|
80
34
|
async function crawlProvider(provider, source, limits) {
|
|
81
35
|
const queue = [];
|
|
82
36
|
const errors = [];
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { extractAppleDocCPage } from "./apple-docc.js";
|
|
2
|
+
import { extractDocumentationPage } from "./html.js";
|
|
3
|
+
import { extractMarkdownDocumentationPage } from "./markdown.js";
|
|
4
|
+
import { resolveAllowedRequestUrl, resolveAllowedUrl, } from "./providers.js";
|
|
5
|
+
import { readBodyWithLimit } from "./response.js";
|
|
6
|
+
import { extractYouTrackIssue } from "./youtrack.js";
|
|
7
|
+
export const onDemandFetchLimits = {
|
|
8
|
+
maxPagesPerProvider: 10,
|
|
9
|
+
maxDepth: 0,
|
|
10
|
+
delayMs: 0,
|
|
11
|
+
timeoutMs: 10_000,
|
|
12
|
+
maxResponseBytes: 5_000_000,
|
|
13
|
+
};
|
|
14
|
+
export async function fetchAllowedContent(provider, documentUrl, limits, fetchImplementation = fetch) {
|
|
15
|
+
let currentUrl = resolveAllowedRequestUrl(provider, provider.requestUrl(documentUrl).href);
|
|
16
|
+
for (let redirectCount = 0; redirectCount <= 5; redirectCount++) {
|
|
17
|
+
const response = await fetchImplementation(currentUrl, {
|
|
18
|
+
redirect: "manual",
|
|
19
|
+
signal: AbortSignal.timeout(limits.timeoutMs),
|
|
20
|
+
headers: {
|
|
21
|
+
"accept-language": "en-US,en;q=0.9",
|
|
22
|
+
accept: (() => {
|
|
23
|
+
const format = provider.responseFormat(documentUrl);
|
|
24
|
+
if (format === "docc-json" || format === "youtrack-json") {
|
|
25
|
+
return "application/json";
|
|
26
|
+
}
|
|
27
|
+
if (format === "markdown") {
|
|
28
|
+
return "text/markdown,text/plain;q=0.9";
|
|
29
|
+
}
|
|
30
|
+
return "text/html,application/xhtml+xml;q=0.9";
|
|
31
|
+
})(),
|
|
32
|
+
"user-agent": "review-research-mcp/0.2 (+on-demand official documentation fetcher)",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
if (response.status >= 300 && response.status < 400) {
|
|
36
|
+
const location = response.headers.get("location");
|
|
37
|
+
if (!location) {
|
|
38
|
+
throw new Error(`redirect ${response.status} did not include Location`);
|
|
39
|
+
}
|
|
40
|
+
currentUrl = resolveAllowedRequestUrl(provider, location, currentUrl.href);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`HTTP ${response.status}`);
|
|
45
|
+
}
|
|
46
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
47
|
+
const expectedFormat = provider.responseFormat(documentUrl);
|
|
48
|
+
const isExpectedType = expectedFormat === "docc-json" || expectedFormat === "youtrack-json"
|
|
49
|
+
? contentType.includes("json")
|
|
50
|
+
: expectedFormat === "markdown"
|
|
51
|
+
? contentType.includes("text/plain") || contentType.includes("text/markdown")
|
|
52
|
+
: contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
|
|
53
|
+
if (!isExpectedType) {
|
|
54
|
+
throw new Error(`unsupported content type: ${contentType || "missing"}`);
|
|
55
|
+
}
|
|
56
|
+
return readBodyWithLimit(response, limits.maxResponseBytes);
|
|
57
|
+
}
|
|
58
|
+
throw new Error("too many redirects");
|
|
59
|
+
}
|
|
60
|
+
export async function fetchDocumentationDocument(provider, rawUrl, sourceKind, fetchImplementation = fetch) {
|
|
61
|
+
const documentUrl = resolveAllowedUrl(provider, rawUrl);
|
|
62
|
+
const content = await fetchAllowedContent(provider, documentUrl, onDemandFetchLimits, fetchImplementation);
|
|
63
|
+
const source = {
|
|
64
|
+
provider: provider.id,
|
|
65
|
+
sourceKind,
|
|
66
|
+
};
|
|
67
|
+
const format = provider.responseFormat(documentUrl);
|
|
68
|
+
const extracted = format === "docc-json"
|
|
69
|
+
? extractAppleDocCPage(content, documentUrl.href, source)
|
|
70
|
+
: format === "markdown"
|
|
71
|
+
? extractMarkdownDocumentationPage(content, documentUrl.href, provider.platform, source)
|
|
72
|
+
: format === "youtrack-json"
|
|
73
|
+
? extractYouTrackIssue(content, documentUrl.href, source)
|
|
74
|
+
: extractDocumentationPage(content, documentUrl.href, provider.platform, source);
|
|
75
|
+
return extracted?.document ?? null;
|
|
76
|
+
}
|