mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(native): page retained observations and source context (#7146)
* feat(native): page retained observations and source context * docs(native): record complete observation workflow measurements * fix(native): preserve context coverage and overflow recovery * fix(benchmarks): finalize partially initialized observation probes * docs(native): publish reviewed workflow verification
This commit is contained in:
1 parent
447907f280
commit
4c8647a706
29 files changed
+2266
-78
No files matched your search
@@ -403,6 +403,21 @@ encoded in bounded batches.
|
||||
The 25000 ms Cutter request deadline and the 256-candidate discovery budget
|
||||
still have no runtime override.
|
||||
|
||||
## Selected native facts and call graphs
|
||||
|
||||
For a large `get_call_graph`, or repeated `procedure_address`,
|
||||
`procedure_callees`, `procedure_callers` and `resolve_containing_procedure`
|
||||
queries, select `detail: "summary"` when the first 64 recorded observations
|
||||
suffice. Read further `native-observation` pages from the returned parent Evidence
|
||||
ID. Graph message IDs index that page's exact-text dictionary; keep level,
|
||||
source index, failed nodes, and partial/unknown relationship coverage.
|
||||
Read referenced source context through `native-context` facets `overview`,
|
||||
`analysis-profile`, `limitations` and `result-limitations`. Reuse inspected
|
||||
context/profile values only for the same digest; until caveats are read,
|
||||
interpretation beyond the returned facts remains unknown. Complete Evidence
|
||||
remains retained and exportable. Small complete replies can cost fewer tokens
|
||||
than additional context/page calls; count the completed task and discovery.
|
||||
|
||||
## Static memory and original file bytes
|
||||
|
||||
For Ghidra, `read_bytes` reads initialized analysis memory, including loader
|
||||
|
||||
@@ -242,6 +242,36 @@ to `/absolute/view-request.json`, then run
|
||||
`rea inspect-analysis-view /absolute/view-request.json --json`. This also reads
|
||||
pre-summary source records without changing their identity; no live provider is started.
|
||||
|
||||
`procedure_address`, `procedure_callees`, `procedure_callers`,
|
||||
`resolve_containing_procedure` and `get_call_graph` also accept `detail: "summary"`
|
||||
(complete remains the default). The summary includes the first 64 recorded
|
||||
observations, exact source parameters and provider/profile identity, relationship
|
||||
coverage where recorded, and the retained complete parent's ID. Continue with
|
||||
`view: {"kind":"native-observation","offset":64,"limit":64}`. Array positions
|
||||
retain `source_index`; graph rows additionally retain their original `level`.
|
||||
Graph pages order levels by Unicode code point and keep original row order within
|
||||
each level. A recorded empty level is a `status: "empty-level"` row with
|
||||
`source_index: null`; observation counts include these markers. Graph diagnostic,
|
||||
limitation and reason IDs index that page's `items.messages` dictionary. Expand
|
||||
those references to recover exact text, repeated messages, and absent versus
|
||||
empty optional fields. Failed nodes retain their original error.
|
||||
|
||||
These views reference complete source context through `source_context.digest`
|
||||
and caveat counts. Read it using the same parent with
|
||||
`view: {"kind":"native-context","facet":"overview"}`. The other facets are
|
||||
`analysis-profile`, `limitations` and `result-limitations`; all accept `offset`
|
||||
and `limit`. Overview includes source subject, provider, authority, execution
|
||||
environment and profile digest. Full profiles and caveat text remain accessible
|
||||
without repeating them in every fact response. Context digests follow Evidence's
|
||||
semantic subject identity, so caller-selected path aliases preserve context
|
||||
identity while responses retain the selected display path. A null profile means
|
||||
unavailable; null result-limitations means not applicable to a primitive and
|
||||
is distinct from an observed empty graph limitation list. Until the source
|
||||
caveats have been inspected, interpretations beyond the returned facts remain
|
||||
unknown. Exhausted pages describe the recorded observations and do not establish
|
||||
complete native discovery. The same views work through the CLI with historical
|
||||
Evidence files or bundles and require no live provider.
|
||||
|
||||
For oversized native function Evidence, `details.reported_limits.continuation`
|
||||
contains a ready-to-call `inspect_analysis_view` request for the retained
|
||||
pseudocode's first page. Use its `name` and `arguments` directly, then follow
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Retained native observations and context
|
||||
|
||||
This experiment answers one fixed question over the unmodified stripped Linux
|
||||
`/usr/bin/ls`: resolve 21 recorded functions, inspect every recorded caller/callee
|
||||
relation and containing-function outcome, two complete graphs, and one missing
|
||||
symbol. Repeat in the same session. Both lanes read all facts, source profiles,
|
||||
and source caveats; no completeness advantage is inferred from smaller replies.
|
||||
|
||||
The final complete control uses `5d428390996498bc4dcca4da934641557f9109bc`,
|
||||
after the unrelated input-schema fitting revert. The first control used
|
||||
`2b0aa212156fe125c60aea8fd73092329e25dd09` (merged as `6381aa0b1`).
|
||||
This already includes request-scoped Ghidra input-error diagnostics, so those
|
||||
savings are not attributed to observation views. The candidate uses the
|
||||
implementation in this PR. Both discover the entire actual compact SDK catalog,
|
||||
use the same full 652-row inventory to choose seeds, and use named SDK tool
|
||||
definitions for response validation. The summary client keeps exact-digest
|
||||
context/profile values within this one investigation. The server adds no cache.
|
||||
|
||||
The first 64 observations arrive inline. Because this question explicitly needs
|
||||
every remaining observation and every caveat, the client requests subsequent
|
||||
pages of up to 512 entries. It decodes each graph page's message dictionary,
|
||||
preserves original source indices, levels, duplicate text, optional fields,
|
||||
failed nodes and coverage, and checks every reconstructed result against the
|
||||
authenticated exported complete parent. All 65 original Evidence IDs and all
|
||||
facts match the control in cold and warm phases. This establishes preservation
|
||||
of the real Ghidra output, not independent completeness of Ghidra discovery.
|
||||
Separate source fixtures establish graph ordering, duplicate/Unicode caveats,
|
||||
empty levels, partial/unknown coverage, negative resolution, malformed omitted
|
||||
rows, and authenticated path-alias identity.
|
||||
|
||||
## Environment and reproduction
|
||||
|
||||
- Target SHA-256: `cb30d69b24245bf2ecdc9e7f53bbad19159999970b6d82c0c00c7d32d9e37aa4`.
|
||||
- Linux x86-64; Node 24.22.1; Ghidra 12.1.4; JDK 21.0.12.1.
|
||||
- Fresh MCP process and Ghidra session for each lane; warm OS file caches.
|
||||
The warm phase reuses the same provider session and the client's validated
|
||||
context values. This is not a cold-disk comparison.
|
||||
- Two CPU affinity slots; Node old-space limit 3 GiB and Ghidra heap 1536 MiB.
|
||||
An external supervisor sampled the whole process family's summed RSS every
|
||||
100 ms, stopped at 8 GiB or below 4 GiB available host memory, and enforced a
|
||||
300-second deadline. No resource stop occurred. RSS can double-count shared
|
||||
pages and miss short peaks. CPU includes driver, SDK, REA and Java descendants.
|
||||
|
||||
Build both worktrees with the pinned toolchain. Run this PR's driver against
|
||||
each built runtime, with separate new output directories:
|
||||
|
||||
```sh
|
||||
node experiments/analysis-efficiency/native-observation-views.mjs \
|
||||
--runtime /absolute/control --target /usr/bin/ls --output /tmp/native-control \
|
||||
--ghidra /absolute/ghidra --java /absolute/jdk --mode complete
|
||||
node experiments/analysis-efficiency/native-observation-views.mjs \
|
||||
--runtime /absolute/candidate --target /usr/bin/ls --output /tmp/native-summary \
|
||||
--ghidra /absolute/ghidra --java /absolute/jdk --mode summary \
|
||||
--control /tmp/native-control
|
||||
```
|
||||
|
||||
Apply a process-family resource supervisor appropriate to the host. The driver
|
||||
itself does not impose a family-memory limit. Each output keeps the actual
|
||||
catalog, every RPC argument/reply, complete Evidence export, per-phase facts,
|
||||
server log and completion/cleanup receipts. Failed runs retain their receipt.
|
||||
|
||||
## Measurements
|
||||
|
||||
| Completed task | Complete control | Summary + complete context |
|
||||
| ------------------------------------------------ | ---------------: | -------------------------: |
|
||||
| RPCs | 137 | 179 |
|
||||
| Catalog token estimate | 919,115 | 932,545 |
|
||||
| Catalog + arguments + text token estimate | 1,243,161 | 1,234,768 |
|
||||
| Catalog + arguments + raw replies token estimate | 1,587,863 | 1,554,038 |
|
||||
| All reply text bytes | 1,331,358 | 1,006,503 |
|
||||
| Whole-family elapsed seconds | 39.38 | 41.06 |
|
||||
| Whole-family CPU seconds | 69.26 | 70.4 |
|
||||
| Sampled family peak RSS GiB | 1.45 | 1.69 |
|
||||
|
||||
The completed-task text estimate decreases 0.68%; raw-reply accounting decreases
|
||||
2.13%, and text bytes decrease 24.4%. The large forward graph's first reply is
|
||||
63,888 → 5,150 estimated text tokens (283,990 → 15,995 bytes); remaining facts
|
||||
and all caveats are included in the completed-task table. A one-address callee
|
||||
reply is only 754 → 744 tokens despite 3,104 → 2,326 bytes: bytes alone would
|
||||
overstate its token improvement. There is no demonstrated time or memory
|
||||
improvement. The complete-read workflow adds 42 RPCs; its warm phase is
|
||||
0.762 → 1.005 seconds. Cold phases are 33.151 → 34.804 seconds. This one fresh
|
||||
session per final lane cannot separate provider-startup variance from overhead.
|
||||
The final candidate implementation and experiment driver are `4ac1971864`;
|
||||
the final control is `5d428390996`. CPU and RSS remain within the stated guard
|
||||
but are higher. An earlier pre-review pair measured 34.55 → 50.29 seconds,
|
||||
so elapsed-time variance must not be mistaken for a speed improvement.
|
||||
|
||||
Token estimates use `cl100k_base` over one pass of raw catalog JSON plus every
|
||||
RPC's arguments and text content. A second accounting counts the full raw RPC
|
||||
reply, including duplicate structured/text representations. Both include page,
|
||||
context, error, export and close calls. They exclude prompts, MCP initialization,
|
||||
JSON-RPC envelopes, host rendering and replay, and are not actual model usage.
|
||||
Catalog discovery dominates this example. No claim of better model reasoning
|
||||
or complete discovery follows from these counts.
|
||||
|
||||
A first control attempt completed both investigations but its experiment driver
|
||||
incorrectly parsed the export acknowledgement as Evidence; it failed during
|
||||
verification, cleaned up, and was rerun after correcting the driver. A first
|
||||
candidate with 64-entry continuation pages and duplicated coverage/verbose
|
||||
caveat instructions increased the completed-task text token estimate by 1.2%.
|
||||
That result was retained; the final implementation removes repeated coverage
|
||||
from the normalized payload (the Evidence envelope owns it), shortens the
|
||||
instructions, and the complete-read client requests larger continuation pages.
|
||||
Neither failed/development attempt is included in the final paired task table.
|
||||
|
||||
Review regressions also cover context-facet partial/unknown relationship
|
||||
coverage and actionable overflow continuations through all five native tools
|
||||
without another provider read. The latter injects a 32 KiB delivery budget to
|
||||
exercise capacity cheaply; it does not establish a real 10 MiB graph failure.
|
||||
Actual missing-target and failing-Git commands each publish a failed receipt
|
||||
without leaking a temporary HOME. Thirteen deterministic receipt tests also
|
||||
cover partially acquired resources and failed cleanup.
|
||||
@@ -0,0 +1,417 @@
|
||||
/** Equivalent real Ghidra investigations, including every page and complete source context. */
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { finished } from "node:stream/promises";
|
||||
import { parseArgs, promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { createRetainedQueryCaller } from "./retained-query-calls.mjs";
|
||||
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
|
||||
const { values } = parseArgs({
|
||||
options: Object.fromEntries(
|
||||
["runtime", "target", "output", "ghidra", "java", "mode", "control"].map(
|
||||
(name) => [name, { type: "string" }],
|
||||
),
|
||||
),
|
||||
});
|
||||
for (const key of ["runtime", "target", "output", "ghidra", "java"]) {
|
||||
assert.ok(values[key], `Missing --${key}`);
|
||||
values[key] = resolve(values[key]);
|
||||
}
|
||||
assert.ok(["complete", "summary"].includes(values.mode));
|
||||
await mkdir(values.output);
|
||||
const load = (path) => import(pathToFileURL(join(values.runtime, path)).href);
|
||||
let parseEvidence, evidenceSubjectIdentity, digestCanonicalValue;
|
||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||
let account, transport, serverLog, serverLogFinished, client, call;
|
||||
const git = async (args) =>
|
||||
(await promisify(execFile)("git", ["-C", values.runtime, ...args])).stdout;
|
||||
const report = {
|
||||
mode: values.mode,
|
||||
runtime: values.runtime,
|
||||
revision: null,
|
||||
dirty: null,
|
||||
target: values.target,
|
||||
sha256: null,
|
||||
question:
|
||||
"Resolve 21 recorded functions, retain all caller/callee facts and two graphs, materialize every source profile and caveat, and inspect one unknown-symbol error, twice in one session.",
|
||||
calls: [],
|
||||
runs: [],
|
||||
};
|
||||
const transcript = [];
|
||||
const good = async (name, input, phase) => {
|
||||
const reply = await call(name, input, phase);
|
||||
assert.notEqual(reply.isError, true, JSON.stringify(reply));
|
||||
return parseEvidence(reply.structuredContent);
|
||||
};
|
||||
const page = async (id, view, phase) =>
|
||||
(
|
||||
await good(
|
||||
"inspect_analysis_view",
|
||||
{ source: { kind: "retained-evidence", evidence_id: id }, view },
|
||||
phase,
|
||||
)
|
||||
).normalized_result;
|
||||
const contexts = new Map(),
|
||||
profiles = new Map(),
|
||||
expectedParents = new Map();
|
||||
const readContext = async (summary, phase) => {
|
||||
const ref = summary.source_context;
|
||||
if (contexts.has(ref.digest)) return contexts.get(ref.digest);
|
||||
const id = summary.parent_evidence_id;
|
||||
const facet = async (name) => {
|
||||
let offset = 0,
|
||||
rows = [];
|
||||
while (true) {
|
||||
const result = await page(
|
||||
id,
|
||||
{ kind: "native-context", facet: name, offset, limit: 512 },
|
||||
`${phase}:context:${name}`,
|
||||
);
|
||||
assert.equal(result.source_context.digest, ref.digest);
|
||||
if (result.items.rows === null) return null;
|
||||
for (const row of result.items.rows) {
|
||||
assert.equal(row.source_index, rows.length);
|
||||
rows.push(
|
||||
name === "limitations" || name === "result-limitations"
|
||||
? row.text
|
||||
: row.record,
|
||||
);
|
||||
}
|
||||
if (result.coverage.exhausted) return rows;
|
||||
assert.ok(result.coverage.next_offset > offset);
|
||||
offset = result.coverage.next_offset;
|
||||
}
|
||||
};
|
||||
const overview = (await facet("overview"))[0];
|
||||
const profileId = overview.analysis_profile_digest;
|
||||
if (!profiles.has(profileId))
|
||||
profiles.set(profileId, (await facet("analysis-profile"))[0]);
|
||||
const { analysis_profile_digest: _digest, ...metadata } = overview;
|
||||
const context = {
|
||||
...metadata,
|
||||
analysis_profile: profiles.get(profileId),
|
||||
limitations: await facet("limitations"),
|
||||
result_limitations: await facet("result-limitations"),
|
||||
};
|
||||
assert.equal(context.limitations.length, ref.evidence_limitation_count);
|
||||
assert.equal(
|
||||
context.result_limitations?.length ?? null,
|
||||
ref.result_limitation_count,
|
||||
);
|
||||
assert.equal(
|
||||
digestCanonicalValue(
|
||||
{ ...context, subject: evidenceSubjectIdentity(context.subject) },
|
||||
"Native source context",
|
||||
),
|
||||
ref.digest,
|
||||
);
|
||||
contexts.set(ref.digest, context);
|
||||
return context;
|
||||
};
|
||||
const decodeGraphRow = (row, messages) => {
|
||||
if (row.status === "empty-level") return null;
|
||||
const { level: _level, source_index: _index, ...node } = row;
|
||||
if (row.status === "error") return node;
|
||||
const { limitation_ids, relationship_coverage: coverage, ...rest } = node;
|
||||
const {
|
||||
diagnostic_ids,
|
||||
limitation_ids: coverageIds,
|
||||
reason_id,
|
||||
...coverageRest
|
||||
} = coverage;
|
||||
return {
|
||||
...rest,
|
||||
...(limitation_ids === undefined
|
||||
? {}
|
||||
: { limitations: limitation_ids.map((id) => messages[id]) }),
|
||||
relationship_coverage: {
|
||||
...coverageRest,
|
||||
...(diagnostic_ids === undefined
|
||||
? {}
|
||||
: { diagnostics: diagnostic_ids.map((id) => messages[id]) }),
|
||||
...(coverageIds === undefined
|
||||
? {}
|
||||
: { limitations: coverageIds.map((id) => messages[id]) }),
|
||||
...(reason_id === undefined ? {} : { reason: messages[reason_id] }),
|
||||
},
|
||||
};
|
||||
};
|
||||
const observation = async (name, args, phase) => {
|
||||
const evidence = await good(
|
||||
name,
|
||||
{ ...args, ...(values.mode === "summary" ? { detail: "summary" } : {}) },
|
||||
phase,
|
||||
);
|
||||
if (values.mode === "complete") {
|
||||
expectedParents.set(evidence.evidence_id, {
|
||||
result: evidence.normalized_result,
|
||||
});
|
||||
return evidence;
|
||||
}
|
||||
const summary = evidence.normalized_result,
|
||||
id = summary.parent_evidence_id;
|
||||
const context = await readContext(summary, phase);
|
||||
let selected = summary,
|
||||
received = 0;
|
||||
const rows = [],
|
||||
graph = {};
|
||||
while (true) {
|
||||
for (const row of selected.items.rows) {
|
||||
if (name === "get_call_graph") {
|
||||
const nodes = (graph[row.level] ??= []);
|
||||
const node = decodeGraphRow(row, selected.items.messages);
|
||||
if (node !== null) {
|
||||
assert.equal(row.source_index, nodes.length);
|
||||
nodes.push(node);
|
||||
}
|
||||
} else {
|
||||
assert.equal(row.source_index, rows.length);
|
||||
rows.push(
|
||||
selected.items.type === "resolutions" ? row.record : row.address,
|
||||
);
|
||||
}
|
||||
received++;
|
||||
}
|
||||
if (selected.coverage.exhausted) break;
|
||||
assert.equal(selected.coverage.next_offset, received);
|
||||
selected = await page(
|
||||
id,
|
||||
{ kind: "native-observation", offset: received, limit: 512 },
|
||||
`${phase}:facts-page`,
|
||||
);
|
||||
assert.equal(selected.source_context.digest, summary.source_context.digest);
|
||||
}
|
||||
assert.equal(received, summary.summary.observations);
|
||||
const result =
|
||||
name === "get_call_graph"
|
||||
? { graph, limitations: context.result_limitations }
|
||||
: name === "resolve_containing_procedure" || name === "procedure_address"
|
||||
? rows[0]
|
||||
: rows;
|
||||
expectedParents.set(id, { result, context });
|
||||
return { evidence_id: id, normalized_result: result };
|
||||
};
|
||||
let opened = false;
|
||||
const started = performance.now();
|
||||
try {
|
||||
({ parseEvidence, evidenceSubjectIdentity } = await load(
|
||||
"dist/domain/evidence.js",
|
||||
));
|
||||
({ digestCanonicalValue } = await load("dist/domain/canonicalDigest.js"));
|
||||
report.revision = (await git(["rev-parse", "HEAD"])).trim();
|
||||
report.dirty = (await git(["status", "--porcelain"])).length > 0;
|
||||
report.sha256 = hash(await readFile(values.target));
|
||||
account = await mkdtemp(join(tmpdir(), "rea-native-observations-"));
|
||||
transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
|
||||
cwd: values.runtime,
|
||||
env: {
|
||||
HOME: account,
|
||||
PATH: process.env.PATH,
|
||||
NODE_OPTIONS: "--max-old-space-size=3072",
|
||||
GHIDRA_INSTALL_DIR: values.ghidra,
|
||||
JAVA_HOME: values.java,
|
||||
GHIDRA_HEADLESS_MAXMEM: "1536M",
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: "600000",
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
serverLog = createWriteStream(join(values.output, "server.log"));
|
||||
serverLogFinished = finished(serverLog);
|
||||
serverLogFinished.catch(() => {});
|
||||
transport.stderr?.pipe(serverLog);
|
||||
client = new Client({ name: "native-observation-views", version: "1" });
|
||||
call = createRetainedQueryCaller(client, (receipt, event) => {
|
||||
report.calls.push(receipt);
|
||||
transcript.push(event);
|
||||
});
|
||||
await client.connect(transport);
|
||||
const catalog = await client.listTools();
|
||||
assert.equal(catalog.nextCursor, undefined);
|
||||
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
|
||||
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
|
||||
const original = client.callTool.bind(client);
|
||||
client.callTool = (input, options) =>
|
||||
original(input, {
|
||||
...options,
|
||||
timeout: 600000,
|
||||
toolDefinition: definitions.get(input.name),
|
||||
});
|
||||
const open = await call(
|
||||
"open_binary",
|
||||
{ path: values.target, provider_id: "ghidra" },
|
||||
"open",
|
||||
);
|
||||
assert.notEqual(open.isError, true, JSON.stringify(open));
|
||||
opened = true;
|
||||
let seeds;
|
||||
for (const phase of ["cold", "warm"]) {
|
||||
const start = performance.now();
|
||||
const inventory = await good("list_procedures", {}, `${phase}:inventory`);
|
||||
const rows = inventory.normalized_result;
|
||||
assert.ok(Array.isArray(rows));
|
||||
seeds ??= rows
|
||||
.filter((row) => !row.procedure?.external && !row.procedure?.thunk)
|
||||
.slice(0, 21)
|
||||
.map((row) => row.address);
|
||||
assert.equal(seeds.length, 21);
|
||||
const ids = [],
|
||||
facts = [];
|
||||
let mostCallers = { address: seeds[0], count: -1 },
|
||||
mostCallees = { address: seeds[0], count: -1 };
|
||||
for (const address of seeds) {
|
||||
for (const [name, args] of [
|
||||
["procedure_callees", { procedure: address }],
|
||||
["procedure_callers", { procedure: address }],
|
||||
["resolve_containing_procedure", { address }],
|
||||
]) {
|
||||
const evidence = await observation(name, args, `${phase}:${name}`);
|
||||
ids.push(evidence.evidence_id);
|
||||
facts.push(evidence.normalized_result);
|
||||
if (
|
||||
name === "procedure_callees" &&
|
||||
evidence.normalized_result.length > mostCallees.count
|
||||
)
|
||||
mostCallees = { address, count: evidence.normalized_result.length };
|
||||
if (
|
||||
name === "procedure_callers" &&
|
||||
evidence.normalized_result.length > mostCallers.count
|
||||
)
|
||||
mostCallers = { address, count: evidence.normalized_result.length };
|
||||
}
|
||||
}
|
||||
for (const direction of ["forward", "backward"]) {
|
||||
const evidence = await observation(
|
||||
"get_call_graph",
|
||||
{
|
||||
address:
|
||||
direction === "forward" ? mostCallees.address : mostCallers.address,
|
||||
direction,
|
||||
},
|
||||
`${phase}:graph:${direction}`,
|
||||
);
|
||||
ids.push(evidence.evidence_id);
|
||||
facts.push(evidence.normalized_result);
|
||||
}
|
||||
const missing = await call(
|
||||
"procedure_callers",
|
||||
{
|
||||
procedure: "rea_unknown_symbol_6426",
|
||||
...(values.mode === "summary" ? { detail: "summary" } : {}),
|
||||
},
|
||||
`${phase}:missing`,
|
||||
);
|
||||
assert.equal(missing.isError, true);
|
||||
const error = JSON.parse(
|
||||
missing.content.find((item) => item.type === "text").text,
|
||||
).error;
|
||||
assert.equal(error.code, "invalid_request");
|
||||
assert.equal(error.retryable, false);
|
||||
assert.equal(error.details.diagnostics.diagnostic_scope, "request");
|
||||
assert.equal(error.details.diagnostics.target_sha256, report.sha256);
|
||||
const run = {
|
||||
phase,
|
||||
inventory_count: rows.length,
|
||||
seeds,
|
||||
mostCallers,
|
||||
mostCallees,
|
||||
success_evidence_ids: ids,
|
||||
facts_sha256: digestCanonicalValue(facts, "Investigation facts"),
|
||||
elapsed_ms: performance.now() - start,
|
||||
};
|
||||
await writeFile(
|
||||
join(values.output, `${phase}-facts.json`),
|
||||
JSON.stringify(facts),
|
||||
);
|
||||
if (values.control) {
|
||||
assert.deepEqual(
|
||||
facts,
|
||||
JSON.parse(
|
||||
await readFile(join(values.control, `${phase}-facts.json`), "utf8"),
|
||||
),
|
||||
);
|
||||
const control = JSON.parse(
|
||||
await readFile(join(values.control, "report.json"), "utf8"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
ids,
|
||||
control.runs.find((item) => item.phase === phase).success_evidence_ids,
|
||||
);
|
||||
}
|
||||
report.runs.push(run);
|
||||
}
|
||||
const exported = await call(
|
||||
"export_evidence_bundle",
|
||||
{ path: join(values.output, "complete-evidence.json") },
|
||||
"export",
|
||||
);
|
||||
assert.notEqual(exported.isError, true, JSON.stringify(exported));
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(values.output, "complete-evidence.json"), "utf8"),
|
||||
);
|
||||
for (const [id, expected] of expectedParents) {
|
||||
const evidence = parseEvidence(
|
||||
bundle.records.find((record) => record.evidence_id === id),
|
||||
);
|
||||
assert.equal(evidence.subject.digest.sha256, report.sha256);
|
||||
assert.deepEqual(evidence.normalized_result, expected.result);
|
||||
if (expected.context)
|
||||
assert.deepEqual(expected.context, {
|
||||
subject: evidence.subject,
|
||||
provider: evidence.provider,
|
||||
analysis_profile: evidence.analysis_profile,
|
||||
authority: evidence.authority,
|
||||
environment: evidence.environment,
|
||||
limitations: evidence.limitations,
|
||||
result_limitations:
|
||||
evidence.operation === "get_call_graph"
|
||||
? evidence.normalized_result.limitations
|
||||
: null,
|
||||
});
|
||||
}
|
||||
report.verified_parent_count = expectedParents.size;
|
||||
report.materialized_contexts = contexts.size;
|
||||
report.materialized_profiles = profiles.size;
|
||||
const close = await call("close_binary", {}, "close");
|
||||
assert.notEqual(close.isError, true, JSON.stringify(close));
|
||||
opened = false;
|
||||
assert.equal(hash(await readFile(values.target)), report.sha256);
|
||||
report.status = "passed";
|
||||
} catch (cause) {
|
||||
report.status = "failed";
|
||||
report.failure = String(cause?.stack ?? cause);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (opened)
|
||||
try {
|
||||
const close = await call("close_binary", {}, "cleanup");
|
||||
assert.notEqual(close.isError, true);
|
||||
} catch (cause) {
|
||||
report.close_failure = String(cause);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
report.elapsed_ms = performance.now() - started;
|
||||
if (
|
||||
!(await finalizeRetainedQueryReceipt({
|
||||
report,
|
||||
client,
|
||||
transport,
|
||||
account,
|
||||
serverLog,
|
||||
serverLogFinished,
|
||||
output: values.output,
|
||||
transcript,
|
||||
}))
|
||||
)
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -45,18 +45,28 @@ export const finalizeRetainedQueryReceipt = async ({
|
||||
}) => {
|
||||
report.cleanup_failures = [];
|
||||
await Promise.all([
|
||||
attempt(report.cleanup_failures, "client", () => client.close()),
|
||||
attempt(report.cleanup_failures, "transport", () => transport.close()),
|
||||
...(client === undefined
|
||||
? []
|
||||
: [attempt(report.cleanup_failures, "client", () => client.close())]),
|
||||
...(transport === undefined
|
||||
? []
|
||||
: [
|
||||
attempt(report.cleanup_failures, "transport", () =>
|
||||
transport.close(),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
await attempt(report.cleanup_failures, "server-log", async () => {
|
||||
if (!serverLog.writableEnded) serverLog.end();
|
||||
await serverLogFinished;
|
||||
});
|
||||
if (report.cleanup_failures.length === 0)
|
||||
if (serverLog !== undefined)
|
||||
await attempt(report.cleanup_failures, "server-log", async () => {
|
||||
if (!serverLog.writableEnded) serverLog.end();
|
||||
await serverLogFinished;
|
||||
});
|
||||
if (account !== undefined && report.cleanup_failures.length === 0)
|
||||
await attempt(report.cleanup_failures, "workspace", () =>
|
||||
rm(account, { recursive: true }),
|
||||
);
|
||||
if (report.cleanup_failures.length > 0) report.retained_workspace = account;
|
||||
if (account !== undefined && report.cleanup_failures.length > 0)
|
||||
report.retained_workspace = account;
|
||||
|
||||
return publishRetainedQueryReceipt({ report, output, transcript, stdout });
|
||||
};
|
||||
|
||||
@@ -180,4 +180,24 @@ assert.match(report.cleanup_failures[0],/client:.*close failed/);
|
||||
assert.equal(report.retained_workspace,account);assert.equal(serverLog.writableEnded,true);
|
||||
assert.equal((await stat(account)).isDirectory(),true);
|
||||
assert.deepEqual(await saved(),report);assert.deepEqual(JSON.parse(chunks.join('')),report);
|
||||
''')
|
||||
|
||||
def test_setup_failure_without_acquired_resources_still_publishes_receipts(self):
|
||||
self.run_finalization('''
|
||||
assert.equal(await finalizeRetainedQueryReceipt({report,output,stdout,transcript:[]}),true);
|
||||
assert.deepEqual(report.cleanup_failures,[]);
|
||||
assert.deepEqual((await saved()).failure,originalFailure);
|
||||
assert.deepEqual(JSON.parse(await readFile(join(output,'transcript.json'),'utf8')),[]);
|
||||
assert.ok((await stat(account)).isDirectory());
|
||||
''')
|
||||
|
||||
def test_partial_setup_closes_acquired_transport_and_releases_its_workspace(self):
|
||||
self.run_finalization('''
|
||||
let closed=0;
|
||||
assert.equal(await finalizeRetainedQueryReceipt({report,output,stdout,account,
|
||||
transport:{close:async()=>{closed++;}}}),true);
|
||||
assert.equal(closed,1);
|
||||
await assert.rejects(stat(account),{code:'ENOENT'});
|
||||
assert.deepEqual((await saved()).failure,originalFailure);
|
||||
assert.deepEqual(report.cleanup_failures,[]);
|
||||
''')
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js";
|
||||
import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js";
|
||||
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
|
||||
import { z } from "zod";
|
||||
@@ -608,7 +609,7 @@ export class EnhancedTools {
|
||||
address: current.address,
|
||||
status: "error",
|
||||
error: projectAnalysisError(result.error),
|
||||
});
|
||||
} satisfies NativeCallGraph["graph"][string][number]);
|
||||
continue;
|
||||
}
|
||||
const related = parseRelatedAddresses(result.value.result, relation);
|
||||
@@ -617,7 +618,7 @@ export class EnhancedTools {
|
||||
address: current.address,
|
||||
status: "error",
|
||||
error: projectAnalysisError(related.error),
|
||||
});
|
||||
} satisfies NativeCallGraph["graph"][string][number]);
|
||||
continue;
|
||||
}
|
||||
for (const limitation of result.value.limitations)
|
||||
@@ -654,7 +655,7 @@ export class EnhancedTools {
|
||||
...(nodeLimitations.length === 0
|
||||
? {}
|
||||
: { limitations: nodeLimitations }),
|
||||
});
|
||||
} satisfies NativeCallGraph["graph"][string][number]);
|
||||
for (const address of related.value) {
|
||||
if (!discovered.has(address)) {
|
||||
discovered.add(address);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isNativeObservationOperation } from "../../domain/native/nativeObservation.js";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { dispatchQueuedInterrupts } from "../../process/queuedInterrupts.js";
|
||||
import { readImmutableEvidenceBundle } from "../EvidenceBundleFiles.js";
|
||||
@@ -202,7 +203,9 @@ const projectResolvedAnalysisView = (
|
||||
evidenceId: parent.evidence_id,
|
||||
operation: parent.operation,
|
||||
provider: parent.provider,
|
||||
analysisProfileDigest: parent.analysis_profile?.digest ?? null,
|
||||
analysisProfile: parent.analysis_profile,
|
||||
authority: parent.authority,
|
||||
environment: parent.environment,
|
||||
normalizedResult:
|
||||
immutableJsonSnapshotMember(parent, "normalized_result") ??
|
||||
parent.normalized_result,
|
||||
@@ -372,6 +375,11 @@ const createAnalysisViewEvidence = (
|
||||
result.parent_operation === "analyze_function")
|
||||
? result
|
||||
: undefined;
|
||||
const nativeObservationView =
|
||||
result.kind === "native-observation" ||
|
||||
result.kind === "native-context" ||
|
||||
(result.kind === "summary" &&
|
||||
isNativeObservationOperation(result.parent_operation));
|
||||
return createEvidence(
|
||||
target,
|
||||
ANALYSIS_VIEW_PROVIDER,
|
||||
@@ -396,7 +404,7 @@ const createAnalysisViewEvidence = (
|
||||
: "derived",
|
||||
authority: parent.authority,
|
||||
limitations: result.limitations,
|
||||
...(nativeFunctionView !== undefined &&
|
||||
...((nativeFunctionView !== undefined || nativeObservationView) &&
|
||||
parent.relationship_coverage !== null
|
||||
? { relationshipCoverage: parent.relationship_coverage }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { nativeGraphViewItemsSchema } from "../../domain/analysisView/nativeObservationSchemas.js";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
createEvidence,
|
||||
rebindEvidenceToTargetPath,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
||||
import { ghidraFunctionIdentity } from "../../domain/ghidraValues.fixture.js";
|
||||
import {
|
||||
nativeCallGraphSchema,
|
||||
type NativeCallGraph,
|
||||
} from "../../domain/native/nativeCallGraph.js";
|
||||
import {
|
||||
analysisViewResultSchema,
|
||||
type AnalysisViewRequest,
|
||||
type AnalysisViewResult,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import { inspectAnalysisView } from "./AnalysisViewService.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture provider", version: "1" };
|
||||
const parent = (
|
||||
operation: string,
|
||||
result: unknown,
|
||||
limitations = ["Source caveat"],
|
||||
profileValue = 1,
|
||||
) =>
|
||||
createEvidence(
|
||||
{
|
||||
path: "/selected/fixture.elf",
|
||||
sha256: "a".repeat(64),
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
},
|
||||
provider,
|
||||
{
|
||||
operation,
|
||||
parameters: { procedure: "selected_symbol" },
|
||||
result,
|
||||
limitations,
|
||||
analysisProfile: createAnalysisProfile(provider, {
|
||||
profile: profileValue,
|
||||
}),
|
||||
...(operation === "procedure_callees"
|
||||
? {
|
||||
relationshipCoverage: {
|
||||
status: "partial" as const,
|
||||
limitations: ["Indirect calls remain unknown"],
|
||||
limited_directions: ["callees"] as ["callees"],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{ immutable: true },
|
||||
);
|
||||
const project = async (source: Evidence, view: AnalysisViewRequest) => {
|
||||
const result = await inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: source },
|
||||
view,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
return {
|
||||
evidence: result.value,
|
||||
value: analysisViewResultSchema.parse(result.value.normalized_result),
|
||||
};
|
||||
};
|
||||
const contextDigest = (value: AnalysisViewResult): string => {
|
||||
if (!("source_context" in value))
|
||||
throw new Error("Expected native source context");
|
||||
return value.source_context.digest;
|
||||
};
|
||||
const graph = (): NativeCallGraph =>
|
||||
nativeCallGraphSchema.parse({
|
||||
graph: {
|
||||
"2": [
|
||||
{
|
||||
address: "ram:0x0010",
|
||||
status: "ok",
|
||||
calls: ["ram:0x0020"],
|
||||
relationship_coverage: {
|
||||
status: "complete",
|
||||
diagnostics: ["Shared diagnostic 雪🧭"],
|
||||
},
|
||||
},
|
||||
],
|
||||
"10": [
|
||||
{
|
||||
address: "ram:0x0010",
|
||||
status: "ok",
|
||||
calls: [],
|
||||
limitations: [],
|
||||
relationship_coverage: {
|
||||
status: "unknown",
|
||||
reason: "Shared caveat",
|
||||
limited_directions: ["callers"],
|
||||
diagnostics: ["Shared diagnostic 雪🧭"],
|
||||
},
|
||||
},
|
||||
],
|
||||
"01": [
|
||||
{
|
||||
address: "ram:0x0010",
|
||||
status: "ok",
|
||||
calls: ["ram:0x0020", "ram:0x0020"],
|
||||
limitations: ["Shared caveat", "Shared caveat"],
|
||||
relationship_coverage: {
|
||||
status: "partial",
|
||||
limitations: ["Shared caveat", "Shared caveat"],
|
||||
limited_directions: ["callees"],
|
||||
diagnostics: ["Shared diagnostic 雪🧭"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
limitations: ["Graph caveat", "Graph caveat"],
|
||||
});
|
||||
|
||||
it("returns useful caller/callee facts with exact source context identity and partial coverage", async () => {
|
||||
const source = parent("procedure_callees", [
|
||||
"ram:0x0010",
|
||||
"ram:0x0010",
|
||||
"ram:0x0020",
|
||||
]);
|
||||
const before = JSON.stringify(source);
|
||||
const summary = await project(source, { kind: "summary" });
|
||||
expect(summary.value).toMatchObject({
|
||||
kind: "summary",
|
||||
parent_evidence_id: source.evidence_id,
|
||||
source_provider: source.provider,
|
||||
source_parameters: source.parameters,
|
||||
source_analysis_profile_digest: source.analysis_profile?.digest,
|
||||
source_context: {
|
||||
evidence_limitation_count: 1,
|
||||
result_limitation_count: null,
|
||||
},
|
||||
summary: { observations: 3 },
|
||||
items: {
|
||||
type: "addresses",
|
||||
rows: [
|
||||
{ source_index: 0, address: "ram:0x0010" },
|
||||
{ source_index: 1, address: "ram:0x0010" },
|
||||
{ source_index: 2, address: "ram:0x0020" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(summary.evidence.relationship_coverage).toEqual(
|
||||
source.relationship_coverage,
|
||||
);
|
||||
const page = await project(source, {
|
||||
kind: "native-observation",
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
});
|
||||
expect(page.value).toMatchObject({
|
||||
items: { rows: [{ source_index: 1, address: "ram:0x0010" }] },
|
||||
coverage: { total: 3, next_offset: 2, exhausted: false },
|
||||
});
|
||||
expect(page.value).toHaveProperty(
|
||||
"source_context.digest",
|
||||
contextDigest(summary.value),
|
||||
);
|
||||
expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":');
|
||||
expect(JSON.stringify(source)).toBe(before);
|
||||
});
|
||||
|
||||
it("pages complete context fields and changes its digest when source profile or caveats change", async () => {
|
||||
const source = parent("procedure_callers", []);
|
||||
const overview = await project(source, {
|
||||
kind: "native-context",
|
||||
facet: "overview",
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
expect(overview.value).toMatchObject({
|
||||
items: {
|
||||
type: "overview",
|
||||
rows: [
|
||||
{
|
||||
record: {
|
||||
subject: source.subject,
|
||||
provider,
|
||||
analysis_profile_digest: source.analysis_profile?.digest,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const profile = await project(source, {
|
||||
kind: "native-context",
|
||||
facet: "analysis-profile",
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
expect(profile.value).toMatchObject({
|
||||
items: { rows: [{ source_index: 0, record: source.analysis_profile }] },
|
||||
});
|
||||
const caveat = await project(source, {
|
||||
kind: "native-context",
|
||||
facet: "limitations",
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
expect(caveat.value).toMatchObject({
|
||||
items: { rows: [{ source_index: 0, text: "Source caveat" }] },
|
||||
});
|
||||
for (const changed of [
|
||||
parent("procedure_callers", [], ["Changed caveat"]),
|
||||
parent("procedure_callers", [], ["Source caveat"], 2),
|
||||
]) {
|
||||
const changedView = await project(changed, { kind: "summary" });
|
||||
expect(changedView.value).not.toHaveProperty(
|
||||
"source_context.digest",
|
||||
contextDigest(overview.value),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps absent profile and inapplicable result caveats distinct from observed empty data", async () => {
|
||||
const source = createEvidence(undefined, provider, {
|
||||
operation: "procedure_address",
|
||||
parameters: {},
|
||||
result: "0x0000",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await project(source, {
|
||||
kind: "native-context",
|
||||
facet: "analysis-profile",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
})
|
||||
).value,
|
||||
).toMatchObject({ items: { rows: [{ source_index: 0, record: null }] } });
|
||||
expect(
|
||||
(
|
||||
await project(source, {
|
||||
kind: "native-context",
|
||||
facet: "result-limitations",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
})
|
||||
).value,
|
||||
).toMatchObject({
|
||||
source_context: { result_limitation_count: null },
|
||||
items: { rows: null },
|
||||
unknowns: expect.arrayContaining([
|
||||
expect.stringContaining("not applicable"),
|
||||
]),
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await project(parent("procedure_callers", []), {
|
||||
kind: "native-observation",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
})
|
||||
).value,
|
||||
).toMatchObject({
|
||||
summary: { observations: 0 },
|
||||
items: { rows: [] },
|
||||
coverage: { status: "empty", exhausted: true },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"preserves the complete containing-procedure outcome (found=%s)",
|
||||
async (found) => {
|
||||
const report = found
|
||||
? {
|
||||
query_address: "0x401001",
|
||||
found: true,
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
}
|
||||
: {
|
||||
query_address: "0x9999",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
};
|
||||
const result = await project(
|
||||
parent("resolve_containing_procedure", report),
|
||||
{ kind: "summary" },
|
||||
);
|
||||
expect(result.value).toMatchObject({
|
||||
summary: { observations: 1 },
|
||||
items: { rows: [{ source_index: 0, record: report }] },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reconstructs every graph field from dictionary pages without changing nulls, duplicate messages or level identities", async () => {
|
||||
const report = graph();
|
||||
const source = parent("get_call_graph", report);
|
||||
const decoded: NativeCallGraph["graph"] = {};
|
||||
for (let offset = 0; offset < 3; offset++) {
|
||||
const { value } = await project(source, {
|
||||
kind: "native-observation",
|
||||
offset,
|
||||
limit: 1,
|
||||
});
|
||||
if (
|
||||
value.kind !== "native-observation" ||
|
||||
value.parent_operation !== "get_call_graph"
|
||||
)
|
||||
throw new Error("Expected graph page");
|
||||
const { rows, messages } = value.items;
|
||||
expect(rows).toHaveLength(1);
|
||||
const row = rows[0]!;
|
||||
expect(row.source_index).toBe(0);
|
||||
expect(row.level).toBe(["01", "10", "2"][offset]);
|
||||
expect(value.coverage).toMatchObject({ total: 3, exhausted: offset === 2 });
|
||||
if (row.status !== "ok") throw new Error("Expected recorded node");
|
||||
const coverage = row.relationship_coverage;
|
||||
const diagnostics =
|
||||
coverage.diagnostic_ids === undefined
|
||||
? {}
|
||||
: { diagnostics: coverage.diagnostic_ids.map((i) => messages[i]!) };
|
||||
const directions =
|
||||
"limited_directions" in coverage &&
|
||||
coverage.limited_directions !== undefined
|
||||
? { limited_directions: coverage.limited_directions }
|
||||
: {};
|
||||
const originalCoverage =
|
||||
coverage.status === "complete"
|
||||
? { status: "complete" as const, ...diagnostics }
|
||||
: coverage.status === "partial"
|
||||
? {
|
||||
status: "partial" as const,
|
||||
limitations: coverage.limitation_ids.map((i) => messages[i]!),
|
||||
...directions,
|
||||
...diagnostics,
|
||||
}
|
||||
: {
|
||||
status: "unknown" as const,
|
||||
reason: messages[coverage.reason_id]!,
|
||||
...directions,
|
||||
...diagnostics,
|
||||
};
|
||||
decoded[row.level] = [
|
||||
{
|
||||
address: row.address,
|
||||
status: row.status,
|
||||
calls: row.calls,
|
||||
relationship_coverage: originalCoverage,
|
||||
...(row.limitation_ids === undefined
|
||||
? {}
|
||||
: { limitations: row.limitation_ids.map((i) => messages[i]!) }),
|
||||
},
|
||||
];
|
||||
expect(messages).toHaveLength(coverage.status === "complete" ? 1 : 2);
|
||||
}
|
||||
expect(decoded).toEqual(report.graph);
|
||||
const caveats = await project(source, {
|
||||
kind: "native-context",
|
||||
facet: "result-limitations",
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
});
|
||||
expect(caveats.value).toMatchObject({
|
||||
source_context: { result_limitation_count: 2 },
|
||||
items: { rows: [{ source_index: 1, text: "Graph caveat" }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed unselected source facts and preserves exact source paths", async () => {
|
||||
const malformed = {
|
||||
...graph(),
|
||||
graph: {
|
||||
"0": [
|
||||
{
|
||||
address: "0x10",
|
||||
status: "ok",
|
||||
calls: [],
|
||||
relationship_coverage: { status: "complete" },
|
||||
},
|
||||
{
|
||||
address: "0x20",
|
||||
status: "ok",
|
||||
calls: [42],
|
||||
relationship_coverage: { status: "complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = await inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: parent("get_call_graph", malformed) },
|
||||
view: { kind: "native-observation", offset: 0, limit: 1 },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: ["source", "normalized_result", "graph", "0", 1, "calls", 0],
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retains failed graph nodes and rejects broken message references", async () => {
|
||||
const source = graph();
|
||||
const failure = {
|
||||
address: "ram:0x0010",
|
||||
status: "error" as const,
|
||||
error: projectAnalysisError(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"fixture",
|
||||
"procedure_callees",
|
||||
"No call references",
|
||||
),
|
||||
),
|
||||
};
|
||||
source.graph.failure = [failure];
|
||||
const result = await project(parent("get_call_graph", source), {
|
||||
kind: "native-observation",
|
||||
offset: 3,
|
||||
limit: 1,
|
||||
});
|
||||
expect(result.value).toMatchObject({
|
||||
items: {
|
||||
type: "graph-nodes",
|
||||
messages: [],
|
||||
rows: [{ level: "failure", source_index: 0, ...failure }],
|
||||
},
|
||||
coverage: { total: 4, exhausted: true },
|
||||
});
|
||||
const normal = await project(parent("get_call_graph", graph()), {
|
||||
kind: "native-observation",
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
if (
|
||||
normal.value.kind !== "native-observation" ||
|
||||
normal.value.parent_operation !== "get_call_graph"
|
||||
)
|
||||
throw new Error("Expected graph page");
|
||||
expect(
|
||||
nativeGraphViewItemsSchema.safeParse({
|
||||
...normal.value.items,
|
||||
messages: [],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps graph row identity stable when source object key insertion order changes", async () => {
|
||||
const report = graph();
|
||||
report.graph.beta = [report.graph["2"]![0]!];
|
||||
const first = parent("get_call_graph", report);
|
||||
const reversed = parent("get_call_graph", {
|
||||
...report,
|
||||
graph: Object.fromEntries(Object.entries(report.graph).reverse()),
|
||||
});
|
||||
expect(Object.keys(report.graph)).not.toEqual(
|
||||
Object.keys(Object.fromEntries(Object.entries(report.graph).reverse())),
|
||||
);
|
||||
expect(first.evidence_id).toBe(reversed.evidence_id);
|
||||
expect(
|
||||
await project(first, { kind: "native-observation", offset: 0, limit: 64 }),
|
||||
).toEqual(
|
||||
await project(reversed, {
|
||||
kind: "native-observation",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps context identity across authenticated target path aliases while preserving selected paths", async () => {
|
||||
const original = parent("procedure_callers", []);
|
||||
const rebound = rebindEvidenceToTargetPath(original, {
|
||||
path: "/other/alias.elf",
|
||||
sha256: "a".repeat(64),
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
});
|
||||
expect(rebound.evidence_id).toBe(original.evidence_id);
|
||||
const view = {
|
||||
kind: "native-context" as const,
|
||||
facet: "overview" as const,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
};
|
||||
const first = await project(original, view);
|
||||
const second = await project(rebound, view);
|
||||
expect(contextDigest(second.value)).toBe(contextDigest(first.value));
|
||||
expect(second.value).toMatchObject({
|
||||
items: {
|
||||
rows: [{ record: { subject: { local_path: "/other/alias.elf" } } }],
|
||||
},
|
||||
});
|
||||
expect(second.evidence.subject?.local_path).toBe("/other/alias.elf");
|
||||
expect(second.evidence.evidence_id).not.toBe(first.evidence.evidence_id);
|
||||
});
|
||||
|
||||
it("pages recorded empty graph levels without inventing nodes or dropping their identity", async () => {
|
||||
const report = { graph: { absent: [], empty: [] }, limitations: [] };
|
||||
const result = await project(parent("get_call_graph", report), {
|
||||
kind: "native-observation",
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
});
|
||||
expect(result.value).toMatchObject({
|
||||
items: {
|
||||
messages: [],
|
||||
rows: [{ level: "empty", source_index: null, status: "empty-level" }],
|
||||
},
|
||||
summary: { observations: 2 },
|
||||
coverage: { total: 2, exhausted: true },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "partial" as const, limitations: ["Unresolved indirect edges"] },
|
||||
{ status: "unknown" as const, reason: "Provider did not establish coverage" },
|
||||
])(
|
||||
"keeps $status relationship coverage on every context facet",
|
||||
async (coverage) => {
|
||||
const source = createEvidence(undefined, provider, {
|
||||
operation: "procedure_callees",
|
||||
parameters: {},
|
||||
result: [],
|
||||
relationshipCoverage: coverage,
|
||||
});
|
||||
for (const facet of [
|
||||
"overview",
|
||||
"analysis-profile",
|
||||
"limitations",
|
||||
"result-limitations",
|
||||
] as const) {
|
||||
const result = await project(source, {
|
||||
kind: "native-context",
|
||||
facet,
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
expect(result.evidence.relationship_coverage).toEqual(coverage);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { analysisDetailSchema } from "./analysisDetail.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { enhancedInputSchemas } from "./enhancedInputs.js";
|
||||
@@ -53,8 +54,10 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
||||
),
|
||||
enhanced(
|
||||
"get_call_graph",
|
||||
"Resolve one symbol or address to its canonical procedure entry, then traverse the bound provider's caller or callee relationships until the reachable graph is exhausted. Every node uses a canonical address and has an ok/error status; failures use safe typed projections. Unresolved indirect calls may be missing and results are not a whole-program CFG.",
|
||||
enhancedInputSchemas.get_call_graph,
|
||||
"Resolve one symbol or address to its canonical procedure entry, then traverse the bound provider's caller or callee relationships until the reachable graph is exhausted. Every node uses a canonical address and has an ok/error status; failures use safe typed projections. Unresolved indirect calls may be missing and results are not a whole-program CFG. Select detail summary for a bounded first node page with shared message references; native-observation pages and native-context fields retain complete source access.",
|
||||
enhancedInputSchemas.get_call_graph.extend({
|
||||
detail: analysisDetailSchema,
|
||||
}),
|
||||
),
|
||||
enhanced(
|
||||
"analyze_swift_types",
|
||||
|
||||
@@ -152,8 +152,8 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"procedure_address",
|
||||
"Resolve an unambiguous procedure symbol name or provider-normalized address to its canonical entry address. External address spaces remain explicit.",
|
||||
z.object({ procedure, document }),
|
||||
"Resolve an unambiguous procedure symbol name or provider-normalized address to its canonical entry address. External address spaces remain explicit. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
|
||||
z.object({ procedure, document, detail: analysisDetailSchema }),
|
||||
),
|
||||
official(
|
||||
"procedure_assembly",
|
||||
@@ -162,13 +162,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"procedure_callees",
|
||||
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
|
||||
z.object({ procedure, document }),
|
||||
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
|
||||
z.object({ procedure, document, detail: analysisDetailSchema }),
|
||||
),
|
||||
official(
|
||||
"procedure_callers",
|
||||
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
|
||||
z.object({ procedure, document }),
|
||||
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
|
||||
z.object({ procedure, document, detail: analysisDetailSchema }),
|
||||
),
|
||||
official(
|
||||
"procedure_info",
|
||||
@@ -199,8 +199,8 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"resolve_containing_procedure",
|
||||
"Resolve an arbitrary address, including an interior instruction or exact external entry, to its provider-analyzed containing procedure. A negative result distinguishes outside segments from not in a procedure and is never guessed from nearby symbols.",
|
||||
z.object({ address, document }),
|
||||
"Resolve an arbitrary address, including an interior instruction or exact external entry, to its provider-analyzed containing procedure. A negative result distinguishes outside segments from not in a procedure and is never guessed from nearby symbols. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
|
||||
z.object({ address, document, detail: analysisDetailSchema }),
|
||||
),
|
||||
official(
|
||||
"search_procedures",
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
nativeAddressListSchema,
|
||||
nativeObservationSourceSchemas,
|
||||
} from "../domain/native/nativeObservation.js";
|
||||
import { nativeObservationSummaryViewResultSchemas } from "../domain/analysisView/analysisView.js";
|
||||
import { literalFeatureMatchSchema } from "../domain/native/literalFeatureMatch.js";
|
||||
import { byteEntropySchema } from "../domain/native/byteEntropy.js";
|
||||
import {
|
||||
@@ -88,10 +93,8 @@ import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
|
||||
import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js";
|
||||
import { nativeInvestigationTraceSchema } from "../domain/native/nativeInvestigationGraph.js";
|
||||
import {
|
||||
addressList,
|
||||
addressedEntry,
|
||||
functionDossierOutput,
|
||||
graphNode,
|
||||
lifecycleResultOf,
|
||||
nullableText,
|
||||
procedureInfoOutput,
|
||||
@@ -168,10 +171,19 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
),
|
||||
next_address: evidenceResultOf(z.string()),
|
||||
prev_address: evidenceResultOf(z.string()),
|
||||
procedure_address: evidenceResultOf(z.string()),
|
||||
procedure_address: analysisDetailOutputSchemaOf(
|
||||
nativeObservationSourceSchemas.procedure_address,
|
||||
nativeObservationSummaryViewResultSchemas.procedure_address,
|
||||
),
|
||||
procedure_assembly: evidenceResultOf(z.string()),
|
||||
procedure_callees: evidenceResultOf(addressList),
|
||||
procedure_callers: evidenceResultOf(addressList),
|
||||
procedure_callees: analysisDetailOutputSchemaOf(
|
||||
nativeObservationSourceSchemas.procedure_callees,
|
||||
nativeObservationSummaryViewResultSchemas.procedure_callees,
|
||||
),
|
||||
procedure_callers: analysisDetailOutputSchemaOf(
|
||||
nativeObservationSourceSchemas.procedure_callers,
|
||||
nativeObservationSummaryViewResultSchemas.procedure_callers,
|
||||
),
|
||||
procedure_info: procedureInfoOutput,
|
||||
read_function_instructions: evidenceResultOf(functionInstructionWindowSchema),
|
||||
read_bytes: evidenceResultOf(nativeByteReadSchema),
|
||||
@@ -213,8 +225,9 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
}),
|
||||
),
|
||||
procedure_pseudo_code: evidenceResultOf(nullableText),
|
||||
resolve_containing_procedure: evidenceResultOf(
|
||||
containingProcedureResolutionSchema,
|
||||
resolve_containing_procedure: analysisDetailOutputSchemaOf(
|
||||
nativeObservationSourceSchemas.resolve_containing_procedure,
|
||||
nativeObservationSummaryViewResultSchemas.resolve_containing_procedure,
|
||||
),
|
||||
search_procedures: analysisDetailOutputSchemaOf(
|
||||
nativeInventoryRowsSchemas.names,
|
||||
@@ -235,7 +248,7 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
set_comment: evidenceResultOf(z.boolean()),
|
||||
set_inline_comment: evidenceResultOf(z.boolean()),
|
||||
unset_bookmark: evidenceResultOf(z.boolean()),
|
||||
xrefs: evidenceResultOf(addressList),
|
||||
xrefs: evidenceResultOf(nativeAddressListSchema),
|
||||
};
|
||||
|
||||
const literalTraceOutput = evidenceResultOf(
|
||||
@@ -345,11 +358,9 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
failed: z.int().min(0),
|
||||
}),
|
||||
),
|
||||
get_call_graph: evidenceResultOf(
|
||||
z.strictObject({
|
||||
graph: z.record(z.string(), z.array(graphNode)),
|
||||
limitations: z.array(z.string()),
|
||||
}),
|
||||
get_call_graph: analysisDetailOutputSchemaOf(
|
||||
nativeObservationSourceSchemas.get_call_graph,
|
||||
nativeObservationSummaryViewResultSchemas.get_call_graph,
|
||||
),
|
||||
analyze_swift_types: evidenceResultOf(
|
||||
z.object({
|
||||
@@ -381,7 +392,7 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
status: z.literal("resolved"),
|
||||
name: z.string(),
|
||||
address: z.string(),
|
||||
xrefs: addressList,
|
||||
xrefs: nativeAddressListSchema,
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("unresolved"),
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
PROVIDER_RETRY_ACTIONS,
|
||||
} from "../domain/providerOperationHealth.js";
|
||||
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
|
||||
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
|
||||
import { ZIP_PACKAGE_FORMATS } from "../domain/zipPackageFormat.js";
|
||||
import { objcClassImportIdentitySchema } from "../domain/native/objcSwiftMetadata.js";
|
||||
import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "./sessionStatusContract.js";
|
||||
@@ -659,7 +658,6 @@ export const sessionProvider = z.discriminatedUnion("detail", [
|
||||
]);
|
||||
|
||||
export const nullableText = z.string().nullable();
|
||||
export const addressList = z.array(z.string());
|
||||
export const addressedEntry = z.object({
|
||||
address: z.string(),
|
||||
name: z.string(),
|
||||
@@ -729,19 +727,4 @@ export const objcClassDiscoveryOutput = evidenceResultOf(
|
||||
}),
|
||||
);
|
||||
|
||||
export const graphNode = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
address: z.string(),
|
||||
status: z.literal("ok"),
|
||||
calls: z.array(z.string()),
|
||||
relationship_coverage: relationshipCoverageSchema,
|
||||
limitations: z.array(z.string()).optional(),
|
||||
}),
|
||||
z.object({
|
||||
address: z.string(),
|
||||
status: z.literal("error"),
|
||||
error: analysisErrorProjectionSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const functionDossierOutput = evidenceResultOf(functionDossierSchema);
|
||||
@@ -1,3 +1,16 @@
|
||||
import {
|
||||
nativeObservationViewRequestSchema,
|
||||
nativeContextViewRequestSchema,
|
||||
nativeObservationItemsSchemas,
|
||||
nativeContextItemsSchemas,
|
||||
nativeSourceContextReferenceSchema,
|
||||
nativeObservationOperationSchema,
|
||||
} from "./nativeObservationSchemas.js";
|
||||
import {
|
||||
NATIVE_OBSERVATION_OPERATIONS,
|
||||
type NativeObservationOperation,
|
||||
} from "../native/nativeObservation.js";
|
||||
import { projectNativeObservationView } from "./nativeObservationView.js";
|
||||
import {
|
||||
devicetreeViewRequestSchema,
|
||||
devicetreeViewItemsSchema,
|
||||
@@ -375,6 +388,8 @@ const nativeViewSchema = z.strictObject({
|
||||
|
||||
/** Caller-selected projection of already completed analysis Evidence. */
|
||||
export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
||||
nativeObservationViewRequestSchema,
|
||||
nativeContextViewRequestSchema,
|
||||
devicetreeViewRequestSchema,
|
||||
erlangBeamViewRequestSchema,
|
||||
pythonPycViewRequestSchema,
|
||||
@@ -534,6 +549,7 @@ const viewResultBase = {
|
||||
"inspect_model_artifact",
|
||||
"inspect_gpu_code_objects",
|
||||
...NATIVE_INVENTORY_OPERATIONS,
|
||||
...NATIVE_OBSERVATION_OPERATIONS,
|
||||
"match_native_functions",
|
||||
]),
|
||||
parent_digest: digestSchema,
|
||||
@@ -550,6 +566,79 @@ const summaryViewResultBase = {
|
||||
...viewResultBase,
|
||||
} as const;
|
||||
|
||||
const nativeObservationSummaryFor = <
|
||||
Operation extends NativeObservationOperation,
|
||||
>(
|
||||
operation: Operation,
|
||||
) =>
|
||||
z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
...nativeViewSourceContext,
|
||||
artifact: artifactSchema.nullable(),
|
||||
parent_operation: z.literal(operation),
|
||||
source_context: nativeSourceContextReferenceSchema,
|
||||
summary: z.strictObject({ observations: z.int().nonnegative() }),
|
||||
items: nativeObservationItemsSchemas[operation],
|
||||
});
|
||||
export const nativeObservationSummaryViewResultSchemas = {
|
||||
procedure_address: nativeObservationSummaryFor("procedure_address"),
|
||||
procedure_callees: nativeObservationSummaryFor("procedure_callees"),
|
||||
procedure_callers: nativeObservationSummaryFor("procedure_callers"),
|
||||
resolve_containing_procedure: nativeObservationSummaryFor(
|
||||
"resolve_containing_procedure",
|
||||
),
|
||||
get_call_graph: nativeObservationSummaryFor("get_call_graph"),
|
||||
};
|
||||
const nativeObservationSummaryViewResultSchema = z.discriminatedUnion(
|
||||
"parent_operation",
|
||||
[
|
||||
nativeObservationSummaryViewResultSchemas.procedure_address,
|
||||
nativeObservationSummaryViewResultSchemas.procedure_callees,
|
||||
nativeObservationSummaryViewResultSchemas.procedure_callers,
|
||||
nativeObservationSummaryViewResultSchemas.resolve_containing_procedure,
|
||||
nativeObservationSummaryViewResultSchemas.get_call_graph,
|
||||
],
|
||||
);
|
||||
const nativeObservationPageFor = <Operation extends NativeObservationOperation>(
|
||||
operation: Operation,
|
||||
) =>
|
||||
nativeObservationSummaryFor(operation)
|
||||
.omit({ kind: true, view: true })
|
||||
.extend({
|
||||
kind: z.literal("native-observation"),
|
||||
view: nativeObservationViewRequestSchema,
|
||||
});
|
||||
const nativeObservationPageViewResultSchema = z.discriminatedUnion(
|
||||
"parent_operation",
|
||||
[
|
||||
nativeObservationPageFor("procedure_address"),
|
||||
nativeObservationPageFor("procedure_callees"),
|
||||
nativeObservationPageFor("procedure_callers"),
|
||||
nativeObservationPageFor("resolve_containing_procedure"),
|
||||
nativeObservationPageFor("get_call_graph"),
|
||||
],
|
||||
);
|
||||
const nativeContextFor = <Facet extends keyof typeof nativeContextItemsSchemas>(
|
||||
facet: Facet,
|
||||
) =>
|
||||
z.strictObject({
|
||||
...viewResultBase,
|
||||
...nativeViewSourceContext,
|
||||
kind: z.literal("native-context"),
|
||||
artifact: artifactSchema.nullable(),
|
||||
parent_operation: nativeObservationOperationSchema,
|
||||
source_context: nativeSourceContextReferenceSchema,
|
||||
facet: z.literal(facet),
|
||||
view: nativeContextViewRequestSchema.extend({ facet: z.literal(facet) }),
|
||||
items: nativeContextItemsSchemas[facet],
|
||||
});
|
||||
const nativeContextViewResultSchema = z.discriminatedUnion("facet", [
|
||||
nativeContextFor("overview"),
|
||||
nativeContextFor("analysis-profile"),
|
||||
nativeContextFor("limitations"),
|
||||
nativeContextFor("result-limitations"),
|
||||
]);
|
||||
|
||||
export const byteEntropySummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
summary: byteEntropySummarySchema,
|
||||
@@ -980,6 +1069,8 @@ export const sourceToBundlePageViewResultSchema = z.strictObject({
|
||||
|
||||
/** Projected facts for one selected view of retained analysis Evidence. */
|
||||
export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
nativeObservationPageViewResultSchema,
|
||||
nativeContextViewResultSchema,
|
||||
javaScriptRoutesViewResultSchema,
|
||||
sourceToBundlePageViewResultSchema,
|
||||
z.strictObject({
|
||||
@@ -1033,6 +1124,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
view: environmentCoverageViewRequestSchema,
|
||||
}),
|
||||
z.discriminatedUnion("parent_operation", [
|
||||
nativeObservationSummaryViewResultSchema,
|
||||
erlangBeamSummaryViewResultSchema,
|
||||
devicetreeSummaryViewResultSchema,
|
||||
pythonPycSummaryViewResultSchema,
|
||||
@@ -1286,7 +1378,9 @@ export interface AnalysisViewParent {
|
||||
readonly operation: string;
|
||||
readonly normalizedResult: unknown;
|
||||
readonly provider?: Evidence["provider"];
|
||||
readonly analysisProfileDigest?: string | null;
|
||||
readonly analysisProfile?: Evidence["analysis_profile"];
|
||||
readonly authority?: Evidence["authority"];
|
||||
readonly environment?: Evidence["environment"];
|
||||
/** The owner proved normalizedResult belongs to a sealed, deep-frozen Evidence snapshot. */
|
||||
readonly sealedResult?: boolean;
|
||||
/** Preserve the authenticated source identity, including explicit absence. */
|
||||
@@ -1362,6 +1456,9 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
|
||||
/** Named source owners for views that need no extra decoder or accumulator. */
|
||||
const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
||||
...NATIVE_OBSERVATION_OPERATIONS.map(
|
||||
(operation) => [operation, projectNativeObservationView] as const,
|
||||
),
|
||||
["inspect_erlang_beam", projectErlangBeamView],
|
||||
["inspect_devicetree", projectDevicetreeView],
|
||||
["inspect_native_exports", projectNativeExportsView],
|
||||
|
||||
@@ -96,7 +96,7 @@ export const projectCommandObservationView = (
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact,
|
||||
source_provider: parent.provider ?? null,
|
||||
source_analysis_profile_digest: parent.analysisProfileDigest ?? null,
|
||||
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
|
||||
source_parameters: parameters.data,
|
||||
coverage: completeWithinViewCoverage(1, 1),
|
||||
limitations: boundedViewMessages(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { z } from "zod";
|
||||
import type { NativeCallGraph } from "../native/nativeCallGraph.js";
|
||||
import type { RelationshipCoverage } from "../relationshipCoverage.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
import {
|
||||
nativeGraphViewItemsSchema,
|
||||
referencedRelationshipCoverageSchema,
|
||||
} from "./nativeObservationSchemas.js";
|
||||
|
||||
type GraphItems = z.output<typeof nativeGraphViewItemsSchema>;
|
||||
type Coverage = z.output<typeof referencedRelationshipCoverageSchema>;
|
||||
|
||||
const referenceCoverage = (
|
||||
source: RelationshipCoverage,
|
||||
intern: (message: string) => number,
|
||||
): Coverage => {
|
||||
const diagnostic =
|
||||
source.diagnostics === undefined
|
||||
? {}
|
||||
: { diagnostic_ids: source.diagnostics.map(intern) };
|
||||
if (source.status === "complete")
|
||||
return { status: "complete", ...diagnostic };
|
||||
const directions =
|
||||
source.limited_directions === undefined
|
||||
? {}
|
||||
: { limited_directions: source.limited_directions };
|
||||
return source.status === "partial"
|
||||
? {
|
||||
status: "partial",
|
||||
limitation_ids: source.limitations.map(intern),
|
||||
...directions,
|
||||
...diagnostic,
|
||||
}
|
||||
: {
|
||||
status: "unknown",
|
||||
reason_id: intern(source.reason),
|
||||
...directions,
|
||||
...diagnostic,
|
||||
};
|
||||
};
|
||||
|
||||
/** Object levels have canonical key order; row identity is (parent, level, source_index). */
|
||||
export const projectNativeGraphRows = (
|
||||
source: NativeCallGraph,
|
||||
view: { readonly offset: number; readonly limit: number },
|
||||
): { items: GraphItems; total: number } => {
|
||||
const messages: string[] = [];
|
||||
const known = new Map<string, number>();
|
||||
const intern = (message: string): number => {
|
||||
const prior = known.get(message);
|
||||
if (prior !== undefined) return prior;
|
||||
const id = messages.length;
|
||||
messages.push(message);
|
||||
known.set(message, id);
|
||||
return id;
|
||||
};
|
||||
const rows: GraphItems["rows"] = [];
|
||||
let total = 0;
|
||||
for (const level of Object.keys(source.graph).sort(
|
||||
compareUnicodeCodePoints,
|
||||
)) {
|
||||
const nodes = source.graph[level]!;
|
||||
if (nodes.length === 0) {
|
||||
if (total >= view.offset && rows.length < view.limit)
|
||||
rows.push({ level, source_index: null, status: "empty-level" });
|
||||
total++;
|
||||
continue;
|
||||
}
|
||||
const begin = Math.max(0, view.offset - total);
|
||||
const end = Math.min(
|
||||
nodes.length,
|
||||
begin + Math.max(0, view.limit - rows.length),
|
||||
);
|
||||
for (let index = begin; index < end; index++) {
|
||||
const node = nodes[index]!;
|
||||
rows.push(
|
||||
node.status === "error"
|
||||
? { level, source_index: index, ...node }
|
||||
: {
|
||||
level,
|
||||
source_index: index,
|
||||
address: node.address,
|
||||
status: node.status,
|
||||
calls: node.calls,
|
||||
relationship_coverage: referenceCoverage(
|
||||
node.relationship_coverage,
|
||||
intern,
|
||||
),
|
||||
...(node.limitations === undefined
|
||||
? {}
|
||||
: { limitation_ids: node.limitations.map(intern) }),
|
||||
},
|
||||
);
|
||||
}
|
||||
total += nodes.length;
|
||||
}
|
||||
return { items: { type: "graph-nodes", messages, rows }, total };
|
||||
};
|
||||
@@ -116,7 +116,7 @@ export const projectNativeInventoryView = (
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: parent.artifact ?? null,
|
||||
source_provider: parent.provider ?? null,
|
||||
source_analysis_profile_digest: parent.analysisProfileDigest ?? null,
|
||||
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
|
||||
source_parameters: jsonValueSchema.parse(parent.parameters ?? null),
|
||||
limitations: boundedViewMessages(
|
||||
parent.limitations,
|
||||
|
||||
@@ -241,7 +241,7 @@ export const projectNativeLoadImageView = (
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: parent.artifact ?? null,
|
||||
source_provider: parent.provider ?? null,
|
||||
source_analysis_profile_digest: parent.analysisProfileDigest ?? null,
|
||||
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
|
||||
source_parameters: jsonValueSchema.parse(parent.parameters ?? null),
|
||||
limitations: boundedViewMessages(
|
||||
[...parent.limitations, ...limitations],
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { NATIVE_OBSERVATION_OPERATIONS } from "../native/nativeObservation.js";
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { evidenceSchema } from "../evidence.js";
|
||||
import { containingProcedureResolutionSchema } from "../hopperValues.js";
|
||||
import {
|
||||
completeRelationshipCoverageSchema,
|
||||
partialRelationshipCoverageSchema,
|
||||
unknownRelationshipCoverageSchema,
|
||||
} from "../relationshipCoverage.js";
|
||||
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
|
||||
|
||||
export const nativeObservationOperationSchema = z.enum(
|
||||
NATIVE_OBSERVATION_OPERATIONS,
|
||||
);
|
||||
const paging = {
|
||||
offset: z.int().nonnegative().default(0),
|
||||
limit: z.int().positive().default(64),
|
||||
};
|
||||
export const nativeObservationViewRequestSchema = z.strictObject({
|
||||
kind: z.literal("native-observation"),
|
||||
...paging,
|
||||
});
|
||||
export const nativeContextViewRequestSchema = z.strictObject({
|
||||
kind: z.literal("native-context"),
|
||||
facet: z
|
||||
.enum(["overview", "analysis-profile", "limitations", "result-limitations"])
|
||||
.default("overview"),
|
||||
...paging,
|
||||
});
|
||||
export const nativeSourceContextSchema = z.strictObject({
|
||||
subject: evidenceSchema.shape.subject,
|
||||
provider: evidenceSchema.shape.provider,
|
||||
analysis_profile: evidenceSchema.shape.analysis_profile,
|
||||
authority: evidenceSchema.shape.authority,
|
||||
environment: evidenceSchema.shape.environment,
|
||||
limitations: evidenceSchema.shape.limitations,
|
||||
result_limitations: z.array(z.string()).nullable(),
|
||||
});
|
||||
export type NativeSourceContext = z.infer<typeof nativeSourceContextSchema>;
|
||||
export const nativeSourceContextReferenceSchema = z.strictObject({
|
||||
materialization: z.literal("reference"),
|
||||
digest: digestSchema,
|
||||
evidence_limitation_count: z.int().nonnegative(),
|
||||
result_limitation_count: z.int().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
const sourceIndex = z.int().nonnegative();
|
||||
const ids = z.array(sourceIndex);
|
||||
const diagnostics = ids.min(1).exactOptional();
|
||||
export const referencedRelationshipCoverageSchema = z.discriminatedUnion(
|
||||
"status",
|
||||
[
|
||||
completeRelationshipCoverageSchema
|
||||
.omit({ diagnostics: true })
|
||||
.extend({ diagnostic_ids: diagnostics }),
|
||||
partialRelationshipCoverageSchema
|
||||
.omit({ diagnostics: true, limitations: true })
|
||||
.extend({ limitation_ids: ids.min(1), diagnostic_ids: diagnostics }),
|
||||
unknownRelationshipCoverageSchema
|
||||
.omit({ diagnostics: true, reason: true })
|
||||
.extend({ reason_id: sourceIndex, diagnostic_ids: diagnostics }),
|
||||
],
|
||||
);
|
||||
const graphRowBase = {
|
||||
level: z.string(),
|
||||
source_index: sourceIndex,
|
||||
address: z.string(),
|
||||
};
|
||||
const graphRowsSchema = z.array(
|
||||
z.discriminatedUnion("status", [
|
||||
z.strictObject({
|
||||
level: z.string(),
|
||||
source_index: z.null(),
|
||||
status: z.literal("empty-level"),
|
||||
}),
|
||||
z.strictObject({
|
||||
...graphRowBase,
|
||||
status: z.literal("ok"),
|
||||
calls: z.array(z.string()),
|
||||
relationship_coverage: referencedRelationshipCoverageSchema,
|
||||
limitation_ids: ids.exactOptional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...graphRowBase,
|
||||
status: z.literal("error"),
|
||||
error: analysisErrorProjectionSchema,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
export const nativeGraphViewItemsSchema = z
|
||||
.strictObject({
|
||||
type: z.literal("graph-nodes"),
|
||||
messages: z.array(z.string()),
|
||||
rows: graphRowsSchema,
|
||||
})
|
||||
.superRefine((items, context) => {
|
||||
const valid = (value: number, path: PropertyKey[]) => {
|
||||
if (value >= items.messages.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path,
|
||||
message: "Message reference is outside this page's dictionary",
|
||||
});
|
||||
};
|
||||
items.rows.forEach((row, index) => {
|
||||
if (row.status !== "ok") return;
|
||||
row.limitation_ids?.forEach((value, i) =>
|
||||
valid(value, ["rows", index, "limitation_ids", i]),
|
||||
);
|
||||
const coverage = row.relationship_coverage;
|
||||
coverage.diagnostic_ids?.forEach((value, i) =>
|
||||
valid(value, [
|
||||
"rows",
|
||||
index,
|
||||
"relationship_coverage",
|
||||
"diagnostic_ids",
|
||||
i,
|
||||
]),
|
||||
);
|
||||
if (coverage.status === "partial")
|
||||
coverage.limitation_ids.forEach((value, i) =>
|
||||
valid(value, [
|
||||
"rows",
|
||||
index,
|
||||
"relationship_coverage",
|
||||
"limitation_ids",
|
||||
i,
|
||||
]),
|
||||
);
|
||||
if (coverage.status === "unknown")
|
||||
valid(coverage.reason_id, [
|
||||
"rows",
|
||||
index,
|
||||
"relationship_coverage",
|
||||
"reason_id",
|
||||
]);
|
||||
});
|
||||
});
|
||||
const addresses = z.strictObject({
|
||||
type: z.literal("addresses"),
|
||||
rows: z.array(
|
||||
z.strictObject({ source_index: sourceIndex, address: z.string() }),
|
||||
),
|
||||
});
|
||||
const resolutions = z.strictObject({
|
||||
type: z.literal("resolutions"),
|
||||
rows: z.array(
|
||||
z.strictObject({
|
||||
source_index: sourceIndex,
|
||||
record: containingProcedureResolutionSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
export const nativeObservationItemsSchemas = {
|
||||
procedure_address: addresses,
|
||||
procedure_callees: addresses,
|
||||
procedure_callers: addresses,
|
||||
resolve_containing_procedure: resolutions,
|
||||
get_call_graph: nativeGraphViewItemsSchema,
|
||||
};
|
||||
export const nativeContextItemsSchemas = {
|
||||
overview: z.strictObject({
|
||||
type: z.literal("overview"),
|
||||
rows: z.array(
|
||||
z.strictObject({
|
||||
source_index: sourceIndex,
|
||||
record: nativeSourceContextSchema
|
||||
.omit({
|
||||
analysis_profile: true,
|
||||
limitations: true,
|
||||
result_limitations: true,
|
||||
})
|
||||
.extend({ analysis_profile_digest: digestSchema.nullable() }),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
"analysis-profile": z.strictObject({
|
||||
type: z.literal("analysis-profile"),
|
||||
rows: z.array(
|
||||
z.strictObject({
|
||||
source_index: sourceIndex,
|
||||
record: evidenceSchema.shape.analysis_profile,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
limitations: z.strictObject({
|
||||
type: z.literal("limitations"),
|
||||
rows: z.array(
|
||||
z.strictObject({ source_index: sourceIndex, text: z.string() }),
|
||||
),
|
||||
}),
|
||||
"result-limitations": z.strictObject({
|
||||
type: z.literal("result-limitations"),
|
||||
rows: z
|
||||
.array(z.strictObject({ source_index: sourceIndex, text: z.string() }))
|
||||
.nullable(),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { evidenceSubjectIdentity } from "../evidence.js";
|
||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import {
|
||||
nativeAddressListSchema,
|
||||
nativeObservationSourceSchemas,
|
||||
type NativeObservation,
|
||||
} from "../native/nativeObservation.js";
|
||||
import type {
|
||||
AnalysisViewParent,
|
||||
AnalysisViewRequest,
|
||||
UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import {
|
||||
pageViewCoverage,
|
||||
completeWithinViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
import {
|
||||
nativeSourceContextSchema,
|
||||
type NativeSourceContext,
|
||||
} from "./nativeObservationSchemas.js";
|
||||
import { projectNativeGraphRows } from "./nativeGraphView.js";
|
||||
|
||||
const readAddresses = createAnalysisViewResultReader(nativeAddressListSchema);
|
||||
const readAddress = createAnalysisViewResultReader(
|
||||
nativeObservationSourceSchemas.procedure_address,
|
||||
);
|
||||
const readResolution = createAnalysisViewResultReader(
|
||||
nativeObservationSourceSchemas.resolve_containing_procedure,
|
||||
);
|
||||
const readGraph = createAnalysisViewResultReader(
|
||||
nativeObservationSourceSchemas.get_call_graph,
|
||||
);
|
||||
const readSource = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<NativeObservation, AnalysisError> => {
|
||||
switch (parent.operation) {
|
||||
case "procedure_callees":
|
||||
case "procedure_callers": {
|
||||
const read = readAddresses(parent);
|
||||
return read.ok
|
||||
? ok({ operation: parent.operation, result: read.value })
|
||||
: read;
|
||||
}
|
||||
case "procedure_address": {
|
||||
const read = readAddress(parent);
|
||||
return read.ok
|
||||
? ok({ operation: parent.operation, result: read.value })
|
||||
: read;
|
||||
}
|
||||
case "resolve_containing_procedure": {
|
||||
const read = readResolution(parent);
|
||||
return read.ok
|
||||
? ok({ operation: parent.operation, result: read.value })
|
||||
: read;
|
||||
}
|
||||
case "get_call_graph": {
|
||||
const read = readGraph(parent);
|
||||
return read.ok
|
||||
? ok({ operation: parent.operation, result: read.value })
|
||||
: read;
|
||||
}
|
||||
default:
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"This parent does not record a supported native observation.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
};
|
||||
const readContext = (
|
||||
parent: AnalysisViewParent,
|
||||
source: NativeObservation,
|
||||
): Result<NativeSourceContext, AnalysisError> => {
|
||||
const values = {
|
||||
subject: parent.subject,
|
||||
provider: parent.provider,
|
||||
analysis_profile: parent.analysisProfile,
|
||||
authority: parent.authority,
|
||||
environment: parent.environment,
|
||||
limitations: parent.limitations,
|
||||
result_limitations:
|
||||
source.operation === "get_call_graph" ? source.result.limitations : null,
|
||||
};
|
||||
const parsed = nativeSourceContextSchema.safeParse(values);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", ...issue.path],
|
||||
})),
|
||||
{ source: values },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
};
|
||||
const selectedRows = <Value>(
|
||||
values: readonly Value[],
|
||||
selection: { readonly offset: number; readonly limit: number },
|
||||
) =>
|
||||
values
|
||||
.slice(selection.offset, selection.offset + selection.limit)
|
||||
.map((value, index) => ({ source_index: selection.offset + index, value }));
|
||||
|
||||
const observationRows = (
|
||||
source: NativeObservation,
|
||||
view: { readonly offset: number; readonly limit: number },
|
||||
) => {
|
||||
switch (source.operation) {
|
||||
case "procedure_address":
|
||||
return {
|
||||
parent_operation: source.operation,
|
||||
total: 1,
|
||||
items: {
|
||||
type: "addresses" as const,
|
||||
rows: selectedRows([source.result], view).map((row) => ({
|
||||
source_index: row.source_index,
|
||||
address: row.value,
|
||||
})),
|
||||
},
|
||||
};
|
||||
case "procedure_callees":
|
||||
case "procedure_callers":
|
||||
return {
|
||||
parent_operation: source.operation,
|
||||
total: source.result.length,
|
||||
items: {
|
||||
type: "addresses" as const,
|
||||
rows: selectedRows(source.result, view).map((row) => ({
|
||||
source_index: row.source_index,
|
||||
address: row.value,
|
||||
})),
|
||||
},
|
||||
};
|
||||
case "resolve_containing_procedure":
|
||||
return {
|
||||
parent_operation: source.operation,
|
||||
total: 1,
|
||||
items: {
|
||||
type: "resolutions" as const,
|
||||
rows: selectedRows([source.result], view).map((row) => ({
|
||||
source_index: row.source_index,
|
||||
record: row.value,
|
||||
})),
|
||||
},
|
||||
};
|
||||
case "get_call_graph":
|
||||
return {
|
||||
parent_operation: source.operation,
|
||||
...projectNativeGraphRows(source.result, view),
|
||||
};
|
||||
}
|
||||
};
|
||||
const contextRows = (
|
||||
source: NativeSourceContext,
|
||||
view: Extract<AnalysisViewRequest, { kind: "native-context" }>,
|
||||
) => {
|
||||
switch (view.facet) {
|
||||
case "overview": {
|
||||
const {
|
||||
analysis_profile,
|
||||
limitations: _limitations,
|
||||
result_limitations: _result,
|
||||
...overview
|
||||
} = source;
|
||||
return {
|
||||
facet: view.facet,
|
||||
view: { ...view, facet: view.facet },
|
||||
total: 1,
|
||||
items: {
|
||||
type: "overview" as const,
|
||||
rows: selectedRows(
|
||||
[
|
||||
{
|
||||
...overview,
|
||||
analysis_profile_digest: analysis_profile?.digest ?? null,
|
||||
},
|
||||
],
|
||||
view,
|
||||
).map((row) => ({
|
||||
source_index: row.source_index,
|
||||
record: row.value,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "analysis-profile":
|
||||
return {
|
||||
facet: view.facet,
|
||||
view: { ...view, facet: view.facet },
|
||||
total: 1,
|
||||
items: {
|
||||
type: "analysis-profile" as const,
|
||||
rows: selectedRows([source.analysis_profile], view).map((row) => ({
|
||||
source_index: row.source_index,
|
||||
record: row.value,
|
||||
})),
|
||||
},
|
||||
};
|
||||
case "limitations":
|
||||
return {
|
||||
facet: view.facet,
|
||||
view: { ...view, facet: view.facet },
|
||||
total: source.limitations.length,
|
||||
items: {
|
||||
type: "limitations" as const,
|
||||
rows: selectedRows(source.limitations, view).map((row) => ({
|
||||
source_index: row.source_index,
|
||||
text: row.value,
|
||||
})),
|
||||
},
|
||||
};
|
||||
case "result-limitations":
|
||||
return {
|
||||
facet: view.facet,
|
||||
view: { ...view, facet: view.facet },
|
||||
total: source.result_limitations?.length ?? null,
|
||||
items: {
|
||||
type: "result-limitations" as const,
|
||||
rows:
|
||||
source.result_limitations === null
|
||||
? null
|
||||
: selectedRows(source.result_limitations, view).map((row) => ({
|
||||
source_index: row.source_index,
|
||||
text: row.value,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/** Select recorded native facts without repeating provider profiles or complete caveat inventories. */
|
||||
export const projectNativeObservationView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (
|
||||
view.kind !== "summary" &&
|
||||
view.kind !== "native-observation" &&
|
||||
view.kind !== "native-context"
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Use summary, native-observation or native-context with this source.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const source = readSource(parent);
|
||||
if (!source.ok) return source;
|
||||
const context = readContext(parent, source.value);
|
||||
if (!context.ok) return context;
|
||||
const sourceContext = {
|
||||
materialization: "reference" as const,
|
||||
digest: digestCanonicalValue(
|
||||
{
|
||||
...context.value,
|
||||
subject: evidenceSubjectIdentity(context.value.subject),
|
||||
},
|
||||
"Native source context",
|
||||
),
|
||||
evidence_limitation_count: context.value.limitations.length,
|
||||
result_limitation_count: context.value.result_limitations?.length ?? null,
|
||||
};
|
||||
const common = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: parent.artifact ?? null,
|
||||
source_provider: context.value.provider,
|
||||
source_analysis_profile_digest:
|
||||
context.value.analysis_profile?.digest ?? null,
|
||||
source_parameters: jsonValueSchema.parse(parent.parameters ?? {}),
|
||||
source_context: sourceContext,
|
||||
limitations: ["Source caveats require native-context."],
|
||||
unknowns: [
|
||||
"Interpretation beyond recorded rows is unknown until source caveats are read.",
|
||||
],
|
||||
};
|
||||
if (view.kind === "native-context") {
|
||||
const { total, ...page } = contextRows(context.value, view);
|
||||
return ok({
|
||||
...common,
|
||||
kind: "native-context",
|
||||
...page,
|
||||
parent_operation: source.value.operation,
|
||||
coverage:
|
||||
total === null
|
||||
? completeWithinViewCoverage(0, 0)
|
||||
: pageViewCoverage(view.offset, page.items.rows?.length ?? 0, total),
|
||||
unknowns:
|
||||
total === null
|
||||
? [
|
||||
"Result limitations are not applicable to this primitive observation; null does not report an examined empty list.",
|
||||
]
|
||||
: common.unknowns,
|
||||
});
|
||||
}
|
||||
const selection = view.kind === "summary" ? { offset: 0, limit: 64 } : view;
|
||||
const page = observationRows(source.value, selection);
|
||||
const { total, ...payload } = page;
|
||||
const observation = {
|
||||
...common,
|
||||
...payload,
|
||||
summary: { observations: total },
|
||||
coverage: pageViewCoverage(selection.offset, page.items.rows.length, total),
|
||||
};
|
||||
return view.kind === "summary"
|
||||
? ok({ ...observation, kind: "summary", view })
|
||||
: ok({ ...observation, kind: "native-observation", view });
|
||||
};
|
||||
+11
-8
@@ -269,18 +269,21 @@ export interface EvidenceObservation extends EvidenceObservationInput {
|
||||
|
||||
type EvidenceWithoutId = Omit<EvidenceSchemaValue, "evidence_id">;
|
||||
|
||||
/** Target identity shared by semantic commitments, excluding caller-selected display paths. */
|
||||
export const evidenceSubjectIdentity = (subject: Evidence["subject"]) =>
|
||||
subject === null
|
||||
? null
|
||||
: {
|
||||
digest: subject.digest,
|
||||
format: subject.format,
|
||||
architecture: subject.architecture,
|
||||
};
|
||||
|
||||
/** Content committed by an Evidence ID, excluding local request/display metadata. */
|
||||
export const evidenceSemanticContent = (
|
||||
evidence: EvidenceWithoutId,
|
||||
): Record<string, JsonValue> => ({
|
||||
subject:
|
||||
evidence.subject === null
|
||||
? null
|
||||
: {
|
||||
digest: evidence.subject.digest,
|
||||
format: evidence.subject.format,
|
||||
architecture: evidence.subject.architecture,
|
||||
},
|
||||
subject: evidenceSubjectIdentity(evidence.subject),
|
||||
provider: evidence.provider,
|
||||
analysis_profile: evidence.analysis_profile,
|
||||
predicate_type: evidence.predicate_type,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
|
||||
import { relationshipCoverageSchema } from "../relationshipCoverage.js";
|
||||
|
||||
/** One recorded traversal outcome; source levels and array positions retain identity. */
|
||||
export const nativeCallGraphNodeSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
address: z.string(),
|
||||
status: z.literal("ok"),
|
||||
calls: z.array(z.string()),
|
||||
relationship_coverage: relationshipCoverageSchema,
|
||||
limitations: z.array(z.string()).optional(),
|
||||
}),
|
||||
z.object({
|
||||
address: z.string(),
|
||||
status: z.literal("error"),
|
||||
error: analysisErrorProjectionSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Provider-independent result owned by the shared call-graph workflow. */
|
||||
export const nativeCallGraphSchema = z.strictObject({
|
||||
graph: z.record(z.string(), z.array(nativeCallGraphNodeSchema)),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type NativeCallGraph = z.infer<typeof nativeCallGraphSchema>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
import { containingProcedureResolutionSchema } from "../hopperValues.js";
|
||||
import { nativeCallGraphSchema } from "./nativeCallGraph.js";
|
||||
|
||||
export const NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS = [
|
||||
"procedure_address",
|
||||
"procedure_callees",
|
||||
"procedure_callers",
|
||||
"resolve_containing_procedure",
|
||||
] as const;
|
||||
export const NATIVE_OBSERVATION_OPERATIONS = [
|
||||
...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS,
|
||||
"get_call_graph",
|
||||
] as const;
|
||||
export type NativeObservationOperation =
|
||||
(typeof NATIVE_OBSERVATION_OPERATIONS)[number];
|
||||
export const nativeAddressListSchema = z.array(z.string());
|
||||
export const nativeObservationSourceSchemas = {
|
||||
procedure_address: z.string(),
|
||||
procedure_callees: nativeAddressListSchema,
|
||||
procedure_callers: nativeAddressListSchema,
|
||||
resolve_containing_procedure: containingProcedureResolutionSchema,
|
||||
get_call_graph: nativeCallGraphSchema,
|
||||
};
|
||||
export type NativeObservation = {
|
||||
[Operation in NativeObservationOperation]: {
|
||||
operation: Operation;
|
||||
result: z.output<(typeof nativeObservationSourceSchemas)[Operation]>;
|
||||
};
|
||||
}[NativeObservationOperation];
|
||||
|
||||
/** One operation family shared by view projection and response-budget recovery. */
|
||||
export const isNativeObservationOperation = (
|
||||
operation: string,
|
||||
): operation is NativeObservationOperation =>
|
||||
NATIVE_OBSERVATION_OPERATIONS.some((supported) => supported === operation);
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { fixedTupleSchema } from "./fixedTupleSchema.js";
|
||||
|
||||
const diagnostics = z.array(z.string().min(1)).min(1).exactOptional();
|
||||
const completeCoverage = z.strictObject({
|
||||
export const completeRelationshipCoverageSchema = z.strictObject({
|
||||
status: z.literal("complete"),
|
||||
diagnostics,
|
||||
});
|
||||
@@ -12,13 +12,13 @@ const limitedDirections = z.union([
|
||||
fixedTupleSchema([z.literal("callees")]),
|
||||
fixedTupleSchema([z.literal("callers"), z.literal("callees")]),
|
||||
]);
|
||||
const partialCoverage = z.strictObject({
|
||||
export const partialRelationshipCoverageSchema = z.strictObject({
|
||||
status: z.literal("partial"),
|
||||
limitations: z.array(z.string().min(1)).min(1),
|
||||
limited_directions: limitedDirections.exactOptional(),
|
||||
diagnostics,
|
||||
});
|
||||
const unknownCoverage = z.strictObject({
|
||||
export const unknownRelationshipCoverageSchema = z.strictObject({
|
||||
status: z.literal("unknown"),
|
||||
reason: z.string().min(1),
|
||||
limited_directions: limitedDirections.exactOptional(),
|
||||
@@ -27,9 +27,9 @@ const unknownCoverage = z.strictObject({
|
||||
|
||||
/** Explicit coverage state for a provider's observed caller/callee set. */
|
||||
export const relationshipCoverageSchema = z.discriminatedUnion("status", [
|
||||
completeCoverage,
|
||||
partialCoverage,
|
||||
unknownCoverage,
|
||||
completeRelationshipCoverageSchema,
|
||||
partialRelationshipCoverageSchema,
|
||||
unknownRelationshipCoverageSchema,
|
||||
]);
|
||||
|
||||
export type RelationshipCoverage = z.infer<typeof relationshipCoverageSchema>;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Logger } from "pino";
|
||||
import { mcpProgressReporter } from "./mcpProgress.js";
|
||||
@@ -146,10 +147,11 @@ export const registerEnhancedTools = (
|
||||
server.registerTool(
|
||||
callGraph.name,
|
||||
toolRegistrationOptions(callGraph),
|
||||
(input, context) =>
|
||||
({ detail, ...input }, context) =>
|
||||
executeEnhancedTool(registration, callGraph, {
|
||||
validatedCall: { name: "get_call_graph", input },
|
||||
context,
|
||||
detail,
|
||||
}),
|
||||
);
|
||||
server.registerTool(
|
||||
@@ -233,10 +235,19 @@ const executeEnhancedTool = async (
|
||||
},
|
||||
contract: ToolContract,
|
||||
request: {
|
||||
readonly detail?: "complete" | "summary";
|
||||
readonly validatedCall: ValidatedEnhancedCall;
|
||||
readonly context: ServerContext;
|
||||
},
|
||||
): Promise<CallToolResult> => {
|
||||
if (request.detail === "summary") {
|
||||
const ready = requireAnalysisViewRetention(request.validatedCall.name, {
|
||||
recordEvidence: registration.recordEvidence,
|
||||
evidenceById: registration.evidenceById,
|
||||
});
|
||||
if (!ready.ok)
|
||||
return registration.delivery.toCallToolResult(ready, contract);
|
||||
}
|
||||
const admitted = await registration.withAdmittedAnalysis(
|
||||
request.validatedCall.name,
|
||||
request.context.mcpReq.signal,
|
||||
@@ -307,10 +318,21 @@ const executeEnhancedTool = async (
|
||||
});
|
||||
if (!unknowns.ok)
|
||||
return registration.delivery.toCallToolResult(unknowns, contract);
|
||||
const delivered =
|
||||
request.detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, {
|
||||
recordEvidence: registration.recordEvidence,
|
||||
evidenceById: registration.evidenceById,
|
||||
})
|
||||
: ok(evidence);
|
||||
if (!delivered.ok)
|
||||
return registration.delivery.toCallToolResult(delivered, contract);
|
||||
return registration.delivery.toEvidenceToolResult(
|
||||
evidence,
|
||||
delivered.value,
|
||||
contract,
|
||||
recorded,
|
||||
request.detail === "summary"
|
||||
? registration.recordEvidence?.(delivered.value)
|
||||
: recorded,
|
||||
);
|
||||
}
|
||||
return registration.delivery.toCallToolResult(result, contract);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../domain/native/nativeObservation.js";
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js";
|
||||
import { NATIVE_INVENTORY_OPERATIONS } from "../domain/native/nativeInventory.js";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
|
||||
const SUMMARY_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
...NATIVE_INVENTORY_OPERATIONS,
|
||||
...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS,
|
||||
"inspect_native_load_image",
|
||||
]);
|
||||
const withoutDetail = withoutDeliveryDetail(SUMMARY_OPERATIONS);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isNativeObservationOperation } from "../domain/native/nativeObservation.js";
|
||||
import { isNativeInventoryOperation } from "../domain/native/nativeInventory.js";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
import { ToolResultDelivery } from "./ToolResultDelivery.js";
|
||||
@@ -24,7 +25,9 @@ const retainedEvidenceContinuation = (
|
||||
? { kind: "native", facet: "pseudocode", offset: 0 }
|
||||
: isNativeInventoryOperation(evidence.operation)
|
||||
? { kind: "native-inventory", offset: 0, limit: 64 }
|
||||
: undefined;
|
||||
: isNativeObservationOperation(evidence.operation)
|
||||
? { kind: "native-observation", offset: 0, limit: 64 }
|
||||
: undefined;
|
||||
if (view === undefined) return {};
|
||||
const input = inspectAnalysisViewInputSchema.safeParse({
|
||||
source: { kind: "retained-evidence", evidence_id: evidence.evidence_id },
|
||||
|
||||
@@ -288,10 +288,18 @@ it("refuses summary before provider work when no retaining session is configured
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
for (const name of ["list_procedures", "inspect_native_load_image"]) {
|
||||
for (const [name, parameters] of [
|
||||
["list_procedures", {}],
|
||||
["inspect_native_load_image", {}],
|
||||
["procedure_address", { procedure: "fixture_main" }],
|
||||
["procedure_callees", { procedure: "fixture_main" }],
|
||||
["procedure_callers", { procedure: "fixture_main" }],
|
||||
["resolve_containing_procedure", { address: "0x401001" }],
|
||||
["get_call_graph", { address: "fixture_main" }],
|
||||
] as const) {
|
||||
const reply = await client.callTool({
|
||||
name,
|
||||
arguments: { detail: "summary" },
|
||||
arguments: { ...parameters, detail: "summary" },
|
||||
});
|
||||
expect(reply.isError).toBe(true);
|
||||
expect(parseMcpToolError(reply).error.code).toBe("capability_unavailable");
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { z } from "zod";
|
||||
import { afterAll, expect, it } from "vitest";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
analysisViewResultSchema,
|
||||
inspectAnalysisViewInputSchema,
|
||||
} from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../../../src/domain/native/nativeObservation.js";
|
||||
import { connectGhidraMcp } from "./ghidraMcpHarness.js";
|
||||
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
||||
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
|
||||
|
||||
const validation = createMcpSchemaValidation();
|
||||
afterAll(() => validation.clear());
|
||||
|
||||
it("delivers native primitive and graph summaries with exact retained parents and SDK-validated context pages", async () => {
|
||||
const harness = await connectGhidraMcp("native-observation-views");
|
||||
try {
|
||||
const definitions = new Map(
|
||||
(await harness.mcp.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const call = async (name: string, input: Record<string, unknown>) => {
|
||||
const tool = definitions.get(name);
|
||||
if (!tool?.outputSchema) throw new Error("Missing named contract");
|
||||
const result = await harness.mcp.callTool(
|
||||
{ name, arguments: input },
|
||||
{ toolDefinition: tool },
|
||||
);
|
||||
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
|
||||
expect(
|
||||
validation.assertions.validate(
|
||||
tool.outputSchema,
|
||||
result.structuredContent,
|
||||
),
|
||||
JSON.stringify(validation.assertions.errors),
|
||||
).toBe(true);
|
||||
return parseEvidence(result.structuredContent);
|
||||
};
|
||||
const viewSchemas = await advertisedMcpToolSchemas(
|
||||
harness.mcp,
|
||||
"inspect_analysis_view",
|
||||
validation.assertions,
|
||||
);
|
||||
for (const operation of [
|
||||
...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS,
|
||||
"get_call_graph",
|
||||
] as const) {
|
||||
const input =
|
||||
operation === "get_call_graph"
|
||||
? { address: "fixture_main" }
|
||||
: operation === "resolve_containing_procedure"
|
||||
? { address: "0x401001" }
|
||||
: { procedure: "fixture_main" };
|
||||
const complete = await call(operation, input);
|
||||
const summary = await call(operation, { ...input, detail: "summary" });
|
||||
expect(complete.operation).toBe(operation);
|
||||
expect(complete.parameters).not.toHaveProperty("detail");
|
||||
expect(summary.normalized_result).toMatchObject({
|
||||
kind: "summary",
|
||||
parent_operation: operation,
|
||||
parent_evidence_id: complete.evidence_id,
|
||||
source_parameters: complete.parameters,
|
||||
source_provider: complete.provider,
|
||||
});
|
||||
expect(harness.session.evidenceById(complete.evidence_id)).toEqual(
|
||||
complete,
|
||||
);
|
||||
const calls = harness.calls.length;
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: complete.evidence_id,
|
||||
};
|
||||
const page = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "native-observation", offset: 0, limit: 1 },
|
||||
});
|
||||
const parsed = analysisViewResultSchema.parse(page.normalized_result);
|
||||
expect(parsed).toMatchObject({
|
||||
kind: "native-observation",
|
||||
parent_evidence_id: complete.evidence_id,
|
||||
});
|
||||
for (const facet of [
|
||||
"overview",
|
||||
"analysis-profile",
|
||||
"limitations",
|
||||
"result-limitations",
|
||||
]) {
|
||||
const context = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "native-context", facet, offset: 0, limit: 1 },
|
||||
});
|
||||
expect(context.relationship_coverage).toEqual(
|
||||
complete.relationship_coverage,
|
||||
);
|
||||
expect(context.normalized_result).toMatchObject({
|
||||
kind: "native-context",
|
||||
facet,
|
||||
parent_evidence_id: complete.evidence_id,
|
||||
});
|
||||
}
|
||||
expect(harness.calls).toHaveLength(calls);
|
||||
const invalid = {
|
||||
source,
|
||||
view: { kind: "native-context", facet: "unknown" },
|
||||
};
|
||||
expect(inspectAnalysisViewInputSchema.safeParse(invalid).success).toBe(
|
||||
false,
|
||||
);
|
||||
const rejected = await harness.mcp.callTool(
|
||||
{ name: "inspect_analysis_view", arguments: invalid },
|
||||
{ toolDefinition: viewSchemas.toolDefinition },
|
||||
);
|
||||
expect(rejected.isError).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it("follows native overflow continuations through MCP without repeating provider work", async () => {
|
||||
let reads = 0;
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const session = createTestBinarySession(() => ({
|
||||
execute: async (operation) => {
|
||||
reads++;
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
operation === "procedure_address"
|
||||
? "0x1000"
|
||||
: operation === "resolve_containing_procedure"
|
||||
? {
|
||||
query_address: "0x9999",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
}
|
||||
: [],
|
||||
provider,
|
||||
{
|
||||
limitations: ["Capacity fixture ".repeat(1500)],
|
||||
relationshipCoverage: {
|
||||
status: "partial",
|
||||
limitations: ["Indirect edges unknown"],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
// Exercise the real delivery/SDK path with a small injected response budget.
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ delivery: createToolResultDelivery(32 * 1024) },
|
||||
);
|
||||
const client = new Client({
|
||||
name: "native-observation-capacity",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: process.execPath },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
for (const name of [
|
||||
...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS,
|
||||
"get_call_graph",
|
||||
] as const) {
|
||||
const input =
|
||||
name === "get_call_graph" || name === "resolve_containing_procedure"
|
||||
? { address: "0x1000" }
|
||||
: { procedure: "fixture_main" };
|
||||
const complete = await client.callTool({ name, arguments: input });
|
||||
const error = parseMcpToolError(complete).error;
|
||||
expect(error.code).toBe("resource_constraint");
|
||||
const continuation = z
|
||||
.object({
|
||||
reported_limits: z.object({
|
||||
continuation: z.object({
|
||||
name: z.literal("inspect_analysis_view"),
|
||||
arguments: inspectAnalysisViewInputSchema,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.parse(error.details).reported_limits.continuation;
|
||||
expect(continuation.arguments.view.kind).toBe("native-observation");
|
||||
const before = reads;
|
||||
const page = await client.callTool(continuation);
|
||||
expect(page.isError, JSON.stringify(page.content)).not.toBe(true);
|
||||
const evidence = parseEvidence(page.structuredContent);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
kind: "native-observation",
|
||||
parent_operation: name,
|
||||
});
|
||||
expect(reads).toBe(before);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([client.close(), server.close()]);
|
||||
const closed = await session.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
}, 90_000);
|
||||
Reference in new issue
Block a user