fix(cli): export source Evidence for native value traces (#8297)

* fix(cli): export source Evidence for native value traces

* fix(cli): export source Evidence for native value traces

* fix(cli): preserve trace completeness in summaries
This commit is contained in:
Ha1baraA1 authored and GitHub committed 2026-11-04 19:56:50 +08:00
1 parent 064bb3f713
commit bd7002b441
9 files changed
+570 -18

No files matched your search

+5
View File
@@ -434,6 +434,11 @@ nodes and edges. Node payloads are bounded to 8 MiB and serialized results to
32 MiB. Pagination returns edges whose source is on the node page and preserves
stable IDs for endpoints outside that page.
The one-shot CLI accepts `--evidence_output <new-path>` to save the trace and
the transitive source Evidence closure named by its links. The destination is
never overwritten. `--detail summary` requires `--evidence_output`; the bundle
still retains the complete trace and source Evidence.
Within one value trace, each exact procedure selector is attempted at most once,
including failed analyses. A failed seed returns the provider's original typed
failure and corrective guidance; it cannot establish a trace or target identity.
+14 -2
View File
@@ -17,7 +17,10 @@ import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js";
import { silentLogger } from "../logger.js";
import type { Logger } from "pino";
import { withEvidenceCallerInputPath } from "../domain/evidence.js";
import { createImmutableEvidenceBundle } from "../domain/evidenceBundle.js";
import {
createImmutableEvidenceBundle,
type EvidenceBundle,
} from "../domain/evidenceBundle.js";
import { retainAnalysisResultSources } from "./investigation/recordEvidenceSources.js";
import type { Evidence } from "../domain/evidence.js";
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
@@ -139,6 +142,8 @@ export const runDirectAnalysis = async (
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly espFlash?: EspFlashSelection;
/** Capture the exact session Evidence available to a one-shot CLI adapter. */
readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void;
} = {},
): Promise<Result<Evidence, JsonValue>> =>
withProcessCancellation(options.signal, (signal) =>
@@ -146,6 +151,9 @@ export const runDirectAnalysis = async (
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
signal,
...(options.onEvidenceBundle === undefined
? {}
: { onEvidenceBundle: options.onEvidenceBundle }),
...targetSelection(options),
...(options.providerId === undefined
? {}
@@ -364,6 +372,7 @@ const runAnalysis = async (
readonly snapshotPath: string | undefined;
readonly signal: AbortSignal;
readonly evidenceParameters?: Readonly<Record<string, JsonValue>>;
readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void;
readonly createSession?: () => BinarySession | Promise<BinarySession>;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
@@ -458,8 +467,10 @@ const runAnalysis = async (
: routeProviderIdentity(route, tool),
evidenceProfile,
});
if (cached !== undefined)
if (cached !== undefined) {
options.onEvidenceBundle?.(snapshot.evidence_bundle);
return ok(withEvidenceCallerInputPath(cached, path));
}
}
}
const openOptions = {
@@ -552,6 +563,7 @@ const runAnalysis = async (
return err(written.error.retainPartialObservation(evidence));
}
}
options.onEvidenceBundle?.(session.exportEvidenceBundle());
return ok(evidence);
};
const outcome = await withSessionCleanup<{
+38
View File
@@ -14,6 +14,7 @@ import {
import type { JsonValue } from "../domain/jsonValue.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { EvidenceReferenceError } from "../domain/evidenceErrors.js";
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
import { err, ok, type Result } from "../domain/result.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
@@ -36,6 +37,7 @@ import {
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { ZodSafeParseResult } from "zod";
import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js";
import { resolveInvestigationEvidenceClosure } from "../application/investigation/InvestigationEvidence.js";
export const analysisEvidenceDeliveryOptions = {
detail: z
@@ -78,6 +80,42 @@ export const cliEvidenceDeliveryInputError = (
])
: undefined;
/** Resolve only the authenticated Evidence records linked by one CLI result. */
export const cliEvidenceSourceClosure = (
root: Evidence,
available: readonly Evidence[],
): Result<Evidence[], AnalysisError> => {
const recordsById = new Map(
available.map((record) => [record.evidence_id, record]),
);
const closure = resolveInvestigationEvidenceClosure(
{ evidenceForAnalysis: (evidenceId) => recordsById.get(evidenceId) },
root.evidence_links,
);
if (!closure.ok) {
if (closure.error instanceof EvidenceReferenceError)
return err(
new EvidenceReferenceError(
closure.error.evidenceId,
closure.error.reason,
{
cause: closure.error,
expected: closure.error.expected,
actual: closure.error.actual,
referenceScope: closure.error.referenceScope,
diagnostics: { operation: root.operation },
},
),
);
return err(closure.error);
}
return ok(
closure.value
.filter((evidenceId) => evidenceId !== root.evidence_id)
.map((evidenceId) => recordsById.get(evidenceId)!),
);
};
/** Parse delivery JSON once and enforce its owning schema before opening a target. */
export const parseCliAnalysisEvidenceDelivery = <
Delivery extends {
+63 -16
View File
@@ -30,9 +30,12 @@ import { withCommandCancellation } from "./commandCancellation.js";
import {
analysisEvidenceDeliveryOptions,
cliEvidenceDeliveryInputError,
cliEvidenceSourceClosure,
publishCliAnalysisEvidence,
} from "./analysisEvidenceDelivery.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
const bytePatternInputSchema = z
.object(bytePatternFields)
@@ -236,25 +239,69 @@ export const registerCoreNativeCommands = (
.max(5000)
.default(1000)
.describe("Maximum nodes on this page"),
...analysisEvidenceDeliveryOptions,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.traceNativeValues, () =>
runDirectAnalysis(
args.path,
"trace_native_values",
{
procedure: args.procedure,
max_depth: options.maxDepth,
max_functions: options.maxFunctions,
max_call_sites: options.maxCallSites,
max_nodes: options.maxNodes,
max_edges: options.maxEdges,
offset: options.offset,
limit: options.limit,
},
directAnalysisOptionsFromCli(logger, options),
).then(projectCliAnalysisResult),
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.traceNativeValues, async () => {
const delivery = {
providerId: "auto",
operation: "trace_native_values",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const invalid = cliEvidenceDeliveryInputError(delivery);
if (invalid !== undefined) return projectAnalysisError(invalid);
let sessionBundle: EvidenceBundle | undefined;
const complete = await runDirectAnalysis(
args.path,
"trace_native_values",
{
procedure: args.procedure,
max_depth: options.maxDepth,
max_functions: options.maxFunctions,
max_call_sites: options.maxCallSites,
max_nodes: options.maxNodes,
max_edges: options.maxEdges,
offset: options.offset,
limit: options.limit,
},
{
...directAnalysisOptionsFromCli(logger, options),
signal,
...(options.evidence_output === undefined
? {}
: {
onEvidenceBundle: (bundle: EvidenceBundle) => {
sessionBundle = bundle;
},
}),
},
);
if (!complete.ok || options.evidence_output === undefined)
return publishCliAnalysisEvidence(complete, delivery);
if (sessionBundle === undefined)
return projectAnalysisError(
new AnalysisOutputError(
"trace_native_values",
"The CLI session did not return its retained Evidence bundle.",
).retainPartialObservation(complete.value),
);
const sources = cliEvidenceSourceClosure(
complete.value,
sessionBundle.records,
);
if (!sources.ok)
return projectAnalysisError(
sources.error.retainPartialObservation(complete.value),
);
return publishCliAnalysisEvidence(complete, {
...delivery,
sourceEvidence: sources.value,
});
}),
),
});
cli.command(CLI_COMMANDS.inspectNativeDataType, {
+3
View File
@@ -472,6 +472,9 @@ const requestErrorDetails = (
return {
evidence_id: error.evidenceId,
reason: error.reason,
...(typeof error.diagnostics?.operation === "string"
? { operation: error.diagnostics.operation }
: {}),
...(error.referenceScope === undefined
? {}
: { reference_scope: error.referenceScope }),
+2
View File
@@ -3,6 +3,7 @@ import {
NATIVE_OBSERVATION_OPERATIONS,
} from "../native/nativeObservation.js";
import { projectNativeObservationView } from "./nativeObservationView.js";
import { projectNativeValueTraceView } from "./nativeValueTraceView.js";
import { projectDevicetreeView } from "./devicetreeView.js";
import { projectApkSignatureView } from "./apkSignatureView.js";
import { byteEntropySchema } from "../native/byteEntropy.js";
@@ -303,6 +304,7 @@ const retainedViewProjectors = new Map<string, RetainedViewProjector>([
...NATIVE_OBSERVATION_OPERATIONS.map((operation) =>
retain(operation, projectNativeObservationView),
),
retain("trace_native_values", projectNativeValueTraceView),
retain("inspect_erlang_beam", projectErlangBeamView),
retain("inspect_devicetree", projectDevicetreeView),
retain("inspect_native_exports", projectNativeExportsView),
@@ -16,6 +16,7 @@ import {
NATIVE_OBSERVATION_OPERATIONS,
type NativeObservationOperation,
} from "../native/nativeObservation.js";
import { nativeValueTraceSummarySchema } from "./nativeValueTraceView.js";
import {
devicetreeViewRequestSchema,
devicetreeViewItemsSchema,
@@ -348,6 +349,7 @@ const viewResultBase = {
"inspect_gpu_code_objects",
...NATIVE_INVENTORY_OPERATIONS,
...NATIVE_OBSERVATION_OPERATIONS,
"trace_native_values",
"match_native_functions",
]),
parent_digest: digestSchema,
@@ -388,6 +390,14 @@ export const nativeObservationSummaryViewResultSchemas = {
),
get_call_graph: nativeObservationSummaryFor("get_call_graph"),
};
export const nativeValueTraceSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
artifact: artifactSchema.nullable(),
parent_operation: z.literal("trace_native_values"),
summary: nativeValueTraceSummarySchema,
});
const nativeObservationSummaryViewResultSchema = z.discriminatedUnion(
"parent_operation",
[
@@ -1120,6 +1130,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
}),
z.discriminatedUnion("parent_operation", [
nativeObservationSummaryViewResultSchema,
nativeValueTraceSummaryViewResultSchema,
erlangBeamSummaryViewResultSchema,
devicetreeSummaryViewResultSchema,
pythonPycSummaryViewResultSchema,
@@ -0,0 +1,95 @@
import { z } from "zod";
import { nativeValueTraceSchema } from "../native/nativeValueTrace.js";
import { err, ok } from "../result.js";
import type { AnalysisViewParent } from "./analysisViewParent.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import {
assertOwnedView,
type AdmittedView,
type SummaryAnalysisView,
} from "./analysisViewShape.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import {
boundedViewMessages,
completeWithinViewCoverage,
} from "./analysisViewCoverage.js";
/** Counts and pagination state for a bounded native value trace page. */
export const nativeValueTraceSummarySchema = z.strictObject({
seed: z.string(),
target_sha256: z.string(),
node_count: z.int().nonnegative(),
total_nodes: z.int().nonnegative(),
edge_count: z.int().nonnegative(),
total_edges: z.int().nonnegative(),
procedure_count: z.int().nonnegative(),
unknown_count: z.int().nonnegative(),
decompilations: z.int().nonnegative(),
next_offset: z.int().nonnegative().nullable(),
page_truncated: nativeValueTraceSchema.shape.page_truncated,
traversal: nativeValueTraceSchema.shape.traversal,
truncated: z.boolean(),
limitation_count: z.int().nonnegative(),
});
const readTrace = createAnalysisViewResultReader(nativeValueTraceSchema);
/** Keep the bounded graph page and its completeness state in source Evidence. */
export const projectNativeValueTraceView = (
parent: AnalysisViewParent,
view: AdmittedView,
) => {
if (view.kind !== "summary")
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Native value trace Evidence supports the summary view; retrieve parent Evidence for graph nodes and edges.",
},
]),
);
assertOwnedView<SummaryAnalysisView>(view, "summary");
const parsed = readTrace(parent);
if (!parsed.ok) return parsed;
const trace = parsed.value;
return ok({
kind: "summary" as const,
view,
parent_evidence_id: parent.evidenceId,
parent_operation: "trace_native_values" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: parent.artifact ?? null,
summary: {
seed: trace.seed,
target_sha256: trace.target_sha256,
node_count: trace.nodes.length,
total_nodes: trace.total_nodes,
edge_count: trace.edges.length,
total_edges: trace.total_edges,
procedure_count: trace.procedures.length,
unknown_count: trace.unknowns.length,
decompilations: trace.decompilations,
next_offset: trace.next_offset,
page_truncated: trace.page_truncated,
traversal: trace.traversal,
truncated: trace.truncated,
limitation_count: trace.limitations.length,
},
coverage: completeWithinViewCoverage(1, 1),
limitations: boundedViewMessages(
[...parent.limitations, ...trace.limitations],
parent.evidenceId,
"limitations",
),
unknowns: boundedViewMessages(
trace.unknowns.map(
({ procedure, address, reason }) =>
`${procedure}${address === null ? "" : ` at ${address}`}: ${reason}`,
),
parent.evidenceId,
"unknowns",
),
});
};
@@ -0,0 +1,339 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { Cli } from "incur";
import pino from "pino";
import { expect, it, vi } from "vitest";
import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js";
import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js";
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
import type { CliInstance } from "../../../src/cli/types.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import {
createEvidenceBundle,
parseEvidenceBundle,
type EvidenceBundle,
} from "../../../src/domain/evidenceBundle.js";
import { REA_WORKFLOW_PROVIDER } from "../../../src/domain/investigationProviders.js";
import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js";
import { ok } from "../../../src/domain/result.js";
import { composedWorkflowFixture } from "../../fixtures/composedWorkflowEvidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const sourceProvider = { id: "fixture", name: "Fixture", version: "1" };
const sourceTarget = {
path: "/fixtures/native.exe",
format: "pe" as const,
sha256: "a".repeat(64),
};
const createTraceFixture = () => {
const leaf = createEvidence(sourceTarget, sourceProvider, {
operation: "read_native_source",
parameters: { address: "0x1000" },
result: { text: "return 0;" },
});
const procedure = createEvidence(sourceTarget, sourceProvider, {
operation: "analyze_function",
parameters: { procedure: "main" },
result: { procedure: { address: "0x1000" } },
evidenceLinks: [leaf.evidence_id],
});
const traceResult = nativeValueTraceSchema.parse({
seed: "main",
target_sha256: sourceTarget.sha256,
nodes: [
{
id: "node-1",
procedure: "0x1000",
evidence_id: procedure.evidence_id,
kind: "operation",
operation: null,
parameter: null,
},
],
edges: [],
procedures: [
{
address: "0x1000",
depth: 0,
evidence_id: procedure.evidence_id,
provider: "fixture",
analysis_profile_digest: null,
},
],
unknowns: [
{ procedure: "0x1000", address: null, reason: "fixture uncertainty" },
],
total_nodes: 2,
total_edges: 0,
decompilations: 1,
next_offset: 1,
page_truncated: true,
traversal: { status: "complete" },
truncated: true,
limitations: ["fixture trace limitation"],
});
const trace = createEvidence(sourceTarget, REA_WORKFLOW_PROVIDER, {
operation: "trace_native_values",
parameters: { procedure: "main" },
result: traceResult,
confidence: "derived",
evidenceLinks: [procedure.evidence_id],
});
const unrelated = createEvidence(sourceTarget, sourceProvider, {
operation: "address_name",
parameters: { address: "0x2000" },
result: { name: "unrelated" },
});
const sessionBundle = createEvidenceBundle([
trace,
procedure,
leaf,
unrelated,
]);
return { leaf, procedure, trace, unrelated, sessionBundle };
};
type DirectAnalysisRunner = Parameters<typeof registerCoreNativeCommands>[2];
const createTraceCli = (run: DirectAnalysisRunner) => {
const cli = Cli.create("rea-trace-evidence-test", {
version: "1",
sync: false,
});
registerCoreNativeCommands(
cli as unknown as CliInstance,
pino({ enabled: false }),
run,
);
return cli;
};
const runJsonCli = async (
cli: ReturnType<typeof Cli.create>,
args: string[],
) => {
let stdout = "";
await cli.serve(args, {
env: {},
stdout: (text: string) => {
stdout += text;
},
exit: () => undefined,
});
return JSON.parse(stdout) as Record<string, unknown>;
};
const verifyDirectAnalysisEvidenceBundleCallback = async (
snapshotPath: string,
) => {
const fixture = await composedWorkflowFixture();
let captured: EvidenceBundle | undefined;
const direct = parseEvidence(
await runDirectAnalysis(
fixture.dependencies,
fixture.path,
"binary_overview",
{},
{ snapshotPath, onEvidenceBundle: (bundle) => (captured = bundle) },
).then(projectCliAnalysisResult),
);
expect(captured?.records.map(({ evidence_id }) => evidence_id)).toContain(
direct.evidence_id,
);
let replayedBundle: EvidenceBundle | undefined;
const replayed = parseEvidence(
await runDirectAnalysis(
fixture.dependencies,
fixture.path,
"binary_overview",
{},
{
snapshotPath,
onEvidenceBundle: (bundle) => (replayedBundle = bundle),
},
).then(projectCliAnalysisResult),
);
expect(replayed).toEqual(direct);
expect(
replayedBundle?.records.map(({ evidence_id }) => evidence_id),
).toContain(replayed.evidence_id);
};
it("exports only the transitive source Evidence closure for a native value trace", async () => {
const { leaf, procedure, trace, unrelated, sessionBundle } =
createTraceFixture();
const run = vi.fn<DirectAnalysisRunner>(
async (_path, _tool, _arguments, options) => {
options?.onEvidenceBundle?.(sessionBundle);
return ok(trace);
},
);
const cli = createTraceCli(run);
const directory = await createTestTempDirectory("rea-trace-evidence-");
const destination = join(directory, "trace-evidence.json");
const delivered = await runJsonCli(cli, [
"trace-native-values",
sourceTarget.path,
"main",
"--evidence_output",
destination,
"--json",
]);
expect(delivered.evidence_id).toBe(trace.evidence_id);
expect(run).toHaveBeenCalledOnce();
const exported = parseEvidenceBundle(
JSON.parse(await readFile(destination, "utf8")),
);
expect(exported.records.map(({ evidence_id }) => evidence_id).sort()).toEqual(
[trace.evidence_id, procedure.evidence_id, leaf.evidence_id].sort(),
);
expect(
exported.records.some(
({ evidence_id }) => evidence_id === unrelated.evidence_id,
),
).toBe(false);
const summaryOutput = await runJsonCli(cli, [
"trace-native-values",
"/missing-target",
"main",
"--detail",
"summary",
"--json",
]);
expect(run).toHaveBeenCalledOnce();
expect(JSON.stringify(summaryOutput)).toContain("evidence_output");
});
it("publishes a bounded trace summary with a complete evidence bundle", async () => {
const { leaf, procedure, trace, sessionBundle } = createTraceFixture();
const run = vi.fn<DirectAnalysisRunner>(
async (_path, _tool, _arguments, options) => {
options?.onEvidenceBundle?.(sessionBundle);
return ok(trace);
},
);
const cli = createTraceCli(run);
const directory = await createTestTempDirectory("rea-trace-summary-");
const destination = join(directory, "trace-summary-evidence.json");
const receipt = await runJsonCli(cli, [
"trace-native-values",
sourceTarget.path,
"main",
"--detail",
"summary",
"--evidence_output",
destination,
"--json",
]);
const summaryResult = (
receipt.summary as { normalized_result?: unknown } | undefined
)?.normalized_result;
const expectedSummary = {
kind: "summary",
parent_operation: "trace_native_values",
parent_evidence_id: trace.evidence_id,
summary: {
seed: "main",
node_count: 1,
total_nodes: 2,
edge_count: 0,
procedure_count: 1,
unknown_count: 1,
decompilations: 1,
next_offset: 1,
page_truncated: true,
traversal: { status: "complete" },
truncated: true,
limitation_count: 1,
},
};
expect(summaryResult).toMatchObject(expectedSummary);
const summaryEvidence = parseEvidence(receipt.summary);
expect(summaryEvidence.operation).toBe("inspect_analysis_view");
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(destination, "utf8")),
);
expect(bundle.records.map(({ evidence_id }) => evidence_id).sort()).toEqual(
[trace.evidence_id, procedure.evidence_id, leaf.evidence_id].sort(),
);
expect(run).toHaveBeenCalledOnce();
});
it("retains completed trace Evidence when the session returns no bundle", async () => {
const { trace } = createTraceFixture();
const run = vi.fn<DirectAnalysisRunner>(async () => ok(trace));
const cli = createTraceCli(run);
const directory = await createTestTempDirectory("rea-trace-no-bundle-");
const destination = join(directory, "missing-bundle.json");
const error = await runJsonCli(cli, [
"trace-native-values",
sourceTarget.path,
"main",
"--evidence_output",
destination,
"--json",
]);
expect(error).toMatchObject({
code: "unreadable_output",
details: {
operation: "trace_native_values",
partial_observation: {
operation: "trace_native_values",
normalized_result: { seed: "main" },
},
},
});
await expect(readFile(destination, "utf8")).rejects.toThrow();
expect(run).toHaveBeenCalledOnce();
});
it("reports a missing linked source with its identity and completed trace", async () => {
const { leaf, procedure, trace } = createTraceFixture();
const run = vi.fn<DirectAnalysisRunner>(
async (_path, _tool, _arguments, options) => {
options?.onEvidenceBundle?.(createEvidenceBundle([trace, procedure]));
return ok(trace);
},
);
const cli = createTraceCli(run);
const directory = await createTestTempDirectory("rea-trace-missing-source-");
const destination = join(directory, "missing-source.json");
const error = await runJsonCli(cli, [
"trace-native-values",
sourceTarget.path,
"main",
"--evidence_output",
destination,
"--json",
]);
expect(error).toMatchObject({
code: "evidence_integrity_mismatch",
details: {
evidence_id: leaf.evidence_id,
reason: "missing",
operation: "trace_native_values",
reference_scope: "retained-closure",
partial_observation: {
operation: "trace_native_values",
normalized_result: { seed: "main" },
},
},
});
await expect(readFile(destination, "utf8")).rejects.toThrow();
expect(run).toHaveBeenCalledOnce();
});
it("replays direct-analysis Evidence bundle callbacks without rerunning", async () => {
const directory = await createTestTempDirectory("rea-trace-callback-");
await verifyDirectAnalysisEvidenceBundleCallback(
join(directory, "analysis-snapshot.json"),
);
});