Compare commits

...
35 changed files with 1763 additions and 114 deletions

No files matched your search

+3
View File
@@ -612,6 +612,9 @@ Saved snapshots include only cache entries whose subject identifies the selected
target. Unbound observations and observations of child artifacts remain available
in the open session and its Evidence bundle; they are excluded from the target's
saved replay entries without changing their subjects.
The snapshot also retains available Evidence linked by selected records, including
transitive sources with an unbound or different subject. Those sources are not
replay entries for the selected target. Unrelated records remain excluded.
Snapshots retain eligible target-scoped question histories in full, including
mutation Evidence recorded while another target was active. Observations and
+12
View File
@@ -218,6 +218,12 @@ Malformed provider replies return `unreadable_output` for
`resolve_containing_procedure` before successful-result projection;
cancellation and incomplete cleanup remain errors.
For a procedure match, `procedure_references` with `direction: "incoming"` can
provide the selected provider's reference kinds when supported. Its scope covers
the procedure body, so compare target addresses when selecting references to the
exact entry. It is a separate observation and does not establish runtime use or
complete reference discovery.
REA prepares complete MCP results within the pinned stdio client's 10 MiB
receive-buffer budget, including both text and structured representations and
room for the JSON-RPC envelope. Tool errors omit `structuredContent` from the
@@ -377,6 +383,12 @@ is null. `total` counts all matching heads; each page preserves complete records
in stable unknown ID order. Filters apply before paging. Pages reflect current
session state, so keep the unknown ledger unchanged while traversing it.
`list_unknowns` only lists registered heads. Import restores the Unknown records
present in a bundle; it does not turn residual question text embedded in Evidence
into registry entries. An empty registry therefore does not establish that
retained Evidence has no open questions. Inspect the relevant Evidence or its
analysis view for its residual questions.
Automatically retained workflow questions include their immutable result
Evidence ID. Distinct results retain independent questions and source links;
repeating the same result does not create another unknown. Workflow Evidence
+14
View File
@@ -300,6 +300,20 @@ relationships but reports their completeness as unknown. In particular, a resolv
tail call observed in one direction does not prove that the reverse inventory is
complete. An empty Hopper inventory cannot establish that no such call exists.
No whole-binary reverse scan is performed.
For large native traces, `trace_feature` accepts `detail: "summary"`. It retains
the complete trace and returns counts and its parent Evidence ID. Use
`inspect_analysis_view` with that ID and `view: {kind: "native-feature-trace",
collection: "references", offset: 0, limit: 64}` to page the original rows;
`matches` and `unknowns` are separate collections. Matches accept an exact
`address`; references accept exact `source_address` and `target_address`
filters. Row indices refer to the retained collection, and page coverage does not establish provider discovery
completeness. `list_unknowns` pages the independently managed question heads.
In the one-shot CLI, `rea trace ... --detail summary --evidence_output PATH`
stores the trace and its linked source Evidence for later
`inspect-analysis-view` calls. This bundle does not include the CLI session's
Unknown registry; its original question texts remain available in the trace's
`unknowns` pages.
Regex searches use ECMAScript Unicode syntax in a cancellable worker with a
matching deadline and a worker old-generation heap. Deadline or cancellation
stops matching while leaving the Hopper API available. The deadline is
+40
View File
@@ -114,6 +114,46 @@ export const verifyKunaReferences = async ({
);
}
assert.equal(trace.value.truncated, false);
const summary = await call("trace_feature", {
query: name,
detail: "summary",
});
assert.equal(
summary.value.parent_evidence_id,
trace.structured.evidence_id,
);
assert.equal(
summary.value.summary.references,
trace.value.references.length,
);
assert.equal(
summary.value.summary.unknowns,
trace.value.residual_unknowns.length,
);
for (const [collection, values] of [
["matches", trace.value.matches],
["references", trace.value.references],
["unknowns", trace.value.residual_unknowns],
]) {
const view = await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: trace.structured.evidence_id,
},
view: {
kind: "native-feature-trace",
collection,
offset: 0,
limit: 1,
},
});
assert.equal(view.value.coverage.total, values.length);
assert.deepEqual(
view.value.items.rows.map(({ value }) => value),
values.slice(0, 1),
);
assert.equal(view.value.items.collection, collection);
}
}
composed = {
graph_ms: graph.ms,
+72 -9
View File
@@ -18,7 +18,14 @@ import { silentLogger } from "../logger.js";
import type { Logger } from "pino";
import { withEvidenceCallerInputPath } from "../domain/evidence.js";
import { createImmutableEvidenceBundle } from "../domain/evidenceBundle.js";
import { retainAnalysisResultSources } from "./investigation/recordEvidenceSources.js";
import {
retainAnalysisResultSources,
type AnalysisResultWithSources,
} from "./investigation/recordEvidenceSources.js";
import {
createInvestigationEvidenceClosureChecker,
resolveInvestigationEvidenceClosure,
} from "./investigation/InvestigationEvidence.js";
import type { Evidence } from "../domain/evidence.js";
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
@@ -139,13 +146,17 @@ export const runDirectAnalysis = async (
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly espFlash?: EspFlashSelection;
readonly includeEvidenceSources?: boolean;
} = {},
): Promise<Result<Evidence, JsonValue>> =>
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> =>
withProcessCancellation(options.signal, (signal) =>
runAnalysis(dependencies, path, tool, arguments_, {
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
signal,
...(options.includeEvidenceSources
? { includeEvidenceSources: true }
: {}),
...targetSelection(options),
...(options.providerId === undefined
? {}
@@ -365,13 +376,14 @@ const runAnalysis = async (
readonly signal: AbortSignal;
readonly evidenceParameters?: Readonly<Record<string, JsonValue>>;
readonly createSession?: () => BinarySession | Promise<BinarySession>;
readonly includeEvidenceSources?: boolean;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly espFlash?: EspFlashSelection;
},
): Promise<Result<Evidence, JsonValue>> => {
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> => {
const { logger, signal, snapshotPath } = options;
const evidenceParameters = options.evidenceParameters ?? arguments_;
let session: BinarySession;
@@ -387,6 +399,29 @@ const runAnalysis = async (
)(config.value, logger);
}
let publishedSnapshot: PublishedEvidenceFile | undefined;
let sourceEvidence: readonly Evidence[] | undefined;
const captureSources = (
evidence: Evidence,
supplied: readonly Evidence[] = [],
): Result<null, AnalysisError> => {
if (!options.includeEvidenceSources) return ok(null);
const records = new Map(
supplied.map((record) => [record.evidence_id, record]),
);
const captured = new Map<string, Evidence>();
const closure = resolveInvestigationEvidenceClosure(
{
evidenceForAnalysis: (id) => {
const record = records.get(id) ?? session.evidenceForAnalysis(id);
if (record !== undefined) captured.set(id, record);
return record;
},
},
evidence.evidence_links,
);
sourceEvidence = [...captured.values()];
return closure.ok ? ok(null) : closure;
};
const execute = async (): Promise<Result<Evidence, AnalysisError>> => {
let isDirectory = false;
if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) {
@@ -458,8 +493,29 @@ const runAnalysis = async (
: routeProviderIdentity(route, tool),
evidenceProfile,
});
if (cached !== undefined)
return ok(withEvidenceCallerInputPath(cached, path));
if (cached !== undefined) {
if (cached.evidence_links.length === 0)
return ok(withEvidenceCallerInputPath(cached, path));
const records = new Map(
snapshot.evidence_bundle.records.map((record) => [
record.evidence_id,
record,
]),
);
const checkClosure = createInvestigationEvidenceClosureChecker({
evidenceForAnalysis: (id) => records.get(id),
});
if (checkClosure(cached.evidence_links)) {
const evidence = withEvidenceCallerInputPath(cached, path);
const captured = captureSources(
evidence,
snapshot.evidence_bundle.records,
);
if (!captured.ok)
return err(captured.error.retainPartialObservation(evidence));
return ok(evidence);
}
}
}
}
const openOptions = {
@@ -496,6 +552,9 @@ const runAnalysis = async (
);
if (!recorded.ok)
return err(recorded.error.retainPartialObservation(evidence));
const captured = captureSources(evidence);
if (!captured.ok)
return err(captured.error.retainPartialObservation(evidence));
if (isWorkflowEvidenceTool(tool)) {
const unknowns = recordWorkflowUnknowns({
name: tool,
@@ -575,11 +634,15 @@ const runAnalysis = async (
outcome.cleanupError.retainDiagnostics({
analysis_snapshot: { ...publishedSnapshot },
});
return err(
directAnalysisCleanupFailure(result, outcome.cleanupError, path),
);
return {
...err(directAnalysisCleanupFailure(result, outcome.cleanupError, path)),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
};
}
return result.ok ? ok(result.value) : err(cliError(result.error, path));
return {
...(result.ok ? ok(result.value) : err(cliError(result.error, path))),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
};
};
const executeAnalysisTool = async (input: {
+2 -6
View File
@@ -3,7 +3,7 @@ import type { z } from "zod";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { TraceContainingProcedure } from "../domain/native/nativeObservation.js";
import type { LiteralFeatureReference } from "../domain/native/literalFeatureTrace.js";
import { err, type Result } from "../domain/result.js";
import {
enhancedInputSchemas,
@@ -27,11 +27,7 @@ export const failedWithPartialResult = (
error: AnalysisError,
partialResult: JsonValue,
): PartialEnhancedFailure => ({ ok: false, error, partialResult });
export type TraceReference = {
readonly target_address: string;
readonly source_address: string;
readonly containing_procedure: TraceContainingProcedure;
};
export type TraceReference = LiteralFeatureReference;
/** One enhanced call whose input was parsed at its owning adapter boundary. */
export type ValidatedEnhancedCall = {
@@ -7,7 +7,8 @@ import {
ANALYSIS_SNAPSHOT_PROVIDER,
ANALYSIS_SNAPSHOT_TARGET,
} from "../../domain/analysisSnapshot.fixture.js";
import { createEvidence } from "../../domain/evidence.js";
import { createEvidence, immutableEvidence } from "../../domain/evidence.js";
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
import {
createEvidenceBundle,
evidenceBundleForTarget,
@@ -1208,6 +1209,154 @@ it("declines source-bearing snapshot entries while replaying omitted and empty s
expect(cache.entries()).toHaveLength(2);
});
it("replays source-bearing entries only when their complete retained closure exists", () => {
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const leaf = immutableEvidence(
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "leaf",
parameters: {},
result: "leaf observation",
}),
);
const source = immutableEvidence(
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "source",
parameters: {},
result: "source observation",
evidenceLinks: [leaf.evidence_id],
}),
);
const parameters = { address: "0x1000", document: "fixture" };
const execution = createAnalysisExecution(
"parent observation",
ANALYSIS_SNAPSHOT_PROVIDER,
{
subject: target,
analysisProfile: profile,
sourceEvidence: [source],
},
);
const parent = immutableEvidence(
createAnalysisExecutionEvidence(
"procedure_info",
parameters,
execution,
"observed",
),
);
const complete = new Map(
[parent, source, leaf].map((record) => [record.evidence_id, record]),
);
const input = {
target,
profile,
operation: "procedure_info" as const,
parameters,
execution: { ...execution, evidenceId: parent.evidence_id },
};
const missingLeaf = new AnalysisSnapshotCache();
missingLeaf.record(input, (id) =>
id === leaf.evidence_id ? undefined : complete.get(id),
);
expect(
missingLeaf.lookup(target, profile, "procedure_info", parameters),
).toBeUndefined();
const cache = new AnalysisSnapshotCache();
cache.record(input, (id) => complete.get(id));
expect(
cache
.lookup(target, profile, "procedure_info", parameters)
?.sourceEvidence?.map((record) => record.evidence_id),
).toEqual([source.evidence_id]);
if (source.subject === null) throw new Error("Expected source subject");
const ledger = new EvidenceLedger();
for (const record of [parent, source, leaf])
expect(ledger.record(record).ok).toBe(true);
const movedSource = {
...source,
subject: {
...source.subject,
local_path: "/relocated/source",
name: "source",
},
};
const replacement = ledger.import(createEvidenceBundle([movedSource]));
if (!replacement.ok) throw replacement.error;
cache.refreshEvidenceMetadata(replacement.value.replacements);
expect(
cache.lookup(target, profile, "procedure_info", parameters)
?.sourceEvidence?.[0]?.subject?.local_path,
).toBe("/relocated/source");
const exported = cache.export(target, profile, ledger.export());
if (!exported.ok) throw exported.error;
const imported = new AnalysisSnapshotCache();
imported.stage(exported.value);
expect(
imported
.lookup(target, profile, "procedure_info", parameters)
?.sourceEvidence?.map((record) => record.evidence_id),
).toEqual([source.evidence_id]);
expect(
imported.lookup(target, profile, "procedure_info", parameters)
?.sourceEvidence?.[0]?.subject?.local_path,
).toBe("/relocated/source");
const pruned = parseAnalysisSnapshot({
...exported.value,
evidence_bundle: createEvidenceBundle([parent, movedSource]),
});
const incomplete = new AnalysisSnapshotCache();
incomplete.stage(pruned);
const partial = new Map(
[parent, movedSource].map((record) => [record.evidence_id, record]),
);
expect(
incomplete.lookup(target, profile, "procedure_info", parameters, (id) =>
partial.get(id),
),
).toBeUndefined();
partial.set(leaf.evidence_id, leaf);
expect(
incomplete
.lookup(target, profile, "procedure_info", parameters, (id) =>
partial.get(id),
)
?.sourceEvidence?.map((record) => record.evidence_id),
).toEqual([source.evidence_id]);
const otherSource = immutableEvidence(
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "other_source",
parameters: {},
result: "different source",
}),
);
const otherParent = immutableEvidence(
createAnalysisExecutionEvidence(
"procedure_info",
parameters,
{ ...execution, sourceEvidence: [otherSource] },
"observed",
),
);
const records = [parent, movedSource, leaf, otherParent, otherSource];
const ambiguous = new AnalysisSnapshotCache();
ambiguous.stage(
parseAnalysisSnapshot({
...exported.value,
evidence_bundle: createEvidenceBundle(records),
}),
);
const allRecords = new Map(
records.map((record) => [record.evidence_id, record]),
);
expect(
ambiguous.lookup(target, profile, "procedure_info", parameters, (id) =>
allRecords.get(id),
),
).toBeUndefined();
});
describe("executions that report truncated references", () => {
const record = (referencesTruncated: boolean | undefined) => {
const cache = new AnalysisSnapshotCache();
+57 -21
View File
@@ -45,6 +45,7 @@ import {
snapshotReplayBinding,
type SnapshotReplayBinding,
} from "./AnalysisSnapshotReplayBinding.js";
import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js";
const RELATIONSHIP_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
"procedure_callers",
@@ -147,9 +148,12 @@ export const SNAPSHOT_REPLAY_LIMITATION =
/** Bounded in-memory cache for one immutable binary identity. */
export class AnalysisSnapshotCache {
readonly #entries = new Map<string, AnalysisSnapshotEntry>();
// Null means imported provenance cannot be faithfully replayed. It is distinct
// from an entry recorded before an Evidence ID was assigned.
readonly #replayBindings = new Map<string, SnapshotReplayBinding | null>();
// Null is ambiguous provenance; a pending ID is an exact parent whose source
// closure was missing and can be checked again against the owning ledger.
readonly #replayBindings = new Map<
string,
SnapshotReplayBinding | { readonly pendingEvidenceId: string } | null
>();
readonly #workflowEntries = new Map<string, AnalysisSnapshotWorkflowEntry>();
#target: AnalysisSnapshotTarget | undefined;
#binding: AnalysisSnapshotBinding | undefined;
@@ -218,6 +222,10 @@ export class AnalysisSnapshotCache {
record,
]),
);
const lookup = (id: string) => records.get(id);
const checkClosure = createInvestigationEvidenceClosureChecker({
evidenceForAnalysis: lookup,
});
const ambiguous = new Set<string>();
for (const evidence of snapshot.evidence_bundle.records) {
const queryId = analysisQueryId(
@@ -242,11 +250,15 @@ export class AnalysisSnapshotCache {
else evidenceByQuery.set(queryId, evidence);
}
for (const [queryId, evidence] of evidenceByQuery) {
if (ambiguous.has(queryId)) {
this.#replayBindings.set(queryId, null);
continue;
}
this.#replayBindings.set(
queryId,
ambiguous.has(queryId)
? null
: snapshotReplayBinding(evidence, records),
snapshotReplayBinding(evidence, lookup, checkClosure) ?? {
pendingEvidenceId: evidence.evidence_id,
},
);
}
for (const entry of snapshot.workflow_entries) {
@@ -272,7 +284,12 @@ export class AnalysisSnapshotCache {
replacements.map(({ current }) => [current.evidence_id, current]),
);
for (const [queryId, binding] of this.#replayBindings) {
if (binding === null || binding.sourceEvidence === undefined) continue;
if (
binding === null ||
"pendingEvidenceId" in binding ||
binding.sourceEvidence === undefined
)
continue;
const sources = binding.sourceEvidence;
if (!sources.some((source) => currentRecords.has(source.evidence_id)))
continue;
@@ -475,6 +492,7 @@ export class AnalysisSnapshotCache {
profile: AnalysisProfileCommitment,
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
lookup?: (id: string) => Evidence | undefined,
): AnalysisExecution | undefined {
const queryId = analysisQueryId(
snapshotTarget(target),
@@ -484,10 +502,18 @@ export class AnalysisSnapshotCache {
);
const cached = this.#entries.get(queryId);
if (cached === undefined) return undefined;
const binding = this.#replayBindings.get(queryId);
let binding = this.#replayBindings.get(queryId);
// Do not turn a refused provenance selection into a fresh, unlinked record.
// The provider must produce a new observation before this query can replay.
if (binding === null) return undefined;
if (binding !== undefined && "pendingEvidenceId" in binding) {
const parent = lookup?.(binding.pendingEvidenceId);
if (parent === undefined || lookup === undefined) return undefined;
const restored = snapshotReplayBinding(parent, lookup);
if (restored === null) return undefined;
this.#replayBindings.set(queryId, restored);
binding = restored;
}
const cachedSubject = cached.execution.subject;
const subject =
cachedSubject == null
@@ -535,21 +561,30 @@ export class AnalysisSnapshotCache {
}
/** Record one successful immutable call unless the cache is full. */
record(input: {
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment;
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly execution: AnalysisExecution;
}): void {
record(
input: {
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment;
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly execution: AnalysisExecution;
},
lookup?: (id: string) => Evidence | undefined,
): void {
const { target, profile, operation, parameters, execution } = input;
this.select(target, profile);
// The snapshot format does not carry reference truncation; a replay would
// present a capped read as complete, so it is not recorded.
if (execution.referencesTruncated === true) return;
// Provider snapshot entries do not carry execution source records. Replay
// must not discard the source closure that supplies the parent's Evidence links.
if ((execution.sourceEvidence?.length ?? 0) > 0) return;
let sourceBinding: SnapshotReplayBinding | undefined;
if ((execution.sourceEvidence?.length ?? 0) > 0) {
if (execution.evidenceId === undefined || lookup === undefined) return;
const parent = lookup(execution.evidenceId);
if (parent === undefined) return;
const closed = snapshotReplayBinding(parent, lookup);
if (closed === null) return;
sourceBinding = closed;
}
const queryId = analysisQueryId(
snapshotTarget(target),
snapshotBinding(profile),
@@ -581,9 +616,10 @@ export class AnalysisSnapshotCache {
if (execution.evidenceId === undefined)
this.#replayBindings.delete(entry.query_id);
else
this.#replayBindings.set(entry.query_id, {
evidenceId: execution.evidenceId,
});
this.#replayBindings.set(
entry.query_id,
sourceBinding ?? { evidenceId: execution.evidenceId },
);
}
/** Forget all target-bound entries. */
@@ -1,4 +1,5 @@
import type { Evidence } from "../../domain/evidence.js";
import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js";
/** Authenticated observation identity and its immediate source records. */
export interface SnapshotReplayBinding {
@@ -9,13 +10,17 @@ export interface SnapshotReplayBinding {
/** Preserve direct links using the snapshot's authenticated immutable records. */
export const snapshotReplayBinding = (
evidence: Evidence,
records: ReadonlyMap<string, Evidence>,
lookup: (id: string) => Evidence | undefined,
checkClosure = createInvestigationEvidenceClosureChecker({
evidenceForAnalysis: lookup,
}),
): SnapshotReplayBinding | null => {
if (evidence.evidence_links.length === 0)
return { evidenceId: evidence.evidence_id };
if (!checkClosure(evidence.evidence_links)) return null;
const sources: Evidence[] = [];
for (const id of evidence.evidence_links) {
const source = records.get(id);
const source = lookup(id);
// An external link without its source cannot be represented by sourceEvidence.
if (source === undefined) return null;
sources.push(source);
@@ -323,14 +323,16 @@ export abstract class BinarySessionRecords {
>,
): AnalysisExecution | undefined {
if (this.#snapshotInvalidated) return undefined;
return this.#snapshot.lookup(target, profile, operation, parameters);
return this.#snapshot.lookup(target, profile, operation, parameters, (id) =>
this.evidenceForAnalysis(id),
);
}
protected recordSnapshot(
input: Parameters<AnalysisSnapshotCache["record"]>[0],
): void {
if (this.#snapshotInvalidated) return;
this.#snapshot.record(input);
this.#snapshot.record(input, (id) => this.evidenceForAnalysis(id));
this.#emitSnapshotChanged();
}
@@ -5,7 +5,10 @@ import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import { InvestigationRecords } from "./InvestigationRecords.js";
import { resolveInvestigationEvidenceClosure } from "./InvestigationEvidence.js";
import {
createInvestigationEvidenceClosureChecker,
resolveInvestigationEvidenceClosure,
} from "./InvestigationEvidence.js";
const observation = (path: string, links: readonly string[] = []): Evidence =>
createEvidence(
@@ -108,3 +111,50 @@ it("rejects a lookup that returns another semantic Evidence identity", () => {
},
});
});
it("resolves an authenticated source with a wide repeated-link array", () => {
const leaf = observation("/wide-leaf");
const source = observation(
"/wide-source",
Array(500_000).fill(leaf.evidence_id),
);
expect(source.evidence_links).toHaveLength(500_000);
const records = new InvestigationRecords();
for (const record of [leaf, source]) {
const added = records.recordEvidence(record);
if (!added.ok) throw added.error;
}
expect(
records.evidenceForAnalysis(source.evidence_id)?.evidence_links,
).toHaveLength(500_000);
expect(
resolveInvestigationEvidenceClosure(records, [source.evidence_id]),
).toEqual({
ok: true,
value: [source.evidence_id, leaf.evidence_id].sort(),
});
});
it("reuses a complete shared source graph without weakening missing-source checks", () => {
const leaf = observation("/shared-leaf");
const source = observation("/shared-source", [leaf.evidence_id]);
const records = new Map(
[leaf, source].map((record) => [record.evidence_id, record]),
);
let lookups = 0;
const check = createInvestigationEvidenceClosureChecker({
evidenceForAnalysis: (id) => {
lookups++;
return records.get(id);
},
});
expect(check([source.evidence_id])).toBe(true);
expect(lookups).toBe(2);
expect(check([source.evidence_id, leaf.evidence_id])).toBe(true);
expect(lookups).toBe(2);
const missing = `ev_${"0".repeat(64)}`;
expect(check([missing])).toBe(false);
expect(check([missing])).toBe(false);
expect(lookups).toBe(3);
expect(check([source.evidence_id, missing])).toBe(false);
});
@@ -5,6 +5,37 @@ import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import type { Evidence } from "../../domain/evidence.js";
import { ok, type Result } from "../../domain/result.js";
type Lookup = (id: string) => Evidence | undefined;
/** Traverse without expanding a wide link array into function arguments. */
const visitClosure = (
lookup: Lookup,
seedIds: readonly string[],
knownComplete?: ReadonlySet<string>,
): Result<Set<string>, EvidenceIntegrityError> => {
const visited = new Set<string>();
const pending = [...seedIds];
while (pending.length > 0) {
const evidenceId = pending.pop();
if (
evidenceId === undefined ||
visited.has(evidenceId) ||
knownComplete?.has(evidenceId)
)
continue;
const evidence = resolveEvidenceInput(
{ kind: "retained-evidence", evidence_id: evidenceId },
lookup,
"retained-closure",
);
if (!evidence.ok) return evidence;
visited.add(evidenceId);
for (const linkedId of evidence.value.evidence_links)
pending.push(linkedId);
}
return ok(visited);
};
/** Follow exact session references without exporting unrelated records or Unknowns. */
export const resolveInvestigationEvidenceClosure = (
reader: Pick<EvidenceReader, "evidenceForAnalysis">,
@@ -16,21 +47,32 @@ export const resolveInvestigationEvidenceClosure = (
const supplied = new Map(
sources.map((source) => [source.evidence_id, source]),
);
const lookup = (id: string) =>
supplied.get(id) ?? reader.evidenceForAnalysis(id);
const visited = new Set<string>();
const pending = [...seedIds];
while (pending.length > 0) {
const evidenceId = pending.pop();
if (evidenceId === undefined || visited.has(evidenceId)) continue;
const evidence = resolveEvidenceInput(
{ kind: "retained-evidence", evidence_id: evidenceId },
lookup,
"retained-closure",
const visited = visitClosure(
(id) => supplied.get(id) ?? reader.evidenceForAnalysis(id),
seedIds,
);
return visited.ok ? ok(uniqueSorted([...visited.value])) : visited;
};
/** Reuse closure decisions while the caller's lookup remains immutable. */
export const createInvestigationEvidenceClosureChecker = (
reader: Pick<EvidenceReader, "evidenceForAnalysis">,
): ((seedIds: readonly string[]) => boolean) => {
const complete = new Set<string>();
const incomplete = new Set<string>();
return (seedIds) => {
const single = seedIds.length === 1 ? seedIds[0] : undefined;
if (single !== undefined && incomplete.has(single)) return false;
const visited = visitClosure(
(id) => reader.evidenceForAnalysis(id),
seedIds,
complete,
);
if (!evidence.ok) return evidence;
visited.add(evidenceId);
pending.push(...evidence.value.evidence_links);
}
return ok(uniqueSorted([...visited]));
if (!visited.ok) {
if (single !== undefined) incomplete.add(single);
return false;
}
for (const id of visited.value) complete.add(id);
return true;
};
};
@@ -5,7 +5,10 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
/** Source facts remain available even when a completed observation is cancelled. */
export type AnalysisResultWithSources<Value> = Result<Value, AnalysisError> & {
export type AnalysisResultWithSources<Value, Failure = AnalysisError> = Result<
Value,
Failure
> & {
readonly sourceEvidence?: readonly Evidence[];
};
+9 -1
View File
@@ -32,6 +32,7 @@ import {
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { ZodSafeParseResult } from "zod";
import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js";
import type { AnalysisResultWithSources } from "../application/investigation/recordEvidenceSources.js";
export const analysisEvidenceDeliveryOptions = {
detail: z
@@ -203,9 +204,16 @@ const withUndeliveredSourceClosure = (
/** Publish already finalized CLI analysis without opening a second provider/session. */
export const publishCliAnalysisEvidence = async (
result: Result<Evidence, JsonValue>,
result: AnalysisResultWithSources<Evidence, JsonValue>,
input: CliEvidenceDelivery,
): Promise<JsonValue> => {
input = {
...input,
sourceEvidence: [
...(input.sourceEvidence ?? []),
...(result.sourceEvidence ?? []),
],
};
const { operation, signal, detail, evidenceOutput } = input;
const projectFailure = (error: AnalysisError) =>
projectAnalysisError(
+37 -16
View File
@@ -357,24 +357,41 @@ const registerTraceCommand = (
.default(false)
.describe("Match the query with exact letter case"),
snapshot: snapshotSelectionOption,
...analysisEvidenceDeliveryOptions,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, "trace", () =>
runDirectAnalysis(
args.path,
"trace_feature",
{
query: resolveCliAnalysisSelector(
args.query,
options.query,
"query",
"trace_feature",
),
case_sensitive: options.caseSensitive,
},
directAnalysisOptionsFromCli(logger, options),
).then(projectCliAnalysisResult),
withCommandCancellation((signal) =>
logCliCommand(logger, "trace", async () => {
const delivery = {
providerId: "auto",
operation: "trace_feature",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const inputError = cliEvidenceDeliveryInputError(delivery);
if (inputError !== undefined) return projectAnalysisError(inputError);
const complete = await runDirectAnalysis(
args.path,
"trace_feature",
{
query: resolveCliAnalysisSelector(
args.query,
options.query,
"query",
"trace_feature",
),
case_sensitive: options.caseSensitive,
},
{
...directAnalysisOptionsFromCli(logger, options),
signal,
includeEvidenceSources: options.evidence_output !== undefined,
},
);
return publishCliAnalysisEvidence(complete, delivery);
}),
),
});
};
@@ -430,7 +447,11 @@ const registerFunctionCommand = (
"analyze_function",
),
},
{ ...directAnalysisOptionsFromCli(logger, options), signal },
{
...directAnalysisOptionsFromCli(logger, options),
signal,
includeEvidenceSources: options.evidence_output !== undefined,
},
);
return publishCliAnalysisEvidence(complete, delivery);
}),
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the fLine truncated
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redactLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+2 -2
View File
@@ -82,8 +82,8 @@ export const ENHANCED_TOOL_CONTRACTS = [
...FUNCTION_WORKFLOW_TOOL_CONTRACTS,
enhanced(
"trace_feature",
"Trace the complete literal query, preserving spaces, through every matching string and procedure and their xrefs. Preserve an explicit unavailable owner for a non-retryable containment capability refusal. Returns observations and residual unknowns without inferring reference kinds; truncated only reports an observed reference cap.",
enhancedInputSchemas.trace_feature,
"Trace the complete literal query, preserving spaces, through every matching string and procedure and their xrefs. Preserve an explicit unavailable owner for a non-retryable containment capability refusal. Returns observations and residual unknowns without inferring reference kinds; truncated only reports an observed reference cap. For a procedure match, use procedure_references with direction incoming for provider-reported kinds when supported; it covers the procedure body, so compare target addresses when selecting exact-entry references. Select detail summary to retain complete Evidence and page matches, references and unknowns with inspect_analysis_view kind native-feature-trace.",
enhancedInputSchemas.trace_feature.extend({ detail: analysisDetailSchema }),
),
enhanced(
"trace_call_path",
+2 -2
View File
@@ -98,7 +98,7 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"import_evidence_bundle",
"Read the JSON bundle at the supplied local path, validate every Evidence ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed.",
"Read the JSON bundle at the supplied local path, validate every Evidence ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed. Import restores registry entries present in the bundle; questions embedded in Evidence do not automatically become Unknown registry entries.",
importEvidenceBundleInputSchema,
),
session(
@@ -153,7 +153,7 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"list_unknowns",
"List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. Results are complete inline by default; supply offset and limit to page the filtered order. total is the matching-head count and next_offset is null when the page exhausts the result. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct.",
"List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. Results are complete inline by default; supply offset and limit to page the filtered order. total is the matching-head count and next_offset is null when the page exhausts the result. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct. An empty registry does not mean retained Evidence has no residual questions; inspect the relevant Evidence or its analysis view.",
listUnknownsInputSchema,
),
session(
+5 -17
View File
@@ -1,7 +1,6 @@
import {
nativeAddressListSchema,
nativeObservationSourceSchemas,
traceContainingProcedureSchema,
} from "../domain/native/nativeObservation.js";
import { overviewInventoryCoverageSchema } from "../domain/native/inventoryCoverage.js";
import {
@@ -10,6 +9,7 @@ import {
nativeFunctionMatchingSummaryViewResultSchema,
nativeFunctionSummaryViewResultSchema,
nativeInventorySummaryViewResultSchema,
nativeFeatureTraceSummaryViewResultSchema,
batchDecompileSummaryViewResultSchema,
nativeLoadImageSummaryViewResultSchema,
artifactInventorySummaryViewResultSchema,
@@ -18,7 +18,7 @@ import {
managedArtifactSummaryViewResultSchema,
managedMemberSummaryViewResultSchema,
} from "../domain/analysisView/analysisView.js";
import { literalFeatureMatchSchema } from "../domain/native/literalFeatureMatch.js";
import { literalFeatureTraceSchema } from "../domain/native/literalFeatureTrace.js";
import { byteEntropySchema } from "../domain/native/byteEntropy.js";
import { nativeFunctionMatchingSchema } from "../domain/native/functionMatching.js";
import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js";
@@ -263,21 +263,9 @@ export const officialOutputSchemas = {
xrefs: evidenceResultOf(nativeAddressListSchema),
} as const satisfies Readonly<Record<string, z.ZodObject>>;
const literalTraceOutput = evidenceResultOf(
z.object({
query: z.string(),
search_mode: z.literal("literal"),
matches: z.array(literalFeatureMatchSchema),
references: z.array(
z.object({
target_address: z.string(),
source_address: z.string(),
containing_procedure: traceContainingProcedureSchema,
}),
),
truncated: z.boolean(),
residual_unknowns: z.array(z.string()),
}),
const literalTraceOutput = analysisDetailOutputSchemaOf(
literalFeatureTraceSchema,
nativeFeatureTraceSummaryViewResultSchema,
);
const callPathTraceOutput = evidenceResultOf(
+56 -1
View File
@@ -462,7 +462,7 @@ describe("analysis snapshot canonical ordering and scope", () => {
foreign,
]),
}),
).toThrow(/another target/u);
).toThrow(/unrelated to the selected target/u);
});
});
@@ -511,6 +511,61 @@ it("does not select arbitrary provenance when multiple authenticated records mat
).toBeUndefined();
});
it("retains linked foreign Evidence without treating it as selected-target replay", () => {
const { current, snapshot } = snapshotWithHistoricalEvidence();
const foreignTarget = { ...ANALYSIS_SNAPSHOT_TARGET, sha256: "b".repeat(64) };
const foreign = createEvidence(foreignTarget, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: current.operation,
parameters: current.parameters,
result: current.normalized_result,
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
});
const linked = createEvidence(
ANALYSIS_SNAPSHOT_TARGET,
ANALYSIS_SNAPSHOT_PROVIDER,
{
operation: current.operation,
parameters: current.parameters,
result: current.normalized_result,
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
evidenceLinks: [foreign.evidence_id],
},
);
const bundle = createEvidenceBundle([
...snapshot.evidence_bundle.records.filter(
(record) => record.evidence_id !== current.evidence_id,
),
linked,
foreign,
]);
const admitted = parseAnalysisSnapshot({
...snapshot,
evidence_bundle: bundle,
});
expect(
snapshotEvidenceForQuery(admitted, {
target: ANALYSIS_SNAPSHOT_TARGET,
bindingProfile: ANALYSIS_SNAPSHOT_PROFILE,
operation: linked.operation,
parameters: linked.parameters,
provider: ANALYSIS_SNAPSHOT_PROVIDER,
evidenceProfile: ANALYSIS_SNAPSHOT_PROFILE,
}),
).toEqual(linked);
expect(snapshotMatchesTarget(admitted.target, foreignTarget)).toBe(false);
const unrelated = createEvidence(foreignTarget, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "unrelated",
parameters: {},
result: "foreign",
});
expect(() =>
parseAnalysisSnapshot({
...snapshot,
evidence_bundle: createEvidenceBundle([...bundle.records, unrelated]),
}),
).toThrow("unrelated to the selected target and its linked sources");
});
describe("analysis snapshot Evidence binding", () => {
it("finds only Evidence committed to the exact binding and profile", () => {
const { current, snapshot } = snapshotWithHistoricalEvidence();
+1 -1
View File
@@ -738,7 +738,7 @@ const restoreSnapshot = (
scopedEvidence.unknowns.length !== bundle.unknowns.length
)
throw new TypeError(
"Analysis snapshot evidence contains records for another target",
"Analysis snapshot evidence contains records unrelated to the selected target and its linked sources",
);
if (!hasCanonicalQueryOrder(providerRows.map(({ entry }) => entry)))
throw new TypeError("Analysis snapshot entries are not canonical");
+5
View File
@@ -18,6 +18,7 @@ import { projectJavaScriptRoutesView } from "./javascriptRoutesView.js";
import { projectWebBundleView } from "./webBundleView.js";
import { projectSourceToBundleView } from "./sourceToBundleView.js";
import { projectFeatureTraceView } from "./featureTraceView.js";
import { projectNativeFeatureTraceView } from "./nativeFeatureTraceView.js";
import { projectBatchDecompileView } from "./batchDecompileView.js";
import { projectGpuCodeObjectsView } from "./gpuCodeObjectsView.js";
import { projectModelArtifactView } from "./modelArtifactView.js";
@@ -144,6 +145,7 @@ import {
modelArtifactPageViewResultSchema,
il2cppMetadataSummaryViewResultSchema,
nativeInventorySummaryViewResultSchema,
nativeFeatureTraceSummaryViewResultSchema,
nativeLoadImageSummaryViewResultSchema,
nativeFunctionMatchingSummaryViewResultSchema,
nativeFunctionSummaryViewResultSchema,
@@ -165,6 +167,7 @@ export {
};
export {
nativeObservationSummaryViewResultSchemas,
nativeFeatureTraceSummaryViewResultSchema,
byteEntropySummaryViewResultSchema,
nativeExportsSummaryViewResultSchema,
devicetreeSummaryViewResultSchema,
@@ -300,6 +303,7 @@ const retain = (
): readonly [string, RetainedViewProjector] => [operation, project];
const retainedViewProjectors = new Map<string, RetainedViewProjector>([
retain("trace_feature", projectNativeFeatureTraceView),
...NATIVE_OBSERVATION_OPERATIONS.map((operation) =>
retain(operation, projectNativeObservationView),
),
@@ -436,6 +440,7 @@ export const projectAnalysisView = (
parent.operation === "match_native_functions"
? NATIVE_FUNCTION_MATCHING_PREDICATE
: parent.operation === "analyze_function" ||
parent.operation === "trace_feature" ||
parent.operation === "batch_decompile" ||
isNativeObservationOperation(parent.operation) ||
isNativeInventoryOperation(parent.operation)
@@ -11,6 +11,7 @@ import { javaScriptRoutesViewRequestSchema } from "./javascriptRoutesView.js";
import { webBundleViewRequestSchema } from "./webBundleView.js";
import { sourceToBundleViewRequestSchema } from "./sourceToBundleView.js";
import { featureTraceViewRequestSchema } from "./featureTraceView.js";
import { nativeFeatureTraceViewRequestSchema } from "./nativeFeatureTraceView.js";
import { batchDecompileViewRequestSchema } from "./batchDecompileView.js";
import { gpuCodeObjectsViewRequestSchema } from "./gpuCodeObjectsView.js";
import { modelArtifactViewRequestSchema } from "../model/modelArtifactQuery.js";
@@ -69,6 +70,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
nativeFunctionMatchingViewRequestSchema,
sourceToBundleViewRequestSchema,
featureTraceViewRequestSchema,
nativeFeatureTraceViewRequestSchema,
batchDecompileViewRequestSchema,
summaryViewSchema,
goBinaryViewRequestSchema,
@@ -68,6 +68,11 @@ import {
featureTraceViewCollections,
featureTraceViewContextSchema,
} from "./featureTraceView.js";
import {
nativeFeatureTraceSummarySchema,
nativeFeatureTraceViewItems,
nativeFeatureTraceRequests,
} from "./nativeFeatureTraceView.js";
import {
batchDecompileSummarySchema,
batchDecompileViewCollections,
@@ -833,6 +838,40 @@ export const nativeInventorySummaryViewResultSchema = z.strictObject({
summary: nativeInventorySummarySchema,
});
export const nativeFeatureTraceSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
...nativeViewSourceContext,
artifact: artifactSchema.nullable(),
parent_operation: z.literal("trace_feature"),
summary: nativeFeatureTraceSummarySchema,
});
const nativeFeatureTracePage = <
Collection extends keyof typeof nativeFeatureTraceViewItems,
>(
collection: Collection,
) =>
z.strictObject({
...viewResultBase,
...nativeViewSourceContext,
artifact: artifactSchema.nullable(),
kind: z.literal("native-feature-trace"),
parent_operation: z.literal("trace_feature"),
view: nativeFeatureTraceRequests[collection],
summary: nativeFeatureTraceSummarySchema,
items: nativeFeatureTraceViewItems[collection],
});
const nativeFeatureTracePageViewResultSchema = z
.object({ kind: z.literal("native-feature-trace") })
.passthrough()
.pipe(
z.union([
nativeFeatureTracePage("matches"),
nativeFeatureTracePage("references"),
nativeFeatureTracePage("unknowns"),
]),
);
/** Compact provider load observations retain the independent verification state. */
export const nativeLoadImageSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -1153,6 +1192,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
modelArtifactSummaryViewResultSchema,
gpuCodeObjectsSummaryViewResultSchema,
nativeInventorySummaryViewResultSchema,
nativeFeatureTraceSummaryViewResultSchema,
nativeLoadImageSummaryViewResultSchema,
nativeFunctionMatchingSummaryViewResultSchema,
nativeFunctionSummaryViewResultSchema,
@@ -1188,6 +1228,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
view: nativeInventoryViewRequestSchema,
rows: z.array(nativeInventoryViewRowSchema),
}),
nativeFeatureTracePageViewResultSchema,
z.strictObject({
...viewResultBase,
kind: z.literal("native-load-image"),
@@ -0,0 +1,224 @@
import { z } from "zod";
import { literalFeatureMatchSchema } from "../native/literalFeatureMatch.js";
import {
literalFeatureReferenceSchema,
literalFeatureTraceSchema,
} from "../native/literalFeatureTrace.js";
import { jsonValueSchema } from "../jsonValue.js";
import { err, ok } from "../result.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { selectViewRows } from "./selectViewRows.js";
import {
createAnalysisViewResultReader,
type AnalysisViewParent,
} from "./analysisViewParent.js";
import {
assertOwnedView,
type AdmittedView,
type SummaryAnalysisView,
} from "./analysisViewShape.js";
import {
boundedViewMessages,
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
const page = {
kind: z.literal("native-feature-trace"),
offset: z.int().nonnegative().default(0),
limit: z.int().positive().default(64),
};
export const nativeFeatureTraceRequests = {
matches: z.strictObject({
...page,
collection: z.literal("matches"),
address: z
.string()
.min(1)
.optional()
.describe("Select rows with this exact retained address."),
}),
references: z.strictObject({
...page,
collection: z.literal("references"),
source_address: z
.string()
.min(1)
.optional()
.describe("Select this exact retained source address."),
target_address: z
.string()
.min(1)
.optional()
.describe("Select this exact retained target address."),
}),
unknowns: z.strictObject({ ...page, collection: z.literal("unknowns") }),
} as const;
export const nativeFeatureTraceViewRequestSchema = z.discriminatedUnion(
"collection",
[
nativeFeatureTraceRequests.matches,
nativeFeatureTraceRequests.references,
nativeFeatureTraceRequests.unknowns,
],
);
const row = <T extends z.ZodType>(value: T) =>
z.strictObject({ source_index: z.int().nonnegative(), value });
export const nativeFeatureTraceViewItems = {
matches: z.strictObject({
collection: z.literal("matches"),
rows: z.array(row(literalFeatureMatchSchema)),
}),
references: z.strictObject({
collection: z.literal("references"),
rows: z.array(row(literalFeatureReferenceSchema)),
}),
unknowns: z.strictObject({
collection: z.literal("unknowns"),
rows: z.array(row(z.string())),
}),
} as const;
export const nativeFeatureTraceSummarySchema = z.strictObject({
query: z.string(),
search_mode: z.literal("literal"),
matches: z.int().nonnegative(),
references: z.int().nonnegative(),
unknowns: z.int().nonnegative(),
truncated: z.boolean(),
});
const readTrace = createAnalysisViewResultReader(literalFeatureTraceSchema);
/** Page retained trace rows without collapsing independently resolvable unknowns. */
export const projectNativeFeatureTraceView = (
parent: AnalysisViewParent,
view: AdmittedView,
) => {
if (view.kind !== "summary" && view.kind !== "native-feature-trace")
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Native feature traces support summary and native-feature-trace views.",
},
]),
);
assertOwnedView<
SummaryAnalysisView | z.output<typeof nativeFeatureTraceViewRequestSchema>
>(view, "summary", "native-feature-trace");
const parsed = readTrace(parent);
if (!parsed.ok) return parsed;
const trace = parsed.value;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "trace_feature" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: parent.artifact ?? null,
source_provider: parent.provider ?? null,
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
source_parameters: jsonValueSchema.parse(parent.parameters ?? null),
limitations: boundedViewMessages(
parent.limitations,
parent.evidenceId,
"limitations",
),
unknowns: [
"Page coverage describes retained trace rows, not completeness of provider discovery. See the retained unknowns and source Evidence for limitations.",
],
summary: {
query: trace.query,
search_mode: trace.search_mode,
matches: trace.matches.length,
references: trace.references.length,
unknowns: trace.residual_unknowns.length,
truncated: trace.truncated,
},
};
if (view.kind === "summary")
return ok({
...shared,
kind: "summary" as const,
view,
coverage: completeWithinViewCoverage(
trace.matches.length +
trace.references.length +
trace.residual_unknowns.length,
trace.matches.length +
trace.references.length +
trace.residual_unknowns.length,
),
});
const select = <Value>(
values: readonly Value[],
matches?: (value: Value) => boolean,
) => {
if (matches === undefined) {
const rows = values
.slice(view.offset, view.offset + view.limit)
.map((value, index) => ({ source_index: view.offset + index, value }));
return {
rows,
coverage: pageViewCoverage(view.offset, rows.length, values.length),
};
}
const { rows, total } = selectViewRows(
values,
view,
matches,
(value, source_index) => ({ source_index, value }),
);
return {
rows,
coverage: pageViewCoverage(view.offset, rows.length, total),
};
};
switch (view.collection) {
case "matches": {
const { rows, coverage } = select(
trace.matches,
view.address === undefined
? undefined
: (row) => row.address === view.address,
);
return ok({
...shared,
kind: "native-feature-trace" as const,
view: { ...view, collection: "matches" as const },
items: { collection: "matches" as const, rows },
coverage,
});
}
case "references": {
const { rows, coverage } = select(
trace.references,
view.source_address === undefined && view.target_address === undefined
? undefined
: (row) =>
(view.source_address === undefined ||
row.source_address === view.source_address) &&
(view.target_address === undefined ||
row.target_address === view.target_address),
);
return ok({
...shared,
kind: "native-feature-trace" as const,
view: { ...view, collection: "references" as const },
items: { collection: "references" as const, rows },
coverage,
});
}
case "unknowns": {
const { rows, coverage } = select(trace.residual_unknowns);
return ok({
...shared,
kind: "native-feature-trace" as const,
view: { ...view, collection: "unknowns" as const },
items: { collection: "unknowns" as const, rows },
coverage,
});
}
}
};
+33
View File
@@ -127,6 +127,39 @@ const makeUnknown = (
};
};
it("retains only linked cross-target source closure in a target snapshot", () => {
const leaf = createEvidence(undefined, provider, {
operation: "source_leaf",
parameters: {},
result: "leaf",
});
const otherTarget = { ...ANALYSIS_SNAPSHOT_TARGET, sha256: foreignDigest };
const source = createEvidence(otherTarget, provider, {
operation: "source",
parameters: {},
result: "source",
evidenceLinks: [leaf.evidence_id],
});
const trace = createEvidence(ANALYSIS_SNAPSHOT_TARGET, provider, {
operation: "trace",
parameters: {},
result: "trace",
evidenceLinks: [source.evidence_id, "ev_" + "c".repeat(64)],
});
const unrelated = createEvidence(otherTarget, provider, {
operation: "unrelated",
parameters: {},
result: "unrelated",
});
const selected = evidenceBundleForTarget(
createEvidenceBundle([trace, source, leaf, unrelated]),
targetDigest,
);
expect(selected).toEqual(createEvidenceBundle([trace, source, leaf]));
expect(evidenceBundleForTarget(selected, targetDigest)).toEqual(selected);
expect(parseEvidenceBundle(selected)).toEqual(selected);
});
describe("evidenceBundleForTarget", () => {
it("retains mutation evidence from other targets for complete revision histories", () => {
const first = makeUnknown(
+24 -6
View File
@@ -157,7 +157,7 @@ export const createEvidenceBundle = (
};
};
/** Retain target records and eligible complete unknown histories with their mutation evidence. */
/** Retain target records, their linked sources, and eligible complete unknown histories. */
export const evidenceBundleForTarget = (
bundle: EvidenceBundle,
sha256: string,
@@ -218,12 +218,30 @@ export const evidenceBundleForTarget = (
const mutationIds = new Set(
unknowns.flatMap(({ mutation_evidence_ids: ids }) => ids),
);
const selectedIds = new Set(
bundle.records
.filter(
(record) =>
record.subject?.digest.sha256 === sha256 ||
mutationIds.has(record.evidence_id),
)
.map((record) => record.evidence_id),
);
// Source Evidence may have no subject or describe a different artifact.
// Follow only explicit links from selected records, retaining missing links
// as missing rather than inventing or importing unrelated observations.
const pendingEvidence = [...selectedIds];
for (let index = 0; index < pendingEvidence.length; index += 1) {
const record = recordsById.get(pendingEvidence[index] ?? "");
for (const id of record?.evidence_links ?? []) {
if (!selectedIds.has(id) && recordsById.has(id)) {
selectedIds.add(id);
pendingEvidence.push(id);
}
}
}
return createEvidenceBundle(
bundle.records.filter(
(record) =>
record.subject?.digest.sha256 === sha256 ||
mutationIds.has(record.evidence_id),
),
bundle.records.filter((record) => selectedIds.has(record.evidence_id)),
unknowns,
);
};
+24
View File
@@ -0,0 +1,24 @@
import { z } from "zod";
import { literalFeatureMatchSchema } from "./literalFeatureMatch.js";
import { traceContainingProcedureSchema } from "./nativeObservation.js";
/** One observed reference with an independently resolved or unavailable owner. */
export const literalFeatureReferenceSchema = z.object({
target_address: z.string(),
source_address: z.string(),
containing_procedure: traceContainingProcedureSchema,
});
/** Complete result of a native literal feature trace. */
export const literalFeatureTraceSchema = z.object({
query: z.string(),
search_mode: z.literal("literal"),
matches: z.array(literalFeatureMatchSchema),
references: z.array(literalFeatureReferenceSchema),
truncated: z.boolean(),
residual_unknowns: z.array(z.string()),
});
export type LiteralFeatureReference = z.infer<
typeof literalFeatureReferenceSchema
>;
+2 -1
View File
@@ -207,8 +207,9 @@ export const registerEnhancedTools = (
server.registerTool(
traceFeature.name,
toolRegistrationOptions(traceFeature),
(input, context) =>
({ detail, ...input }, context) =>
executeEnhancedTool(registration, traceFeature, {
detail,
validatedCall: { name: "trace_feature", input },
context,
}),
@@ -89,6 +89,9 @@ it("summary delivery returns the compact view and writes a bundle inspect-analys
evidence_bundle: { path: string; bytes: number };
};
expect(h.runDirectAnalysis).toHaveBeenCalledOnce();
expect(h.runDirectAnalysis.mock.calls[0]?.[3]).toMatchObject({
includeEvidenceSources: true,
});
expect(out.summary.normalized_result.kind).toBe("summary");
expect(typeof out.summary.normalized_result.parent_evidence_id).toBe(
"string",
@@ -0,0 +1,226 @@
import { readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Cli } from "incur";
import pino from "pino";
import { expect, it } from "vitest";
import {
createAnalysisExecution,
type AnalysisProvider,
} from "../../../src/application/AnalysisProvider.js";
import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js";
import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js";
import { registerCoreBinaryCommands } from "../../../src/cli/coreBinaryCommands.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { ok } from "../../../src/domain/result.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const identity = { id: "fixture", name: "Fixture", version: "1" } as const;
const effects = {
mutatesArtifact: false,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
changesPermissions: false,
requiresRoot: false,
};
const verifyReplay = async (
cli: ReturnType<typeof Cli.create>,
path: string,
snapshot: string,
root: string,
originalBundle: ReturnType<typeof parseEvidenceBundle>,
traceId: string | undefined,
) => {
const output = join(root, "replay-evidence.json");
let stdout = "";
await cli.serve(
[
"trace",
path,
"goal",
"--provider",
"fixture",
"--snapshot",
snapshot,
"--detail",
"summary",
"--evidence_output",
output,
"--json",
],
{
env: {},
stdout: (text) => {
stdout += text;
},
exit: () => undefined,
},
);
const response = JSON.parse(stdout);
expect(response, JSON.stringify(response)).toHaveProperty("summary");
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(output, "utf8")),
);
expect(response.summary.normalized_result.parent_evidence_id).toBe(traceId);
expect(bundle.records.map((record) => record.evidence_id).sort()).toEqual(
originalBundle.records.map((record) => record.evidence_id).sort(),
);
};
it.each([
{ detail: "summary", missing: false },
{ detail: "complete", missing: false },
{ detail: "summary", missing: true },
{ detail: "summary", missing: false, replay: true },
])(
"publishes $detail trace only when its source closure is available (missing=$missing, replay=$replay)",
async ({ detail, missing, replay }) => {
const root = await createTestTempDirectory("rea-native-trace-cli-source-");
const path = join(root, "fixture.hop");
const output = join(root, "evidence.json");
const snapshot = join(root, "snapshot.json");
let allowProvider = true;
await writeFile(path, "synthetic analysis database");
const history = createEvidence(undefined, identity, {
operation: "health",
parameters: { facet: "historical-project-analysis-reports" },
result: { origin: "caller-project" },
confidence: "observed",
...(missing ? { evidenceLinks: ["ev_" + "a".repeat(64)] } : {}),
});
const provider: AnalysisProvider = {
identity: () => identity,
capabilities: () =>
(
[
"search_strings",
"search_procedures",
"xrefs",
"resolve_containing_procedure",
] as const
).map((operation) => ({
provider: identity,
operation,
available: true,
reason: null,
cachePolicy: replay ? "snapshot" : "live",
limitations: [],
effects,
})),
resolveAnalysisProfile: async () =>
ok({ profile: createAnalysisProfile(identity, { fixture: true }) }),
createClient: () => {
if (!allowProvider)
throw new Error("Snapshot replay reopened the provider");
return {
execute: async (operation) => {
const value =
operation === "search_procedures"
? [{ address: "0x1000", value: "goal" }]
: operation === "xrefs"
? ["0x1010"]
: operation === "resolve_containing_procedure"
? {
query_address: "0x1010",
found: false,
procedure: null,
reason: "not_in_procedure",
}
: operation === "search_strings"
? []
: null;
return ok(
createAnalysisExecution(value, identity, {
sourceEvidence: [history],
}),
);
},
close: async () => ok(null),
};
},
};
const createSession = () => createTestBinarySession(provider);
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createBinarySession: createSession,
createNativeDispatchBinarySession: createSession,
createArtifactBinarySession: createSession,
createNativeBinarySession: createSession,
createManagedBinarySession: createSession,
};
const cli = Cli.create("rea-native-trace-cli-source", { sync: false });
registerCoreBinaryCommands(
cli,
pino({ enabled: false }),
(target, tool, args, options) =>
runDirectAnalysis(dependencies, target, tool, args, options),
{},
);
let stdout = "";
await cli.serve(
[
"trace",
path,
"goal",
"--provider",
"fixture",
...(replay ? ["--snapshot", snapshot] : []),
"--detail",
detail,
"--evidence_output",
output,
"--json",
],
{
env: {},
stdout: (text) => {
stdout += text;
},
exit: () => undefined,
},
);
const response = JSON.parse(stdout);
if (missing) {
expect(response).toMatchObject({
code: "evidence_integrity_mismatch",
details: { reason: "missing", evidence_id: "ev_" + "a".repeat(64) },
});
await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
} else {
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(output, "utf8")),
);
const trace = bundle.records.find(
(record) => record.operation === "trace_feature",
);
expect(
detail === "summary"
? response.summary.normalized_result.parent_evidence_id
: response.evidence_id,
).toBe(trace?.evidence_id);
expect(trace?.evidence_links).toContain(history.evidence_id);
expect(
bundle.records.some(
(record) => record.evidence_id === history.evidence_id,
),
).toBe(true);
if (replay) {
allowProvider = false;
await verifyReplay(
cli,
path,
snapshot,
root,
bundle,
trace?.evidence_id,
);
}
}
},
);
@@ -3,6 +3,7 @@ import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
import { publishCliAnalysisEvidence } from "../../../src/cli/analysisEvidenceDelivery.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { readFile, symlink, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
@@ -131,6 +132,19 @@ const expectWorkflowQuestionsRetained = (
);
};
const expectSourceSnapshotRetained = (snapshot: AnalysisSnapshot) => {
expect(snapshot.entries).toHaveLength(1);
const parent = snapshot.evidence_bundle.records.find(
(record) => record.operation === "list_strings",
);
expect(parent?.evidence_links).toHaveLength(1);
expect(snapshot.evidence_bundle.records).toEqual(
expect.arrayContaining([
expect.objectContaining({ evidence_id: parent?.evidence_links[0] }),
]),
);
};
interface SnapshotProviderTrace {
readonly starts: string[];
readonly queries: Array<{
@@ -145,6 +159,8 @@ interface SnapshotProviderOptions {
readonly identity?: ProviderIdentity;
readonly supportedOperations?: readonly CapabilityDescriptor["operation"][];
readonly limitations?: readonly string[];
readonly sourceEvidence?: boolean;
readonly sourceMissingLink?: boolean;
}
const makeProvider = (
@@ -181,7 +197,7 @@ const makeProvider = (
trace.resolutions.push(profile.digest);
return ok({ profile });
},
createClient: () => {
createClient: (target) => {
trace.starts.push("start");
return {
execute: async (operation, parameters) => {
@@ -242,6 +258,20 @@ const makeProvider = (
analysisProfile: profile,
rawResult: result,
limitations: options.limitations ?? [],
...(options.sourceEvidence
? {
sourceEvidence: [
createEvidence(target, profile.provider, {
operation: "health",
parameters: {},
result: "fixture source",
...(options.sourceMissingLink
? { evidenceLinks: [`ev_${"0".repeat(64)}`] }
: {}),
}),
],
}
: {}),
}),
);
},
@@ -386,13 +416,18 @@ const withEarlierHistoricalEvidence = (
};
describe("snapshot output cleanup lifecycle", () => {
it.each(["cleanup", "cancelled"] as const)(
"preserves the committed snapshot receipt after %s finalization and retry",
async (failureKind) => {
it.each([
{ failureKind: "cleanup", sourceEvidence: false },
{ failureKind: "cancelled", sourceEvidence: false },
{ failureKind: "cleanup", sourceEvidence: true },
{ failureKind: "cancelled", sourceEvidence: true },
] as const)(
"preserves a committed snapshot after $failureKind finalization with source=$sourceEvidence",
async ({ failureKind, sourceEvidence }) => {
const fixture = await createSnapshotReplayFixture(
"rea-snapshot-close-failure-",
);
const provider = fixture.createProvider();
const provider = fixture.createProvider({ sourceEvidence });
const failure =
failureKind === "cleanup"
? new ProviderCleanupError(
@@ -423,7 +458,9 @@ describe("snapshot output cleanup lifecycle", () => {
try {
const opened = await session.open(fixture.path);
if (!opened.ok) throw opened.error;
const analyzed = await session.execute("list_segments", {});
const analyzed = sourceEvidence
? await session.execute("list_strings", { document: "fixture" })
: await session.execute("list_segments", {});
if (!analyzed.ok) throw analyzed.error;
const closed = await session.closeWithSnapshot(
fixture.snapshotPath,
@@ -434,6 +471,7 @@ describe("snapshot output cleanup lifecycle", () => {
const committed = await readFile(fixture.snapshotPath);
const snapshot = await readAnalysisSnapshot(fixture.snapshotPath);
if (!snapshot.ok) throw snapshot.error;
if (sourceEvidence) expectSourceSnapshotRetained(snapshot.value);
const receipt = {
path: fixture.snapshotPath,
bytes: committed.byteLength,
@@ -1671,3 +1709,140 @@ it("retains a native dispatch workflow snapshot without a deep provider profile"
saved.value,
);
});
it("replays source-bearing direct analysis only while snapshot source Evidence is present", async () => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-source-replay-",
);
const provider = fixture.createProvider({
supportedOperations: ["procedure_pseudo_code"],
sourceEvidence: true,
});
const dependencies = fixture.dependencies(provider);
const parameters = { procedure: "0x1000", document: "fixture" };
const options = {
snapshotPath: fixture.snapshotPath,
includeEvidenceSources: true,
};
const run = () =>
runDirectAnalysis(
dependencies,
fixture.path,
"procedure_pseudo_code",
parameters,
options,
);
const firstResult = await run();
const first = parseEvidence(projectCliAnalysisResult(firstResult));
expect(
firstResult.sourceEvidence?.map((record) => record.evidence_id),
).toEqual(first.evidence_links);
expect(first.evidence_links).toHaveLength(1);
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
if (!saved.ok) throw saved.error;
expect(saved.value.entries).toHaveLength(1);
const sourceId = first.evidence_links[0];
expect(
saved.value.evidence_bundle.records.some(
(record) => record.evidence_id === sourceId,
),
).toBe(true);
const initialStarts = fixture.starts.length;
const replayed = await run();
expect(
parseEvidence(projectCliAnalysisResult(replayed)).evidence_links,
).toEqual(first.evidence_links);
expect(replayed.sourceEvidence?.map((record) => record.evidence_id)).toEqual(
first.evidence_links,
);
const outputPath = join(
dirname(fixture.snapshotPath),
"replayed-evidence.json",
);
await publishCliAnalysisEvidence(replayed, {
providerId: IDENTITY.id,
operation: "procedure_pseudo_code",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
});
const published = JSON.parse(await readFile(outputPath, "utf8")) as {
records: Evidence[];
};
expect(published.records.map((record) => record.evidence_id).sort()).toEqual(
[first.evidence_id, ...first.evidence_links].sort(),
);
expect(fixture.starts).toHaveLength(initialStarts);
const withoutSource = {
...saved.value,
evidence_bundle: createEvidenceBundle(
saved.value.evidence_bundle.records.filter(
(record) => record.evidence_id !== sourceId,
),
),
};
const written = await withArtifactResourceScope((resources) =>
writeAnalysisSnapshot(withoutSource, fixture.snapshotPath, true, resources),
);
if (!written.ok) throw written.error;
const refreshedResult = await run();
const refreshed = parseEvidence(projectCliAnalysisResult(refreshedResult));
expect(
refreshedResult.sourceEvidence?.map((record) => record.evidence_id),
).toEqual(first.evidence_links);
expect(fixture.starts).toHaveLength(initialStarts + 1);
expect(refreshed.evidence_links).toEqual(first.evidence_links);
const repaired = await readAnalysisSnapshot(fixture.snapshotPath);
if (!repaired.ok) throw repaired.error;
expect(
repaired.value.evidence_bundle.records.some(
(record) => record.evidence_id === sourceId,
),
).toBe(true);
});
it("preserves captured sources when a transitive source is missing", async () => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-partial-source-",
);
const provider = fixture.createProvider({
supportedOperations: ["procedure_pseudo_code"],
sourceEvidence: true,
sourceMissingLink: true,
});
const result = await runDirectAnalysis(
fixture.dependencies(provider),
fixture.path,
"procedure_pseudo_code",
{ procedure: "0x1000", document: "fixture" },
{ includeEvidenceSources: true },
);
expect(result.ok).toBe(false);
expect(result.sourceEvidence).toHaveLength(1);
const outputPath = join(dirname(fixture.snapshotPath), "missing-source.json");
const output = await publishCliAnalysisEvidence(result, {
providerId: IDENTITY.id,
operation: "procedure_pseudo_code",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
});
const failure = output as {
details: {
source_closure: {
records: Evidence[];
omitted: { evidence_id: string }[];
complete: boolean;
};
};
};
expect(failure.details.source_closure.complete).toBe(false);
expect(
failure.details.source_closure.records.map((record) => record.evidence_id),
).toEqual(result.sourceEvidence?.map((record) => record.evidence_id));
expect(failure.details.source_closure.omitted).toEqual([
{ evidence_id: `ev_${"0".repeat(64)}`, reason: "not_captured" },
]);
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
});
@@ -572,6 +572,63 @@ it.each(["interrupt", "cleanup-failure", "interrupt-cleanup-failure"] as const)(
},
);
it.each(["interrupt", "cleanup-failure"] as const)(
"retains captured source closure when direct finalization fails: %s",
async (scenario) => {
const workflow = await composedWorkflowFixture();
const controller = new AbortController();
const close = workflow.session.close.bind(workflow.session);
workflow.session.close = async () => {
workflow.session.close = close;
if (scenario === "interrupt") controller.abort();
const closed = await close();
if (scenario === "interrupt" || !closed.ok) return closed;
return err(
new ProviderCleanupError(
"fixture",
["owned-root"],
{ reason: "controlled session close failure" },
{ operation: "close_binary" },
),
);
};
const root = await createTestTempDirectory("rea-closure-direct-captured-");
const complete = await runDirectAnalysis(
workflow.dependencies,
workflow.path,
"binary_overview",
{},
{ signal: controller.signal, includeEvidenceSources: true },
);
const output = await publishCliAnalysisEvidence(complete, {
...delivery(root),
signal: controller.signal,
});
const failure = output as {
code: string;
details: { partial_observation: unknown };
};
expect(failure.code).toBe(
scenario === "interrupt" ? "cancelled" : "cleanup_incomplete",
);
const links = parseEvidence(
failure.details.partial_observation,
).evidence_links;
expect(links.length).toBeGreaterThan(0);
expect(closureOf(output)).toMatchObject({
publication: "not_published",
complete: true,
omitted_count: 0,
});
expect(
closureOf(output)
.records.map((record) => record.evidence_id)
.sort(),
).toEqual([...links].sort());
expect(await readdir(root)).toEqual([]);
},
);
it("reports through the shared one-shot delivery when cancellation arrives during close", async () => {
const root = await createTestTempDirectory("rea-closure-deliver-");
const held = source("held");
@@ -0,0 +1,167 @@
import { join } from "node:path";
import { expect, it } from "vitest";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const traceFixture = () => ({
query: "goal",
search_mode: "literal" as const,
matches: [{ type: "procedure" as const, address: "0x1000", value: "goal" }],
references: ["0x1010", "0x1020", "0x1010"].map((source_address) => ({
target_address: "0x1000",
source_address,
containing_procedure: {
query_address: source_address,
status: "unavailable" as const,
provider_id: "kuna",
reason: "unsupported",
},
})),
truncated: true,
residual_unknowns: ["0x1010 owner unknown", "0x1020 owner unknown"],
});
it.each(["full", "compact"] as const)(
"pages retained native trace rows and preserves source coverage across %s offline handoff",
async (profile) => {
const result = traceFixture();
const parent = createEvidence(
undefined,
{ id: "rea-workflow", name: "REA workflow", version: "1" },
{
operation: "trace_feature",
parameters: { query: "goal", case_sensitive: false },
result,
confidence: "derived",
limitations: ["fixture provider coverage"],
},
);
const setup = async () => {
const session = createTestBinarySession(() => {
throw new Error("View must not open a provider");
});
const harness = await createApplicationMcpHarness({
session,
clientIdentity: { name: "native-feature-view", version: "1" },
serverOptions: {
environment: {
...process.env,
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
},
},
});
return { ...harness, session };
};
const first = await setup();
const call = async (
client: typeof first.client,
name: string,
args: Record<string, unknown>,
) => {
const reply = await client.callTool({ name, arguments: args });
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
return reply.structuredContent;
};
const source = {
kind: "retained-evidence",
evidence_id: parent.evidence_id,
};
const view = async (
client: typeof first.client,
collection: "matches" | "references" | "unknowns",
offset: number,
limit: number,
) => {
const response = parseEvidence(
await call(client, "inspect_analysis_view", {
source,
view: { kind: "native-feature-trace", collection, offset, limit },
}),
);
return analysisViewResultSchema.parse(response.normalized_result);
};
const root = await createTestTempDirectory(
"rea-native-feature-trace-view-",
);
const path = join(root, "evidence.json");
try {
expect(first.session.recordEvidence(parent).ok).toBe(true);
const summary = analysisViewResultSchema.parse(
parseEvidence(
await call(first.client, "inspect_analysis_view", {
source,
view: { kind: "summary" },
}),
).normalized_result,
);
expect(summary).toMatchObject({
parent_evidence_id: parent.evidence_id,
summary: { matches: 1, references: 3, unknowns: 2, truncated: true },
});
const firstPage = await view(first.client, "references", 0, 2);
const lastPage = await view(first.client, "references", 2, 2);
expect(
analysisViewResultSchema.safeParse({
...firstPage,
items: { collection: "unknowns", rows: [] },
}).success,
).toBe(false);
expect(firstPage).toMatchObject({
coverage: { total: 3, next_offset: 2, exhausted: false },
items: {
rows: [
{ source_index: 0, value: result.references[0] },
{ source_index: 1, value: result.references[1] },
],
},
});
expect(lastPage).toMatchObject({
coverage: { total: 3, next_offset: null, exhausted: true },
items: { rows: [{ source_index: 2, value: result.references[2] }] },
});
const filtered = analysisViewResultSchema.parse(
parseEvidence(
await call(first.client, "inspect_analysis_view", {
source,
view: {
kind: "native-feature-trace",
collection: "references",
source_address: "0x1010",
offset: 1,
limit: 1,
},
}),
).normalized_result,
);
expect(filtered).toMatchObject({
coverage: { total: 2, next_offset: null, exhausted: true },
items: { rows: [{ source_index: 2, value: result.references[2] }] },
});
expect(await view(first.client, "unknowns", 1, 5)).toMatchObject({
items: {
rows: [{ source_index: 1, value: result.residual_unknowns[1] }],
},
});
expect(await view(first.client, "matches", 9, 1)).toMatchObject({
coverage: { status: "empty", total: 1, next_offset: null },
});
await call(first.client, "export_evidence_bundle", { path });
} finally {
await first.close();
}
const second = await setup();
try {
await call(second.client, "import_evidence_bundle", { path });
expect(await view(second.client, "references", 2, 1)).toMatchObject({
parent_evidence_id: parent.evidence_id,
items: { rows: [{ source_index: 2, value: result.references[2] }] },
});
} finally {
await second.close();
}
},
);
@@ -22,9 +22,13 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js";
import { ok } from "../../../src/domain/result.js";
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
import {
createEvidenceBundle,
evidenceBundleSchema,
} from "../../../src/domain/evidenceBundle.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
@@ -577,6 +581,188 @@ describe("MCP investigation history snapshot replay", () => {
);
});
it("replays a source-bearing observation through an exported MCP snapshot", async () => {
const { targetPaths, snapshotPath } = await createWorkflowFiles(
"rea-mcp-source-replay-",
["fixture.hop"],
);
const targetPath = targetPaths[0];
if (targetPath === undefined) throw new Error("Expected a fixture target");
const starts: string[] = [];
const calls: string[] = [];
const base = makeProvider(starts, calls);
const provider: AnalysisProvider = {
...base,
createClient: (target, selectedProfile, context) => {
const client = base.createClient(target, selectedProfile, context);
return {
...client,
execute: async (...args) => {
const result = await client.execute(...args);
if (!result.ok || args[0] !== "list_strings") return result;
return ok({
...result.value,
sourceEvidence: [
createEvidence(target, identity, {
operation: "health",
parameters: {},
result: "historical source",
}),
],
});
},
};
},
};
const { mcp } = await connectWorkflowMcp(
createWorkflowSession(provider),
"source-replay",
);
await openWorkflowTarget(mcp, targetPath);
const read = (client: Client) =>
client.callTool({
name: "list_strings",
arguments: { document: "fixture" },
});
const first = await read(mcp);
const firstEvidence = toolContract("list_strings").outputSchema.parse(
first.structuredContent,
);
expect(firstEvidence.evidence_links).toHaveLength(1);
const second = await read(mcp);
const secondEvidence = toolContract("list_strings").outputSchema.parse(
second.structuredContent,
);
expect(secondEvidence.evidence_links).toEqual(firstEvidence.evidence_links);
expect(secondEvidence.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("did not re-run the provider"),
]),
);
expect(
calls.filter((operation) => operation === "list_strings"),
).toHaveLength(1);
expect(
(
await mcp.callTool({
name: "close_binary",
arguments: { snapshot_path: snapshotPath },
})
).isError,
).not.toBe(true);
const { mcp: reopened } = await connectWorkflowMcp(
createWorkflowSession(provider),
"source-replay-import",
);
expect(
(
await reopened.callTool({
name: "open_binary",
arguments: { path: targetPath, snapshot_path: snapshotPath },
})
).isError,
).not.toBe(true);
const replayed = toolContract("list_strings").outputSchema.parse(
(await read(reopened)).structuredContent,
);
expect(replayed.evidence_links).toEqual(firstEvidence.evidence_links);
expect(replayed.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("did not re-run the provider"),
]),
);
expect(
calls.filter((operation) => operation === "list_strings"),
).toHaveLength(1);
const bundle = await reopened.callTool({
name: "get_evidence_bundle",
arguments: {
detail: "records",
filters: { evidence_id: firstEvidence.evidence_links[0] },
},
});
expect(bundle.isError).not.toBe(true);
expect(
toolContract("get_evidence_bundle").outputSchema.parse(
bundle.structuredContent,
).result,
).toMatchObject({
records: [
{
evidence_id: firstEvidence.evidence_links[0],
normalized_result: "historical source",
},
],
});
await expectRestoredSourceReplay(provider, targetPath, snapshotPath, calls);
});
const expectRestoredSourceReplay = async (
provider: AnalysisProvider,
targetPath: string,
snapshotPath: string,
calls: string[],
) => {
const saved = await readAnalysisSnapshot(snapshotPath);
if (!saved.ok) throw saved.error;
const source = saved.value.evidence_bundle.records.find(
(record) => record.operation === "health",
);
if (source === undefined) throw new Error("Expected historical source");
const directory = dirname(snapshotPath);
const prunedPath = join(directory, "missing-source.json");
const sourcePath = join(directory, "source-bundle.json");
await writeFile(
prunedPath,
JSON.stringify({
...saved.value,
evidence_bundle: createEvidenceBundle(
saved.value.evidence_bundle.records.filter(
(record) => record.evidence_id !== source.evidence_id,
),
),
}),
);
await writeFile(sourcePath, JSON.stringify(createEvidenceBundle([source])));
const { mcp } = await connectWorkflowMcp(
createWorkflowSession(provider),
"source-restored-after-open",
);
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: targetPath, snapshot_path: prunedPath },
})
).isError,
).not.toBe(true);
expect(
(
await mcp.callTool({
name: "import_evidence_bundle",
arguments: { path: sourcePath },
})
).isError,
).not.toBe(true);
const replayed = toolContract("list_strings").outputSchema.parse(
(
await mcp.callTool({
name: "list_strings",
arguments: { document: "fixture" },
})
).structuredContent,
);
expect(replayed.evidence_links).toEqual([source.evidence_id]);
expect(replayed.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("did not re-run the provider"),
]),
);
expect(
calls.filter((operation) => operation === "list_strings"),
).toHaveLength(1);
};
const createWorkflowFiles = async (
prefix: string,
filenames: readonly string[],