mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
* feat(decompile): write function pseudocode to a directory and return a manifest Add decompile_all and `rea decompile-all` so a caller can write one provider pseudocode file per function, or a filtered set, and receive only a small manifest. The result stays bounded when the program is larger than a local model's tool-result cap. Resume continues an export for the same target digest, provider, analysis profile, and layout. Matching files are skipped, conflicting files stay unchanged, and one function's failure does not stop the rest. Cancellation leaves files already written. Requesting dwarf layout reports that compilation-unit paths are unavailable. Simulated-provider tests cover the manifest, resume, filters, limits, cancellation, and the failure cap. MCP registration, CLI coverage, schema validity, the product catalog, check:fast, and docs:check passed. Real Ghidra was not exercised. Refs #6090 * fix(decompile): keep unknown export identity unresumable and recover torn journals Resume treated two missing target or profile digests as a match, so an export of one binary could continue into another. Resume now requires both digests, and a decompiler observation without the export's profile, or with a different target, is recorded as profile_changed. A crash during a journal append left a torn final line that made the export unresumable. Text after the last newline is now treated as uncommitted and truncated before the next append, and stale temporary files are removed. An inventory row with an empty address aborted the whole export because its failure record failed the journal schema. Such rows are now excluded and counted in the limitations. A limited resume retried the same recorded failures first and never reached new functions. Functions not yet attempted now run before retries. File path reservations come from every journal line, so an output_io record no longer releases a path. The index is rebuilt from the journal in numeric address order, and journal close and cleanup failures return a Result. The cancellation test now cancels after a file is committed and checks that file survives and is skipped on resume. Redundant snapshot-replay checks are removed, the CLI limit uses the contract constant, and --snapshot no longer claims decompile-all updates it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * fix(decompile): refuse resume of a missing export and repair journals by byte offset A second review round found that resume against a directory that does not exist silently started a full new export and reported it as resumed. Resume now requires an existing export directory. The torn-line repair measured the committed journal from decoded text, so damaged UTF-8 could move the truncation point past the end of the file. The journal is now read as bytes and cut at the last newline byte, and an intact journal is not truncated, so a concurrent append is kept. A per-function observation for a different target is now recorded as target_changed instead of profile_changed. A failure to remove a partly created output directory is reported instead of hidden. The journal handle is closed on every path again, including an unexpected throw. The path allocator no longer gives up after 1,000 collisions and reuses a taken name. Tests now use createTestTempDirectory, assert journal lines against the journal schema, and cover resume of a missing directory, resume with an unknown analysis profile, and a target-mismatched observation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * fix(decompile): keep a function's first file name across failed retries Reserving every journal path stopped other addresses from taking a path, but an address looked up only its latest record. After its file went missing and a retry failed without a path, the next resume moved the function to a hash-suffixed name and orphaned the original. Each address now keeps the first path the journal gave it. A matched commit still ran the decompiler but did not count toward limit. The limit now counts every function taken on after skipping verified files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * fix(decompile): never follow a journal symlink and check procedure_info identity Torn-line repair truncates the journal, and the journal was opened through any symlink in the export directory, so repair could truncate a file outside it. The journal must now be a regular file and is opened without following symlinks where the platform supports it. Body ranges from procedure_info were written without checking that the observation belonged to this export or described the requested function. An observation with a different provider, profile or target, or another entry point, now leaves the body unavailable with that reason. File, journal and index writes now use FileHandle.writeFile, which writes the whole buffer instead of trusting one write call. The stale temporary sweep is removed: a matching file name does not prove this export created the file. The inverted-range test now uses one address space so it reaches the inversion check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * fix(decompile): bound returned caveats and keep verified checksums Ghidra names each terminal call's address in its per-function caveats, so decompile_all copied one new caveat per function into the returned Evidence and the answer grew with the program. The returned Evidence now keeps the eight most often observed distinct per-function caveats, grouped by provider and text as they are rendered, plus a count of the rest. Inventory caveats are unchanged, and other workflows are not bounded. Each function keeps the caveats of the observations it used: a written file in its header, now including procedure_info caveats, and a failed attempt in its journal and index record. A procedure_info observation for another target or profile is foreign and contributes none. A failure record for a function that was already written replaced the record holding its checksum, so after a transient read error a resume decompiled that function again. The journal now keeps the last successful write separately from the latest attempt. Resume verifies against that write and records it again once the file verifies. A caller abort that arrives while the export finishes now returns cancellation with the caller's reason instead of a success manifest, as #6143 does for other late finalization. A publish, index or close failure stays the primary error. Also make the missing-journal resume error say what to do, and add boundary tests: one runs decompile-all through the real CLI against a fake Kuna to check flag mapping, the relative output directory, filters and limit; one runs decompile_all through production MCP registration to check that the caveat bound is applied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * fix(decompile): keep each function's complete caveats in the index The returned Evidence keeps eight distinct per-function caveats and counts the rest, pointing readers at the export. A written function's file header truncates its caveats after 4,000 characters, and its journal record kept none, so a large function with many terminal calls could lose caveats from both the answer and the export. Every journal record, written or failed, now keeps the complete caveats of the observations its attempt used, and decompile-index.json carries them. The header stays a readable summary and, when truncated, says that the index keeps the complete list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * fix(decompile): report external functions instead of failing them Ghidra classifies imports as external functions with an empty body and returns null pseudocode for them, as its limitations state. decompile_all recorded each one as an invalid_pseudocode failure ("not text"), so a dynamically linked program reported every import as a decompiler failure: 34 of 116 functions in /usr/bin/xxd. procedure_info owns the external classification. When it classifies a function as external, the export now records status "external", counts it in counts.external, writes no file and requests no pseudocode. The record keeps the body fact as observed. The journal header pins the target, provider and profile, so a resume skips a recorded external function without asking the provider again; a limit below the external count still leaves nothing remaining. A null pseudocode result for a function whose classification is not external is reported as no_pseudocode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * fix(decompile): report finalization failures after a cancelled export When the caller's abort stopped the publish loop, decompile_all returned that cancellation before checking the index write and the journal close, which had already run. A failure to finalize the export was hidden, so the caller could not tell the saved index or journal was incomplete. Publish failures stay primary, then index and close failures. A cancellation counts as the caller's only when the caller's signal is aborted, and is then reported after a successful finalization with the caller's own reason. A provider's own cancellation, without a caller abort, stays a publish failure like any other. Tests cover an index failure and a close failure after the caller stops the loop, a stopped loop with a successful finalization keeping the caller's reason, and a provider cancellation staying primary over an index failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * fix(decompile): remove a created output directory that cannot be secured createPrivateDirectory ran mkdir and chmod as one step. When mkdir succeeded and chmod failed, it returned an error before #create reached its cleanup, leaving an empty directory behind. A fresh export then refused the existing directory, and a resume refused it for having no journal, so the caller had to remove it by hand. mkdir and chmod are now separate. A mkdir failure created nothing, so nothing is removed. A chmod failure after this call created the directory removes it with a non-recursive rmdir: it is still empty, so a path another process filled or replaced meanwhile is refused rather than deleted, and that refusal is reported. A filesystem boundary test fails chmod for the new root and checks the directory is gone and a retry succeeds. A module test confirms that a function written earlier and now classified as external still reports a changed file at its path as output_exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * refactor(process): share the socket lease with owners other than Hopper The socket lease that keeps two REA processes from opening the same Hopper document lived only in HopperLease, with Hopper error messages built in. A decompile export needs the same guarantee for its output directory. OwnedLease now holds the lease mechanism unchanged: a listening local socket that the operating system ends with its process, owner identity answered by the live holder, and recovery of a stale socket serialized through a recovery directory. Callers map a tagged LeaseFailure to their own error. HopperLease keeps its exports and maps each failure to the same HopperStartError message as before. On Windows the lease is a named pipe, which needs no stale recovery. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * fix(decompile): hold the output directory per run and stop on repeated write failures Two runs could resume the same output directory at once. Each read the journal once, and the index each wrote reflected only its own view, so the last writer could drop the other run's records. A run's torn-line repair could also truncate the other run's append. DecompileExport now holds a lease on the output directory, named by its device and inode, from before the journal is read (or, for a new export, from just after the directory is created and before its journal exists) until the journal is closed. A second run is refused as a retryable capacity constraint that reports the holder's process ID. The lease ends with its process, so a crashed run leaves no lock to clear. The lease is released on every open failure, and a failed release is reported. An export also kept decompiling every remaining function when its files had stopped being writable mid-run, for example on a full disk, failing each one with output_io. After five consecutive functions whose files could not be written or read, the export now stops with an output error that carries the counts and the last failures. A verified file or any other outcome ends a run of failures. Committed files and records stay, the index is still written, and a later resume continues. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoJZDPcrgmMsCqLG4LfiFb * docs(native): record directory export verification Record matched real Ghidra outputs, bounded caveat and cleanup regressions, actual catalog-inclusive context estimates, runtime measurements and coverage limits for the pinned integration. * fix(native): bound export summaries and reserve portable paths Use one case-folded reservation owner for allocation and resumed journals. Budget inline failures and provider caveats by encoded JSON bytes while retaining complete records in the output index. Align the tool contract with the collector order and verify SDK result-window behavior. * docs(native): refresh final export measurements --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
582 lines
19 KiB
TypeScript
582 lines
19 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
recordWorkflowUnknowns,
|
|
createWorkflowEvidence,
|
|
executeComposedWorkflow,
|
|
workflowSnapshotRecord,
|
|
} from "./WorkflowEvidence.js";
|
|
import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
|
|
import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import {
|
|
createAnalysisExecution,
|
|
type AnalysisOperationPort,
|
|
} from "./AnalysisProvider.js";
|
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
|
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
|
|
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
|
|
import { functionDossierSchema } from "../domain/hopperValues.js";
|
|
import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
|
|
import { ANALYSIS_SNAPSHOT_PROFILE } from "../domain/analysisSnapshot.fixture.js";
|
|
|
|
it.each([false, true])(
|
|
"propagates transient=%s through composed snapshot decisions while retaining Evidence",
|
|
async (transient) => {
|
|
let calls = 0;
|
|
const composed = await executeComposedWorkflow(
|
|
{
|
|
execute: async () =>
|
|
ok(
|
|
createAnalysisExecution(
|
|
"observed",
|
|
provider,
|
|
transient && calls++ === 0
|
|
? { cachePolicy: "live" as const }
|
|
: {},
|
|
),
|
|
),
|
|
},
|
|
async (observed) => {
|
|
await observed.execute("analyze_function", { procedure: "0x1000" });
|
|
const last = await observed.execute("procedure_info", {
|
|
procedure: "0x1000",
|
|
});
|
|
return last.ok ? ok(last.value.result) : last;
|
|
},
|
|
);
|
|
if (!composed.ok) throw composed.error;
|
|
const evidence = createWorkflowEvidence({
|
|
target: undefined,
|
|
operation: "inspect_native_api",
|
|
parameters: {},
|
|
result: composed.value.result,
|
|
upstreamProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
|
upstreamLimitations: composed.value.limitations,
|
|
});
|
|
expect(new InvestigationRecords().recordEvidence(evidence).ok).toBe(true);
|
|
expect(
|
|
workflowSnapshotRecord(
|
|
evidence,
|
|
"inspect_native_api",
|
|
ANALYSIS_SNAPSHOT_PROFILE,
|
|
composed.value.cachePolicy,
|
|
) === undefined,
|
|
).toBe(transient);
|
|
expect(evidence.normalized_result).toBe("observed");
|
|
},
|
|
);
|
|
|
|
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
|
|
|
it("keeps Evidence identity stable when dependent branches finish in opposite orders", async () => {
|
|
const run = async (order: readonly number[]) => {
|
|
const gate = () => {
|
|
let resolve: () => void = () => {
|
|
throw new Error("gate not initialized");
|
|
};
|
|
const promise = new Promise<void>((release) => {
|
|
resolve = release;
|
|
});
|
|
return { promise, resolve };
|
|
};
|
|
const parents = [gate(), gate()];
|
|
const children = [gate(), gate()];
|
|
const analysis: AnalysisOperationPort = {
|
|
execute: async (operation, parameters) => {
|
|
const index = Number(parameters.index);
|
|
if (operation === "procedure_address") {
|
|
await parents[index]!.promise;
|
|
return ok(createAnalysisExecution(String(index), provider));
|
|
}
|
|
children[index]!.resolve();
|
|
return ok(
|
|
createAnalysisExecution(String(index), provider, {
|
|
limitations: [
|
|
index === 0 ? "first child caveat" : "second child caveat",
|
|
],
|
|
}),
|
|
);
|
|
},
|
|
};
|
|
const composed = await executeComposedWorkflow(
|
|
analysis,
|
|
async (observed) => {
|
|
const branches = Promise.all(
|
|
[0, 1].map(async (index) => {
|
|
await observed.execute("procedure_address", { index });
|
|
const child = await observed.execute("address_name", { index });
|
|
if (!child.ok) throw child.error;
|
|
return child.value.result;
|
|
}),
|
|
);
|
|
for (const index of order) {
|
|
parents[index]!.resolve();
|
|
await children[index]!.promise;
|
|
}
|
|
return ok(await branches);
|
|
},
|
|
);
|
|
if (!composed.ok) throw composed.error;
|
|
return createWorkflowEvidence({
|
|
target: undefined,
|
|
operation: "batch_decompile",
|
|
parameters: {},
|
|
result: composed.value.result,
|
|
upstreamProfile: undefined,
|
|
upstreamLimitations: composed.value.limitations,
|
|
});
|
|
};
|
|
const left = await run([0, 1]);
|
|
const right = await run([1, 0]);
|
|
expect(left.normalized_result).toEqual(right.normalized_result);
|
|
expect(left.limitations).toEqual(right.limitations);
|
|
expect(left.evidence_id).toBe(right.evidence_id);
|
|
});
|
|
|
|
it("retains unique upstream limits in canonical order across concurrent observations", async () => {
|
|
let release: () => void = () => {
|
|
throw new Error("gate not initialized");
|
|
};
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let calls = 0;
|
|
const options = { signal: new AbortController().signal };
|
|
const analysis: AnalysisOperationPort = {
|
|
execute: async (operation, parameters, forwarded) => {
|
|
expect(parameters).toEqual({ marker: true });
|
|
expect(forwarded).toBe(options);
|
|
const call = calls++;
|
|
if (call === 0) await gate;
|
|
else release();
|
|
const limitation =
|
|
operation === "list_strings"
|
|
? ["first", "shared"]
|
|
: ["second", "shared"];
|
|
return ok(
|
|
createAnalysisExecution([], provider, { limitations: limitation }),
|
|
);
|
|
},
|
|
};
|
|
const result = await executeComposedWorkflow(analysis, async (observed) => {
|
|
await Promise.all([
|
|
observed.execute("list_strings", { marker: true }, options),
|
|
observed.execute("list_procedures", { marker: true }, options),
|
|
]);
|
|
return ok({ count: 0 });
|
|
});
|
|
expect(calls).toBe(2);
|
|
expect(result).toEqual(
|
|
ok({
|
|
result: { count: 0 },
|
|
limitations: [
|
|
"Fixture (fixture 1) list_procedures, list_strings: shared",
|
|
"Fixture (fixture 1) list_procedures: second",
|
|
"Fixture (fixture 1) list_strings: first",
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("deduplicates exact source caveats without merging providers or operations", async () => {
|
|
const firstProvider = { id: "first", name: "First", version: "1" };
|
|
const secondProvider = { id: "second", name: "Second", version: "1" };
|
|
const result = await executeComposedWorkflow(
|
|
{
|
|
execute: async (operation, parameters) =>
|
|
ok(
|
|
createAnalysisExecution(
|
|
null,
|
|
parameters.provider === "second" ? secondProvider : firstProvider,
|
|
{ limitations: ["same source caveat"] },
|
|
),
|
|
),
|
|
},
|
|
async (observed) => {
|
|
await observed.execute("list_strings", { provider: "first" });
|
|
await observed.execute("list_strings", { provider: "first" });
|
|
await observed.execute("list_strings", { provider: "second" });
|
|
await observed.execute("list_procedures", { provider: "first" });
|
|
return ok({ count: 0 });
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual(
|
|
ok({
|
|
result: { count: 0 },
|
|
limitations: [
|
|
"First (first 1) list_procedures, list_strings: same source caveat",
|
|
"Second (second 1) list_strings: same source caveat",
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("bounds per-item caveat retention while preserving inventory caveats", async () => {
|
|
const result = await executeComposedWorkflow(
|
|
{
|
|
execute: async (operation, parameters) =>
|
|
ok(
|
|
createAnalysisExecution(null, provider, {
|
|
limitations:
|
|
operation === "list_procedures"
|
|
? ["inventory caveat"]
|
|
: [
|
|
"shared caveat",
|
|
`caveat at ${String(parameters.procedure)}`,
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
async (observed) => {
|
|
await observed.execute("list_procedures", {});
|
|
for (const procedure of ["0x1", "0x2", "0x3"])
|
|
await observed.execute("procedure_pseudo_code", { procedure });
|
|
return ok({ count: 3 });
|
|
},
|
|
{
|
|
perItemOperations: new Set(["procedure_pseudo_code"]),
|
|
maxDistinct: 2,
|
|
retainedIn: "the files keep them",
|
|
},
|
|
);
|
|
expect(result).toEqual(
|
|
ok({
|
|
result: { count: 3 },
|
|
limitations: [
|
|
"Fixture (fixture 1) list_procedures: inventory caveat",
|
|
"Fixture (fixture 1) procedure_pseudo_code: caveat at 0x1",
|
|
"Fixture (fixture 1) procedure_pseudo_code: shared caveat",
|
|
"2 per-item provider limitation observations were omitted from this answer; the files keep them.",
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("spends one budget slot on a caveat that two per-item operations share", async () => {
|
|
const result = await executeComposedWorkflow(
|
|
{
|
|
execute: async (operation, parameters) =>
|
|
ok(
|
|
createAnalysisExecution(null, provider, {
|
|
limitations: [
|
|
"shared caveat",
|
|
`${operation} at ${String(parameters.procedure)}`,
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
async (observed) => {
|
|
await observed.execute("procedure_info", { procedure: "0x1" });
|
|
await observed.execute("procedure_pseudo_code", { procedure: "0x1" });
|
|
return ok(null);
|
|
},
|
|
{
|
|
perItemOperations: new Set(["procedure_info", "procedure_pseudo_code"]),
|
|
maxDistinct: 2,
|
|
retainedIn: "the files keep them",
|
|
},
|
|
);
|
|
expect(result).toEqual(
|
|
ok({
|
|
result: null,
|
|
limitations: [
|
|
"Fixture (fixture 1) procedure_info, procedure_pseudo_code: shared caveat",
|
|
"Fixture (fixture 1) procedure_info: procedure_info at 0x1",
|
|
"1 per-item provider limitation observation was omitted from this answer; the files keep them.",
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("counts repeated omitted observations without retaining their text for ranking", async () => {
|
|
const result = await executeComposedWorkflow(
|
|
{
|
|
execute: async (_operation, parameters) =>
|
|
ok(
|
|
createAnalysisExecution(null, provider, {
|
|
limitations: [
|
|
parameters.procedure === "first"
|
|
? "first caveat"
|
|
: "later caveat",
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
async (observed) => {
|
|
await observed.execute("procedure_pseudo_code", { procedure: "first" });
|
|
for (let index = 0; index < 30; index += 1)
|
|
await observed.execute("procedure_pseudo_code", { procedure: "later" });
|
|
return ok(null);
|
|
},
|
|
{
|
|
perItemOperations: new Set(["procedure_pseudo_code"]),
|
|
maxDistinct: 1,
|
|
retainedIn: "the index keeps them",
|
|
},
|
|
);
|
|
expect(result).toEqual(
|
|
ok({
|
|
result: null,
|
|
limitations: [
|
|
"Fixture (fixture 1) procedure_pseudo_code: first caveat",
|
|
"30 per-item provider limitation observations were omitted from this answer; the index keeps them.",
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("bounds retained caveat text while preserving smaller later observations", async () => {
|
|
const result = await executeComposedWorkflow(
|
|
{
|
|
execute: async () =>
|
|
ok(
|
|
createAnalysisExecution(null, provider, {
|
|
limitations: [
|
|
"x".repeat(9_000),
|
|
"first",
|
|
"second",
|
|
"x".repeat(9_000),
|
|
"ok",
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
async (observed) => {
|
|
await observed.execute("procedure_pseudo_code", {});
|
|
return ok(null);
|
|
},
|
|
{
|
|
perItemOperations: new Set(["procedure_pseudo_code"]),
|
|
maxDistinct: 8,
|
|
maxBytes: 12,
|
|
retainedIn: "the index keeps them",
|
|
},
|
|
);
|
|
expect(result).toEqual(
|
|
ok({
|
|
result: null,
|
|
limitations: [
|
|
"Fixture (fixture 1) procedure_pseudo_code: first",
|
|
"Fixture (fixture 1) procedure_pseudo_code: ok",
|
|
"3 per-item provider limitation observations were omitted from this answer; the index keeps them.",
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves a failed composed operation without an extra provider read", async () => {
|
|
const failure = err(new AnalysisCancelledError("list_strings"));
|
|
let calls = 0;
|
|
const result = await executeComposedWorkflow(
|
|
{
|
|
execute: async () => {
|
|
calls++;
|
|
return failure;
|
|
},
|
|
},
|
|
async (observed) => {
|
|
const observedResult = await observed.execute("list_strings", {});
|
|
return observedResult.ok
|
|
? ok(observedResult.value.result)
|
|
: observedResult;
|
|
},
|
|
);
|
|
expect(result).toBe(failure);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
describe("workflow residual question retention", () => {
|
|
it("keeps questions linked to workflow Evidence and records repeats idempotently", () => {
|
|
const records = new InvestigationRecords();
|
|
const result = {
|
|
residual_unknowns: ["Which provider resolves this boundary?"],
|
|
};
|
|
const evidence = createWorkflowEvidence({
|
|
target: undefined,
|
|
operation: "inspect_native_api",
|
|
parameters: { procedure: "0x1000" },
|
|
result,
|
|
upstreamProfile: undefined,
|
|
upstreamLimitations: [],
|
|
});
|
|
expect(records.recordEvidence(evidence).ok).toBe(true);
|
|
const input = {
|
|
name: "inspect_native_api",
|
|
result,
|
|
evidenceId: evidence.evidence_id,
|
|
recordUnknown: (
|
|
unknown: Parameters<InvestigationRecords["recordUnknown"]>[0],
|
|
) => records.recordUnknown(unknown),
|
|
};
|
|
expect(recordWorkflowUnknowns(input).ok).toBe(true);
|
|
expect(recordWorkflowUnknowns(input).ok).toBe(true);
|
|
expect(records.listUnknowns()).toMatchObject([
|
|
{
|
|
question: `${result.residual_unknowns[0]} (Evidence ${evidence.evidence_id})`,
|
|
domain: "native-api",
|
|
supporting_evidence_ids: [evidence.evidence_id],
|
|
required_authority: "shipped-artifact",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("retains one identical residual question per distinct workflow Evidence", () => {
|
|
const records = new InvestigationRecords();
|
|
const result = {
|
|
residual_unknowns: ["Which provider resolves this boundary?"],
|
|
};
|
|
const evidence = ["0x1000", "0x2000"].map((procedure) =>
|
|
createWorkflowEvidence({
|
|
target: undefined,
|
|
operation: "inspect_native_api",
|
|
parameters: { procedure },
|
|
result,
|
|
upstreamProfile: undefined,
|
|
upstreamLimitations: [],
|
|
}),
|
|
);
|
|
expect(evidence[0]?.evidence_id).not.toBe(evidence[1]?.evidence_id);
|
|
|
|
for (const item of evidence) {
|
|
expect(records.recordEvidence(item).ok).toBe(true);
|
|
const input = {
|
|
name: "inspect_native_api",
|
|
result,
|
|
evidenceId: item.evidence_id,
|
|
recordUnknown: (
|
|
unknown: Parameters<InvestigationRecords["recordUnknown"]>[0],
|
|
) => records.recordUnknown(unknown),
|
|
};
|
|
expect(recordWorkflowUnknowns(input).ok).toBe(true);
|
|
expect(recordWorkflowUnknowns(input).ok).toBe(true);
|
|
}
|
|
|
|
expect(records.listUnknowns()).toHaveLength(2);
|
|
expect(
|
|
records
|
|
.listUnknowns()
|
|
.map(({ supporting_evidence_ids }) => supporting_evidence_ids),
|
|
).toEqual(
|
|
expect.arrayContaining(evidence.map(({ evidence_id }) => [evidence_id])),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["spaces", " Which boundary? "],
|
|
["line feed and tab", "\nWhich boundary?\t"],
|
|
["byte order mark", "\uFEFFWhich boundary?\uFEFF"],
|
|
])(
|
|
"normalizes edge whitespace from %s without changing workflow Evidence",
|
|
(_, question) => {
|
|
const records = new InvestigationRecords();
|
|
const result = { residual_unknowns: [question] };
|
|
const evidence = createWorkflowEvidence({
|
|
target: undefined,
|
|
operation: "inspect_native_api",
|
|
parameters: {},
|
|
result,
|
|
upstreamProfile: undefined,
|
|
upstreamLimitations: [],
|
|
});
|
|
|
|
expect(records.recordEvidence(evidence).ok).toBe(true);
|
|
expect(
|
|
recordWorkflowUnknowns({
|
|
name: "inspect_native_api",
|
|
result,
|
|
evidenceId: evidence.evidence_id,
|
|
recordUnknown: (unknown) => records.recordUnknown(unknown),
|
|
}).ok,
|
|
).toBe(true);
|
|
expect(records.listUnknowns()).toMatchObject([
|
|
{ question: `Which boundary? (Evidence ${evidence.evidence_id})` },
|
|
]);
|
|
expect(evidence.normalized_result).toEqual(result);
|
|
},
|
|
);
|
|
|
|
it("rejects questions that are empty after whitespace normalization", () => {
|
|
const records = new InvestigationRecords();
|
|
const result = { residual_unknowns: [" \n\t "] };
|
|
const evidence = createWorkflowEvidence({
|
|
target: undefined,
|
|
operation: "inspect_native_api",
|
|
parameters: {},
|
|
result,
|
|
upstreamProfile: undefined,
|
|
upstreamLimitations: [],
|
|
});
|
|
|
|
expect(records.recordEvidence(evidence).ok).toBe(true);
|
|
expect(
|
|
recordWorkflowUnknowns({
|
|
name: "inspect_native_api",
|
|
result,
|
|
evidenceId: evidence.evidence_id,
|
|
recordUnknown: (unknown) => records.recordUnknown(unknown),
|
|
}).ok,
|
|
).toBe(false);
|
|
expect(records.listUnknowns()).toEqual([]);
|
|
expect(evidence.normalized_result).toEqual(result);
|
|
});
|
|
|
|
it("preserves registry failures rather than presenting a completed workflow", () => {
|
|
const error = new UnknownRegistryError("limit");
|
|
expect(
|
|
recordWorkflowUnknowns({
|
|
name: "trace_feature",
|
|
result: { residual_unknowns: ["Which branch handles the request?"] },
|
|
evidenceId: "unretained",
|
|
recordUnknown: () => err(error),
|
|
}),
|
|
).toEqual(err(error));
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
" Which boundary? ",
|
|
"\nWhich boundary?\t",
|
|
"\uFEFFWhich boundary?\uFEFF",
|
|
])(
|
|
"retains contract-accepted question %j without rewriting workflow Evidence",
|
|
(question) => {
|
|
const boundary = nativeApiBoundarySchema.parse({
|
|
available: false,
|
|
reason: "Synthetic provider unavailable boundary",
|
|
residual_unknowns: [question],
|
|
});
|
|
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const result = projectNativeApiInspection({
|
|
...dossier,
|
|
native_api: boundary,
|
|
});
|
|
const evidence = createWorkflowEvidence({
|
|
target: undefined,
|
|
operation: "inspect_native_api",
|
|
parameters: { procedure: "0x1000" },
|
|
result,
|
|
upstreamProfile: undefined,
|
|
upstreamLimitations: [],
|
|
});
|
|
const records = new InvestigationRecords();
|
|
expect(records.recordEvidence(evidence).ok).toBe(true);
|
|
expect(
|
|
recordWorkflowUnknowns({
|
|
name: "inspect_native_api",
|
|
result,
|
|
evidenceId: evidence.evidence_id,
|
|
recordUnknown: (input) => records.recordUnknown(input),
|
|
}).ok,
|
|
).toBe(true);
|
|
expect(records.listUnknowns()).toMatchObject([
|
|
{ question: `Which boundary? (Evidence ${evidence.evidence_id})` },
|
|
]);
|
|
expect(records.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
|
expect(evidence.normalized_result).toMatchObject({
|
|
residual_unknowns: [question],
|
|
});
|
|
},
|
|
);
|