Files
rea/src/server/registerProcessComparisonTool.ts
T
Kaoru0822andClaude Opus 5.5 0ecf3f2438 perf(mcp): keep Playwright and type-only imports off MCP startup (#1691)
* fix(build): emit no runtime imports for type-only bindings

Under verbatimModuleSyntax, an import whose specifiers all carry inline
`type` modifiers compiles to `import {} from "..."`, a side-effect import
that still resolves and evaluates the module. 78 compiled modules carried
one, including `import {} from "playwright-core"` in the browser scenario
session, which loaded Playwright's module graph at MCP startup.

Use top-level `import type` for these bindings and enable oxlint's
typescript/no-import-type-side-effects rule so the pattern cannot return.

Refs #1685

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(mcp): load Playwright when a scenario launches, not at startup

The browser scenario and Electron active providers are constructed before
the stdio server serves, and both bound Playwright's launchers at module
load. Resolve `chromium` and `_electron` from playwright-core when a
capture opens instead, as the web module resolver already does.

MCP startup now resolves 1,312 modules (4,902 resolutions) instead of
1,598 (6,076) before reading the first request, and no longer loads
playwright-core. Provider construction, the tool catalog, and capability
inventory are unchanged.

A boundary test spawns the stdio server, initializes, lists tools, and
asserts playwright-core and isomorphic-git were not resolved; the CLI
probe now exercises the real launch path to prove detection.

Closes #1685

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 21:04:05 -07:00

228 lines
8.7 KiB
TypeScript

import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
import { toolContract } from "../contracts/toolContracts.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { describeValidationFailure } from "../domain/evidenceBundle.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import {
createEvidence,
parseEvidence,
type Evidence,
type EvidenceLocation,
} from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { compareProcessCaptures } from "../domain/process/processComparison.js";
import {
parseProcessCapture,
type ProcessCapture,
} from "../domain/process/processCaptureParsing.js";
import type { RecordUnknownInput } from "../domain/residualUnknown.js";
import { err, ok, type Result } from "../domain/result.js";
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
import { recordSessionEvidenceSources } from "./sessionEvidence.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
import { PROCESS_PROVIDER } from "../domain/process/processEvidenceProvider.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { withAdmittedAnalysis } from "./analysisAdmission.js";
import { runAdmittedToolOperation } from "./admittedToolOperation.js";
const PROCESS_CAPTURE_EVIDENCE = {
operation: "capture_process_scenario",
predicate: "rea.process-capture",
} as const;
const sourceLocations = (
left: readonly EvidenceLocation[] | undefined,
right: readonly EvidenceLocation[] | undefined,
): readonly EvidenceLocation[] => [...(left ?? []), ...(right ?? [])];
/**
* Parse one side and name the failed constraint: tampered Evidence, Evidence
* from another workflow, or an invalid capture result.
*/
const parseCaptureSide = (
operation: string,
side: "left" | "right",
value: unknown,
): Result<
{ readonly record: Evidence; readonly capture: ProcessCapture },
AnalysisError
> => {
let record: Evidence;
try {
record = parseEvidence(value);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError(`${side} Evidence failed validation`, {
cause,
userMessage: `The ${side} Evidence failed validation (${describeValidationFailure(cause)}). Supply unmodified capture Evidence from capture_process_scenario.`,
}),
);
}
if (
record.operation !== PROCESS_CAPTURE_EVIDENCE.operation ||
record.predicate_type !== PROCESS_CAPTURE_EVIDENCE.predicate
)
return err(
new AnalysisInputError(operation, undefined, [
{
path: [side],
reason: "invalid_value",
message: `Expected process capture Evidence (operation ${PROCESS_CAPTURE_EVIDENCE.operation}, predicate ${PROCESS_CAPTURE_EVIDENCE.predicate}); the ${side} Evidence has operation ${record.operation} and predicate ${record.predicate_type}.`,
},
]),
);
try {
return ok({
record,
capture: parseProcessCapture(record.normalized_result),
});
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError(`${side} process capture failed validation`, {
cause,
userMessage: `The ${side} Evidence has an invalid process capture result (${describeValidationFailure(cause)}). Recreate the capture with capture_process_scenario.`,
}),
);
}
};
/** Register deterministic process-capture comparison and contradiction tracking. */
export const registerProcessComparisonTool = (
server: EvidenceMcpServer,
session: BinarySessionPort,
contract: ReturnType<typeof toolContract<"compare_process_captures">>,
now: () => number = Date.now,
): void => {
const admission = withAdmittedAnalysis({ kind: "session", session });
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async (input, context) =>
runAdmittedToolOperation(
server,
admission,
contract.name,
context.mcpReq.signal,
async () => {
const left = parseCaptureSide(contract.name, "left", input.left);
if (!left.ok) return server.delivery.toCallToolResult(left, contract);
const right = parseCaptureSide(contract.name, "right", input.right);
if (!right.ok)
return server.delivery.toCallToolResult(right, contract);
const { record: leftRecord, capture: leftCapture } = left.value;
const { record: rightRecord, capture: rightCapture } = right.value;
const computed = await runDerivedOperation(
context,
contract.name,
() =>
compareProcessCaptures(leftCapture, rightCapture, {
...(input.max_capture_age_ms === undefined
? {}
: { maxCaptureAgeMs: input.max_capture_age_ms }),
...(input.trace_spec === undefined
? {}
: { traceSpecification: input.trace_spec }),
now,
}),
);
if (!computed.ok)
return server.delivery.toCallToolResult(computed, contract);
const comparison = computed.value;
const evidence = createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-comparison",
operation: contract.name,
parameters: {
left_evidence_id: leftRecord.evidence_id,
right_evidence_id: rightRecord.evidence_id,
left_normalization: leftCapture.normalization,
right_normalization: rightCapture.normalization,
...(input.trace_spec === undefined
? {}
: { trace_spec: jsonValueSchema.parse(input.trace_spec) }),
},
result: jsonValueSchema.parse(comparison),
confidence: "derived",
authority: "analyst-inference",
limitations: comparison.limitations,
locations: sourceLocations(
leftRecord.locations,
rightRecord.locations,
),
evidenceLinks: [leftRecord.evidence_id, rightRecord.evidence_id],
});
const recordedSources = recordSessionEvidenceSources(
(evidence) => session.recordEvidence(evidence),
[leftRecord, rightRecord],
);
if (!recordedSources.ok)
return server.delivery.toCallToolResult(recordedSources, contract);
return server.delivery.toEvidenceToolResult(
evidence,
contract,
recordDerivedEvidence(
session,
evidence,
comparisonUnknownInput(
{
left_evidence_id: leftRecord.evidence_id,
right_evidence_id: rightRecord.evidence_id,
comparison_evidence_id: evidence.evidence_id,
},
comparison,
),
),
);
},
),
);
};
const comparisonUnknownInput = (
parsed: {
readonly left_evidence_id: string;
readonly right_evidence_id: string;
readonly comparison_evidence_id: string;
},
comparison: ReturnType<typeof compareProcessCaptures>,
): RecordUnknownInput | undefined => {
if (comparison.status === "unchanged") return undefined;
const differingScopes = [
["terminal", comparison.terminal],
["interaction", comparison.interaction],
["exit", comparison.exit],
["filesystem", comparison.filesystem],
["process", comparison.process],
]
.filter(([, status]) => status !== "unchanged")
.map(([scope]) => scope)
.join(", ");
const contradictory =
comparison.status === "changed" &&
parsed.left_evidence_id !== parsed.right_evidence_id;
// Unknown identity uses the question, not its supporting records. Bind the
// question to this comparison so distinct capture pairs and policies coexist.
return {
question: `${contradictory ? "Process captures disagree" : `Process comparison is ${comparison.status}`} across: ${differingScopes} (comparison ${parsed.comparison_evidence_id})`,
severity: "high",
domain: "process-comparison",
supporting_evidence_ids: contradictory
? [parsed.left_evidence_id]
: [...new Set([parsed.left_evidence_id, parsed.right_evidence_id])],
contradicting_evidence_ids: contradictory ? [parsed.right_evidence_id] : [],
required_authority: "controlled-replay",
required_confidence: "observed",
required_environment: null,
recommended_probes: [
{
operation: "capture_process_scenario",
rationale:
"Repeat both scenarios under the same controlled environment.",
},
],
relationships: [],
};
};