fix(javascript): honor cancellation before evidence publication (#1098)

This commit is contained in:
Ysopwnable 2026-10-09 01:15:50 +08:00 committed by GitHub
parent f8e143a3bc
commit cc0ee2edc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1080 additions and 74 deletions

View File

@ -81,6 +81,15 @@ uses `cleanup_incomplete` and lists only the owned resource kinds that remain.
Derived comparisons and reconstruction verification yield before computation
and before publication, so cancellation cannot race with successful Evidence.
`analyze_javascript_application` also yields between reconstruction phases and
during complete-result sealing, Evidence JSON validation, and canonical hashing.
Cancellation observed before completion returns `cancelled` and prevents the
provisional result from entering the session ledger; prior Evidence stays usable.
Single-file parsing, graph completion, and full application-result schema
validation still run synchronously, so control messages can wait for those
phases to release the event loop. A rejected client promise alone does not
establish that the server has stopped its work.
CLI calls work without a progress token and translate SIGINT into the same
AbortSignal used by providers. Existing controlled-process cleanup and provider
shutdown rules still apply; REA never kills a process it cannot prove it owns.

View File

@ -0,0 +1,39 @@
import { setImmediate } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js";
describe("JavaScript cooperative computation lifecycle", () => {
it("observes scheduler cancellation and closes abandoned work", async () => {
const controller = new AbortController();
let released = false;
function* work(): Generator<void, number> {
try {
for (;;) yield;
} finally {
released = true;
}
}
const cancellation = setImmediate().then(() => controller.abort());
await expect(
completeJavaScriptAnalysisSteps(work(), controller.signal),
).rejects.toMatchObject({ reason: "cancelled" });
await cancellation;
expect(released).toBe(true);
});
it("does not start pre-cancelled work and preserves completed values", async () => {
const controller = new AbortController();
controller.abort();
let started = false;
function* work(): Generator<void, number> {
started = true;
yield;
return 42;
}
await expect(
completeJavaScriptAnalysisSteps(work(), controller.signal),
).rejects.toMatchObject({ reason: "cancelled" });
expect(started).toBe(false);
expect(await completeJavaScriptAnalysisSteps(work())).toBe(42);
});
});

View File

@ -0,0 +1,44 @@
import { setImmediate } from "node:timers/promises";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
/** Reject cancelled application work before computing or publishing more results. */
export const assertJavaScriptAnalysisActive = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"JavaScript application analysis cancelled",
);
};
/** Let request control messages run, then recheck the current analysis intent. */
export const checkpointJavaScriptAnalysis = async (
signal?: AbortSignal,
): Promise<void> => {
assertJavaScriptAnalysisActive(signal);
await setImmediate();
assertJavaScriptAnalysisActive(signal);
};
/** Drive bounded owned-domain steps while permitting cancellation and control I/O. */
export const completeJavaScriptAnalysisSteps = async <Value>(
steps: Iterator<void, Value, void>,
signal?: AbortSignal,
): Promise<Value> => {
let deadline = performance.now() + 8;
try {
for (;;) {
assertJavaScriptAnalysisActive(signal);
const step = steps.next();
if (step.done) {
assertJavaScriptAnalysisActive(signal);
return step.value;
}
if (performance.now() >= deadline) {
await checkpointJavaScriptAnalysis(signal);
deadline = performance.now() + 8;
}
}
} finally {
steps.return?.();
}
};

View File

@ -0,0 +1,75 @@
import { setImmediate } from "node:timers";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
describe("JavaScript analysis cancellation before publication", () => {
it.each([
"parse_javascript_source",
"build_javascript_application_graph",
"build_javascript_semantic_graph",
"validate_javascript_application_result",
"create_javascript_application_evidence",
"seal_javascript_application_result",
"validate_javascript_application_evidence",
"hash_javascript_application_evidence",
])("rejects cancellation scheduled during %s", async (phase) => {
const root = await createTestTempDirectory("rea-js-cancel-phase-");
await writeFile(
join(root, "main.js"),
"export function inspect(value) { return JSON.stringify(value); }\n",
);
const controller = new AbortController();
let requested = false;
const terminal: string[] = [];
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
{
signal: controller.signal,
progress: {
async report(event) {
if (event.terminal) terminal.push(event.phase);
if (event.phase === phase) {
requested = true;
setImmediate(() => controller.abort());
}
},
},
},
);
expect(requested).toBe(true);
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("Cancelled analysis must not publish Evidence");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: { reason: "cancelled" },
});
expect(terminal).toEqual([]);
});
it("honors cancellation observed during the terminal publication race", async () => {
const root = await createTestTempDirectory("rea-js-cancel-terminal-");
await writeFile(join(root, "main.js"), "export const value = 1;\n");
const controller = new AbortController();
const result = await analyzeJavaScriptApplication(
{ input_path: root, format: "directory" },
{
signal: controller.signal,
progress: {
async report(event) {
if (event.terminal) controller.abort();
},
},
},
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation before admission");
expect(projectAnalysisError(result.error)).toMatchObject({
details: { reason: "cancelled" },
});
});
});

View File

