@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.
- package/README.md +71 -17
- package/build/core/claude-code.js +23 -3
- package/build/core/opencode.js +23 -4
- package/build/core/prompts.js +53 -6
- package/build/core/render.js +13 -0
- package/build/core/research.js +352 -34
- package/build/core/review.js +101 -24
- package/build/core/schema.js +32 -0
- package/build/core/tools.js +5 -0
- package/build/research-mcp/audit.js +170 -0
- package/build/research-mcp/brave-search.js +2 -11
- package/build/research-mcp/cli.js +19 -2
- package/build/research-mcp/direct-fetch.js +160 -0
- package/build/research-mcp/html.js +4 -1
- package/build/research-mcp/providers.js +38 -0
- package/build/research-mcp/query-sanitizer.js +146 -0
- package/build/research-mcp/remote-search.js +4 -0
- package/build/research-mcp/search-index.js +4 -0
- package/build/research-mcp/server.js +221 -109
- package/build/research-mcp/types.js +1 -0
- package/package.json +1 -1
- package/research/sources.json +12 -0
- package/templates/config.jsonc +6 -6
- package/templates/coordinator.md +2 -0
- package/templates/shared.md +21 -2
package/build/core/review.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
@@ -133,25 +133,8 @@ export async function runReview(source, options) {
|
|
|
133
133
|
});
|
|
134
134
|
return output;
|
|
135
135
|
}
|
|
136
|
-
let
|
|
137
|
-
|
|
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
|
}
|
|
@@ -309,6 +307,9 @@ export async function runReview(source, options) {
|
|
|
309
307
|
// reviewers produced before the failure — partial findings are exactly what's
|
|
310
308
|
// needed to debug a run that died mid-way.
|
|
311
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 = {};
|
|
312
313
|
// Bounded, conclusion-only diagnostics for machine consumers of the hidden
|
|
313
314
|
// comment state. These are deliberately separate from findings: they never reach
|
|
314
315
|
// the coordinator, verification, policy, or decision paths.
|
|
@@ -319,6 +320,9 @@ export async function runReview(source, options) {
|
|
|
319
320
|
// run log stay byte-identical (attribution is engine metadata, never sent to a model).
|
|
320
321
|
// @ref LLP 0011#attribution-and-identity [constrained-by] — engine-set, excluded from fingerprintFinding, so attribution never re-keys a dismissal
|
|
321
322
|
const agentByFp = new Map();
|
|
323
|
+
// Grounded source citations ride through coordinator rewrites by the same stable
|
|
324
|
+
// fingerprint. The model may select an injected source, but cannot invent its URL.
|
|
325
|
+
const sourcesByFp = new Map();
|
|
322
326
|
// Every model request's usage lands in the run total AND its bucket, so the run
|
|
323
327
|
// log can show cache effectiveness per pass and not just run-wide.
|
|
324
328
|
const trackTokens = (bucket, tokens) => {
|
|
@@ -480,8 +484,8 @@ export async function runReview(source, options) {
|
|
|
480
484
|
// smaller file set); a fallback task forbids tools and reviews the inlined diff.
|
|
481
485
|
const buildTaskText = (task) => {
|
|
482
486
|
const base = task.kind === "cross-cutting"
|
|
483
|
-
? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText,
|
|
484
|
-
: buildReviewerTask(task.files, workspace.files, filtered, options.contextText,
|
|
487
|
+
? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText, Boolean(researchRuntime) && !task.fallback)
|
|
488
|
+
: buildReviewerTask(task.files, workspace.files, filtered, options.contextText, Boolean(researchRuntime) && !task.fallback);
|
|
485
489
|
return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
|
|
486
490
|
};
|
|
487
491
|
const filesLabel = (files) => files.length === 1
|
|
@@ -520,6 +524,9 @@ export async function runReview(source, options) {
|
|
|
520
524
|
trackTokens(task.bucket, tokens);
|
|
521
525
|
trackModel(task.bucket, taskModel(task), model);
|
|
522
526
|
(agentFindings[task.bucket] ??= []).push(...value.findings);
|
|
527
|
+
if (value.researchDecisions) {
|
|
528
|
+
(agentResearchDecisions[task.bucket] ??= []).push(...value.researchDecisions);
|
|
529
|
+
}
|
|
523
530
|
if (value.trace) {
|
|
524
531
|
mergeTraceNotes(agentTrace, task.bucket, value.trace);
|
|
525
532
|
}
|
|
@@ -616,6 +623,47 @@ export async function runReview(source, options) {
|
|
|
616
623
|
}
|
|
617
624
|
}
|
|
618
625
|
});
|
|
626
|
+
if (researchRuntime) {
|
|
627
|
+
try {
|
|
628
|
+
const audited = await researchProvenanceFromAudit(researchRuntime.auditPath);
|
|
629
|
+
researchRecord = audited.provenance;
|
|
630
|
+
researchEvidence = audited.evidence;
|
|
631
|
+
for (const line of formatResearchProgress(researchRecord))
|
|
632
|
+
progress(line);
|
|
633
|
+
await appendStepSummary(renderResearchMarkdown(researchRecord));
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
researchRecord = { queries: [], results: [], warnings: [], error: errorMessage(error) };
|
|
637
|
+
progress(` research audit unavailable: ${researchRecord.error}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Citations are accepted only when their exact canonical URL appeared in this
|
|
641
|
+
// run's MCP audit. This strips invented URLs even if a model copied a plausible
|
|
642
|
+
// official-looking address into its structured output.
|
|
643
|
+
for (const [bucket, findings] of Object.entries(agentFindings)) {
|
|
644
|
+
const grounded = groundResearchSources(findings, researchEvidence);
|
|
645
|
+
agentFindings[bucket] = grounded;
|
|
646
|
+
for (const finding of grounded) {
|
|
647
|
+
if (!finding.sources?.length)
|
|
648
|
+
continue;
|
|
649
|
+
const fp = fingerprintFinding(finding);
|
|
650
|
+
sourcesByFp.set(fp, mergeResearchSources(sourcesByFp.get(fp), finding.sources));
|
|
651
|
+
}
|
|
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
|
+
}
|
|
619
667
|
// A substituted model means the review did not run on the model this repo
|
|
620
668
|
// configured — the findings may be from a weaker (or free-tier) model entirely.
|
|
621
669
|
// Never silent: it goes to the log, the coverage notes, and the run log.
|
|
@@ -693,6 +741,12 @@ export async function runReview(source, options) {
|
|
|
693
741
|
: consolidated.decision;
|
|
694
742
|
output = { ...consolidated, decision, incomplete: [...new Set(coverageNotes)] };
|
|
695
743
|
}
|
|
744
|
+
// The coordinator remains model output. Revalidate every citation against the
|
|
745
|
+
// allowlisted prepass before verification, persistence, or rendering.
|
|
746
|
+
output = {
|
|
747
|
+
...output,
|
|
748
|
+
findings: groundResearchSources(output.findings, researchEvidence),
|
|
749
|
+
};
|
|
696
750
|
// Guard against hallucinated findings before surfacing: quote-ground every
|
|
697
751
|
// finding against the real file, and adversarially verify criticals. This is
|
|
698
752
|
// what stops a confident but wrong critical from shipping.
|
|
@@ -796,6 +850,17 @@ export async function runReview(source, options) {
|
|
|
796
850
|
else if (output.decision !== decisionBeforeChecks) {
|
|
797
851
|
output = { ...output, summary: reconcileRequalifiedSummary(output.summary) };
|
|
798
852
|
}
|
|
853
|
+
// Carry a reviewer's grounded citations through a coordinator rewrite. A changed
|
|
854
|
+
// fingerprint fails closed, so the engine never guesses which source applies.
|
|
855
|
+
if (output.findings.length > 0) {
|
|
856
|
+
output = {
|
|
857
|
+
...output,
|
|
858
|
+
findings: output.findings.map((finding) => {
|
|
859
|
+
const sources = mergeResearchSources(finding.sources, sourcesByFp.get(fingerprintFinding(finding)));
|
|
860
|
+
return sources.length > 0 ? { ...finding, sources } : finding;
|
|
861
|
+
}),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
799
864
|
// Attribution: carry each surviving finding's originating agent onto the output. The
|
|
800
865
|
// coordinator merges and rewrites findings, so match by fingerprint and keep the
|
|
801
866
|
// first agent that produced it; a finding the coordinator changed enough to break the
|
|
@@ -858,6 +923,15 @@ export async function runReview(source, options) {
|
|
|
858
923
|
progress(`Author-reply adjudication failed (${errorMessage(error)}); continuing without it.`);
|
|
859
924
|
}
|
|
860
925
|
}
|
|
926
|
+
// Measure utility only after verification, suppression, citation carry-through,
|
|
927
|
+
// and feedback adjudication have produced the final finding set. Counts use
|
|
928
|
+
// unique audited URLs, not passages, so duplicate search hits cannot inflate them.
|
|
929
|
+
if (researchRecord) {
|
|
930
|
+
const usefulness = summarizeResearchUsefulness(researchRecord, output.findings);
|
|
931
|
+
researchRecord = { ...researchRecord, usefulness };
|
|
932
|
+
progress(formatResearchUsefulness(usefulness));
|
|
933
|
+
await appendStepSummary(renderResearchUsefulnessMarkdown(researchRecord));
|
|
934
|
+
}
|
|
861
935
|
// Surface provider throttling as a fact about the run: passes already waited or
|
|
862
936
|
// backed off, but the operator should still SEE that it happened (a run that
|
|
863
937
|
// was rate-limited is slower and may carry partial passes — that's the cause).
|
|
@@ -891,6 +965,7 @@ export async function runReview(source, options) {
|
|
|
891
965
|
const reviewTrace = buildReviewTrace(agentTrace);
|
|
892
966
|
await safeLog(logPath, {
|
|
893
967
|
...baseRecord,
|
|
968
|
+
...(researchRecord ? { research: researchRecord } : {}),
|
|
894
969
|
agentCosts,
|
|
895
970
|
totalCost: sum(agentCosts),
|
|
896
971
|
tokens: tokenTotals,
|
|
@@ -927,6 +1002,7 @@ export async function runReview(source, options) {
|
|
|
927
1002
|
catch (error) {
|
|
928
1003
|
await safeLog(logPath, {
|
|
929
1004
|
...baseRecord,
|
|
1005
|
+
...(researchRecord ? { research: researchRecord } : {}),
|
|
930
1006
|
agentCosts,
|
|
931
1007
|
totalCost: sum(agentCosts),
|
|
932
1008
|
tokens: tokenTotals,
|
|
@@ -944,6 +1020,7 @@ export async function runReview(source, options) {
|
|
|
944
1020
|
finally {
|
|
945
1021
|
handle.close();
|
|
946
1022
|
await auth.cleanup();
|
|
1023
|
+
await researchRuntime?.cleanup();
|
|
947
1024
|
await restoreCwd();
|
|
948
1025
|
}
|
|
949
1026
|
}
|
package/build/core/schema.js
CHANGED
|
@@ -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,25 @@ 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
|
+
});
|
|
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
|
+
});
|
|
11
31
|
export const FindingSchema = z.object({
|
|
12
32
|
severity: z.enum(SEVERITIES),
|
|
13
33
|
category: z.enum(CATEGORIES),
|
|
@@ -16,6 +36,11 @@ export const FindingSchema = z.object({
|
|
|
16
36
|
title: z.string(),
|
|
17
37
|
rationale: z.string(),
|
|
18
38
|
suggestion: z.string().optional(),
|
|
39
|
+
sources: z
|
|
40
|
+
.array(FindingSourceSchema)
|
|
41
|
+
.max(5)
|
|
42
|
+
.optional()
|
|
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"),
|
|
19
44
|
/**
|
|
20
45
|
* Verbatim snippet of the flagged code, copied from the file. Used to
|
|
21
46
|
* quote-ground the finding: if this text isn't actually present in the file,
|
|
@@ -111,6 +136,7 @@ export const ReviewerTraceNotesSchema = z.object({
|
|
|
111
136
|
const ReviewerModelOutputSchema = z.object({
|
|
112
137
|
findings: z.array(ModelFindingSchema).default([]),
|
|
113
138
|
trace: ReviewerTraceNotesSchema.optional(),
|
|
139
|
+
researchDecisions: z.array(ResearchDecisionSchema).max(8).optional(),
|
|
114
140
|
});
|
|
115
141
|
/**
|
|
116
142
|
* Local trust boundary for reviewer output. Findings stay strict, while diagnostics
|
|
@@ -121,12 +147,18 @@ export const ReviewerOutputSchema = z
|
|
|
121
147
|
.object({
|
|
122
148
|
findings: z.array(ModelFindingSchema).default([]),
|
|
123
149
|
trace: z.unknown().optional(),
|
|
150
|
+
researchDecisions: z.unknown().optional(),
|
|
124
151
|
})
|
|
125
152
|
.transform((output) => {
|
|
126
153
|
const trace = ReviewerTraceNotesSchema.safeParse(output.trace);
|
|
154
|
+
const researchDecisions = z
|
|
155
|
+
.array(ResearchDecisionSchema)
|
|
156
|
+
.max(8)
|
|
157
|
+
.safeParse(output.researchDecisions);
|
|
127
158
|
return {
|
|
128
159
|
findings: output.findings,
|
|
129
160
|
...(trace.success ? { trace: trace.data } : {}),
|
|
161
|
+
...(researchDecisions.success ? { researchDecisions: researchDecisions.data } : {}),
|
|
130
162
|
};
|
|
131
163
|
});
|
|
132
164
|
export const ReviewTraceSchema = z.object({
|
package/build/core/tools.js
CHANGED
|
@@ -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,170 @@
|
|
|
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
|
+
const MAX_AUDITED_PASSAGE_CHARACTERS = 20_000;
|
|
6
|
+
function delay(ms) {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
function boundedError(error) {
|
|
10
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
11
|
+
// oxlint-disable-next-line no-control-regex -- audit lines must stay single-line JSONL
|
|
12
|
+
return message.replace(/[\r\n\u0000-\u001f\u007f]+/g, " ").slice(0, 500);
|
|
13
|
+
}
|
|
14
|
+
function boundedResult(result) {
|
|
15
|
+
return {
|
|
16
|
+
id: result.id.slice(0, 240),
|
|
17
|
+
platform: result.platform,
|
|
18
|
+
provider: result.provider,
|
|
19
|
+
sourceKind: result.sourceKind,
|
|
20
|
+
title: result.title.slice(0, 240),
|
|
21
|
+
url: result.url.slice(0, 2_000),
|
|
22
|
+
// This equals the direct-fetch document ceiling, so the artifact preserves the
|
|
23
|
+
// exact bounded text shown to the reviewer without ever storing the raw page.
|
|
24
|
+
passage: result.passage.slice(0, MAX_AUDITED_PASSAGE_CHARACTERS),
|
|
25
|
+
...(result.availability?.length
|
|
26
|
+
? { availability: result.availability.slice(0, 20).map((value) => value.slice(0, 240)) }
|
|
27
|
+
: {}),
|
|
28
|
+
...(result.framework ? { framework: result.framework.slice(0, 240) } : {}),
|
|
29
|
+
...(result.language ? { language: result.language } : {}),
|
|
30
|
+
...(result.symbol ? { symbol: result.symbol.slice(0, 240) } : {}),
|
|
31
|
+
...(result.previousPassageId
|
|
32
|
+
? { previousPassageId: result.previousPassageId.slice(0, 240) }
|
|
33
|
+
: {}),
|
|
34
|
+
...(result.nextPassageId ? { nextPassageId: result.nextPassageId.slice(0, 240) } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Shared append-only audit and global request budget for all MCP processes in one review. */
|
|
38
|
+
export class ResearchAudit {
|
|
39
|
+
path;
|
|
40
|
+
maxCalls;
|
|
41
|
+
localReservations = 0;
|
|
42
|
+
constructor(path, maxCalls) {
|
|
43
|
+
this.path = path;
|
|
44
|
+
this.maxCalls = maxCalls;
|
|
45
|
+
}
|
|
46
|
+
async append(event) {
|
|
47
|
+
if (!this.path)
|
|
48
|
+
return;
|
|
49
|
+
await appendFile(this.path, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
50
|
+
}
|
|
51
|
+
async withLock(callback) {
|
|
52
|
+
if (!this.path)
|
|
53
|
+
return callback();
|
|
54
|
+
const lockPath = `${this.path}.lock`;
|
|
55
|
+
for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
|
|
56
|
+
try {
|
|
57
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
58
|
+
try {
|
|
59
|
+
return await callback();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await rmdir(lockPath).catch(() => { });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code !== "EEXIST")
|
|
67
|
+
throw error;
|
|
68
|
+
await delay(LOCK_DELAY_MS);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw new Error("Documentation research audit lock timed out");
|
|
72
|
+
}
|
|
73
|
+
async reservationCount() {
|
|
74
|
+
if (!this.path)
|
|
75
|
+
return this.localReservations;
|
|
76
|
+
let contents = "";
|
|
77
|
+
try {
|
|
78
|
+
contents = await readFile(this.path, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code !== "ENOENT")
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
return contents.split("\n").reduce((count, line) => {
|
|
85
|
+
if (!line)
|
|
86
|
+
return count;
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(line).type === "reserved" ? count + 1 : count;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return count;
|
|
92
|
+
}
|
|
93
|
+
}, 0);
|
|
94
|
+
}
|
|
95
|
+
async reserve(tool, input) {
|
|
96
|
+
const requestId = randomUUID();
|
|
97
|
+
await this.withLock(async () => {
|
|
98
|
+
const used = await this.reservationCount();
|
|
99
|
+
if (used >= this.maxCalls) {
|
|
100
|
+
throw new Error(`Documentation research call budget exhausted (${this.maxCalls})`);
|
|
101
|
+
}
|
|
102
|
+
if (!this.path)
|
|
103
|
+
this.localReservations++;
|
|
104
|
+
await this.append({
|
|
105
|
+
type: "reserved",
|
|
106
|
+
requestId,
|
|
107
|
+
tool,
|
|
108
|
+
input,
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
return requestId;
|
|
113
|
+
}
|
|
114
|
+
async complete(requestId, tool, input, results, warnings = []) {
|
|
115
|
+
await this.append({
|
|
116
|
+
type: "completed",
|
|
117
|
+
requestId,
|
|
118
|
+
tool,
|
|
119
|
+
input,
|
|
120
|
+
results: results.map(boundedResult),
|
|
121
|
+
warnings: warnings.slice(0, 10).map((warning) => warning.slice(0, 500)),
|
|
122
|
+
timestamp: new Date().toISOString(),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async fail(requestId, tool, input, error) {
|
|
126
|
+
await this.append({
|
|
127
|
+
type: "failed",
|
|
128
|
+
requestId,
|
|
129
|
+
tool,
|
|
130
|
+
input,
|
|
131
|
+
error: boundedError(error),
|
|
132
|
+
timestamp: new Date().toISOString(),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export async function readResearchAudit(path) {
|
|
137
|
+
let contents = "";
|
|
138
|
+
try {
|
|
139
|
+
contents = await readFile(path, "utf8");
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code === "ENOENT")
|
|
143
|
+
return [];
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
const records = [];
|
|
147
|
+
for (const line of contents.split("\n")) {
|
|
148
|
+
if (!line)
|
|
149
|
+
continue;
|
|
150
|
+
try {
|
|
151
|
+
const event = JSON.parse(line);
|
|
152
|
+
if (event.type === "completed")
|
|
153
|
+
records.push(event);
|
|
154
|
+
if (event.type === "failed") {
|
|
155
|
+
records.push({
|
|
156
|
+
requestId: event.requestId,
|
|
157
|
+
tool: event.tool,
|
|
158
|
+
input: event.input,
|
|
159
|
+
results: [],
|
|
160
|
+
warnings: [],
|
|
161
|
+
error: event.error,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Ignore a partial final line from a process that was terminated mid-write.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return records;
|
|
170
|
+
}
|
|
@@ -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 =
|
|
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,22 @@ 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.
|
|
20
|
-
|
|
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 and return focused,
|
|
21
|
+
section, or bounded-document extracted context. The update command is
|
|
22
|
+
an optional offline crawler for operator-managed fallback indexes.
|
|
21
23
|
`);
|
|
22
24
|
}
|
|
25
|
+
function boundedInteger(name, fallback, minimum, maximum) {
|
|
26
|
+
const raw = process.env[name];
|
|
27
|
+
if (!raw)
|
|
28
|
+
return fallback;
|
|
29
|
+
const value = Number(raw);
|
|
30
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
31
|
+
throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
23
35
|
async function main() {
|
|
24
36
|
const [command = "serve", ...rest] = process.argv.slice(2);
|
|
25
37
|
if (command === "--help" || command === "-h" || command === "help") {
|
|
@@ -41,6 +53,11 @@ async function main() {
|
|
|
41
53
|
const indexPath = values.index ?? process.env.REVIEW_RESEARCH_INDEX_PATH;
|
|
42
54
|
await runStdioServer({
|
|
43
55
|
...(indexPath ? { indexPath } : {}),
|
|
56
|
+
...(process.env.REVIEW_RESEARCH_AUDIT_PATH
|
|
57
|
+
? { auditPath: process.env.REVIEW_RESEARCH_AUDIT_PATH }
|
|
58
|
+
: {}),
|
|
59
|
+
maxCalls: boundedInteger("REVIEW_RESEARCH_MAX_CALLS", 8, 1, 20),
|
|
60
|
+
maxResultsPerCall: boundedInteger("REVIEW_RESEARCH_MAX_RESULTS", 3, 1, 3),
|
|
44
61
|
...(process.env.BRAVE_SEARCH_API_KEY
|
|
45
62
|
? { braveApiKey: process.env.BRAVE_SEARCH_API_KEY }
|
|
46
63
|
: {}),
|