@ -1,5 +1,6 @@
import {
createEvidence,
createImmutableEvidenceSteps,
type Evidence,
type EvidenceObservation,
} from "../../domain/evidence.js";
@ -8,8 +9,14 @@ import type {
JavaScriptApplicationAnalysisResult,
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
import {
freezeJsonSnapshot,
freezeOwnedJsonSnapshotSteps,
} from "../../domain/immutableJson.js";
import { rememberOwnedApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js";
import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js";
import { jsonValueValidationSteps } from "../../domain/jsonValue.js";
import type { ProgressReporter } from "../ProgressReporter.js";
/** Transfer an already validated, application-owned result into immutable analysis Evidence. */
export const createOwnedJavaScriptApplicationEvidence = (
@ -21,32 +28,89 @@ export const createOwnedJavaScriptApplicationEvidence = (
result,
);
/** Create owned analysis Evidence while allowing request cancellation during sealing and hashing. */
export const createOwnedJavaScriptApplicationEvidenceCooperatively = async (
input: AnalyzeJavaScriptApplicationInput,
result: JavaScriptApplicationAnalysisResult,
signal?: AbortSignal,
progress?: ProgressReporter,
): Promise<Evidence> => {
await reportEvidencePhase(
progress,
"seal_javascript_application_result",
"Sealing the complete application analysis result",
);
const snapshot = await completeJavaScriptAnalysisSteps(
freezeOwnedJsonSnapshotSteps(result),
signal,
);
await reportEvidencePhase(
progress,
"validate_javascript_application_evidence",
"Validating the immutable application Evidence JSON",
);
// The Evidence envelope consumes the completed immutable validation cache.
await completeJavaScriptAnalysisSteps(
jsonValueValidationSteps(snapshot),
signal,
);
await reportEvidencePhase(
progress,
"hash_javascript_application_evidence",
"Hashing the complete application analysis Evidence",
);
const evidence = await completeJavaScriptAnalysisSteps(
createImmutableEvidenceSteps(
applicationSubject(snapshot),
JAVASCRIPT_APPLICATION_PROVIDER,
applicationObservation(input, snapshot),
),
signal,
);
return rememberOwnedApplicationGraphEvidence(evidence, snapshot);
};
const reportEvidencePhase = (
progress: ProgressReporter | undefined,
phase: string,
message: string,
): Promise<void> =>
progress?.report({ phase, completed: 0, total: 1, message }) ??
Promise.resolve();
/** Create Evidence for one deterministic local JavaScript application graph. */
export const createJavaScriptApplicationEvidence = (
input: AnalyzeJavaScriptApplicationInput,
result: JavaScriptApplicationAnalysisResult,
): Evidence =>
createEvidence(
{
path: result.input_path,
sha256: result.root_artifact_sha256,
format: result.format,
},
applicationSubject(result),
JAVASCRIPT_APPLICATION_PROVIDER,
{
predicateType: "rea.javascript-application-analysis",
operation: "analyze_javascript_application",
parameters: parameters(input),
result,
rawResult: null,
confidence: "derived",
authority: "shipped-artifact",
environment: null,
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: "artifact-root" }],
},
applicationObservation(input, result),
);
const applicationSubject = (result: JavaScriptApplicationAnalysisResult) => ({
path: result.input_path,
sha256: result.root_artifact_sha256,
format: result.format,
});
const applicationObservation = (
input: AnalyzeJavaScriptApplicationInput,
result: JavaScriptApplicationAnalysisResult,
): EvidenceObservation => ({
predicateType: "rea.javascript-application-analysis",
operation: "analyze_javascript_application",
parameters: parameters(input),
result,
rawResult: null,
confidence: "derived",
authority: "shipped-artifact",
environment: null,
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: "artifact-root" }],
});
const parameters = (
input: AnalyzeJavaScriptApplicationInput,
): EvidenceObservation["parameters"] => ({

View File

@ -13,7 +13,11 @@ import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.
import { err, ok, type Result } from "../../domain/result.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import { createOwnedJavaScriptApplicationEvidence } from "./JavaScriptApplicationEvidence.js";
import { createOwnedJavaScriptApplicationEvidenceCooperatively } from "./JavaScriptApplicationEvidence.js";
import {
assertJavaScriptAnalysisActive,
checkpointJavaScriptAnalysis,
} from "./JavaScriptAnalysisControl.js";
import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js";
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
@ -37,13 +41,15 @@ export const analyzeJavaScriptApplicationValidated = async (
input: z.output<typeof analyzeJavaScriptApplicationInputSchema>,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
await options.progress?.report({
phase: "analyze_javascript_application",
completed: 0,
total: 1,
message: "Inventorying and parsing application artifacts",
});
try {
assertJavaScriptAnalysisActive(options.signal);
await options.progress?.report({
phase: "analyze_javascript_application",
completed: 0,
total: 1,
message: "Inventorying and parsing application artifacts",
});
await checkpointJavaScriptAnalysis(options.signal);
const reconstructed = await reconstructJavaScriptArtifact(
{
input_path: input.input_path,
@ -59,6 +65,7 @@ export const analyzeJavaScriptApplicationValidated = async (
total: 1,
message: "Validating the application analysis result",
});
await checkpointJavaScriptAnalysis(options.signal);
const result = javascriptApplicationAnalysisResultSchema.parse({
...application,
summary,
@ -70,7 +77,15 @@ export const analyzeJavaScriptApplicationValidated = async (
total: 1,
message: "Creating and hashing application analysis Evidence",
});
const evidence = createOwnedJavaScriptApplicationEvidence(input, result);
await checkpointJavaScriptAnalysis(options.signal);
const evidence =
await createOwnedJavaScriptApplicationEvidenceCooperatively(
input,
result,
options.signal,
options.progress,
);
await checkpointJavaScriptAnalysis(options.signal);
await options.progress?.report({
phase: "analyze_javascript_application",
completed: 1,
@ -78,6 +93,7 @@ export const analyzeJavaScriptApplicationValidated = async (
message: "Application graph and Electron boundaries reconstructed",
terminal: true,
});
assertJavaScriptAnalysisActive(options.signal);
return ok(evidence);
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure)

View File

@ -16,6 +16,7 @@ import { scanCanonicalArtifactInventory } from "../../artifacts/inventory/Artifa
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import type { ProgressReporter } from "../ProgressReporter.js";
import { checkpointJavaScriptAnalysis } from "./JavaScriptAnalysisControl.js";
/** Application-layer result retaining local diagnostics outside the canonical graph. */
export interface JavaScriptArtifactReconstructionResult {
@ -50,6 +51,7 @@ export const reconstructJavaScriptArtifact = async (
): Promise<JavaScriptArtifactReconstructionResult> => {
const reportPhase = async (phase: string, message: string): Promise<void> => {
await progress?.report({ phase, completed: 0, total: 1, message });
await checkpointJavaScriptAnalysis(signal);
};
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
abortIfNeeded(signal);
@ -87,6 +89,7 @@ export const reconstructJavaScriptArtifact = async (
abortIfNeeded(signal);
},
);
await checkpointJavaScriptAnalysis(signal);
abortIfNeeded(signal);
await reportPhase(
"build_javascript_application_graph",
@ -102,6 +105,7 @@ export const reconstructJavaScriptArtifact = async (
graph,
analysis,
);
await checkpointJavaScriptAnalysis(signal);
return {
input_path: path,
format,

View File

@ -25,6 +25,7 @@ import {
} from "../cliObservationOptions.js";
import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js";
import type { CliResultOutput } from "./streamedJsonOutput.js";
import { withCommandCancellation } from "./commandCancellation.js";
/** Register CLI equivalents of the Electron MCP tools. */
export const registerElectronCommands = (
@ -184,16 +185,19 @@ const registerJavaScriptApplicationCommand = (
}),
options: javascriptApplicationOptions,
run: ({ args, options, format }) =>
logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () =>
runCliJavaScriptApplicationAnalysis(
{ input_path: args.path, format: options.artifactFormat },
resultOutput === undefined
? undefined
: {
output: resultOutput,
command: CLI_COMMANDS.analyzeJavaScriptApplication,
format,
},
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () =>
runCliJavaScriptApplicationAnalysis(
{ input_path: args.path, format: options.artifactFormat },
resultOutput === undefined
? undefined
: {
output: resultOutput,
command: CLI_COMMANDS.analyzeJavaScriptApplication,
format,
},
signal,
),
),
),
});

View File

@ -8,6 +8,7 @@ import type { CliCommandOutput } from "./streamedJsonOutput.js";
export const runCliJavaScriptApplicationAnalysis = async (
input: unknown,
output?: CliCommandOutput,
signal?: AbortSignal,
): Promise<JsonValue | undefined> => {
const started = performance.now();
const progress = createProgressReporter(
@ -16,7 +17,10 @@ export const runCliJavaScriptApplicationAnalysis = async (
},
{ minimumIntervalMs: 0 },
);
const result = await analyzeJavaScriptApplication(input, { progress });
const result = await analyzeJavaScriptApplication(input, {
progress,
...(signal === undefined ? {} : { signal }),
});
const value = result.ok ? result.value : cliError(result.error);
if (
output !== undefined &&
@ -26,7 +30,8 @@ export const runCliJavaScriptApplicationAnalysis = async (
duration: `${Math.round(performance.now() - started)}ms`,
}))
)
return undefined;
// Keep failures visible to command logging after the stream has handled stdout.
return result.ok ? undefined : value;
return value;
};

View File

@ -0,0 +1,66 @@
import { fc, it } from "@fast-check/vitest";
import { describe, expect } from "vitest";
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
import { digestCanonicalValue } from "./canonicalDigest.js";
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
const complete = <Value>(steps: Iterator<void, Value>): Value => {
for (;;) {
const next = steps.next();
if (next.done) return next.value;
}
};
describe("cooperative canonical JSON digest", () => {
const values: readonly JsonValue[] = [
null,
[true, false, 0, -0, 1e-7, 1e21, "\ud800", '𐀀\n"'],
{ z: null, a: { b: 2, a: 1 } },
jsonValueSchema.parse(
JSON.parse('{"__proto__":{"x":1},"constructor":2,"toJSON":"text"}'),
),
{ payload: `${"x".repeat(8191)}𐀀${"\n\ud800".repeat(20_000)}` },
{ [`${"x".repeat(8191)}𐀀${"\n".repeat(20_000)}`]: "value" },
];
it.each(values.map((value) => ({ value })))(
"preserves complete canonical meaning (%#)",
({ value }) => {
expect(
complete(canonicalJsonDigestSteps(jsonValueSchema.parse(value))),
).toBe(digestCanonicalValue(value));
},
);
it("supports repeated references without treating them as cycles", () => {
const shared = { nested: ["value"] };
const value = { a: shared, b: shared };
expect(complete(canonicalJsonDigestSteps(value))).toBe(
digestCanonicalValue(value),
);
});
it.prop([fc.jsonValue()])(
"matches the existing digest across JSON values",
(value) => {
expect(
complete(canonicalJsonDigestSteps(jsonValueSchema.parse(value))),
).toBe(digestCanonicalValue(value));
},
);
it("permits abandoning a large string before completing its digest", () => {
const steps = canonicalJsonDigestSteps("𐀀".repeat(100_000));
expect(steps.next().done).toBe(false);
steps.return("abandoned");
expect(steps.next().done).toBe(true);
});
it("rejects sparse arrays, nonfinite numbers and cycles", () => {
const sparse: JsonValue[] = [];
sparse.length = 2;
const cycle: { [key: string]: JsonValue } = {};
cycle.self = cycle;
for (const value of [sparse, Number.NaN, cycle])
expect(() => complete(canonicalJsonDigestSteps(value))).toThrow();
});
});

View File

@ -0,0 +1,156 @@
import { createHash } from "node:crypto";
import type { JsonValue } from "./jsonValue.js";
type DigestFrame =
| { readonly kind: "separator" }
| { readonly kind: "value"; readonly value: JsonValue }
| { readonly kind: "string"; readonly value: string; index: number }
| {
readonly kind: "array";
readonly value: readonly JsonValue[];
readonly length: number;
index: number;
}
| {
readonly kind: "object";
readonly value: { readonly [key: string]: JsonValue };
readonly keys: readonly string[];
index: number;
written: number;
};
/** Hash owned, validated JSON in bounded steps without a document-sized string. */
export function* canonicalJsonDigestSteps(
value: JsonValue,
): Generator<void, string> {
const hash = createHash("sha256");
const ancestors = new Set<object>();
const stack: DigestFrame[] = [{ kind: "value", value }];
let buffered = "";
let flushed = false;
const emit = (part: string): void => {
buffered += part;
if (buffered.length >= 8192) {
hash.update(buffered);
buffered = "";
flushed = true;
}
};
try {
while (stack.length > 0) {
if (flushed) {
flushed = false;
yield;
}
const frame = stack.pop();
if (frame === undefined) break;
if (frame.kind === "separator") {
emit(":");
continue;
}
if (frame.kind === "string") {
if (frame.index === frame.value.length) {
emit('"');
continue;
}
let end = Math.min(frame.index + 8192, frame.value.length);
const last = frame.value.charCodeAt(end - 1);
const next = frame.value.charCodeAt(end);
if (
end < frame.value.length &&
last >= 0xd800 &&
last <= 0xdbff &&
next >= 0xdc00 &&
next <= 0xdfff
)
end -= 1;
emit(JSON.stringify(frame.value.slice(frame.index, end)).slice(1, -1));
frame.index = end;
stack.push(frame);
continue;
}
if (frame.kind === "array") {
if (frame.index === frame.length) {
emit("]");
ancestors.delete(frame.value);
continue;
}
const item = frame.value[frame.index];
if (item === undefined)
throw new TypeError(
"Canonical JSON digest requires dense JSON arrays",
);
if (frame.index++ > 0) emit(",");
stack.push(frame, { kind: "value", value: item });
continue;
}
if (frame.kind === "object") {
if (frame.index === frame.keys.length) {
emit("}");
ancestors.delete(frame.value);
continue;
}
const key = frame.keys[frame.index++];
if (key === undefined)
throw new TypeError("Canonical JSON object changed during hashing");
const item = frame.value[key];
if (item === undefined) continue;
if (frame.written++ > 0) emit(",");
if (key.length > 8192) {
emit('"');
stack.push(
frame,
{ kind: "value", value: item },
{ kind: "separator" },
{ kind: "string", value: key, index: 0 },
);
continue;
}
emit(JSON.stringify(key));
emit(":");
stack.push(frame, { kind: "value", value: item });
continue;
}
const item = frame.value;
if (typeof item === "string" && item.length > 8192) {
emit('"');
stack.push({ kind: "string", value: item, index: 0 });
} else if (item === null || typeof item !== "object") {
if (typeof item === "number" && !Number.isFinite(item))
throw new Error(
Number.isNaN(item)
? "NaN is not allowed"
: "Infinity is not allowed",
);
emit(JSON.stringify(item));
} else {
if (ancestors.has(item)) throw new Error("Circular reference detected");
ancestors.add(item);
if (Array.isArray(item)) {
emit("[");
stack.push({
kind: "array",
value: item,
length: item.length,
index: 0,
});
} else {
emit("{");
stack.push({
kind: "object",
value: item,
keys: Object.keys(item).sort(),
index: 0,
written: 0,
});
}
}
}
if (buffered !== "") hash.update(buffered);
return hash.digest("hex");
} finally {
stack.length = 0;
ancestors.clear();
buffered = "";
}
}

View File

@ -1,6 +1,7 @@
import { z } from "zod";
import { digestCanonicalValue } from "./canonicalDigest.js";
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
import {
analysisProfileSchema,
@ -11,6 +12,7 @@ import {
jsonObjectSchema,
jsonValueSchema,
jsonValueValidationIssue,
jsonValueValidationSteps,
type JsonValue,
} from "./jsonValue.js";
import {
@ -223,7 +225,7 @@ const semanticProjection = (evidence: EvidenceWithoutId): JsonValue => ({
evidence_links: evidence.evidence_links,
});
/** Recompute the semantic identifier, excluding paths and raw payload bytes. */
/** Hash semantic content, including raw results and excluding display-subject fields. */
const computeEvidenceId = (evidence: EvidenceWithoutId): string =>
`ev_${digestCanonicalValue(semanticProjection(evidence), "Evidence")}`;
@ -270,6 +272,50 @@ export const createEvidence = (
provider: EvidenceProvider,
observation: EvidenceObservation,
): Evidence => {
const { normalized, sharedResult } = normalizeEvidenceObservation(
target,
provider,
observation,
);
const evidence = {
...normalized,
evidence_id: computeEvidenceId(normalized),
};
return sharedResult ? rememberImmutableEvidence(evidence) : evidence;
};
/** Create Evidence from owned immutable JSON while exposing cooperative computation steps. */
export function* createImmutableEvidenceSteps(
target: EvidenceSubjectTarget | BinaryTarget | undefined,
provider: EvidenceProvider,
observation: EvidenceObservation,
): Generator<void, Evidence> {
if (!isImmutableJsonSnapshot(observation.result))
throw new TypeError(
"Cooperative Evidence requires an authenticated immutable result",
);
// The envelope parser below retains its usual diagnostics, using the completed
// immutable validation rather than traversing the payload synchronously again.
yield* jsonValueValidationSteps(observation.result);
const { normalized } = normalizeEvidenceObservation(
target,
provider,
observation,
);
const digest = yield* canonicalJsonDigestSteps(
semanticProjection(normalized),
);
return rememberImmutableEvidence({
...normalized,
evidence_id: `ev_${digest}`,
});
}
const normalizeEvidenceObservation = (
target: EvidenceSubjectTarget | BinaryTarget | undefined,
provider: EvidenceProvider,
observation: EvidenceObservation,
) => {
const subject =
target === undefined
? null
@ -339,9 +385,5 @@ export const createEvidence = (
// The envelope has already been parsed into an independent snapshot. Only
// its derived identifier changes here; parsing again clones the full payload
// and recomputes the same digest while the previous snapshot is still live.
const evidence = {
...normalized,
evidence_id: computeEvidenceId(normalized),
};
return sharedResult ? rememberImmutableEvidence(evidence) : evidence;
return { normalized, sharedResult };
};

View File

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { createAnalysisProfile } from "./analysisProfile.js";
import {
createEvidence,
createImmutableEvidenceSteps,
isImmutableEvidence,
parseEvidence,
} from "./evidence.js";
import { freezeJsonSnapshot } from "./immutableJson.js";
import {
jsonValueValidationSteps,
MAX_JSON_DEPTH,
type JsonValue,
} from "./jsonValue.js";
const provider = {
id: "cooperative",
name: "Cooperative fixture",
version: "1",
};
const target = {
path: "/artifact",
sha256: "a".repeat(64),
format: "file",
} as const;
const finish = <Value>(steps: Iterator<void, Value, void>): Value => {
for (;;) {
const step = steps.next();
if (step.done) return step.value;
}
};
describe("cooperative immutable Evidence", () => {
it.each([false, true])(
"preserves the complete envelope and identifier (profile: %s)",
(profiled) => {
const result = freezeJsonSnapshot({
["__proto__"]: { preserved: [1, "\ud800", "😀", null] },
constructor: "ordinary member",
long: 'quote"\\\n😀'.repeat(5000),
});
const observation = {
operation: "inspect",
parameters: { ["__proto__"]: "caller-selected" },
result,
rawResult: { retained: true },
...(profiled
? { analysisProfile: createAnalysisProfile(provider, {}) }
: {}),
};
const ordinary = createEvidence(target, provider, observation);
const cooperative = finish(
createImmutableEvidenceSteps(target, provider, observation),
);
expect(JSON.stringify(cooperative)).toBe(JSON.stringify(ordinary));
expect(cooperative.normalized_result).toBe(result);
expect(isImmutableEvidence(cooperative)).toBe(true);
expect(parseEvidence(cooperative)).toBe(cooperative);
expect(parseEvidence(JSON.parse(JSON.stringify(cooperative)))).toEqual(
ordinary,
);
},
);
it("retains scalar results and unavailable-subject limitations", () => {
const observation = { operation: "inspect", parameters: {}, result: null };
expect(
finish(createImmutableEvidenceSteps(undefined, provider, observation)),
).toEqual(createEvidence(undefined, provider, observation));
});
it("rejects mutable results and mismatched producer profiles", () => {
expect(() =>
finish(
createImmutableEvidenceSteps(target, provider, {
operation: "inspect",
parameters: {},
result: { borrowed: true },
}),
),
).toThrow("authenticated immutable result");
expect(() =>
finish(
createImmutableEvidenceSteps(target, provider, {
operation: "inspect",
parameters: {},
result: freezeJsonSnapshot({ value: true }),
analysisProfile: createAnalysisProfile(
{ ...provider, id: "different" },
{},
),
}),
),
).toThrow("profile provider");
});
it("does not cache an interrupted depth validation as successful", () => {
let nested: JsonValue = 1;
for (let index = 0; index <= MAX_JSON_DEPTH; index += 1)
nested = { nested };
const result = freezeJsonSnapshot([
nested,
...Array<JsonValue>(5000).fill(null),
]);
const validation = jsonValueValidationSteps(result);
expect(validation.next().done).toBe(false);
validation.return(undefined);
const observation = { operation: "inspect", parameters: {}, result };
expect(() =>
finish(createImmutableEvidenceSteps(target, provider, observation)),
).toThrow("maximum nesting depth");
expect(() => createEvidence(target, provider, observation)).toThrow(
"maximum nesting depth",
);
});
});

View File

@ -1,6 +1,10 @@
import type { JsonValue } from "./jsonValue.js";
const immutableSnapshots = new WeakSet<object>();
interface SnapshotEntry {
readonly value: unknown;
readonly exiting: boolean;
}
/** Recognize an owned JSON snapshot sealed by this module, or an immutable scalar. */
export const isImmutableJsonSnapshot = (value: unknown): value is JsonValue =>
@ -12,9 +16,7 @@ export const isImmutableJsonSnapshot = (value: unknown): value is JsonValue =>
/** Seal an already parsed, owned JSON snapshot for immutable zero-copy reuse. */
export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
const pending: { value: unknown; exiting: boolean }[] = [
{ value, exiting: false },
];
const pending: SnapshotEntry[] = [{ value, exiting: false }];
// These sets exist only during traversal, while the owned root already
// retains every child. Strong sets avoid per-node ephemeron GC work.
const visited = new Set<object>();
@ -33,13 +35,6 @@ export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
if (isImmutableJsonSnapshot(item)) continue;
if (typeof item !== "object" || item === null)
throw new TypeError("Immutable JSON snapshots require JSON values");
const prototype: unknown = Object.getPrototypeOf(item);
if (
!Array.isArray(item) &&
prototype !== Object.prototype &&
prototype !== null
)
throw new TypeError("Immutable JSON snapshots require ordinary objects");
if (ancestors.has(item))
throw new TypeError(
"Immutable JSON snapshots cannot contain circular references",
@ -47,21 +42,7 @@ export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
if (visited.has(item)) continue;
ancestors.add(item);
pending.push({ value: item, exiting: true });
if (Array.isArray(item))
for (let index = 0; index < item.length; index += 1)
if (!Object.hasOwn(item, index))
throw new TypeError(
"Immutable JSON snapshots cannot contain sparse arrays",
);
for (const key of Object.getOwnPropertyNames(item)) {
const descriptor = Object.getOwnPropertyDescriptor(item, key);
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
throw new TypeError(
"Immutable JSON snapshots cannot contain accessors",
);
const child: unknown = descriptor.value;
pending.push({ value: child, exiting: false });
}
pushSnapshotChildren(item, pending);
}
// Register only after every value is checked and every object is frozen.
// An arbitrary Object.freeze call cannot forge this snapshot identity.
@ -72,3 +53,75 @@ export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
immutableSnapshots.add(value);
return value;
};
/**
* Seal exclusively owned JSON in bounded steps, authenticating only completion.
* Abandonment or failure can leave the provisional input partly frozen.
*/
export function* freezeOwnedJsonSnapshotSteps<T extends JsonValue>(
value: T,
): Generator<void, T> {
const pending: SnapshotEntry[] = [{ value, exiting: false }];
const visited = new Set<object>();
const ancestors = new Set<object>();
let examined = 0;
try {
while (pending.length > 0) {
if (examined++ === 4096) {
examined = 0;
yield;
}
const entry = pending.pop();
if (entry === undefined) break;
const item = entry.value;
if (entry.exiting && typeof item === "object" && item !== null) {
ancestors.delete(item);
visited.add(item);
continue;
}
if (isImmutableJsonSnapshot(item)) continue;
if (typeof item !== "object" || item === null)
throw new TypeError("Immutable JSON snapshots require JSON values");
if (ancestors.has(item))
throw new TypeError(
"Immutable JSON snapshots cannot contain circular references",
);
if (visited.has(item)) continue;
ancestors.add(item);
pending.push({ value: item, exiting: true });
pushSnapshotChildren(item, pending);
// Lock every inspected edge before the scheduler can release control.
Object.freeze(item);
}
if (typeof value === "object" && value !== null)
immutableSnapshots.add(value);
return value;
} finally {
pending.length = 0;
visited.clear();
ancestors.clear();
}
}
const pushSnapshotChildren = (item: object, pending: SnapshotEntry[]): void => {
const prototype: unknown = Object.getPrototypeOf(item);
if (
!Array.isArray(item) &&
prototype !== Object.prototype &&
prototype !== null
)
throw new TypeError("Immutable JSON snapshots require ordinary objects");
if (Array.isArray(item))
for (let index = 0; index < item.length; index += 1)
if (!Object.hasOwn(item, index))
throw new TypeError(
"Immutable JSON snapshots cannot contain sparse arrays",
);
for (const key of Object.getOwnPropertyNames(item)) {
const descriptor = Object.getOwnPropertyDescriptor(item, key);
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
throw new TypeError("Immutable JSON snapshots cannot contain accessors");
const child: unknown = descriptor.value;
pending.push({ value: child, exiting: false });
}
};

View File

@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
freezeOwnedJsonSnapshotSteps,
isImmutableJsonSnapshot,
} from "./immutableJson.js";
import { jsonValueValidationIssue, type JsonValue } from "./jsonValue.js";
const complete = <Value>(steps: Iterator<void, Value>): Value => {
for (;;) {
const next = steps.next();
if (next.done) return next.value;
}
};
describe("cooperative owned snapshot sealing", () => {
it("locks reachable edges before yielding and authenticates only completion", () => {
const deferred: { [key: string]: JsonValue } = { value: 1 };
const value = {
deferred,
many: Array.from({ length: 10_000 }, () => ({ value: 1 })),
};
const steps = freezeOwnedJsonSnapshotSteps(value);
expect(steps.next().done).toBe(false);
expect(Object.isFrozen(value)).toBe(true);
expect(isImmutableJsonSnapshot(value)).toBe(false);
const introduced = { value: 2 };
deferred.child = introduced;
expect(complete(steps)).toBe(value);
expect(Object.isFrozen(introduced)).toBe(true);
expect(isImmutableJsonSnapshot(value)).toBe(true);
expect(jsonValueValidationIssue(value)).toBeUndefined();
});
it("never authenticates abandoned or invalid provisional roots", () => {
const deferred: { [key: string]: JsonValue } = { value: 1 };
const value = {
deferred,
many: Array.from({ length: 10_000 }, () => ({ value: 1 })),
};
const steps = freezeOwnedJsonSnapshotSteps(value);
expect(steps.next().done).toBe(false);
deferred.value = Number.NaN;
expect(() => complete(steps)).toThrow();
expect(isImmutableJsonSnapshot(value)).toBe(false);
const cancelled = {
values: Array.from({ length: 10_000 }, () => ({ value: 1 })),
};
const interrupted = freezeOwnedJsonSnapshotSteps(cancelled);
expect(interrupted.next().done).toBe(false);
interrupted.return(cancelled);
expect(isImmutableJsonSnapshot(cancelled)).toBe(false);
});
it("preserves aliases and rejects accessors, sparse values and cycles", () => {
const shared = { value: 1 };
const value = { a: shared, b: shared };
complete(freezeOwnedJsonSnapshotSteps(value));
expect(Object.isFrozen(shared)).toBe(true);
const sparse: JsonValue[] = [];
sparse.length = 2;
const cycle: { [key: string]: JsonValue } = {};
cycle.self = cycle;
let accessed = false;
const accessor = {
get value() {
accessed = true;
return 1;
},
};
for (const malformed of [sparse, cycle, accessor]) {
expect(() => complete(freezeOwnedJsonSnapshotSteps(malformed))).toThrow();
expect(isImmutableJsonSnapshot(malformed)).toBe(false);
}
expect(accessed).toBe(false);
});
it("does not cache validation of a caller's shallow freeze", () => {
const value = Object.freeze({ child: { value: 1 } });
expect(jsonValueValidationIssue(value)).toBeUndefined();
value.child.value = Number.NaN;
expect(jsonValueValidationIssue(value)).toBe("JSON numbers must be finite");
});
});

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { isImmutableJsonSnapshot } from "./immutableJson.js";
/** Maximum JSON nesting accepted before recursive result cloning. */
export const MAX_JSON_DEPTH = 1000;
@ -15,17 +16,46 @@ export type JsonValue =
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const recordShape = z.record(z.string(), z.unknown());
const immutableValidation = new WeakMap<object, string | undefined>();
/** Validate JSON meaning and nesting without cloning a trusted immutable value. */
export const jsonValueValidationIssue = (root: unknown): string | undefined => {
const steps = jsonValueValidationSteps(root);
for (;;) {
const step = steps.next();
if (step.done) return step.value;
}
};
/** Validate JSON in bounded steps; cache only authenticated immutable roots. */
export function* jsonValueValidationSteps(
root: unknown,
): Generator<void, string | undefined> {
const immutable =
typeof root === "object" && root !== null && isImmutableJsonSnapshot(root)
? root
: undefined;
if (immutable !== undefined && immutableValidation.has(immutable))
return immutableValidation.get(immutable);
const finish = (issue: string | undefined): string | undefined => {
if (immutable !== undefined) immutableValidation.set(immutable, issue);
return issue;
};
const pending: { value: unknown; depth: number }[] = [
{ value: root, depth: 0 },
];
let examined = 0;
while (pending.length > 0) {
if (examined++ === 4096) {
examined = 0;
yield;
}
const item = pending.pop();
if (item === undefined) break;
if (item.depth > MAX_JSON_DEPTH)
return `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`;
return finish(
`JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`,
);
const value = item.value;
if (
value === null ||
@ -34,7 +64,7 @@ export const jsonValueValidationIssue = (root: unknown): string | undefined => {
)
continue;
if (typeof value === "number") {
if (!Number.isFinite(value)) return "JSON numbers must be finite";
if (!Number.isFinite(value)) return finish("JSON numbers must be finite");
} else if (Array.isArray(value)) {
for (const child of value)
pending.push({ value: child, depth: item.depth + 1 });
@ -42,10 +72,10 @@ export const jsonValueValidationIssue = (root: unknown): string | undefined => {
// Inspect every own member, including keys Zod's record parser skips.
for (const child of Object.values(value))
pending.push({ value: child, depth: item.depth + 1 });
} else return "Invalid JSON value";
} else return finish("Invalid JSON value");
}
return undefined;
};
return finish(undefined);
}
const preserveJsonValue = (value: JsonValue): JsonValue => {
if (Array.isArray(value)) return value.map(preserveJsonValue);

View File

@ -0,0 +1,89 @@
import { execFile, spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("returns a nonzero exit for a streamed JSON analysis failure", async () => {
const root = await createTestTempDirectory("rea-cli-js-missing-");
const result = await new Promise<{ code: number | string; stdout: string }>(
(resolve, reject) => {
execFile(
process.execPath,
[
"scripts/rea.mjs",
"analyze-javascript-application",
join(root, "missing"),
"--json",
],
(error, stdout) => {
if (error !== null && error.code === undefined) {
reject(error);
return;
}
resolve({ code: error?.code ?? 0, stdout });
},
);
},
);
expect(result.code).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
code: "artifact_operation_failed",
details: { reason: "io" },
});
});
it.skipIf(process.platform === "win32")(
"returns a typed cancelled CLI result and exit 130 after SIGINT",
async () => {
const root = await createTestTempDirectory("rea-cli-js-cancellation-");
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
const child = spawn(
process.execPath,
["scripts/rea.mjs", "analyze-javascript-application", root, "--json"],
{
stdio: ["ignore", "pipe", "pipe"],
},
);
onTestFinished(() => {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGTERM");
});
let stdout = "",
partial = "",
requested = false;
const progressSchema = z.object({
rea_progress: z.object({ phase: z.string() }),
});
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
partial += chunk.toString();
let newline: number;
while ((newline = partial.indexOf("\n")) >= 0) {
const line = partial.slice(0, newline);
partial = partial.slice(newline + 1);
const parsed = progressSchema.safeParse(JSON.parse(line));
if (parsed.success && !requested) {
requested = true;
child.kill("SIGINT");
}
}
});
const exited = await new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => resolve({ code, signal }));
});
expect(requested).toBe(true);
expect(exited).toEqual({ code: 130, signal: null });
expect(JSON.parse(stdout)).toMatchObject({
code: "cancelled",
details: { reason: "cancelled" },
});
},
);

View File

@ -0,0 +1,110 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import pino from "pino";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { createEvidence } from "../../../src/domain/evidence.js";
import { createServer } from "../../../src/server/createServer.js";
import {
createDeferred,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("stops a cancelled SDK request on the server, retains prior Evidence, and accepts subsequent work", async () => {
const root = await createTestTempDirectory("rea-mcp-js-cancellation-");
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
const session = createTestBinarySession(() => {
throw new Error(
"Static JavaScript analysis must not start a binary provider",
);
});
const prior = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "prior_observation",
parameters: {},
result: { preserved: true },
},
);
expect(session.recordEvidence(prior).ok).toBe(true);
const completed = createDeferred<string>();
const completionSchema = z.object({
tool: z.string(),
status: z.string(),
msg: z.string(),
});
const logger = pino(
{ level: "info" },
{
write(line) {
const value: unknown = JSON.parse(line);
const parsed = completionSchema.safeParse(value);
if (
parsed.success &&
parsed.data.tool === "analyze_javascript_application" &&
parsed.data.msg === "MCP tool execution completed"
)
completed.resolve(parsed.data.status);
},
},
);
const server = createServer(session, session, { logger });
const client = new Client({ name: "javascript-cancellation", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const controller = new AbortController();
let requested = false;
const call = client.callTool(
{
name: "analyze_javascript_application",
arguments: {
input_path: root,
format: "directory",
},
},
{
signal: controller.signal,
onprogress() {
requested = true;
controller.abort(new Error("SDK cancellation verification"));
},
},
);
await expect(call).rejects.toThrow("SDK cancellation verification");
expect(requested).toBe(true);
// A rejected client promise alone does not prove that the server stopped.
expect(await completed.promise).toBe("error");
await client.ping();
const bundle = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
expect(
z
.object({
result: z.object({
records: z.array(z.object({ evidence_id: z.string() })),
}),
})
.parse(bundle.structuredContent).result.records,
).toEqual([{ evidence_id: prior.evidence_id }]);
const next = await client.callTool({
name: "analyze_javascript_application",
arguments: {
input_path: root,
format: "directory",
},
});
expect(next.isError, JSON.stringify(next)).not.toBe(true);
await client.ping();
});