Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3893d48e |
No files matched your search
@@ -38,6 +38,8 @@ Page-declared WebMCP tools are untrusted inventory and are never invoked by REA.
|
|||||||
browser and profile. Check `browser.cleanup` for the outcome.
|
browser and profile. Check `browser.cleanup` for the outcome.
|
||||||
Request only the DOM, accessibility, or network details needed for the question.
|
Request only the DOM, accessibility, or network details needed for the question.
|
||||||
The default final capture contains the URL; step outcomes are reported separately.
|
The default final capture contains the URL; step outcomes are reported separately.
|
||||||
|
For multi-step flows with trees or events, select `detail: "summary"` and page
|
||||||
|
exact steps or events with `inspect_analysis_view` kind `browser-scenario`.
|
||||||
Add a wait action for the expected page state before capturing it. Report that
|
Add a wait action for the expected page state before capturing it. Report that
|
||||||
REA performed the declared actions.
|
REA performed the declared actions.
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,46 @@ external browser without closing it. The request itself defines the operation;
|
|||||||
there is no additional REA grant step. The browser and host still enforce their
|
there is no additional REA grant step. The browser and host still enforce their
|
||||||
own access and process rules.
|
own access and process rules.
|
||||||
|
|
||||||
|
## Summary delivery
|
||||||
|
|
||||||
|
Multi-step scenarios that select accessibility trees, DOM or network events can
|
||||||
|
produce results far larger than an MCP client accepts. Select
|
||||||
|
`"detail": "summary"` (CLI: `--detail summary --evidence_output <bundle>`) to
|
||||||
|
retain the complete Evidence and receive a compact view instead:
|
||||||
|
|
||||||
|
- one row per step with its status, error, elapsed time and `after_url`;
|
||||||
|
`before_url` appears only when it differs from the previous step's
|
||||||
|
`after_url`, and sections are listed as captured, missing or truncated
|
||||||
|
(`not_requested` sections are omitted);
|
||||||
|
- `final_accessibility`: the accessibility tree of the first failed or
|
||||||
|
cancelled step, or of the last step, falling back to the nearest earlier step
|
||||||
|
that captured one (`step_index` is `null` when none did);
|
||||||
|
- retained event counts by kind, with the budget omissions, and network
|
||||||
|
transaction counts by origin, resource type and final status class.
|
||||||
|
|
||||||
|
Page exact records from `normalized_result.parent_evidence_id` with
|
||||||
|
`inspect_analysis_view`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": { "kind": "retained-evidence", "evidence_id": "ev_..." },
|
||||||
|
"view": {
|
||||||
|
"kind": "browser-scenario",
|
||||||
|
"collection": "steps",
|
||||||
|
"step_indices": [3],
|
||||||
|
"artifacts": ["dom"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps return only the captured sections named in `artifacts` and list the
|
||||||
|
others with their state. Events (`"collection": "events"`) accept
|
||||||
|
`step_indices`, `event_kinds` and `transaction_ids` and keep each event's
|
||||||
|
original `item_index`. An omitted filter selects all records; `[]` selects
|
||||||
|
none. Every page repeats the capture's completeness and dropped-event count:
|
||||||
|
exhausting the pages does not make a truncated capture complete. The default
|
||||||
|
`"detail": "complete"` keeps returning the whole capture inline.
|
||||||
|
|
||||||
## Network evidence
|
## Network evidence
|
||||||
|
|
||||||
`capture.events: ["network"]` retains request, response, completion, failure,
|
`capture.events: ["network"]` retains request, response, completion, failure,
|
||||||
|
|||||||
@@ -551,14 +551,6 @@ command, objdump, Rizin and GDB, and the agent environment server
|
|||||||
signal-derived status. This describes the current source routing; it is not a
|
signal-derived status. This describes the current source routing; it is not a
|
||||||
claim that every command or provider runtime has been independently exercised.
|
claim that every command or provider runtime has been independently exercised.
|
||||||
|
|
||||||
Commands with `--evidence_output` select their delivered view, then publish the
|
|
||||||
complete Evidence bundle atomically. Cancellation before publication leaves no
|
|
||||||
new file. A publication that has committed can win a later cancellation; the
|
|
||||||
`cancelled` result then keeps the written path and byte count under
|
|
||||||
`details.diagnostics.evidence_bundle`. If staging cleanup stays incomplete
|
|
||||||
after commit, the `cleanup_incomplete` error reports the same file as
|
|
||||||
`details.published_path` and `details.bytes`.
|
|
||||||
|
|
||||||
When the reader of piped output closes early, as `rea ... | head` does, the
|
When the reader of piped output closes early, as `rea ... | head` does, the
|
||||||
CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a
|
CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a
|
||||||
SIGPIPE-terminated writer). The operation itself may have completed; only its
|
SIGPIPE-terminated writer). The operation itself may have completed; only its
|
||||||
|
|||||||
@@ -106,11 +106,7 @@ nodes. REA evaluates compatible static candidates conservatively:
|
|||||||
that disagreement. If a mapped candidate's digest is unavailable and no
|
that disagreement. If a mapped candidate's digest is unavailable and no
|
||||||
mapped content match is known, the comparison remains `unknown`.
|
mapped content match is known, the comparison remains `unknown`.
|
||||||
6. Missing authorization mappings and incomplete or truncated input coverage
|
6. Missing authorization mappings and incomplete or truncated input coverage
|
||||||
remain unknown or truncated rather than becoming absence claims. A runtime
|
remain unknown or truncated rather than becoming absence claims.
|
||||||
location with no automatic artifact-root or caller-declared mapping into
|
|
||||||
any layer, and no unique content match, is `unknown` with
|
|
||||||
`no-authorized-location-mapping`, with or without captured bytes. Only a
|
|
||||||
mapped location can be `unmatched` with `no-static-content-match`.
|
|
||||||
|
|
||||||
Every successful cross-layer `observed_as` edge has
|
Every successful cross-layer `observed_as` edge has
|
||||||
`authority: cross-layer-reconciliation` and `state: inferred`. The underlying
|
`authority: cross-layer-reconciliation` and `state: inferred`. The underlying
|
||||||
|
|||||||
@@ -65,18 +65,6 @@ API dependency, and independent fixture compiler oracles keep their existing
|
|||||||
TypeScript versions.
|
TypeScript versions.
|
||||||
|
|
||||||
`npm run build:cached` compiles the CLI/MCP runtime and its packaged skill.
|
`npm run build:cached` compiles the CLI/MCP runtime and its packaged skill.
|
||||||
The build also records `dist/mcp-catalog.json` (compact inputs) and
|
|
||||||
`dist/mcp-catalog-full.json` from the canonical SDK registrations and binds
|
|
||||||
each to the runtime with its own SHA-256 digest. These are
|
|
||||||
runtime artifacts, separate from the test catalog below. Default target-free
|
|
||||||
MCP startup serves its schemas without constructing analysis validators;
|
|
||||||
the first tool or guided-workflow request materializes the canonical runtime
|
|
||||||
on that same SDK server. Explicit compact/full input selections preserve
|
|
||||||
their advertised schemas throughout materialization. Initial-target,
|
|
||||||
HTTP and output-schema-off startup keep the eager path; source runs without
|
|
||||||
recorded artifacts also use eager composition. Compare memory both after discovery and after a completed
|
|
||||||
request; report diagnostic GC separately from normal resident memory.
|
|
||||||
|
|
||||||
Turbo's strict task environment preserves caller-selected `GOMAXPROCS`,
|
Turbo's strict task environment preserves caller-selected `GOMAXPROCS`,
|
||||||
`GOMEMLIMIT`, and `UV_THREADPOOL_SIZE` for resource-constrained builds and
|
`GOMEMLIMIT`, and `UV_THREADPOOL_SIZE` for resource-constrained builds and
|
||||||
checks. These execution controls do not change artifact content, so changing
|
checks. These execution controls do not change artifact content, so changing
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { join, relative } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
||||||
|
|
||||||
/** Bind a trial to its source checkout and compiled runtime, including deferred assets. */
|
/** Bind a trial to its source checkout and the compiled CLI actually launched. */
|
||||||
export const captureRuntimeBinding = async (runtime, kind = "cli") => {
|
export const captureRuntimeBinding = async (runtime) => {
|
||||||
assert.ok(kind === "cli" || kind === "mcp", "Unknown runtime entry");
|
|
||||||
const root = await realpath(runtime);
|
const root = await realpath(runtime);
|
||||||
const git = (args) =>
|
const git = (args) =>
|
||||||
execFileSync("git", args, { cwd: root, maxBuffer: 16 * 1024 * 1024 });
|
execFileSync("git", args, { cwd: root, maxBuffer: 16 * 1024 * 1024 });
|
||||||
@@ -51,16 +50,10 @@ export const captureRuntimeBinding = async (runtime, kind = "cli") => {
|
|||||||
files.push(relative(root, path));
|
files.push(relative(root, path));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// MCP materializes additional workers and reads build-owned JSON after
|
await visit(join(root, "dist/cli-runtime"));
|
||||||
// discovery. Bind every compiled JS/JSON asset, rather than only its entry.
|
|
||||||
await visit(join(root, kind === "mcp" ? "dist" : "dist/cli-runtime"));
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
files.includes(
|
files.includes(join("dist", "cli-runtime", "cli.js")),
|
||||||
kind === "mcp"
|
"Missing compiled CLI entry",
|
||||||
? join("dist", "mcp", "main.js")
|
|
||||||
: join("dist", "cli-runtime", "cli.js"),
|
|
||||||
),
|
|
||||||
"Missing compiled runtime entry",
|
|
||||||
);
|
);
|
||||||
const hash = createHash("sha256");
|
const hash = createHash("sha256");
|
||||||
let bytes = 0;
|
let bytes = 0;
|
||||||
@@ -70,23 +63,15 @@ export const captureRuntimeBinding = async (runtime, kind = "cli") => {
|
|||||||
hash.update(JSON.stringify([name, value.length, sha(value)]) + "\n");
|
hash.update(JSON.stringify([name, value.length, sha(value)]) + "\n");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
binding_version: kind === "mcp" ? 2 : 1,
|
binding_version: 1,
|
||||||
root,
|
root,
|
||||||
node: process.version,
|
node: process.version,
|
||||||
git_revision: gitRevision,
|
git_revision: gitRevision,
|
||||||
source_diff_sha256: sha(sourceDiff),
|
source_diff_sha256: sha(sourceDiff),
|
||||||
untracked_source_sha256: sha(JSON.stringify(untrackedEntries)),
|
untracked_source_sha256: sha(JSON.stringify(untrackedEntries)),
|
||||||
...(kind === "mcp"
|
compiled_cli_sha256: hash.digest("hex"),
|
||||||
? {
|
compiled_cli_files: files.length,
|
||||||
compiled_runtime_sha256: hash.digest("hex"),
|
compiled_cli_bytes: bytes,
|
||||||
compiled_runtime_files: files.length,
|
|
||||||
compiled_runtime_bytes: bytes,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
compiled_cli_sha256: hash.digest("hex"),
|
|
||||||
compiled_cli_files: files.length,
|
|
||||||
compiled_cli_bytes: bytes,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,10 +82,6 @@ export const assertRuntimeBinding = (actual, expected, mode) => {
|
|||||||
|
|
||||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(await captureRuntimeBinding(process.argv[2]), null, 2),
|
||||||
await captureRuntimeBinding(process.argv[2], process.argv[3]),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -226,8 +226,3 @@ keeps default valid calls and typed refusals while checking full producer
|
|||||||
constraints separately. Five isolated suites passed 14 tests across call paths,
|
constraints separately. Five isolated suites passed 14 tests across call paths,
|
||||||
ADB start, browser scenarios, source selection and managed analysis. The first
|
ADB start, browser scenarios, source selection and managed analysis. The first
|
||||||
browser attempt failed and remains recorded; no performance cohort changed.
|
browser attempt failed and remains recorded; no performance cohort changed.
|
||||||
|
|
||||||
The subsequent [startup audit](mcp-startup.md) extends this harness with
|
|
||||||
source/bundle bindings and a post-materialization directory check. Its new
|
|
||||||
measurements are a separate cohort; reproduce this report at its recorded
|
|
||||||
revision when comparing the original timing and model-session receipts.
|
|
||||||
@@ -7,10 +7,6 @@ import { parseArgs } from "node:util";
|
|||||||
import { Client } from "@modelcontextprotocol/client";
|
import { Client } from "@modelcontextprotocol/client";
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||||
import {
|
|
||||||
captureRuntimeBinding,
|
|
||||||
assertRuntimeBinding,
|
|
||||||
} from "./managed-cli-runtime.mjs";
|
|
||||||
|
|
||||||
const { values } = parseArgs({
|
const { values } = parseArgs({
|
||||||
options: {
|
options: {
|
||||||
@@ -19,68 +15,26 @@ const { values } = parseArgs({
|
|||||||
oracle: { type: "string" },
|
oracle: { type: "string" },
|
||||||
output: { type: "string" },
|
output: { type: "string" },
|
||||||
profile: { type: "string", default: "default" },
|
profile: { type: "string", default: "default" },
|
||||||
phase: { type: "string", default: "analysis" },
|
|
||||||
cache: { type: "string", default: "cold" },
|
|
||||||
"cache-root": { type: "string" },
|
|
||||||
bindings: { type: "string" },
|
|
||||||
role: { type: "string" },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.ok(["analysis", "discovery"].includes(values.phase));
|
for (const key of ["runtime", "artifact", "oracle", "output"])
|
||||||
assert.ok(["cold", "warm"].includes(values.cache));
|
|
||||||
for (const key of values.phase === "analysis"
|
|
||||||
? ["runtime", "artifact", "oracle", "output"]
|
|
||||||
: ["runtime", "output"])
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
values[key] && resolve(values[key]) === values[key],
|
values[key] && resolve(values[key]) === values[key],
|
||||||
`${key} must be absolute`,
|
`${key} must be absolute`,
|
||||||
);
|
);
|
||||||
assert.ok(["default", "full", "compact"].includes(values.profile));
|
assert.ok(["default", "full", "compact"].includes(values.profile));
|
||||||
if (values.cache === "warm")
|
|
||||||
assert.ok(
|
|
||||||
values["cache-root"] &&
|
|
||||||
resolve(values["cache-root"]) === values["cache-root"],
|
|
||||||
);
|
|
||||||
await mkdir(values.output, { recursive: false });
|
await mkdir(values.output, { recursive: false });
|
||||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||||
let oracle;
|
const oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
||||||
let artifactSha;
|
const artifactSha = hash(await readFile(values.artifact));
|
||||||
if (values.phase === "analysis") {
|
assert.equal(artifactSha, oracle.artifact_sha256);
|
||||||
oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
|
||||||
artifactSha = hash(await readFile(values.artifact));
|
|
||||||
assert.equal(artifactSha, oracle.artifact_sha256);
|
|
||||||
}
|
|
||||||
let expectedBinding;
|
|
||||||
let bindingBefore;
|
|
||||||
let auditBeforeMs;
|
|
||||||
let measurementInputsSha256;
|
|
||||||
const producerSources = {};
|
|
||||||
if (values.bindings !== undefined) {
|
|
||||||
assert.ok(["baseline", "candidate"].includes(values.role));
|
|
||||||
const inputBytes = await readFile(values.bindings);
|
|
||||||
measurementInputsSha256 = hash(inputBytes);
|
|
||||||
const inputs = JSON.parse(inputBytes.toString("utf8"));
|
|
||||||
for (const name of ["mcp-input-catalog.mjs", "managed-cli-runtime.mjs"]) {
|
|
||||||
producerSources[name] = hash(
|
|
||||||
await readFile(new URL(name, import.meta.url)),
|
|
||||||
);
|
|
||||||
assert.equal(producerSources[name], inputs.harness_sources_sha256[name]);
|
|
||||||
}
|
|
||||||
expectedBinding = inputs.runtime_bindings[values.role];
|
|
||||||
const start = performance.now();
|
|
||||||
bindingBefore = await captureRuntimeBinding(values.runtime, "mcp");
|
|
||||||
auditBeforeMs = performance.now() - start;
|
|
||||||
assertRuntimeBinding(bindingBefore, expectedBinding, values.role);
|
|
||||||
}
|
|
||||||
const environment = {
|
const environment = {
|
||||||
PATH: process.env.PATH,
|
PATH: process.env.PATH,
|
||||||
HOME: values.output,
|
HOME: values.output,
|
||||||
REA_LOG_LEVEL: "silent",
|
REA_LOG_LEVEL: "silent",
|
||||||
UV_THREADPOOL_SIZE: "1",
|
UV_THREADPOOL_SIZE: "1",
|
||||||
NODE_OPTIONS: "--max-old-space-size=2048",
|
NODE_OPTIONS: "--max-old-space-size=2048",
|
||||||
...(values.cache === "cold"
|
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||||
? { NODE_DISABLE_COMPILE_CACHE: "1" }
|
|
||||||
: { NODE_COMPILE_CACHE: values["cache-root"] }),
|
|
||||||
...(values.profile === "default"
|
...(values.profile === "default"
|
||||||
? {}
|
? {}
|
||||||
: { REA_MCP_INPUT_SCHEMA_PROFILE: values.profile }),
|
: { REA_MCP_INPUT_SCHEMA_PROFILE: values.profile }),
|
||||||
@@ -92,29 +46,14 @@ const transport = new StdioClientTransport({
|
|||||||
env: environment,
|
env: environment,
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
const protocolRequests = {};
|
|
||||||
const send = transport.send.bind(transport);
|
|
||||||
transport.send = (...args) => {
|
|
||||||
const method = args[0].method;
|
|
||||||
if (typeof method === "string")
|
|
||||||
protocolRequests[method] = (protocolRequests[method] ?? 0) + 1;
|
|
||||||
return send(...args);
|
|
||||||
};
|
|
||||||
const client = new Client({ name: "rea-catalog-task", version: "1" });
|
const client = new Client({ name: "rea-catalog-task", version: "1" });
|
||||||
const report = {
|
const report = {
|
||||||
profile: values.profile,
|
profile: values.profile,
|
||||||
phase: values.phase,
|
|
||||||
cache: values.cache,
|
|
||||||
cache_root: values["cache-root"] ?? null,
|
|
||||||
role: values.role,
|
|
||||||
measurement_inputs_sha256: measurementInputsSha256,
|
|
||||||
producer_sources_sha256: producerSources,
|
|
||||||
artifact_sha256: artifactSha,
|
artifact_sha256: artifactSha,
|
||||||
calls: [],
|
calls: [],
|
||||||
checks: [],
|
checks: [],
|
||||||
status: "incomplete",
|
status: "incomplete",
|
||||||
cleanup_verified: false,
|
cleanup_verified: false,
|
||||||
protocol_requests: protocolRequests,
|
|
||||||
};
|
};
|
||||||
const transcript = [];
|
const transcript = [];
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
@@ -124,15 +63,6 @@ transport.stderr?.on("data", (value) => {
|
|||||||
const began = performance.now();
|
const began = performance.now();
|
||||||
try {
|
try {
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
report.protocol_era = client.getProtocolEra();
|
|
||||||
await writeFile(
|
|
||||||
join(values.output, "server.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
info: client.getServerVersion(),
|
|
||||||
capabilities: client.getServerCapabilities(),
|
|
||||||
instructions: client.getInstructions(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const catalog = await client.listTools();
|
const catalog = await client.listTools();
|
||||||
assert.equal(catalog.nextCursor, undefined);
|
assert.equal(catalog.nextCursor, undefined);
|
||||||
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
|
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
|
||||||
@@ -143,145 +73,97 @@ try {
|
|||||||
ajv.validateSchema(tool.inputSchema),
|
ajv.validateSchema(tool.inputSchema),
|
||||||
`Invalid schema: ${tool.name}`,
|
`Invalid schema: ${tool.name}`,
|
||||||
);
|
);
|
||||||
{
|
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
|
||||||
const encoded = JSON.stringify(catalog);
|
|
||||||
await writeFile(join(values.output, "catalog.json"), encoded);
|
|
||||||
report.catalog_sha256 = hash(encoded);
|
|
||||||
}
|
|
||||||
report.tool_names = [...definitions.keys()];
|
report.tool_names = [...definitions.keys()];
|
||||||
if (values.phase === "discovery") {
|
const call = async (name, args) => {
|
||||||
const prompts = await client.listPrompts();
|
const definition = definitions.get(name);
|
||||||
assert.equal(prompts.nextCursor, undefined);
|
assert.ok(definition, `Missing named tool ${name}`);
|
||||||
await writeFile(
|
const start = performance.now();
|
||||||
join(values.output, "prompts.json"),
|
const reply = await client.callTool(
|
||||||
JSON.stringify(prompts),
|
{ name, arguments: args },
|
||||||
|
{ toolDefinition: definition },
|
||||||
);
|
);
|
||||||
report.prompts_sha256 = hash(JSON.stringify(prompts));
|
report.calls.push({
|
||||||
report.answer = {
|
name,
|
||||||
tools: report.tool_names,
|
elapsed_ms: performance.now() - start,
|
||||||
prompts: prompts.prompts.map((prompt) => prompt.name),
|
is_error: reply.isError === true,
|
||||||
};
|
});
|
||||||
report.status = "passed";
|
transcript.push({ name, arguments: args, reply });
|
||||||
} else {
|
return reply;
|
||||||
const call = async (name, args) => {
|
};
|
||||||
const definition = definitions.get(name);
|
const input = {
|
||||||
assert.ok(definition, `Missing named tool ${name}`);
|
path: values.artifact,
|
||||||
const start = performance.now();
|
view: {
|
||||||
const reply = await client.callTool(
|
collection: "packages",
|
||||||
{ name, arguments: args },
|
names: ["serde"],
|
||||||
{ toolDefinition: definition },
|
fields: ["name", "version", "kind"],
|
||||||
);
|
},
|
||||||
report.calls.push({
|
};
|
||||||
name,
|
const first = await call("inspect_rust_binary", input);
|
||||||
elapsed_ms: performance.now() - start,
|
assert.notEqual(first.isError, true);
|
||||||
is_error: reply.isError === true,
|
const selected = first.structuredContent;
|
||||||
});
|
assert.ok(selected?.normalized_result?.page);
|
||||||
transcript.push({ name, arguments: args, reply });
|
assert.deepEqual(selected.normalized_result.page.rows, oracle.serde_rows);
|
||||||
return reply;
|
assert.equal(selected.subject.digest.sha256, artifactSha);
|
||||||
};
|
const parentId = selected.normalized_result.parent_evidence_id;
|
||||||
const input = {
|
for (let index = 0; index < 2; index++) {
|
||||||
path: values.artifact,
|
const reply = await call("inspect_analysis_view", {
|
||||||
|
source: { kind: "retained-evidence", evidence_id: parentId },
|
||||||
view: {
|
view: {
|
||||||
|
kind: "rust",
|
||||||
collection: "packages",
|
collection: "packages",
|
||||||
names: ["serde"],
|
names: ["serde"],
|
||||||
fields: ["name", "version", "kind"],
|
fields: ["name", "version", "kind"],
|
||||||
},
|
},
|
||||||
};
|
|
||||||
const first = await call("inspect_rust_binary", input);
|
|
||||||
assert.notEqual(first.isError, true);
|
|
||||||
const selected = first.structuredContent;
|
|
||||||
assert.ok(selected?.normalized_result?.page);
|
|
||||||
assert.deepEqual(selected.normalized_result.page.rows, oracle.serde_rows);
|
|
||||||
assert.equal(selected.subject.digest.sha256, artifactSha);
|
|
||||||
const parentId = selected.normalized_result.parent_evidence_id;
|
|
||||||
for (let index = 0; index < 2; index++) {
|
|
||||||
const reply = await call("inspect_analysis_view", {
|
|
||||||
source: { kind: "retained-evidence", evidence_id: parentId },
|
|
||||||
view: {
|
|
||||||
kind: "rust",
|
|
||||||
collection: "packages",
|
|
||||||
names: ["serde"],
|
|
||||||
fields: ["name", "version", "kind"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.notEqual(reply.isError, true);
|
|
||||||
assert.deepEqual(
|
|
||||||
reply.structuredContent.normalized_result.page.rows,
|
|
||||||
oracle.serde_rows,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
reply.structuredContent.normalized_result.parent_evidence_id,
|
|
||||||
parentId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const malformed = await call("inspect_rust_binary", {
|
|
||||||
path: values.artifact,
|
|
||||||
view: { collection: "packages", offset: -1 },
|
|
||||||
});
|
});
|
||||||
assert.equal(malformed.isError, true);
|
assert.notEqual(reply.isError, true);
|
||||||
report.checks.push(
|
|
||||||
"Independent package rows and digest; repeated retained identity; canonical negative-offset refusal",
|
|
||||||
);
|
|
||||||
const exported = await call("export_evidence_bundle", {
|
|
||||||
path: join(values.output, "evidence.json"),
|
|
||||||
});
|
|
||||||
assert.notEqual(exported.isError, true);
|
|
||||||
const bundle = JSON.parse(
|
|
||||||
await readFile(join(values.output, "evidence.json"), "utf8"),
|
|
||||||
);
|
|
||||||
const parent = bundle.records.find(
|
|
||||||
(record) => record.evidence_id === parentId,
|
|
||||||
);
|
|
||||||
assert.ok(parent, "Complete original Evidence is retained");
|
|
||||||
assert.equal(parent.subject.digest.sha256, artifactSha);
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
parent.normalized_result.auditable_metadata.packages,
|
reply.structuredContent.normalized_result.page.rows,
|
||||||
oracle.packages,
|
oracle.serde_rows,
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.equal(
|
||||||
parent.normalized_result.auditable_metadata.dependency_edges,
|
reply.structuredContent.normalized_result.parent_evidence_id,
|
||||||
oracle.edges,
|
parentId,
|
||||||
);
|
);
|
||||||
report.parent_evidence_id = parentId;
|
|
||||||
report.parent_semantic_sha256 = hash(JSON.stringify(parent));
|
|
||||||
report.answer = oracle.serde_rows;
|
|
||||||
assert.deepEqual(
|
|
||||||
await client.listTools(),
|
|
||||||
catalog,
|
|
||||||
"Advertisement changed after canonical materialization",
|
|
||||||
);
|
|
||||||
report.checks.push(
|
|
||||||
"Catalog remains identical after canonical materialization",
|
|
||||||
);
|
|
||||||
report.status = "passed";
|
|
||||||
}
|
}
|
||||||
|
const malformed = await call("inspect_rust_binary", {
|
||||||
|
path: values.artifact,
|
||||||
|
view: { collection: "packages", offset: -1 },
|
||||||
|
});
|
||||||
|
assert.equal(malformed.isError, true);
|
||||||
|
report.checks.push(
|
||||||
|
"Independent package rows and digest; repeated retained identity; canonical negative-offset refusal",
|
||||||
|
);
|
||||||
|
const exported = await call("export_evidence_bundle", {
|
||||||
|
path: join(values.output, "evidence.json"),
|
||||||
|
});
|
||||||
|
assert.notEqual(exported.isError, true);
|
||||||
|
const bundle = JSON.parse(
|
||||||
|
await readFile(join(values.output, "evidence.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const parent = bundle.records.find(
|
||||||
|
(record) => record.evidence_id === parentId,
|
||||||
|
);
|
||||||
|
assert.ok(parent, "Complete original Evidence is retained");
|
||||||
|
assert.equal(parent.subject.digest.sha256, artifactSha);
|
||||||
|
assert.deepEqual(
|
||||||
|
parent.normalized_result.auditable_metadata.packages,
|
||||||
|
oracle.packages,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parent.normalized_result.auditable_metadata.dependency_edges,
|
||||||
|
oracle.edges,
|
||||||
|
);
|
||||||
|
report.parent_evidence_id = parentId;
|
||||||
|
report.parent_semantic_sha256 = hash(JSON.stringify(parent));
|
||||||
|
report.answer = oracle.serde_rows;
|
||||||
|
report.status = "passed";
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await transport.close();
|
await transport.close();
|
||||||
report.cleanup_verified = transport.pid === null;
|
report.cleanup_verified = transport.pid === null;
|
||||||
assert.ok(report.cleanup_verified);
|
assert.ok(report.cleanup_verified);
|
||||||
report.elapsed_ms = performance.now() - began;
|
report.elapsed_ms = performance.now() - began;
|
||||||
if (expectedBinding !== undefined) {
|
|
||||||
const start = performance.now();
|
|
||||||
const after = await captureRuntimeBinding(values.runtime, "mcp");
|
|
||||||
report.runtime_audit_ms = {
|
|
||||||
before: auditBeforeMs,
|
|
||||||
after: performance.now() - start,
|
|
||||||
};
|
|
||||||
assertRuntimeBinding(after, expectedBinding, values.role);
|
|
||||||
assert.deepEqual(after, bindingBefore);
|
|
||||||
assert.equal(
|
|
||||||
hash(await readFile(values.bindings)),
|
|
||||||
measurementInputsSha256,
|
|
||||||
);
|
|
||||||
for (const [name, digest] of Object.entries(producerSources))
|
|
||||||
assert.equal(
|
|
||||||
hash(await readFile(new URL(name, import.meta.url))),
|
|
||||||
digest,
|
|
||||||
);
|
|
||||||
report.runtime_binding = after;
|
|
||||||
report.runtime_binding_verified = true;
|
|
||||||
}
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(values.output, "report.json"),
|
join(values.output, "report.json"),
|
||||||
JSON.stringify(report, null, 2) + "\n",
|
JSON.stringify(report, null, 2) + "\n",
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
"""Verify paired SDK startup/analysis receipts and account for the whole task."""
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import statistics
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import tiktoken
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument('root', type=Path)
|
|
||||||
parser.add_argument('--output', type=Path, required=True)
|
|
||||||
args = parser.parse_args()
|
|
||||||
root = args.root
|
|
||||||
input_bytes = (root / 'measurement-inputs.json').read_bytes()
|
|
||||||
inputs = json.loads(input_bytes)
|
|
||||||
expected = inputs['runtime_bindings']
|
|
||||||
sha = lambda value: hashlib.sha256(value).hexdigest()
|
|
||||||
compact = lambda value: json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False)
|
|
||||||
encoding = tiktoken.get_encoding('cl100k_base')
|
|
||||||
count = lambda value: len(encoding.encode(value, disallowed_special=()))
|
|
||||||
for role in ['baseline', 'candidate']:
|
|
||||||
assert expected[role]['git_revision'] == inputs[f'{role}_revision']
|
|
||||||
assert expected[role]['source_diff_sha256'] == sha(b'')
|
|
||||||
assert expected[role]['untracked_source_sha256'] == sha(b'[]')
|
|
||||||
assert expected['baseline']['root'] != expected['candidate']['root']
|
|
||||||
assert expected['baseline']['compiled_runtime_sha256'] != expected['candidate']['compiled_runtime_sha256']
|
|
||||||
spec = importlib.util.spec_from_file_location('catalog_costs', Path(__file__).with_name('mcp-input-catalog-costs.py'))
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
catalogs = {}
|
|
||||||
identities = {}
|
|
||||||
rows = []
|
|
||||||
for case in inputs['cases']:
|
|
||||||
directory = root / case['case']
|
|
||||||
report = json.loads((directory / 'report.json').read_text())
|
|
||||||
assert report['status'] == 'passed' and report['cleanup_verified']
|
|
||||||
assert report['runtime_binding_verified']
|
|
||||||
assert report['measurement_inputs_sha256'] == sha(input_bytes)
|
|
||||||
for name, producer_digest in report['producer_sources_sha256'].items():
|
|
||||||
assert producer_digest == inputs['harness_sources_sha256'][name]
|
|
||||||
assert report['runtime_binding'] == expected[case['role']]
|
|
||||||
for key in ['role', 'cache', 'profile', 'phase']:
|
|
||||||
assert report[key] == case[key]
|
|
||||||
raw = (directory / 'catalog.json').read_bytes()
|
|
||||||
catalog_digest = sha(raw)
|
|
||||||
assert catalog_digest == report['catalog_sha256']
|
|
||||||
profile = case['profile']
|
|
||||||
if profile not in catalogs:
|
|
||||||
catalog = json.loads(raw)
|
|
||||||
definitions = [{key: tool[key] for key in ['name', 'description', 'inputSchema']} for tool in catalog['tools']]
|
|
||||||
catalogs[profile] = {'sha256': catalog_digest, 'tool_count': len(catalog['tools']),
|
|
||||||
'input_catalog_tokens': count(compact(definitions)),
|
|
||||||
'raw_catalog_tokens': count(raw.decode()), 'raw_catalog_bytes': len(raw)}
|
|
||||||
assert catalog_digest == catalogs[profile]['sha256'], 'Directory differs across roles or phases'
|
|
||||||
transcript = json.loads((directory / 'transcript.json').read_text())
|
|
||||||
server = json.loads((directory / 'server.json').read_text())
|
|
||||||
tokens = {'question_and_recipe': count(inputs['questions'][case['phase']] + '\n' + inputs['recipes'][case['phase']]),
|
|
||||||
'server_instructions': count(server.get('instructions') or ''),
|
|
||||||
'input_catalog_once': catalogs[profile]['input_catalog_tokens'],
|
|
||||||
'arguments': sum(count(compact({'name': call['name'], 'arguments': call['arguments']})) for call in transcript),
|
|
||||||
'text_replies': sum(count(block['text']) for call in transcript for block in call['reply']['content'] if block['type'] == 'text')}
|
|
||||||
requests = report['protocol_requests']
|
|
||||||
assert requests['initialize'] == 1
|
|
||||||
if case['phase'] == 'analysis':
|
|
||||||
assert len(transcript) == 5 and requests['tools/call'] == 5 and requests['tools/list'] == 2
|
|
||||||
assert report['artifact_sha256'] == inputs['artifacts'][case['artifact']]['sha256']
|
|
||||||
identity = (report['parent_evidence_id'], report['parent_semantic_sha256'], sha(compact(report['answer']).encode()))
|
|
||||||
assert identities.setdefault(case['artifact'], identity) == identity
|
|
||||||
assert sum(call['is_error'] for call in report['calls']) == 1
|
|
||||||
else:
|
|
||||||
assert not transcript and requests.get('tools/call', 0) == 0
|
|
||||||
assert requests['tools/list'] == 1 and requests['prompts/list'] == 1
|
|
||||||
prompts = (directory / 'prompts.json').read_bytes()
|
|
||||||
assert sha(prompts) == report['prompts_sha256']
|
|
||||||
assert identities.setdefault('prompts', report['prompts_sha256']) == report['prompts_sha256']
|
|
||||||
tokens['prompt_directory'] = count(prompts.decode())
|
|
||||||
tokens['whole_task'] = sum(tokens.values())
|
|
||||||
resource = module.resource(directory)
|
|
||||||
for name, guard_digest in resource['guard_sources_sha256'].items():
|
|
||||||
assert guard_digest == inputs['harness_sources_sha256'][name]
|
|
||||||
del resource['guard_sources_sha256']
|
|
||||||
row = {**case, 'runtime_binding_sha256': sha(compact(report['runtime_binding']).encode()), 'runtime_audit_ms': report['runtime_audit_ms'],
|
|
||||||
'catalog_sha256': catalog_digest, 'parent_evidence_id': report.get('parent_evidence_id'),
|
|
||||||
'parent_semantic_sha256': report.get('parent_semantic_sha256'),
|
|
||||||
'answer_sha256': sha(compact(report['answer']).encode()), 'tokens_estimated': tokens,
|
|
||||||
'raw_catalog_result_tokens_all_lists': catalogs[profile]['raw_catalog_tokens'] * requests['tools/list'],
|
|
||||||
'raw_catalog_result_bytes_all_lists': catalogs[profile]['raw_catalog_bytes'] * requests['tools/list'],
|
|
||||||
'actual_model_usage': None, 'protocol_era': report['protocol_era'], 'protocol_requests': requests, 'calls': report['calls'],
|
|
||||||
**resource,
|
|
||||||
'receipt_sha256': {name: sha((directory / name).read_bytes()) for name in ['report.json', 'catalog.json', 'server.json', 'transcript.json']}}
|
|
||||||
assert row['catalog_sha256'] == row['receipt_sha256']['catalog.json'] == catalogs[profile]['sha256']
|
|
||||||
rows.append(row)
|
|
||||||
assert len(rows) == 72
|
|
||||||
summary = []
|
|
||||||
for phase in ['discovery', 'analysis']:
|
|
||||||
for cache in ['cold', 'warm']:
|
|
||||||
for profile in ['default', 'full']:
|
|
||||||
for artifact in ([None] if phase == 'discovery' else ['ripgrep', 'zellij']):
|
|
||||||
item = {'phase': phase, 'cache': cache, 'profile': profile, 'artifact': artifact}
|
|
||||||
for role in ['baseline', 'candidate']:
|
|
||||||
selected = [row for row in rows if all(row[key] == item[key] for key in ['phase', 'cache', 'profile', 'artifact']) and row['role'] == role]
|
|
||||||
assert len(selected) == 3
|
|
||||||
item[role] = {metric: {'median': statistics.median(row[metric] for row in selected),
|
|
||||||
'min': min(row[metric] for row in selected), 'max': max(row[metric] for row in selected)}
|
|
||||||
for metric in ['wall_seconds', 'cpu_seconds', 'kernel_family_peak_bytes_including_page_cache']}
|
|
||||||
item[role]['tokens_estimated'] = [row['tokens_estimated']['whole_task'] for row in selected]
|
|
||||||
summary.append(item)
|
|
||||||
result = {'status': 'passed', 'tokenizer': f'tiktoken=={tiktoken.__version__}/cl100k_base; estimates, not model usage',
|
|
||||||
'aggregation_sources_sha256': {path.name: sha(path.read_bytes()) for path in [Path(__file__), Path(spec.origin)]},
|
|
||||||
'context_scope': 'Question, explicit recipe, server instructions, one complete name/description/input-schema directory, every tool argument and text reply, plus prompt directory for discovery. This projection assumes the client retains a single unchanged directory. Raw catalog result costs include every observed tools/list result separately, with output schemas and without JSON-RPC envelopes. SDK initialization/framing, reasoning and actual model usage are unmeasured. The analysis task includes retained-read, rejection, export/oracle and post-materialization directory checks.',
|
|
||||||
'cache_scope': 'Cold disables server Node compile cache. Warm enables a prepopulated role/profile-specific cache after one complete analysis priming run. Fresh server/client processes and HOME each trial. Driver compile cache disabled in both cohorts. Cache hit rate and OS page cache are unmeasured; source/runtime audits warm compiled-file pages before startup.',
|
|
||||||
'resource_scope': 'Whole SDK driver, resource observer and owned REA process family. Includes before/after source-and-all-compiled-JS/JSON binding, SDK schema checks, extra validation calls, bundle export and independent oracle checking. Kernel cgroup peak includes file cache. Build/download/oracle preparation and cache priming excluded. Retained V8 heap and forced-GC diagnostics were not separately measured.',
|
|
||||||
'measurement_inputs': inputs, 'catalogs': catalogs, 'summary': summary, 'records': rows,
|
|
||||||
'warmup_receipts': {name: sha((root / name / 'report.json').read_bytes()) for name in [f'warmup-{profile}-{role}' for profile in ['default', 'full'] for role in ['baseline', 'candidate']]}}
|
|
||||||
args.output.write_text(json.dumps(result, indent=2) + '\n')
|
|
||||||
print(json.dumps(summary, indent=2))
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,61 +0,0 @@
|
|||||||
# MCP discovery and complete Rust tasks
|
|
||||||
|
|
||||||
Target-free stdio discovery previously constructed the canonical analysis runtime before the client could inspect its directory. The candidate records SDK advertisements at build time for default/explicit compact and explicit full inputs. Its first tool, prompt or completion request materializes canonical validators and handlers on the same SDK server. HTTP, output-schema-off, initial-target and unrecorded source runs retain eager composition.
|
|
||||||
|
|
||||||
Pinned baseline: [`main@c465ff5a8`](https://github.com/morluto/rea/commit/c465ff5a814b8e1a386fc547cbcd94df5c35f086). Measured candidate: [`04900abbc`](https://github.com/leeguooooo/rea/commit/04900abbcd3c3438fce4af932ff28400fe744a7d). Both use the same main base and locked dependencies. Before/after source and all compiled JS/JSON bindings identify the exact runtime used in every trial. The input manifest and producer scripts are also hashed and checked.
|
|
||||||
|
|
||||||
## Real tasks and independent facts
|
|
||||||
|
|
||||||
Official Alpine v3.21 packages supply ripgrep 14.1.1-r0 (3,416,168-byte executable) and Zellij 0.40.0-r0 (17,151,864 bytes). Their download URLs, archive/executable digests and aports revisions are retained in [the measurement data](mcp-startup.json). The independent Python ELF/section/zlib/JSON oracle checks 42 packages and 88 dependency edges for ripgrep, and 422 packages and 1,063 edges for Zellij. Serde versions are 1.0.210 and 1.0.137 respectively; the selected package kind remains explicit unknown (`null`). Verification covers embedded static build metadata; execution and runtime reachability remain unverified.
|
|
||||||
|
|
||||||
Each analysis task initializes the actual SDK client, lists and validates the input directory, selects serde name/version/kind, reads the exact retained parent twice, verifies canonical rejection of a negative offset, exports and independently checks the complete package/edge inventory, confirms unchanged advertisements after materialization, then closes. All five tool calls and both directory reads are included in the measured task. Discovery tasks initialize, list tools/prompts, validate the input directory and close without an analysis call.
|
|
||||||
|
|
||||||
All 72 paired trials and four priming runs passed. Each artifact has identical answers, parent Evidence IDs and complete semantic hashes across roles, profiles and cache modes. Both profiles retain byte-identical directories across baseline/candidate and before/after canonical materialization. The directory contains 213 tools. Complete Evidence is retained and exported through the existing shared workflow.
|
|
||||||
|
|
||||||
## Measured cost
|
|
||||||
|
|
||||||
Three alternating pairs per condition; table values are medians. Min/max ranges and individual receipts are in [mcp-startup.json](mcp-startup.json). Each cell is baseline → candidate. Kernel process-family peak includes file cache, SDK driver, observer and REA children.
|
|
||||||
|
|
||||||
| Task | Cache | Inputs | Wall seconds | Family CPU seconds | Family peak MiB |
|
|
||||||
| --------- | ----- | ----------------- | ------------: | -----------------: | --------------: |
|
|
||||||
| Directory | cold | compact (default) | 5.661 → 2.236 | 5.403 → 2.050 | 559.3 → 248.6 |
|
|
||||||
| Directory | cold | full | 5.256 → 2.337 | 5.040 → 2.175 | 650.9 → 349.1 |
|
|
||||||
| Directory | warm | compact (default) | 5.056 → 2.236 | 4.800 → 2.015 | 572.8 → 251.4 |
|
|
||||||
| Directory | warm | full | 4.957 → 2.338 | 4.754 → 2.139 | 672.7 → 349.6 |
|
|
||||||
| ripgrep | cold | compact (default) | 8.177 → 8.078 | 7.945 → 7.899 | 849.7 → 792.5 |
|
|
||||||
| zellij | cold | compact (default) | 8.276 → 7.276 | 8.041 → 7.105 | 846.2 → 819.0 |
|
|
||||||
| ripgrep | cold | full | 8.180 → 7.483 | 8.000 → 7.312 | 846.5 → 794.4 |
|
|
||||||
| zellij | cold | full | 8.484 → 7.776 | 8.284 → 7.514 | 850.4 → 813.1 |
|
|
||||||
| ripgrep | warm | compact (default) | 7.976 → 6.879 | 7.739 → 6.678 | 826.8 → 802.6 |
|
|
||||||
| zellij | warm | compact (default) | 8.285 → 7.472 | 8.019 → 7.254 | 882.1 → 811.4 |
|
|
||||||
| ripgrep | warm | full | 8.074 → 7.476 | 7.890 → 7.265 | 852.1 → 803.6 |
|
|
||||||
| zellij | warm | full | 8.382 → 7.479 | 8.151 → 7.293 | 854.3 → 830.0 |
|
|
||||||
|
|
||||||
Discovery improves clearly in this cohort. Completed-task changes depend on the workload: default cold ripgrep is approximately unchanged. Initialization moves into the first actual analysis request; its latency must be assessed with the full workflow.
|
|
||||||
|
|
||||||
| Default ripgrep first RPC | Baseline ms | Candidate ms |
|
|
||||||
| ------------------------- | ----------: | -----------: |
|
|
||||||
| cold | 231.5 | 2532.1 |
|
|
||||||
| warm | 227.1 | 2118.2 |
|
|
||||||
|
|
||||||
Within each profile, advertised context remains identical. Input-directory estimates are 49,670 tokens for default compact and 205,744 for full. Whole-task estimates include the supplied question/recipe, server instructions, directory once, every tool argument and text reply, and the prompt directory in discovery tasks. This projection assumes the client retains one unchanged directory; raw catalog result costs count every observed list result separately, including output schemas. These use tiktoken 0.14.0/cl100k_base; actual model usage, reasoning and SDK initialization/framing are unmeasured.
|
|
||||||
|
|
||||||
Linux x64 / AMD EPYC 7B12 / kernel 6.1.0-54-cloud-amd64 / Node 24.18.0, npm 11.16.0. Timed SDK tasks negotiated the legacy protocol era; auto/modern correctness is covered separately by compiled boundary tests. Trials run serially under a 1-CPU quota pinned to CPU 15, 4 GiB hard memory / 3 GiB early stop, no swap and 192 tasks. No resource stop, OOM or remaining payload process was observed. Cold disables server Node compile cache; warm uses a role/profile-specific cache populated by one complete task. Fresh processes and HOME are used each trial. Cache hit rate and OS page cache are unmeasured. Runtime audits warm compiled-file pages and are included in whole-task costs. Builds, downloads, oracle generation and cache priming are preparation outside those costs. Retained V8 heap and forced-GC measurements were not separately collected.
|
|
||||||
|
|
||||||
## Ownership and validation
|
|
||||||
|
|
||||||
`OwnedMcpServers` seals publication before taking its close snapshot, keeps failed exact close capabilities for retry and serves both eager and deferred paths. Deferred startup still joins servers published before an asynchronous serving handle settles. Post-snapshot and post-close factory calls are refused before allocation. Both production-boundary regressions fail with only the shared guard removed and pass restored.
|
|
||||||
|
|
||||||
The moved transport/delivery implementation preserves current complete-error retention, escaped-preview fitting, response/string-limit recovery, target-content provenance and native Evidence continuations. Canonical parsers remain authoritative after lazy binding. Both protocol eras, input/output profile choices, invalid input/output, completion cancellation, partial startup and cleanup retry are exercised.
|
|
||||||
|
|
||||||
Aggregation keeps catalog, producer-script and guard-script digests in distinct variables. Before publishing a trial it verifies that the catalog digest matches both its catalog-file receipt and the shared profile identity. Deliberately selecting the guard digest for that field is rejected. Regeneration preserves all measured timing, CPU, memory, context and parent-Evidence values.
|
|
||||||
|
|
||||||
Executed local gates: 125 focused tests across 14 files; a repeated 22-case schema/profile subset after the pinned main merge; all six static-check tasks plus architecture guards; builds for both pinned runtimes; six documentation checks and five README consistency tests. HTTP coverage includes the real compiled CLI session/replacement/Host tests. Runtime-binding admission rejects a mismatched compiled graph before MCP launch; aggregation rejects a tampered trial binding. The raw receipts remain in `/tmp/rea-perf-mcp-startup-20261022`, identified by hashes in the data. Real initial-target providers, foreign OS runtime behavior and other providers remain outside this measurement.
|
|
||||||
|
|
||||||
## Reproduction
|
|
||||||
|
|
||||||
Use the pinned Node/npm toolchain and separate clean baseline/candidate checkouts. Build each with `npm ci` and `npm run build:cached` (the measured workspace shared one locked dependency installation). Acquire and verify the official packages listed in the data, extract the named executables and generate independent oracles with `mcp-input-catalog-oracle.py`.
|
|
||||||
|
|
||||||
Capture each checkout with `node experiments/analysis-efficiency/managed-cli-runtime.mjs CHECKOUT mcp`. Construct `measurement-inputs.json` using the recorded schema, updating absolute checkout/artifact/oracle paths and their independently verified bindings. Keep it immutable through a cohort; producer source digests cover `mcp-input-catalog.mjs`, `managed-cli-runtime.mjs`, `resource-run.py` and `resource_payload.py`.
|
|
||||||
|
|
||||||
Run `mcp-input-catalog.mjs` through `resource-run.py --observe-payload-exit` with `--runtime`, `--output`, `--bindings`, `--role baseline|candidate`, `--profile default|full`, `--phase discovery|analysis` and `--cache cold|warm`. Analysis also takes `--artifact` and `--oracle`; warm requires a role/profile-specific `--cache-root`. Disable the driver compile cache for both cohorts. Prime each warm role/profile once with the complete ripgrep task. Follow the 72-case order in the data, reversing role order in every second pair. Aggregate with `python mcp-startup-costs.py LAB --output mcp-startup.json` using tiktoken 0.14.0. The aggregator verifies every input/runtime/guard binding and completed receipt before producing summaries.
|
|
||||||
+2
-2
@@ -305,8 +305,8 @@
|
|||||||
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
|
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
|
||||||
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
|
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
|
||||||
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
|
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
|
||||||
"compile": "node scripts/clean-build-output.mjs && node scripts/run-typescript.mjs -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/bundle-runtime.mjs",
|
"compile": "node scripts/clean-build-output.mjs && node scripts/run-typescript.mjs -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs",
|
||||||
"compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/bundle-runtime.mjs",
|
"compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs",
|
||||||
"artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
|
"artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
|
||||||
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
|
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
|
||||||
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
|
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export const parseDevelopmentTestRequest = (mode, arguments_) => {
|
|||||||
// runtime or typecheck prerequisites. Add a consumer here when it reads one.
|
// runtime or typecheck prerequisites. Add a consumer here when it reads one.
|
||||||
const artifactConsumers = {
|
const artifactConsumers = {
|
||||||
"artifacts:mcp-catalog": [
|
"artifacts:mcp-catalog": [
|
||||||
"tests/boundary/mcp/deferredRuntime.test.ts",
|
|
||||||
"tests/boundary/mcp/contractPresentation.test.ts",
|
"tests/boundary/mcp/contractPresentation.test.ts",
|
||||||
"tests/boundary/mcp/toolSchemaValidity.test.ts",
|
"tests/boundary/mcp/toolSchemaValidity.test.ts",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { dirname, join, resolve } from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
||||||
|
|
||||||
// Runtime discovery is generated from the same canonical registrations, after
|
|
||||||
// default identity has been recorded and before bundling. Test catalogs stay separate.
|
|
||||||
const compiled = resolve(dirname(fileURLToPath(import.meta.url)), "../dist");
|
|
||||||
const read = (name) => import(pathToFileURL(join(compiled, name)).href);
|
|
||||||
const [
|
|
||||||
{ createServer },
|
|
||||||
{ createBinarySession },
|
|
||||||
{ parseConfig },
|
|
||||||
{ silentLogger },
|
|
||||||
{ PROMPT_CONTRACTS },
|
|
||||||
] = await Promise.all([
|
|
||||||
read("server/createServer.js"),
|
|
||||||
read("composition/binary.js"),
|
|
||||||
read("config/parseConfig.js"),
|
|
||||||
read("logger.js"),
|
|
||||||
read("contracts/promptContracts.js"),
|
|
||||||
]);
|
|
||||||
const recordCatalog = async (profile) => {
|
|
||||||
// Catalog generation is independent of the host account and user configuration.
|
|
||||||
const home = await mkdtemp(join(tmpdir(), "rea-mcp-catalog-"));
|
|
||||||
const environment = {
|
|
||||||
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
|
|
||||||
HOME: home,
|
|
||||||
USERPROFILE: home,
|
|
||||||
XDG_CONFIG_HOME: join(home, "config"),
|
|
||||||
XDG_CACHE_HOME: join(home, "cache"),
|
|
||||||
XDG_STATE_HOME: join(home, "state"),
|
|
||||||
};
|
|
||||||
const cleanup = [];
|
|
||||||
let producerFailure;
|
|
||||||
try {
|
|
||||||
const config = parseConfig(environment);
|
|
||||||
if (!config.ok) throw config.error;
|
|
||||||
const session = createBinarySession(
|
|
||||||
config.value,
|
|
||||||
silentLogger,
|
|
||||||
environment,
|
|
||||||
);
|
|
||||||
cleanup.push(async () => {
|
|
||||||
const closed = await session.close({ retainProviderDocuments: true });
|
|
||||||
if (!closed.ok) throw closed.error;
|
|
||||||
});
|
|
||||||
const server = createServer({ kind: "session", session }, { environment });
|
|
||||||
cleanup.push(() => server.close());
|
|
||||||
const client = new Client({ name: "rea-catalog-recorder", version: "1" });
|
|
||||||
cleanup.push(() => client.close());
|
|
||||||
const [clientTransport, serverTransport] =
|
|
||||||
InMemoryTransport.createLinkedPair();
|
|
||||||
await server.connect(serverTransport);
|
|
||||||
await client.connect(clientTransport);
|
|
||||||
const tools = (await client.listTools()).tools;
|
|
||||||
const prompts = (await client.listPrompts()).prompts.map((prompt) => {
|
|
||||||
const contract = PROMPT_CONTRACTS.find(
|
|
||||||
(candidate) => candidate.name === prompt.name,
|
|
||||||
);
|
|
||||||
if (contract === undefined)
|
|
||||||
throw new Error(`Missing prompt ${prompt.name}`);
|
|
||||||
return { ...prompt, parameters: contract.arguments };
|
|
||||||
});
|
|
||||||
const catalog = {
|
|
||||||
version: 1,
|
|
||||||
info: client.getServerVersion(),
|
|
||||||
capabilities: client.getServerCapabilities(),
|
|
||||||
instructions: client.getInstructions(),
|
|
||||||
identity: server.catalogIdentity,
|
|
||||||
tools,
|
|
||||||
prompts,
|
|
||||||
};
|
|
||||||
const encoded = JSON.stringify(catalog);
|
|
||||||
const digest = createHash("sha256").update(encoded).digest("hex");
|
|
||||||
await writeFile(
|
|
||||||
join(
|
|
||||||
compiled,
|
|
||||||
profile === "full" ? "mcp-catalog-full.json" : "mcp-catalog.json",
|
|
||||||
),
|
|
||||||
encoded,
|
|
||||||
);
|
|
||||||
return digest;
|
|
||||||
} catch (cause) {
|
|
||||||
producerFailure = { cause };
|
|
||||||
throw cause;
|
|
||||||
} finally {
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
cleanup.reverse().map((close) => Promise.resolve().then(close)),
|
|
||||||
);
|
|
||||||
const failures = results.flatMap((result) =>
|
|
||||||
result.status === "rejected" ? [result.reason] : [],
|
|
||||||
);
|
|
||||||
if (failures.length === 0) {
|
|
||||||
try {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
} catch (cause) {
|
|
||||||
failures.push(cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failures.length > 0)
|
|
||||||
throw new AggregateError(
|
|
||||||
producerFailure === undefined
|
|
||||||
? failures
|
|
||||||
: [producerFailure.cause, ...failures],
|
|
||||||
`Catalog cleanup failed; temporary home retained at ${home}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const digests = {};
|
|
||||||
for (const profile of ["compact", "full"])
|
|
||||||
digests[profile] = await recordCatalog(profile);
|
|
||||||
await writeFile(
|
|
||||||
join(compiled, "recordedMcpCatalog.js"),
|
|
||||||
`/** Recorded from the compiled runtime; do not edit. */\nexport const RECORDED_MCP_CATALOG_SHA256 = ${JSON.stringify(digests)};\n`,
|
|
||||||
);
|
|
||||||
@@ -98,60 +98,26 @@ export const deliverAnalysisEvidence = async (
|
|||||||
)
|
)
|
||||||
: ok(evidence);
|
: ok(evidence);
|
||||||
if (!delivered.ok) return delivered;
|
if (!delivered.ok) return delivered;
|
||||||
const published = await publishEvidenceDelivery({
|
|
||||||
bundle,
|
|
||||||
complete: evidence,
|
|
||||||
delivered: delivered.value,
|
|
||||||
outputPath,
|
|
||||||
operation: evidence.operation,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
return published.ok ? ok(published.value.delivered) : published;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Selected Evidence delivered after its complete bundle was committed. */
|
|
||||||
export interface PublishedEvidenceDelivery {
|
|
||||||
readonly delivered: Evidence;
|
|
||||||
readonly evidenceBundle: PublishedEvidenceFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a complete bundle, then deliver Evidence already projected from it.
|
|
||||||
* Cancellation observed before commit leaves no output and retains the
|
|
||||||
* complete Evidence. A committed publication wins a later cancellation race;
|
|
||||||
* its receipt stays in the typed cancellation as diagnostics.evidence_bundle,
|
|
||||||
* and a cleanup failure after commit keeps its own published receipt.
|
|
||||||
*/
|
|
||||||
export const publishEvidenceDelivery = async (input: {
|
|
||||||
readonly bundle: EvidenceBundle;
|
|
||||||
readonly complete: Evidence;
|
|
||||||
readonly delivered: Evidence;
|
|
||||||
readonly outputPath: string;
|
|
||||||
readonly operation: string;
|
|
||||||
readonly signal?: AbortSignal | undefined;
|
|
||||||
}): Promise<Result<PublishedEvidenceDelivery, AnalysisError>> => {
|
|
||||||
const { signal, operation } = input;
|
|
||||||
const written = await persistEvidenceBundleCommand(
|
const written = await persistEvidenceBundleCommand(
|
||||||
input.bundle,
|
bundle,
|
||||||
input.outputPath,
|
outputPath,
|
||||||
false,
|
false,
|
||||||
signal,
|
signal,
|
||||||
operation,
|
evidence.operation,
|
||||||
);
|
);
|
||||||
// Deliver interrupts queued during final publication cleanup before the
|
// Deliver interrupts queued during final publication cleanup before the
|
||||||
// command's signal owner accepts completion and removes its listeners.
|
// command's signal owner accepts completion and removes its listeners.
|
||||||
await dispatchQueuedInterrupts();
|
await dispatchQueuedInterrupts();
|
||||||
if (!written.ok)
|
if (!written.ok) return err(written.error.retainPartialObservation(evidence));
|
||||||
return err(written.error.retainPartialObservation(input.complete));
|
|
||||||
if (signal?.aborted)
|
if (signal?.aborted)
|
||||||
return err(
|
return err(
|
||||||
new AnalysisCancelledError(operation, {
|
new AnalysisCancelledError(evidence.operation, {
|
||||||
cause: signal.reason,
|
cause: signal.reason,
|
||||||
partialObservation: input.delivered,
|
partialObservation: delivered.value,
|
||||||
diagnostics: { evidence_bundle: { ...written.value } },
|
diagnostics: { evidence_bundle: { ...written.value } },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return ok({ delivered: input.delivered, evidenceBundle: written.value });
|
return delivered;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Resolve the caller's exact retained parent from an authenticated portable bundle. */
|
/** Resolve the caller's exact retained parent from an authenticated portable bundle. */
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
RegularFileCleanupFailure,
|
RegularFileCleanupFailure,
|
||||||
retryRegularFileCleanup,
|
retryRegularFileCleanup,
|
||||||
} from "./RegularFileRead.js";
|
} from "./RegularFileRead.js";
|
||||||
import { referenceSourceResourceFailure } from "../reference/ReferenceSourceReaderErrors.js";
|
|
||||||
import type { ReferenceSourceReaderError } from "../reference/ReferenceSourceReaderTypes.js";
|
import type { ReferenceSourceReaderError } from "../reference/ReferenceSourceReaderTypes.js";
|
||||||
|
|
||||||
export type ReferenceSourceVcsInfo =
|
export type ReferenceSourceVcsInfo =
|
||||||
@@ -60,7 +59,13 @@ export const readReferenceSourceVcs = async (
|
|||||||
});
|
});
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
|
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
|
||||||
return err(referenceSourceResourceFailure(cause));
|
return err({
|
||||||
|
tag: "reference-source-reader",
|
||||||
|
code: cause.reason === "cancelled" ? "cancelled" : "io",
|
||||||
|
message: cause.message,
|
||||||
|
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
|
||||||
|
cause,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SCENARIO_ORIGIN,
|
||||||
|
scenarioCaptureEvidence,
|
||||||
|
scenarioCaptureFixture,
|
||||||
|
} from "../../../tests/fixtures/browserScenarioCapture.js";
|
||||||
|
import { inspectAnalysisView } from "./AnalysisViewService.js";
|
||||||
|
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||||
|
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
|
||||||
|
import type { BrowserScenarioCapture } from "../../domain/browserScenarioCapture.js";
|
||||||
|
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisView.js";
|
||||||
|
|
||||||
|
const tree = (text: string) => ({
|
||||||
|
state: "captured" as const,
|
||||||
|
value: {
|
||||||
|
sha256: createHash("sha256").update(text).digest("hex"),
|
||||||
|
bytes: Buffer.byteLength(text),
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Three steps: navigate to /quiz, capture its tree, then fail on a missing control. */
|
||||||
|
const quizCapture = (
|
||||||
|
thirdStep: "failed" | "completed" = "failed",
|
||||||
|
): BrowserScenarioCapture => {
|
||||||
|
const base = scenarioCaptureFixture();
|
||||||
|
const [initial, browse] = base.steps;
|
||||||
|
if (initial === undefined || browse === undefined)
|
||||||
|
throw new Error("Fixture lacks steps");
|
||||||
|
const quiz = sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/quiz`);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
scenario: { ...base.scenario, action_count: 2 },
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
...initial,
|
||||||
|
artifacts: {
|
||||||
|
...initial.artifacts,
|
||||||
|
url: { state: "captured", value: initial.after_url },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...browse,
|
||||||
|
after_url: quiz,
|
||||||
|
artifacts: {
|
||||||
|
...browse.artifacts,
|
||||||
|
accessibility: tree('- heading "Question 1"'),
|
||||||
|
screenshot: { state: "missing", reason: "page closed" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...browse,
|
||||||
|
step_index: 2,
|
||||||
|
step_id: "answer",
|
||||||
|
action: "click",
|
||||||
|
before_url: quiz,
|
||||||
|
after_url: quiz,
|
||||||
|
...(thirdStep === "failed"
|
||||||
|
? { status: "failed" as const, error: "locator not found" }
|
||||||
|
: { status: "completed" as const, error: null }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const view = (
|
||||||
|
capture: BrowserScenarioCapture,
|
||||||
|
request: AnalysisViewRequest,
|
||||||
|
) => {
|
||||||
|
const projected = inspectAnalysisView({
|
||||||
|
source: { kind: "inline", evidence: scenarioCaptureEvidence(capture) },
|
||||||
|
view: request,
|
||||||
|
});
|
||||||
|
if (!projected.ok) throw projected.error;
|
||||||
|
return projected.value.normalized_result as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("browser scenario analysis views", () => {
|
||||||
|
it("summarizes steps compactly and focuses the first failing step's nearest captured tree", () => {
|
||||||
|
const summary = view(quizCapture(), { kind: "summary" });
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
kind: "summary",
|
||||||
|
parent_operation: "capture_browser_scenario",
|
||||||
|
artifact: { path: "", sha256: null },
|
||||||
|
coverage: { status: "complete-within-view", exhausted: true },
|
||||||
|
});
|
||||||
|
const { steps, final_accessibility, network, events } =
|
||||||
|
summary.summary as Record<string, unknown> & {
|
||||||
|
steps: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
// before_url repeats only across a discontinuity; not_requested sections are omitted.
|
||||||
|
expect(steps[0]).toHaveProperty("before_url");
|
||||||
|
expect(steps[1]).not.toHaveProperty("before_url");
|
||||||
|
expect(steps[2]).not.toHaveProperty("before_url");
|
||||||
|
expect(steps[0]?.sections).toEqual({
|
||||||
|
captured: ["url"],
|
||||||
|
missing: [],
|
||||||
|
truncated: [],
|
||||||
|
});
|
||||||
|
expect(steps[1]?.sections).toEqual({
|
||||||
|
captured: ["accessibility"],
|
||||||
|
missing: ["screenshot"],
|
||||||
|
truncated: [],
|
||||||
|
});
|
||||||
|
expect(steps[2]).toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
error: "locator not found",
|
||||||
|
after_url: { url: `${SCENARIO_ORIGIN}/quiz` },
|
||||||
|
});
|
||||||
|
expect(final_accessibility).toEqual({
|
||||||
|
focus_step_index: 2,
|
||||||
|
focus: "first-unsuccessful-step",
|
||||||
|
step_index: 1,
|
||||||
|
accessibility: tree('- heading "Question 1"'),
|
||||||
|
});
|
||||||
|
expect(network).toEqual({
|
||||||
|
transactions: 8,
|
||||||
|
by_origin: [{ origin: SCENARIO_ORIGIN, transactions: 8 }],
|
||||||
|
by_resource_type: [
|
||||||
|
{ resource_type: "document", transactions: 1 },
|
||||||
|
{ resource_type: "fetch", transactions: 7 },
|
||||||
|
],
|
||||||
|
by_status_class: [
|
||||||
|
{ status_class: "2xx", transactions: 7 },
|
||||||
|
{ status_class: "failed", transactions: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(events).toMatchObject({ retained: 24, dropped: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the last step's own state when no step captured a tree", () => {
|
||||||
|
const capture = scenarioCaptureFixture();
|
||||||
|
expect(view(capture, { kind: "summary" })).toMatchObject({
|
||||||
|
summary: {
|
||||||
|
final_accessibility: {
|
||||||
|
focus_step_index: 1,
|
||||||
|
focus: "last-step",
|
||||||
|
step_index: null,
|
||||||
|
accessibility: { state: "not_requested" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browser scenario analysis view pages", () => {
|
||||||
|
it("pages exact steps with only the selected captured sections", () => {
|
||||||
|
const page = view(quizCapture("completed"), {
|
||||||
|
kind: "browser-scenario",
|
||||||
|
collection: "steps",
|
||||||
|
step_indices: [1, 2],
|
||||||
|
artifacts: ["accessibility"],
|
||||||
|
offset: 0,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
expect(page).toMatchObject({
|
||||||
|
kind: "browser-scenario",
|
||||||
|
source_completeness: { status: "complete" },
|
||||||
|
source_events: { retained: 24, dropped: 0 },
|
||||||
|
coverage: { status: "page", examined: 1, total: 2, next_offset: 1 },
|
||||||
|
items: {
|
||||||
|
collection: "steps",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
step_index: 1,
|
||||||
|
artifacts: { accessibility: tree('- heading "Question 1"') },
|
||||||
|
omitted_artifacts: [{ section: "screenshot", state: "missing" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters events by step, kind and transaction, keeping original item indices", () => {
|
||||||
|
const capture = scenarioCaptureFixture();
|
||||||
|
const responses = view(capture, {
|
||||||
|
kind: "browser-scenario",
|
||||||
|
collection: "events",
|
||||||
|
event_kinds: ["response", "request-failed"],
|
||||||
|
transaction_ids: ["request-2", "request-8"],
|
||||||
|
artifacts: [],
|
||||||
|
offset: 0,
|
||||||
|
limit: 64,
|
||||||
|
}) as { items: { rows: { item_index: number; event: unknown }[] } };
|
||||||
|
expect(
|
||||||
|
responses.items.rows.map(({ item_index, event }) => ({
|
||||||
|
item_index,
|
||||||
|
event,
|
||||||
|
})),
|
||||||
|
).toEqual(
|
||||||
|
capture.events.items.flatMap((event, item_index) =>
|
||||||
|
(event.kind === "response" || event.kind === "request-failed") &&
|
||||||
|
["request-2", "request-8"].includes(event.transaction_id)
|
||||||
|
? [{ item_index, event }]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(responses.items.rows).toHaveLength(2);
|
||||||
|
for (const empty of [
|
||||||
|
{ step_indices: [] },
|
||||||
|
{ event_kinds: [] },
|
||||||
|
{ transaction_ids: [] },
|
||||||
|
{ step_indices: [0] },
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
view(capture, {
|
||||||
|
kind: "browser-scenario",
|
||||||
|
collection: "events",
|
||||||
|
artifacts: [],
|
||||||
|
offset: 0,
|
||||||
|
limit: 64,
|
||||||
|
...empty,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
items: { rows: [] },
|
||||||
|
coverage: { status: "empty", total: 0, exhausted: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states dropped events and refuses views of other parents", () => {
|
||||||
|
const base = scenarioCaptureFixture();
|
||||||
|
const dropped = {
|
||||||
|
...base,
|
||||||
|
events: {
|
||||||
|
...base.events,
|
||||||
|
dropped: 3,
|
||||||
|
omissions: {
|
||||||
|
...base.events.omissions,
|
||||||
|
by_kind: [
|
||||||
|
{
|
||||||
|
kind: "console" as const,
|
||||||
|
count: 3,
|
||||||
|
reasons: ["max-events" as const],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completeness: {
|
||||||
|
status: "truncated" as const,
|
||||||
|
equality_eligible: false as const,
|
||||||
|
missing_sections: [],
|
||||||
|
truncated_sections: ["events" as const],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const summary = view(dropped, { kind: "summary" });
|
||||||
|
expect(summary.unknowns).toContain(
|
||||||
|
"3 events were dropped by capture budgets; counts and pages cover retained events only.",
|
||||||
|
);
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
summary: {
|
||||||
|
events: {
|
||||||
|
dropped: 3,
|
||||||
|
omissions: {
|
||||||
|
by_kind: [{ kind: "console", count: 3 }],
|
||||||
|
network_transactions: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const refused = inspectAnalysisView({
|
||||||
|
source: { kind: "inline", evidence: scenarioCaptureEvidence() },
|
||||||
|
view: { kind: "go", collection: "dependencies" },
|
||||||
|
});
|
||||||
|
if (refused.ok) throw new Error("Expected an unsupported view refusal");
|
||||||
|
expect(projectAnalysisError(refused.error)).toMatchObject({
|
||||||
|
code: "invalid_request",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createAnalysisExecution,
|
|
||||||
type ExecutionOptions,
|
|
||||||
} from "../AnalysisProvider.js";
|
|
||||||
import { PacketCaptureService } from "./PacketCaptureService.js";
|
|
||||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
|
||||||
import { inspectPacketCaptureInputSchema } from "../../domain/captures/packetCapture.js";
|
|
||||||
import { ok } from "../../domain/result.js";
|
|
||||||
|
|
||||||
it("retains provider metadata in a late-cancellation observation", async () => {
|
|
||||||
const input = inspectPacketCaptureInputSchema.parse({
|
|
||||||
path: process.platform === "win32" ? "C:\\capture.pcap" : "/capture.pcap",
|
|
||||||
fields: ["ip.addr"],
|
|
||||||
});
|
|
||||||
const provider = {
|
|
||||||
id: "fixture-capture",
|
|
||||||
name: "Fixture capture provider",
|
|
||||||
version: "1",
|
|
||||||
} as const;
|
|
||||||
const subject = {
|
|
||||||
path: input.path,
|
|
||||||
sha256: "a".repeat(64),
|
|
||||||
format: "file",
|
|
||||||
} as const;
|
|
||||||
const report = {
|
|
||||||
artifact: { path: input.path, sha256: subject.sha256, bytes: 12 },
|
|
||||||
selection: input,
|
|
||||||
packets: [],
|
|
||||||
output_window: {
|
|
||||||
matching_packets: 0,
|
|
||||||
returned_packets: 0,
|
|
||||||
has_more: false,
|
|
||||||
},
|
|
||||||
read_scope: "to-eof",
|
|
||||||
diagnostics: "",
|
|
||||||
tool: {
|
|
||||||
configured_path: "/fixture/tshark",
|
|
||||||
path: "/fixture/tshark",
|
|
||||||
sha256: "b".repeat(64),
|
|
||||||
version_banner: "TShark fixture",
|
|
||||||
version_diagnostics: "",
|
|
||||||
arguments: [],
|
|
||||||
},
|
|
||||||
configuration: "private-empty-personal-profile",
|
|
||||||
resource_limiter: {
|
|
||||||
configured_path: "/fixture/prlimit",
|
|
||||||
path: "/fixture/prlimit",
|
|
||||||
sha256: "c".repeat(64),
|
|
||||||
arguments: [],
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
input_bytes: 1,
|
|
||||||
output_bytes: 1,
|
|
||||||
command_timeout_ms: 1,
|
|
||||||
address_space_bytes: 0,
|
|
||||||
file_bytes: 0,
|
|
||||||
cpu_seconds: 0,
|
|
||||||
},
|
|
||||||
hardware_acquisition: "not-performed",
|
|
||||||
} as const;
|
|
||||||
const rawResult = { producer: "fixture", bytes: 12 };
|
|
||||||
const profile = createAnalysisProfile(provider, { filter: null });
|
|
||||||
const relationshipCoverage = {
|
|
||||||
status: "unknown",
|
|
||||||
reason: "Packet relationships are not reported.",
|
|
||||||
} as const;
|
|
||||||
const execution = createAnalysisExecution(report, provider, {
|
|
||||||
subject,
|
|
||||||
rawResult,
|
|
||||||
analysisProfile: profile,
|
|
||||||
relationshipCoverage,
|
|
||||||
locations: [{ kind: "artifact-path", path: input.path }],
|
|
||||||
});
|
|
||||||
const controller = new AbortController();
|
|
||||||
const reason = Object.freeze({ requested: "cancel after capture result" });
|
|
||||||
const service = new PacketCaptureService({
|
|
||||||
inspect: async (_input, options?: ExecutionOptions) => {
|
|
||||||
controller.abort(reason);
|
|
||||||
expect(options?.signal).toBe(controller.signal);
|
|
||||||
return ok(execution);
|
|
||||||
},
|
|
||||||
inspectFields: async () => {
|
|
||||||
throw new Error("Unexpected field discovery call");
|
|
||||||
},
|
|
||||||
close: async () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.inspect(input, { signal: controller.signal });
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (result.ok) throw new Error("Expected late cancellation");
|
|
||||||
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
|
|
||||||
expect(result.error.cause).toBe(reason);
|
|
||||||
expect(result.error.partialObservation).toMatchObject({
|
|
||||||
operation: "inspect_packet_capture",
|
|
||||||
raw_result: rawResult,
|
|
||||||
analysis_profile: profile,
|
|
||||||
relationship_coverage: relationshipCoverage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,25 +3,24 @@ import {
|
|||||||
packetFieldsCatalogSchema,
|
packetFieldsCatalogSchema,
|
||||||
type InspectPacketFieldsInput,
|
type InspectPacketFieldsInput,
|
||||||
} from "../../domain/captures/packetFields.js";
|
} from "../../domain/captures/packetFields.js";
|
||||||
import {
|
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||||
createAnalysisExecutionEvidence,
|
import type {
|
||||||
type AnalysisExecution,
|
AnalysisExecution,
|
||||||
type ExecutionOptions,
|
ExecutionOptions,
|
||||||
} from "../AnalysisProvider.js";
|
} from "../AnalysisProvider.js";
|
||||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import {
|
import {
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
AnalysisOutputError,
|
AnalysisOutputError,
|
||||||
} from "../../domain/analysisErrorCore.js";
|
} from "../../domain/analysisErrorCore.js";
|
||||||
import type { Evidence } from "../../domain/evidence.js";
|
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||||
import {
|
import {
|
||||||
inspectPacketCaptureInputSchema,
|
inspectPacketCaptureInputSchema,
|
||||||
packetCaptureSchema,
|
packetCaptureSchema,
|
||||||
type InspectPacketCaptureInput,
|
type InspectPacketCaptureInput,
|
||||||
} from "../../domain/captures/packetCapture.js";
|
} from "../../domain/captures/packetCapture.js";
|
||||||
import { err, type Result } from "../../domain/result.js";
|
import { err, ok, type Result } from "../../domain/result.js";
|
||||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
|
||||||
|
|
||||||
export interface PacketCapturePort {
|
export interface PacketCapturePort {
|
||||||
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
|
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
|
||||||
@@ -75,15 +74,7 @@ export class PacketCaptureService {
|
|||||||
"Capture analyzer returned malformed data or changed the selected artifact identity/options.",
|
"Capture analyzer returned malformed data or changed the selected artifact identity/options.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return completeProviderEvidence(
|
return this.#evidence(operation, input.data, execution, options);
|
||||||
createAnalysisExecutionEvidence(
|
|
||||||
operation,
|
|
||||||
input.data,
|
|
||||||
execution,
|
|
||||||
"observed",
|
|
||||||
),
|
|
||||||
options?.signal,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
async inspectFields(
|
async inspectFields(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
@@ -117,14 +108,33 @@ export class PacketCaptureService {
|
|||||||
"Field discovery returned malformed data or changed the selected installation/options.",
|
"Field discovery returned malformed data or changed the selected installation/options.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return completeProviderEvidence(
|
return this.#evidence(operation, input.data, execution, options);
|
||||||
createAnalysisExecutionEvidence(
|
}
|
||||||
|
#evidence(
|
||||||
|
operation: string,
|
||||||
|
parameters: Readonly<Record<string, JsonValue>>,
|
||||||
|
execution: AnalysisExecution,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Result<Evidence, AnalysisError> {
|
||||||
|
const evidence = createEvidence(
|
||||||
|
execution.subject ?? undefined,
|
||||||
|
execution.provider,
|
||||||
|
{
|
||||||
operation,
|
operation,
|
||||||
input.data,
|
parameters,
|
||||||
execution,
|
result: execution.result,
|
||||||
"observed",
|
confidence: "observed",
|
||||||
),
|
locations: execution.locations,
|
||||||
options?.signal,
|
limitations: execution.limitations,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
return options?.signal?.aborted
|
||||||
|
? err(
|
||||||
|
new AnalysisCancelledError(operation, {
|
||||||
|
cause: options.signal.reason,
|
||||||
|
partialObservation: evidence,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: ok(evidence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,15 +76,14 @@ describe("environment manifests from capture_browser_scenario parents", () => {
|
|||||||
kind: "inline",
|
kind: "inline",
|
||||||
evidence: scenarioCaptureEvidence(scenarioCaptureFixture()),
|
evidence: scenarioCaptureEvidence(scenarioCaptureFixture()),
|
||||||
},
|
},
|
||||||
view: { kind: "summary" },
|
view: { kind: "dart-aot", collection: "package_uris" },
|
||||||
});
|
});
|
||||||
if (refused.ok) throw new Error("Expected the unsupported view refusal");
|
if (refused.ok) throw new Error("Expected the unsupported view refusal");
|
||||||
expect(projectAnalysisError(refused.error)).toMatchObject({
|
const projected = projectAnalysisError(refused.error);
|
||||||
code: "unsupported_target",
|
expect(projected).toMatchObject({ code: "invalid_request" });
|
||||||
message: expect.stringContaining(
|
expect(JSON.stringify(projected)).toContain(
|
||||||
"supports only the environment-manifest and environment-state views",
|
"supports the summary, browser-scenario, environment-manifest and environment-state views",
|
||||||
),
|
);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves and exports a scenario-built manifest from the same capture", () => {
|
it("serves and exports a scenario-built manifest from the same capture", () => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "../../domain/firmware/firmwareAnalysis.js";
|
} from "../../domain/firmware/firmwareAnalysis.js";
|
||||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||||
import { err, ok } from "../../domain/result.js";
|
import { err, ok } from "../../domain/result.js";
|
||||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
|
||||||
|
|
||||||
const fixture = (
|
const fixture = (
|
||||||
operation: FirmwareOperation,
|
operation: FirmwareOperation,
|
||||||
@@ -69,35 +68,15 @@ it.each(["inspect_firmware_regions", "extract_firmware"] as const)(
|
|||||||
"retains %s Evidence after late cancellation and preserves provider failure precedence",
|
"retains %s Evidence after late cancellation and preserves provider failure precedence",
|
||||||
async (operation) => {
|
async (operation) => {
|
||||||
const { input, execution } = fixture(operation);
|
const { input, execution } = fixture(operation);
|
||||||
const profile = createAnalysisProfile(
|
|
||||||
{ ...execution.provider, version: "1" },
|
|
||||||
{
|
|
||||||
signature_database: "caller-selected snapshot",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const coverage = {
|
|
||||||
status: "unknown",
|
|
||||||
reason: "No call graph examined",
|
|
||||||
} as const;
|
|
||||||
const observation = {
|
|
||||||
...execution,
|
|
||||||
analysisProfile: profile,
|
|
||||||
relationshipCoverage: coverage,
|
|
||||||
};
|
|
||||||
const control = await new FirmwareAnalysisService({
|
const control = await new FirmwareAnalysisService({
|
||||||
execute: async () => ok(observation),
|
execute: async () => ok(execution),
|
||||||
}).execute(operation, input);
|
}).execute(operation, input);
|
||||||
if (!control.ok) throw control.error;
|
if (!control.ok) throw control.error;
|
||||||
expect(control.value).toMatchObject({
|
|
||||||
raw_result: { source: "controlled producer" },
|
|
||||||
analysis_profile: profile,
|
|
||||||
relationship_coverage: coverage,
|
|
||||||
});
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const result = await new FirmwareAnalysisService({
|
const result = await new FirmwareAnalysisService({
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
return ok(observation);
|
return ok(execution);
|
||||||
},
|
},
|
||||||
}).execute(operation, input, { signal: controller.signal });
|
}).execute(operation, input, { signal: controller.signal });
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||||
createAnalysisExecutionEvidence,
|
|
||||||
type ExecutionOptions,
|
|
||||||
} from "../AnalysisProvider.js";
|
|
||||||
import type { FirmwareAnalysisPort } from "./FirmwareAnalysisPort.js";
|
import type { FirmwareAnalysisPort } from "./FirmwareAnalysisPort.js";
|
||||||
import {
|
import {
|
||||||
firmwareRequestSchema,
|
firmwareRequestSchema,
|
||||||
@@ -15,7 +12,8 @@ import {
|
|||||||
AnalysisOutputError,
|
AnalysisOutputError,
|
||||||
} from "../../domain/analysisErrorCore.js";
|
} from "../../domain/analysisErrorCore.js";
|
||||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import type { Evidence } from "../../domain/evidence.js";
|
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||||
|
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||||
import { err, type Result } from "../../domain/result.js";
|
import { err, type Result } from "../../domain/result.js";
|
||||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||||
@@ -73,12 +71,15 @@ export class FirmwareAnalysisService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
return completeProviderEvidence(
|
return completeProviderEvidence(
|
||||||
createAnalysisExecutionEvidence(
|
createEvidence(execution.subject, execution.provider, {
|
||||||
operation,
|
operation,
|
||||||
parsed.data.input,
|
parameters: jsonObjectSchema.parse(parsed.data.input),
|
||||||
execution,
|
result: execution.result,
|
||||||
"observed",
|
rawResult: execution.rawResult,
|
||||||
),
|
locations: execution.locations,
|
||||||
|
limitations: execution.limitations,
|
||||||
|
confidence: "observed",
|
||||||
|
}),
|
||||||
options?.signal,
|
options?.signal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
|
||||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||||
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
addElectronInferenceEdge,
|
addElectronInferenceEdge,
|
||||||
electronFindingSourceNode,
|
electronFindingSourceNode,
|
||||||
|
electronRangeKey,
|
||||||
} from "./ElectronBoundaryGraphContext.js";
|
} from "./ElectronBoundaryGraphContext.js";
|
||||||
import {
|
import {
|
||||||
artifactLocalIdentity,
|
artifactLocalIdentity,
|
||||||
@@ -191,15 +191,13 @@ const matches = (
|
|||||||
path.steps.some(
|
path.steps.some(
|
||||||
(step) =>
|
(step) =>
|
||||||
step.kind === sourceStepKind &&
|
step.kind === sourceStepKind &&
|
||||||
javascriptSourceRangeKey(step.location) ===
|
electronRangeKey(step.location) === electronRangeKey(finding.location),
|
||||||
javascriptSourceRangeKey(finding.location),
|
|
||||||
) &&
|
) &&
|
||||||
(invocation === undefined ||
|
(invocation === undefined ||
|
||||||
path.steps.some(
|
path.steps.some(
|
||||||
(step) =>
|
(step) =>
|
||||||
(step.kind === "call" || step.kind === "exposure") &&
|
(step.kind === "call" || step.kind === "exposure") &&
|
||||||
javascriptSourceRangeKey(step.location) ===
|
electronRangeKey(step.location) === electronRangeKey(invocation),
|
||||||
javascriptSourceRangeKey(invocation),
|
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -108,3 +108,23 @@ export const addElectronInferenceEdge = (
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Deterministic source-range key for artifact-local graph identities. */
|
||||||
|
export const electronRangeKey = (range: JavaScriptSourceRange): string =>
|
||||||
|
`${String(range.start.line)}:${String(range.start.column)}-${String(range.end.line)}:${String(range.end.column)}`;
|
||||||
|
|
||||||
|
/** Test exact source-range containment without assuming runtime reachability. */
|
||||||
|
export const electronRangeContains = (
|
||||||
|
outer: JavaScriptSourceRange,
|
||||||
|
inner: JavaScriptSourceRange,
|
||||||
|
): boolean =>
|
||||||
|
comparePoint(outer.start, inner.start) <= 0 &&
|
||||||
|
comparePoint(outer.end, inner.end) >= 0;
|
||||||
|
|
||||||
|
const comparePoint = (
|
||||||
|
left: JavaScriptSourceRange["start"],
|
||||||
|
right: JavaScriptSourceRange["start"],
|
||||||
|
): number =>
|
||||||
|
left.line === right.line
|
||||||
|
? left.column - right.column
|
||||||
|
: left.line - right.line;
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
javascriptSourceRangeKey,
|
|
||||||
javascriptSourceRangeContains,
|
|
||||||
} from "../../domain/javascript/javascriptSourceCoordinates.js";
|
|
||||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||||
import type { ElectronSenderValidationFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
import type { ElectronSenderValidationFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||||
@@ -25,6 +21,8 @@ import {
|
|||||||
addElectronInferenceEdge,
|
addElectronInferenceEdge,
|
||||||
electronFindingSourceNode,
|
electronFindingSourceNode,
|
||||||
electronObservationIdentity,
|
electronObservationIdentity,
|
||||||
|
electronRangeContains,
|
||||||
|
electronRangeKey,
|
||||||
} from "./ElectronBoundaryGraphContext.js";
|
} from "./ElectronBoundaryGraphContext.js";
|
||||||
|
|
||||||
interface IpcGraphState {
|
interface IpcGraphState {
|
||||||
@@ -184,7 +182,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
|
|||||||
identity: artifactLocalIdentity(
|
identity: artifactLocalIdentity(
|
||||||
file.sha256,
|
file.sha256,
|
||||||
"electron-ipc-handler",
|
"electron-ipc-handler",
|
||||||
`${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`,
|
`${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${electronRangeKey(handlerRange)}`,
|
||||||
),
|
),
|
||||||
observations: [
|
observations: [
|
||||||
{
|
{
|
||||||
@@ -340,7 +338,7 @@ const containingHandler = (
|
|||||||
(record) =>
|
(record) =>
|
||||||
record.file.path === file.path &&
|
record.file.path === file.path &&
|
||||||
record.finding.handler_location !== null &&
|
record.finding.handler_location !== null &&
|
||||||
javascriptSourceRangeContains(
|
electronRangeContains(
|
||||||
record.finding.handler_location,
|
record.finding.handler_location,
|
||||||
validation.location,
|
validation.location,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
|
||||||
import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
addElectronInferenceEdge,
|
addElectronInferenceEdge,
|
||||||
electronFindingSourceNode,
|
electronFindingSourceNode,
|
||||||
electronObservationIdentity,
|
electronObservationIdentity,
|
||||||
|
electronRangeKey,
|
||||||
} from "./ElectronBoundaryGraphContext.js";
|
} from "./ElectronBoundaryGraphContext.js";
|
||||||
|
|
||||||
interface NativeBindingInput {
|
interface NativeBindingInput {
|
||||||
@@ -65,7 +65,7 @@ const addNativeBinding = (input: NativeBindingInput): void => {
|
|||||||
value.members,
|
value.members,
|
||||||
value.namespace_access ?? null,
|
value.namespace_access ?? null,
|
||||||
value.dynamic_member_access ?? null,
|
value.dynamic_member_access ?? null,
|
||||||
javascriptSourceRangeKey(value.location),
|
electronRangeKey(value.location),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
observations: [
|
observations: [
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
|
||||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||||
import type {
|
import type {
|
||||||
ElectronBrowserWindowFinding,
|
ElectronBrowserWindowFinding,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
addElectronAstEdge,
|
addElectronAstEdge,
|
||||||
addElectronInferenceEdge,
|
addElectronInferenceEdge,
|
||||||
electronFindingSourceNode,
|
electronFindingSourceNode,
|
||||||
|
electronRangeKey,
|
||||||
} from "./ElectronBoundaryGraphContext.js";
|
} from "./ElectronBoundaryGraphContext.js";
|
||||||
import {
|
import {
|
||||||
artifactPathResolutionProperties,
|
artifactPathResolutionProperties,
|
||||||
@@ -86,11 +86,11 @@ const addBrowserWindow = (
|
|||||||
identity: artifactLocalIdentity(
|
identity: artifactLocalIdentity(
|
||||||
file.sha256,
|
file.sha256,
|
||||||
"electron-browser-window",
|
"electron-browser-window",
|
||||||
javascriptSourceRangeKey(value.location),
|
electronRangeKey(value.location),
|
||||||
),
|
),
|
||||||
observations: [
|
observations: [
|
||||||
{
|
{
|
||||||
label: `BrowserWindow@${javascriptSourceRangeKey(value.location)}`,
|
label: `BrowserWindow@${electronRangeKey(value.location)}`,
|
||||||
properties: {
|
properties: {
|
||||||
source_path: file.path,
|
source_path: file.path,
|
||||||
options_status: value.options_status,
|
options_status: value.options_status,
|
||||||
@@ -202,7 +202,7 @@ const addContextBridge = (
|
|||||||
identity: artifactLocalIdentity(
|
identity: artifactLocalIdentity(
|
||||||
file.sha256,
|
file.sha256,
|
||||||
"electron-context-bridge",
|
"electron-context-bridge",
|
||||||
`${value.world}:${value.api_key ?? value.api_key_expression ?? "[missing]"}:${javascriptSourceRangeKey(value.location)}`,
|
`${value.world}:${value.api_key ?? value.api_key_expression ?? "[missing]"}:${electronRangeKey(value.location)}`,
|
||||||
),
|
),
|
||||||
observations: [
|
observations: [
|
||||||
{
|
{
|
||||||
@@ -271,7 +271,7 @@ const addUtilityProcess = (
|
|||||||
identity: artifactLocalIdentity(
|
identity: artifactLocalIdentity(
|
||||||
file.sha256,
|
file.sha256,
|
||||||
"electron-utility-process",
|
"electron-utility-process",
|
||||||
`${value.module_path ?? value.module_expression}:${javascriptSourceRangeKey(value.location)}`,
|
`${value.module_path ?? value.module_expression}:${electronRangeKey(value.location)}`,
|
||||||
),
|
),
|
||||||
observations: [
|
observations: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import {
|
|||||||
type JavaScriptApplicationAnalysisResult,
|
type JavaScriptApplicationAnalysisResult,
|
||||||
parseOwnedJavaScriptApplicationAnalysisSteps,
|
parseOwnedJavaScriptApplicationAnalysisSteps,
|
||||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||||
import {
|
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||||
AnalysisOutputError,
|
|
||||||
AnalysisResourceConstraintError,
|
|
||||||
} from "../../domain/analysisErrorCore.js";
|
|
||||||
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
||||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import type { Evidence } from "../../domain/evidence.js";
|
import type { Evidence } from "../../domain/evidence.js";
|
||||||
@@ -152,8 +149,13 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||||||
cause,
|
cause,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const filesystemFailure = hostFilesystemFailure(cause);
|
if (
|
||||||
if (filesystemFailure !== undefined) return err(filesystemFailure);
|
cause instanceof Error &&
|
||||||
|
"code" in cause &&
|
||||||
|
typeof cause.code === "string" &&
|
||||||
|
FILESYSTEM_ERROR_CODES.has(cause.code)
|
||||||
|
)
|
||||||
|
return err(new ArtifactOperationError(OPERATION, "io"));
|
||||||
return err(
|
return err(
|
||||||
new ProviderAdapterError(JAVASCRIPT_APPLICATION_PROVIDER.id, OPERATION, {
|
new ProviderAdapterError(JAVASCRIPT_APPLICATION_PROVIDER.id, OPERATION, {
|
||||||
cause,
|
cause,
|
||||||
@@ -206,43 +208,6 @@ const describeResultSchemaFailure = (cause: z.ZodError): string => {
|
|||||||
const escapePointerPart = (part: string): string =>
|
const escapePointerPart = (part: string): string =>
|
||||||
part.replaceAll("~", "~0").replaceAll("/", "~1");
|
part.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||||
|
|
||||||
/**
|
|
||||||
* Project a host filesystem failure that no narrower owner classified. Selected
|
|
||||||
* input denial is classified at selection; this failure may concern any path,
|
|
||||||
* including private workspace files, so it keeps the host's own facts rather
|
|
||||||
* than claiming a selected-read denial.
|
|
||||||
*/
|
|
||||||
const hostFilesystemFailure = (cause: unknown): AnalysisError | undefined => {
|
|
||||||
if (!(cause instanceof Error)) return undefined;
|
|
||||||
const code: unknown = Reflect.get(cause, "code");
|
|
||||||
if (typeof code !== "string" || !FILESYSTEM_ERROR_CODES.has(code))
|
|
||||||
return undefined;
|
|
||||||
const syscall: unknown = Reflect.get(cause, "syscall");
|
|
||||||
const path: unknown = Reflect.get(cause, "path");
|
|
||||||
const facts = {
|
|
||||||
system_code: code,
|
|
||||||
...(typeof syscall === "string" ? { syscall } : {}),
|
|
||||||
...(typeof path === "string" ? { path } : {}),
|
|
||||||
};
|
|
||||||
if (code === "EMFILE" || code === "ENFILE")
|
|
||||||
return new AnalysisResourceConstraintError(
|
|
||||||
OPERATION,
|
|
||||||
"capacity",
|
|
||||||
`Host file-descriptor limit reached (${code})${typeof path === "string" ? ` while accessing ${path}` : ""}`,
|
|
||||||
facts,
|
|
||||||
{
|
|
||||||
cause,
|
|
||||||
retryable: true,
|
|
||||||
remediationAction:
|
|
||||||
"Close unused files or raise the open-file limit for the process or host running REA, then retry.",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return new ArtifactOperationError(OPERATION, "io", undefined, cause.message, {
|
|
||||||
cause,
|
|
||||||
diagnostics: facts,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const FILESYSTEM_ERROR_CODES = new Set([
|
const FILESYSTEM_ERROR_CODES = new Set([
|
||||||
"ENOENT",
|
"ENOENT",
|
||||||
"EACCES",
|
"EACCES",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { constants, type Stats } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { access, lstat, realpath } from "node:fs/promises";
|
import { access, lstat, realpath } from "node:fs/promises";
|
||||||
import { setImmediate } from "node:timers/promises";
|
import { setImmediate } from "node:timers/promises";
|
||||||
|
|
||||||
@@ -91,8 +91,8 @@ export const reconstructJavaScriptArtifact = async (
|
|||||||
};
|
};
|
||||||
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
||||||
abortIfNeeded(signal);
|
abortIfNeeded(signal);
|
||||||
const { path, metadata } = await resolveSelectedInput(input.input_path);
|
const path = await resolveSelectedInput(input.input_path);
|
||||||
const format = selectedFormat(path, metadata, input);
|
const format = await resolveFormat(path, input);
|
||||||
const snapshot = await scanCanonicalArtifactInventoryInScope(path, {
|
const snapshot = await scanCanonicalArtifactInventoryInScope(path, {
|
||||||
resourceScope: scope,
|
resourceScope: scope,
|
||||||
signal,
|
signal,
|
||||||
@@ -346,21 +346,13 @@ const OPERATION = "analyze_javascript_application";
|
|||||||
/**
|
/**
|
||||||
* Resolve the caller-selected input. A missing, looping, overlong, or
|
* Resolve the caller-selected input. A missing, looping, overlong, or
|
||||||
* unreadable selection is a caller or host-resolution failure, not a damaged
|
* unreadable selection is a caller or host-resolution failure, not a damaged
|
||||||
* artifact. A selected directory must also be searchable: without search
|
* artifact.
|
||||||
* permission its entries can be listed but not inspected, so no entry is
|
|
||||||
* readable. Other host failures propagate with their errno facts intact.
|
|
||||||
*/
|
*/
|
||||||
const resolveSelectedInput = async (
|
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
|
||||||
inputPath: string,
|
|
||||||
): Promise<{ readonly path: string; readonly metadata: Stats }> => {
|
|
||||||
try {
|
try {
|
||||||
const path = await realpath(inputPath);
|
const path = await realpath(inputPath);
|
||||||
const metadata = await lstat(path);
|
await access(path, constants.R_OK);
|
||||||
await access(
|
return path;
|
||||||
path,
|
|
||||||
metadata.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK,
|
|
||||||
);
|
|
||||||
return { path, metadata };
|
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
const code =
|
const code =
|
||||||
cause instanceof Error && "code" in cause ? String(cause.code) : "";
|
cause instanceof Error && "code" in cause ? String(cause.code) : "";
|
||||||
@@ -397,11 +389,11 @@ const selectedInputResolutionMessage = (
|
|||||||
: `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${inputPath}`;
|
: `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${inputPath}`;
|
||||||
|
|
||||||
/** The selected input's kind, checked against the caller's requested format. */
|
/** The selected input's kind, checked against the caller's requested format. */
|
||||||
const selectedFormat = (
|
const resolveFormat = async (
|
||||||
path: string,
|
path: string,
|
||||||
metadata: Stats,
|
|
||||||
input: JavaScriptArtifactReconstructionInput,
|
input: JavaScriptArtifactReconstructionInput,
|
||||||
): "asar" | "directory" => {
|
): Promise<"asar" | "directory"> => {
|
||||||
|
const metadata = await lstat(path);
|
||||||
const observed = metadata.isDirectory()
|
const observed = metadata.isDirectory()
|
||||||
? "directory"
|
? "directory"
|
||||||
: metadata.isFile() && path.toLowerCase().endsWith(".asar")
|
: metadata.isFile() && path.toLowerCase().endsWith(".asar")
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ import {
|
|||||||
addSemanticGraphUnknown,
|
addSemanticGraphUnknown,
|
||||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||||
import {
|
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||||
projectSemanticResultBindings,
|
|
||||||
type SemanticFlowProjectionContext,
|
|
||||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
|
||||||
|
|
||||||
/** Project literal EventEmitter registrations, removals, and dispatches. */
|
/** Project literal EventEmitter registrations, removals, and dispatches. */
|
||||||
export const projectSemanticEvents = (
|
export const projectSemanticEvents = (
|
||||||
@@ -183,11 +180,18 @@ const projectTimerSchedule = (
|
|||||||
relation: "schedules-timer",
|
relation: "schedules-timer",
|
||||||
resolution: "resolved",
|
resolution: "resolved",
|
||||||
});
|
});
|
||||||
projectSemanticResultBindings(
|
for (const projected of operation.projectedResultBindings)
|
||||||
context,
|
addSemanticGraphRelation(context.state, {
|
||||||
timerNodes.get(operation.timerId),
|
source: timerNodes.get(operation.timerId),
|
||||||
operation.projectedResultBindings,
|
target: context.bindingNodes.get(projected.bindingId),
|
||||||
);
|
relation: "destructures",
|
||||||
|
resolution:
|
||||||
|
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||||
|
properties: {
|
||||||
|
projection_path: [...projected.projectionPath],
|
||||||
|
projection_resolution: projected.resolution,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectTimerCancellation = (
|
const projectTimerCancellation = (
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import {
|
|||||||
addSemanticGraphUnknown,
|
addSemanticGraphUnknown,
|
||||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||||
import {
|
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||||
projectSemanticResultBindings,
|
|
||||||
type SemanticFlowProjectionContext,
|
|
||||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
|
||||||
|
|
||||||
interface ChildUnknownInput {
|
interface ChildUnknownInput {
|
||||||
readonly context: SemanticFlowProjectionContext;
|
readonly context: SemanticFlowProjectionContext;
|
||||||
@@ -83,7 +80,18 @@ const projectSpawn = (
|
|||||||
relation: "spawns",
|
relation: "spawns",
|
||||||
resolution: spawn.resolution === "complete" ? "resolved" : "candidate",
|
resolution: spawn.resolution === "complete" ? "resolved" : "candidate",
|
||||||
});
|
});
|
||||||
projectSemanticResultBindings(context, child, spawn.projectedResultBindings);
|
for (const projected of spawn.projectedResultBindings)
|
||||||
|
addSemanticGraphRelation(context.state, {
|
||||||
|
source: child,
|
||||||
|
target: context.bindingNodes.get(projected.bindingId),
|
||||||
|
relation: "destructures",
|
||||||
|
resolution:
|
||||||
|
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||||
|
properties: {
|
||||||
|
projection_path: [...projected.projectionPath],
|
||||||
|
projection_resolution: projected.resolution,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (spawn.argvCount !== null)
|
if (spawn.argvCount !== null)
|
||||||
addSemanticGraphRelation(context.state, {
|
addSemanticGraphRelation(context.state, {
|
||||||
source: callSite,
|
source: callSite,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||||
@@ -339,5 +338,7 @@ const applicationLocationKey = (
|
|||||||
[
|
[
|
||||||
artifactSha256,
|
artifactSha256,
|
||||||
path,
|
path,
|
||||||
range === null ? "artifact-path" : javascriptSourceRangeKey(range),
|
range === null
|
||||||
|
? "artifact-path"
|
||||||
|
: `${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`,
|
||||||
].join("\u0000");
|
].join("\u0000");
|
||||||
@@ -10,10 +10,7 @@ import {
|
|||||||
addSemanticGraphUnknown,
|
addSemanticGraphUnknown,
|
||||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||||
import {
|
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||||
projectSemanticResultBindings,
|
|
||||||
type SemanticFlowProjectionContext,
|
|
||||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
|
||||||
import { semanticResourceLimitReason } from "../../domain/javascript/javascriptSemanticResourceLimits.js";
|
import { semanticResourceLimitReason } from "../../domain/javascript/javascriptSemanticResourceLimits.js";
|
||||||
|
|
||||||
/** Project environment, argv, file, default, and precedence candidates. */
|
/** Project environment, argv, file, default, and precedence candidates. */
|
||||||
@@ -182,11 +179,18 @@ const projectRequestConstruction = (
|
|||||||
relation: "constructs-request",
|
relation: "constructs-request",
|
||||||
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
|
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
|
||||||
});
|
});
|
||||||
projectSemanticResultBindings(
|
for (const projected of operation.projectedResultBindings)
|
||||||
context,
|
addSemanticGraphRelation(context.state, {
|
||||||
request,
|
source: request,
|
||||||
operation.projectedResultBindings,
|
target: context.bindingNodes.get(projected.bindingId),
|
||||||
);
|
relation: "destructures",
|
||||||
|
resolution:
|
||||||
|
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||||
|
properties: {
|
||||||
|
projection_path: [...projected.projectionPath],
|
||||||
|
projection_resolution: projected.resolution,
|
||||||
|
},
|
||||||
|
});
|
||||||
for (const field of operation.fields)
|
for (const field of operation.fields)
|
||||||
addSemanticGraphRelation(context.state, {
|
addSemanticGraphRelation(context.state, {
|
||||||
source:
|
source:
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
|
||||||
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||||
import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js";
|
import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||||
import type {
|
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
|
||||||
JavaScriptSemanticIr,
|
|
||||||
JavaScriptSemanticProjectedResultBinding,
|
|
||||||
} from "../../domain/javascript/javascriptSemanticIr.js";
|
|
||||||
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||||
@@ -38,26 +34,6 @@ export interface SemanticFlowProjectionContext {
|
|||||||
) => JavaScriptSemanticGraphNode | undefined;
|
) => JavaScriptSemanticGraphNode | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Project result destructuring consistently for timers, child processes, and requests. */
|
|
||||||
export const projectSemanticResultBindings = (
|
|
||||||
context: Pick<SemanticFlowProjectionContext, "state" | "bindingNodes">,
|
|
||||||
source: JavaScriptSemanticGraphNode | undefined,
|
|
||||||
bindings: readonly JavaScriptSemanticProjectedResultBinding[],
|
|
||||||
): void => {
|
|
||||||
for (const projected of bindings)
|
|
||||||
addSemanticGraphRelation(context.state, {
|
|
||||||
source,
|
|
||||||
target: context.bindingNodes.get(projected.bindingId),
|
|
||||||
relation: "destructures",
|
|
||||||
resolution:
|
|
||||||
projected.resolution === "complete" ? "resolved" : "candidate",
|
|
||||||
properties: {
|
|
||||||
projection_path: [...projected.projectionPath],
|
|
||||||
projection_resolution: projected.resolution,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Project explicit static Promise/task ownership without runtime claims. */
|
/** Project explicit static Promise/task ownership without runtime claims. */
|
||||||
export const projectSemanticPromises = (
|
export const projectSemanticPromises = (
|
||||||
context: SemanticFlowProjectionContext,
|
context: SemanticFlowProjectionContext,
|
||||||
@@ -222,6 +198,9 @@ interface ObjectReadIndex {
|
|||||||
|
|
||||||
const objectReadIndexes = new WeakMap<JavaScriptSemanticIr, ObjectReadIndex>();
|
const objectReadIndexes = new WeakMap<JavaScriptSemanticIr, ObjectReadIndex>();
|
||||||
|
|
||||||
|
const objectReadRangeKey = (location: JavaScriptSourceRange): string =>
|
||||||
|
`${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The object projection owns a member read's occurrence. Index it once per
|
* The object projection owns a member read's occurrence. Index it once per
|
||||||
* file so return and argument flow can connect that expression without
|
* file so return and argument flow can connect that expression without
|
||||||
@@ -248,7 +227,7 @@ const objectReadOccurrences = (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (node !== undefined)
|
if (node !== undefined)
|
||||||
nodes.set(javascriptSourceRangeKey(operation.location), node);
|
nodes.set(objectReadRangeKey(operation.location), node);
|
||||||
}
|
}
|
||||||
objectReadIndexes.set(context.ir, { state: context.state, nodes });
|
objectReadIndexes.set(context.ir, { state: context.state, nodes });
|
||||||
return nodes;
|
return nodes;
|
||||||
@@ -261,7 +240,7 @@ export const semanticObjectReadOccurrence = (
|
|||||||
): JavaScriptSemanticGraphNode | undefined =>
|
): JavaScriptSemanticGraphNode | undefined =>
|
||||||
location === null
|
location === null
|
||||||
? undefined
|
? undefined
|
||||||
: objectReadOccurrences(context).get(javascriptSourceRangeKey(location));
|
: objectReadOccurrences(context).get(objectReadRangeKey(location));
|
||||||
|
|
||||||
/** Link direct return expressions to their retained semantic references. */
|
/** Link direct return expressions to their retained semantic references. */
|
||||||
export const projectSemanticReturnValues = (
|
export const projectSemanticReturnValues = (
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import {
|
|
||||||
compareJavaScriptSourcePoints,
|
|
||||||
javascriptSourceRangeContains,
|
|
||||||
javascriptSourceRangeKey,
|
|
||||||
} from "../../domain/javascript/javascriptSourceCoordinates.js";
|
|
||||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||||
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||||
import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||||
@@ -45,7 +40,7 @@ export const createSemanticCallableOwnerLookup = (
|
|||||||
}))
|
}))
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
compareJavaScriptSourcePoints(left.range.start, right.range.start) ||
|
pointCompare(left.range.start, right.range.start) ||
|
||||||
left.order - right.order,
|
left.order - right.order,
|
||||||
);
|
);
|
||||||
const tree = buildSourceIntervalTree(entries, 0, entries.length);
|
const tree = buildSourceIntervalTree(entries, 0, entries.length);
|
||||||
@@ -59,13 +54,12 @@ export const createSemanticCallableOwnerLookup = (
|
|||||||
const current = pending.pop();
|
const current = pending.pop();
|
||||||
if (
|
if (
|
||||||
current === undefined ||
|
current === undefined ||
|
||||||
compareJavaScriptSourcePoints(current.minimumStart, location.start) >
|
pointCompare(current.minimumStart, location.start) > 0 ||
|
||||||
0 ||
|
pointCompare(current.maximumEnd, location.end) < 0
|
||||||
compareJavaScriptSourcePoints(current.maximumEnd, location.end) < 0
|
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
const candidate = current.entry;
|
const candidate = current.entry;
|
||||||
if (javascriptSourceRangeContains(candidate.range, location)) {
|
if (contains(candidate.range, location)) {
|
||||||
const node = nodes.get(candidate.callableId);
|
const node = nodes.get(candidate.callableId);
|
||||||
const rank =
|
const rank =
|
||||||
best === undefined
|
best === undefined
|
||||||
@@ -99,15 +93,9 @@ const buildSourceIntervalTree = <Interval extends SourceInterval>(
|
|||||||
let minimumEnd = entry.range.end;
|
let minimumEnd = entry.range.end;
|
||||||
let maximumEnd = entry.range.end;
|
let maximumEnd = entry.range.end;
|
||||||
for (const child of [left, right]) {
|
for (const child of [left, right]) {
|
||||||
if (
|
if (child !== null && pointCompare(child.minimumEnd, minimumEnd) < 0)
|
||||||
child !== null &&
|
|
||||||
compareJavaScriptSourcePoints(child.minimumEnd, minimumEnd) < 0
|
|
||||||
)
|
|
||||||
minimumEnd = child.minimumEnd;
|
minimumEnd = child.minimumEnd;
|
||||||
if (
|
if (child !== null && pointCompare(child.maximumEnd, maximumEnd) > 0)
|
||||||
child !== null &&
|
|
||||||
compareJavaScriptSourcePoints(child.maximumEnd, maximumEnd) > 0
|
|
||||||
)
|
|
||||||
maximumEnd = child.maximumEnd;
|
maximumEnd = child.maximumEnd;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -132,7 +120,7 @@ export const createSemanticNodeRangeLookup = (
|
|||||||
})
|
})
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
compareJavaScriptSourcePoints(left.range.start, right.range.start) ||
|
pointCompare(left.range.start, right.range.start) ||
|
||||||
left.order - right.order,
|
left.order - right.order,
|
||||||
);
|
);
|
||||||
const tree = buildSourceIntervalTree(entries, 0, entries.length);
|
const tree = buildSourceIntervalTree(entries, 0, entries.length);
|
||||||
@@ -144,12 +132,11 @@ export const createSemanticNodeRangeLookup = (
|
|||||||
const current = pending.pop();
|
const current = pending.pop();
|
||||||
if (
|
if (
|
||||||
current === undefined ||
|
current === undefined ||
|
||||||
compareJavaScriptSourcePoints(current.maximumStart, range.start) < 0 ||
|
pointCompare(current.maximumStart, range.start) < 0 ||
|
||||||
compareJavaScriptSourcePoints(current.minimumEnd, range.end) > 0
|
pointCompare(current.minimumEnd, range.end) > 0
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
if (javascriptSourceRangeContains(range, current.entry.range))
|
if (contains(range, current.entry.range)) matches.push(current.entry);
|
||||||
matches.push(current.entry);
|
|
||||||
if (current.left !== null) pending.push(current.left);
|
if (current.left !== null) pending.push(current.left);
|
||||||
if (current.right !== null) pending.push(current.right);
|
if (current.right !== null) pending.push(current.right);
|
||||||
}
|
}
|
||||||
@@ -168,15 +155,18 @@ export const createSemanticCallSiteLookup = (
|
|||||||
) => JavaScriptSemanticGraphNode | undefined) => {
|
) => JavaScriptSemanticGraphNode | undefined) => {
|
||||||
const identifiers = new Map<string, string>();
|
const identifiers = new Map<string, string>();
|
||||||
for (const call of ir.callSites) {
|
for (const call of ir.callSites) {
|
||||||
const key = javascriptSourceRangeKey(call.location);
|
const key = rangeKey(call.location);
|
||||||
if (!identifiers.has(key)) identifiers.set(key, call.callSiteId);
|
if (!identifiers.has(key)) identifiers.set(key, call.callSiteId);
|
||||||
}
|
}
|
||||||
return (range) => {
|
return (range) => {
|
||||||
const identifier = identifiers.get(javascriptSourceRangeKey(range));
|
const identifier = identifiers.get(rangeKey(range));
|
||||||
return identifier === undefined ? undefined : nodes.get(identifier);
|
return identifier === undefined ? undefined : nodes.get(identifier);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rangeKey = (range: JavaScriptSourceRange): string =>
|
||||||
|
`${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`;
|
||||||
|
|
||||||
/** Report extractor support without treating missing families as absence. */
|
/** Report extractor support without treating missing families as absence. */
|
||||||
export const semanticFamilyStatus = (
|
export const semanticFamilyStatus = (
|
||||||
family: (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number],
|
family: (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number],
|
||||||
@@ -201,5 +191,17 @@ export const semanticFamilyStatus = (
|
|||||||
return "partial";
|
return "partial";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const contains = (
|
||||||
|
outer: JavaScriptSourceRange,
|
||||||
|
inner: JavaScriptSourceRange,
|
||||||
|
): boolean =>
|
||||||
|
pointCompare(outer.start, inner.start) <= 0 &&
|
||||||
|
pointCompare(outer.end, inner.end) >= 0;
|
||||||
|
|
||||||
|
const pointCompare = (
|
||||||
|
left: JavaScriptSourceRange["start"],
|
||||||
|
right: JavaScriptSourceRange["start"],
|
||||||
|
): number => left.line - right.line || left.column - right.column;
|
||||||
|
|
||||||
const positionKey = (range: JavaScriptSourceRange): string =>
|
const positionKey = (range: JavaScriptSourceRange): string =>
|
||||||
`${String(range.start.line).padStart(12, "0")}:${String(range.start.column).padStart(12, "0")}`;
|
`${String(range.start.line).padStart(12, "0")}:${String(range.start.column).padStart(12, "0")}`;
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createAnalysisExecution,
|
|
||||||
type ExecutionOptions,
|
|
||||||
} from "../AnalysisProvider.js";
|
|
||||||
import { SignalRecordingService } from "./SignalRecordingService.js";
|
|
||||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
|
||||||
import { decodeSignalRecordingInputSchema } from "../../domain/signals/signalRecording.js";
|
|
||||||
import { ok } from "../../domain/result.js";
|
|
||||||
|
|
||||||
it("retains provider metadata in a late-cancellation observation", async () => {
|
|
||||||
const input = decodeSignalRecordingInputSchema.parse({
|
|
||||||
path: process.platform === "win32" ? "C:\\recording.vcd" : "/recording.vcd",
|
|
||||||
decoders: "uart",
|
|
||||||
});
|
|
||||||
const provider = {
|
|
||||||
id: "fixture-signal",
|
|
||||||
name: "Fixture signal provider",
|
|
||||||
version: "1",
|
|
||||||
} as const;
|
|
||||||
const subject = {
|
|
||||||
path: input.path,
|
|
||||||
sha256: "a".repeat(64),
|
|
||||||
format: "file",
|
|
||||||
} as const;
|
|
||||||
const report = {
|
|
||||||
artifact: { path: input.path, sha256: subject.sha256, bytes: 12 },
|
|
||||||
selection: input,
|
|
||||||
annotations_text: "10-20 uart-1: 42\n",
|
|
||||||
output_window: { total_lines: 1, returned_lines: 1, has_more: false },
|
|
||||||
diagnostics: "",
|
|
||||||
tool: {
|
|
||||||
configured_path: "/fixture/sigrok-cli",
|
|
||||||
path: "/fixture/sigrok-cli",
|
|
||||||
sha256: "b".repeat(64),
|
|
||||||
version_banner: "sigrok-cli fixture",
|
|
||||||
version_diagnostics: "",
|
|
||||||
arguments: [],
|
|
||||||
},
|
|
||||||
resource_limiter: {
|
|
||||||
configured_path: "/fixture/prlimit",
|
|
||||||
path: "/fixture/prlimit",
|
|
||||||
sha256: "c".repeat(64),
|
|
||||||
arguments: [],
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
input_bytes: 1,
|
|
||||||
output_bytes: 1,
|
|
||||||
command_timeout_ms: 1,
|
|
||||||
address_space_bytes: 0,
|
|
||||||
file_bytes: 0,
|
|
||||||
cpu_seconds: 0,
|
|
||||||
},
|
|
||||||
hardware_acquisition: "not-performed",
|
|
||||||
} as const;
|
|
||||||
const rawResult = { producer: "fixture", bytes: 12 };
|
|
||||||
const profile = createAnalysisProfile(provider, { decoder: "uart" });
|
|
||||||
const relationshipCoverage = {
|
|
||||||
status: "unknown",
|
|
||||||
reason: "Recording relationships are not reported.",
|
|
||||||
} as const;
|
|
||||||
const execution = createAnalysisExecution(report, provider, {
|
|
||||||
subject,
|
|
||||||
rawResult,
|
|
||||||
analysisProfile: profile,
|
|
||||||
relationshipCoverage,
|
|
||||||
locations: [{ kind: "artifact-path", path: input.path }],
|
|
||||||
});
|
|
||||||
const controller = new AbortController();
|
|
||||||
const reason = Object.freeze({ requested: "cancel after recording result" });
|
|
||||||
const service = new SignalRecordingService({
|
|
||||||
decode: async (_input, options?: ExecutionOptions) => {
|
|
||||||
controller.abort(reason);
|
|
||||||
expect(options?.signal).toBe(controller.signal);
|
|
||||||
return ok(execution);
|
|
||||||
},
|
|
||||||
inspectDecoders: async () => {
|
|
||||||
throw new Error("Unexpected decoder discovery call");
|
|
||||||
},
|
|
||||||
close: async () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.decode(input, { signal: controller.signal });
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (result.ok) throw new Error("Expected late cancellation");
|
|
||||||
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
|
|
||||||
expect(result.error.cause).toBe(reason);
|
|
||||||
expect(result.error.partialObservation).toMatchObject({
|
|
||||||
operation: "decode_signal_recording",
|
|
||||||
raw_result: rawResult,
|
|
||||||
analysis_profile: profile,
|
|
||||||
relationship_coverage: relationshipCoverage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,25 +3,24 @@ import {
|
|||||||
signalDecodersSchema,
|
signalDecodersSchema,
|
||||||
type InspectSignalDecodersInput,
|
type InspectSignalDecodersInput,
|
||||||
} from "../../domain/signals/signalDecoders.js";
|
} from "../../domain/signals/signalDecoders.js";
|
||||||
import {
|
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||||
createAnalysisExecutionEvidence,
|
import type {
|
||||||
type AnalysisExecution,
|
AnalysisExecution,
|
||||||
type ExecutionOptions,
|
ExecutionOptions,
|
||||||
} from "../AnalysisProvider.js";
|
} from "../AnalysisProvider.js";
|
||||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import {
|
import {
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
AnalysisOutputError,
|
AnalysisOutputError,
|
||||||
} from "../../domain/analysisErrorCore.js";
|
} from "../../domain/analysisErrorCore.js";
|
||||||
import type { Evidence } from "../../domain/evidence.js";
|
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||||
import {
|
import {
|
||||||
decodeSignalRecordingInputSchema,
|
decodeSignalRecordingInputSchema,
|
||||||
signalRecordingSchema,
|
signalRecordingSchema,
|
||||||
type DecodeSignalRecordingInput,
|
type DecodeSignalRecordingInput,
|
||||||
} from "../../domain/signals/signalRecording.js";
|
} from "../../domain/signals/signalRecording.js";
|
||||||
import { err, type Result } from "../../domain/result.js";
|
import { err, ok, type Result } from "../../domain/result.js";
|
||||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
|
||||||
|
|
||||||
export interface SignalRecordingPort {
|
export interface SignalRecordingPort {
|
||||||
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
|
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
|
||||||
@@ -75,15 +74,7 @@ export class SignalRecordingService {
|
|||||||
"Decoder returned malformed data or changed the selected recording identity/options.",
|
"Decoder returned malformed data or changed the selected recording identity/options.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return completeProviderEvidence(
|
return this.#evidence(operation, input.data, execution, options);
|
||||||
createAnalysisExecutionEvidence(
|
|
||||||
operation,
|
|
||||||
input.data,
|
|
||||||
execution,
|
|
||||||
"observed",
|
|
||||||
),
|
|
||||||
options?.signal,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
async inspectDecoders(
|
async inspectDecoders(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
@@ -117,14 +108,33 @@ export class SignalRecordingService {
|
|||||||
"Decoder discovery returned malformed data or changed the selected installation/options.",
|
"Decoder discovery returned malformed data or changed the selected installation/options.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return completeProviderEvidence(
|
return this.#evidence(operation, input.data, execution, options);
|
||||||
createAnalysisExecutionEvidence(
|
}
|
||||||
|
#evidence(
|
||||||
|
operation: string,
|
||||||
|
parameters: Readonly<Record<string, JsonValue>>,
|
||||||
|
execution: AnalysisExecution,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Result<Evidence, AnalysisError> {
|
||||||
|
const evidence = createEvidence(
|
||||||
|
execution.subject ?? undefined,
|
||||||
|
execution.provider,
|
||||||
|
{
|
||||||
operation,
|
operation,
|
||||||
input.data,
|
parameters,
|
||||||
execution,
|
result: execution.result,
|
||||||
"observed",
|
confidence: "observed",
|
||||||
),
|
locations: execution.locations,
|
||||||
options?.signal,
|
limitations: execution.limitations,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
return options?.signal?.aborted
|
||||||
|
? err(
|
||||||
|
new AnalysisCancelledError(operation, {
|
||||||
|
cause: options.signal.reason,
|
||||||
|
partialObservation: evidence,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: ok(evidence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
wasmArtifactExecution,
|
wasmArtifactExecution,
|
||||||
} from "../../../tests/fixtures/wasm/artifact.js";
|
} from "../../../tests/fixtures/wasm/artifact.js";
|
||||||
import { ok } from "../../domain/result.js";
|
import { ok } from "../../domain/result.js";
|
||||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
|
||||||
it.each([
|
it.each([
|
||||||
{ path: "relative" },
|
{ path: "relative" },
|
||||||
{ path: "/module", glue_paths: ["relative"] },
|
{ path: "/module", glue_paths: ["relative"] },
|
||||||
@@ -120,54 +119,30 @@ it("cancels before provider work and delivers observed Evidence on success", asy
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([false, true])(
|
it("retains completed Evidence when cancellation arrives after provider return", async () => {
|
||||||
"retains provider provenance after completion (late cancellation: %s)",
|
const value = wasmArtifactFixture();
|
||||||
async (cancel) => {
|
const controller = new AbortController();
|
||||||
const value = wasmArtifactFixture();
|
const service = new WasmArtifactService({
|
||||||
const execution = wasmArtifactExecution(value);
|
close: () => Promise.resolve(),
|
||||||
const profile = createAnalysisProfile(
|
inspect: async () => {
|
||||||
{ ...execution.provider, version: "1" },
|
controller.abort();
|
||||||
{
|
return ok(wasmArtifactExecution(value));
|
||||||
features: "selected",
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
const coverage = {
|
|
||||||
status: "unknown",
|
|
||||||
reason: "No call graph examined",
|
|
||||||
} as const;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const service = new WasmArtifactService({
|
|
||||||
close: () => Promise.resolve(),
|
|
||||||
inspect: async () => {
|
|
||||||
if (cancel) controller.abort();
|
|
||||||
return ok({
|
|
||||||
...execution,
|
|
||||||
rawResult: { stdout: "retained command output" },
|
|
||||||
analysisProfile: profile,
|
|
||||||
relationshipCoverage: coverage,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.inspect(
|
expect(
|
||||||
|
await service.inspect(
|
||||||
{ path: value.artifact.path },
|
{ path: value.artifact.path },
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
),
|
||||||
if (cancel)
|
).toMatchObject({
|
||||||
expect(result).toMatchObject({
|
ok: false,
|
||||||
ok: false,
|
error: {
|
||||||
error: { _tag: "AnalysisCancelledError" },
|
_tag: "AnalysisCancelledError",
|
||||||
});
|
partialObservation: { normalized_result: value },
|
||||||
else expect(result.ok).toBe(true);
|
},
|
||||||
const evidence = result.ok ? result.value : result.error.partialObservation;
|
});
|
||||||
expect(evidence).toMatchObject({
|
});
|
||||||
normalized_result: value,
|
|
||||||
raw_result: { stdout: "retained command output" },
|
|
||||||
analysis_profile: profile,
|
|
||||||
relationship_coverage: coverage,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("preserves malformed-output failure when cancellation arrives with it", async () => {
|
it("preserves malformed-output failure when cancellation arrives with it", async () => {
|
||||||
const value = wasmArtifactFixture();
|
const value = wasmArtifactFixture();
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import {
|
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||||
createAnalysisExecutionEvidence,
|
|
||||||
type ExecutionOptions,
|
|
||||||
} from "../AnalysisProvider.js";
|
|
||||||
import type { WasmArtifactPort } from "./WasmArtifactPort.js";
|
import type { WasmArtifactPort } from "./WasmArtifactPort.js";
|
||||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import {
|
import {
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
AnalysisOutputError,
|
AnalysisOutputError,
|
||||||
} from "../../domain/analysisErrorCore.js";
|
} from "../../domain/analysisErrorCore.js";
|
||||||
import type { Evidence } from "../../domain/evidence.js";
|
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||||
import {
|
import {
|
||||||
inspectWasmArtifactInputSchema,
|
inspectWasmArtifactInputSchema,
|
||||||
wasmArtifactSchema,
|
wasmArtifactSchema,
|
||||||
@@ -72,12 +69,17 @@ export class WasmArtifactService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
return completeProviderEvidence(
|
return completeProviderEvidence(
|
||||||
createAnalysisExecutionEvidence(
|
createEvidence(execution.subject, execution.provider, {
|
||||||
OPERATION,
|
operation: OPERATION,
|
||||||
input.data,
|
parameters: input.data,
|
||||||
{ ...execution, result: report.data },
|
result: report.data,
|
||||||
"observed",
|
...(execution.rawResult === null
|
||||||
),
|
? {}
|
||||||
|
: { rawResult: execution.rawResult }),
|
||||||
|
confidence: "observed",
|
||||||
|
locations: execution.locations,
|
||||||
|
limitations: execution.limitations,
|
||||||
|
}),
|
||||||
options?.signal,
|
options?.signal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type WebBundleAnalysis,
|
type WebBundleAnalysis,
|
||||||
} from "../domain/webBundleAnalysis.js";
|
} from "../domain/webBundleAnalysis.js";
|
||||||
import { buildCapturedWebBundleAnalysis } from "../domain/webBundleAnalyzerResult.js";
|
import { buildCapturedWebBundleAnalysis } from "../domain/webBundleAnalyzerResult.js";
|
||||||
import { isIncludedScript } from "../domain/webBundleAnalyzerState.js";
|
import { isIncludedScript } from "../domain/webBundleAnalyzerInspection.js";
|
||||||
import type { WebBundleScriptAnalysis } from "../domain/webBundleScriptAnalysis.js";
|
import type { WebBundleScriptAnalysis } from "../domain/webBundleScriptAnalysis.js";
|
||||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
|||||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||||
import {
|
import {
|
||||||
electronActiveTimelineEventSchema,
|
electronActiveTimelineEventSchema,
|
||||||
electronActiveProcessMetricSchema,
|
|
||||||
electronActiveWindowSchema,
|
|
||||||
electronActiveObservationResultSchema,
|
|
||||||
type ElectronActiveObservationInput,
|
type ElectronActiveObservationInput,
|
||||||
type ElectronActiveObservationResult,
|
type ElectronActiveObservationResult,
|
||||||
} from "../domain/javascript/electronActiveObservation.js";
|
} from "../domain/javascript/electronActiveObservation.js";
|
||||||
@@ -20,25 +17,49 @@ const OPERATION = "capture_electron_scenario" as const;
|
|||||||
const CANCELLED_STATE_READ_TIMEOUT_MS = 5_000;
|
const CANCELLED_STATE_READ_TIMEOUT_MS = 5_000;
|
||||||
const GRACE_EXPIRED = Symbol("cancellation grace expired");
|
const GRACE_EXPIRED = Symbol("cancellation grace expired");
|
||||||
|
|
||||||
const retention = electronActiveObservationResultSchema.shape.retention.shape;
|
|
||||||
const hookSnapshotSchema = z.strictObject({
|
const hookSnapshotSchema = z.strictObject({
|
||||||
events: z.array(electronActiveTimelineEventSchema),
|
events: z.array(electronActiveTimelineEventSchema),
|
||||||
retention_budget_bytes: retention.budget_bytes,
|
retention_budget_bytes: z.number().int().min(0),
|
||||||
estimated_retained_bytes: retention.estimated_retained_bytes,
|
estimated_retained_bytes: z.number().int().min(0),
|
||||||
event_serialized_byte_upper_bound:
|
event_serialized_byte_upper_bound: z.number().int().min(0),
|
||||||
retention.event_serialized_byte_upper_bound,
|
retained: z.number().int().min(0),
|
||||||
retained: retention.retained,
|
dropped: z.number().int().min(0),
|
||||||
dropped: retention.dropped,
|
dropped_ipc: z.number().int().min(0),
|
||||||
dropped_ipc: retention.dropped_ipc,
|
dropped_runtime: z.number().int().min(0),
|
||||||
dropped_runtime: retention.dropped_runtime,
|
dropped_event_families: z.array(
|
||||||
dropped_event_families: retention.dropped_event_families,
|
z.strictObject({
|
||||||
dropped_event_roles: retention.dropped_event_roles,
|
family: z.string().min(1),
|
||||||
|
count: z.number().int().min(1),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
dropped_event_roles: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
role: z.string().min(1),
|
||||||
|
count: z.number().int().min(1),
|
||||||
|
}),
|
||||||
|
),
|
||||||
observed: z.number().int().min(0),
|
observed: z.number().int().min(0),
|
||||||
observed_ipc: retention.observed_ipc,
|
observed_ipc: z.number().int().min(0),
|
||||||
observed_runtime: retention.observed_runtime,
|
observed_runtime: z.number().int().min(0),
|
||||||
hook_error: z.boolean(),
|
hook_error: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const metricSchema = z.strictObject({
|
||||||
|
pid: z.number().int().min(1),
|
||||||
|
type: z.string().min(1),
|
||||||
|
name: z.string().nullable(),
|
||||||
|
service_name: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const windowMetadataSchema = z.strictObject({
|
||||||
|
window_id: z.string(),
|
||||||
|
web_contents_id: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
visible: z.boolean().nullable(),
|
||||||
|
destroyed: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
type ElectronAction = ElectronActiveObservationInput["actions"][number];
|
type ElectronAction = ElectronActiveObservationInput["actions"][number];
|
||||||
type ElectronActions = ElectronActiveObservationResult["actions"];
|
type ElectronActions = ElectronActiveObservationResult["actions"];
|
||||||
|
|
||||||
@@ -46,10 +67,8 @@ export type ElectronHookSnapshot = z.infer<typeof hookSnapshotSchema>;
|
|||||||
export type ElectronHookEvent = z.infer<
|
export type ElectronHookEvent = z.infer<
|
||||||
typeof electronActiveTimelineEventSchema
|
typeof electronActiveTimelineEventSchema
|
||||||
>;
|
>;
|
||||||
export type ElectronMetrics = z.infer<
|
export type ElectronMetrics = z.infer<typeof metricSchema>[];
|
||||||
typeof electronActiveProcessMetricSchema
|
type ElectronWindowMetadata = z.infer<typeof windowMetadataSchema>;
|
||||||
>[];
|
|
||||||
type ElectronWindowMetadata = z.infer<typeof electronActiveWindowSchema>;
|
|
||||||
type ElectronApplicationState = Awaited<
|
type ElectronApplicationState = Awaited<
|
||||||
ReturnType<typeof readApplicationState>
|
ReturnType<typeof readApplicationState>
|
||||||
>;
|
>;
|
||||||
@@ -270,7 +289,14 @@ const emitDeepLink = (
|
|||||||
export const readApplicationState = async (
|
export const readApplicationState = async (
|
||||||
application: ElectronApplication,
|
application: ElectronApplication,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
readonly windows: ReadonlyArray<ElectronWindowMetadata>;
|
readonly windows: ReadonlyArray<{
|
||||||
|
readonly window_id: string;
|
||||||
|
readonly web_contents_id: string;
|
||||||
|
readonly url: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly visible: boolean | null;
|
||||||
|
readonly destroyed: boolean;
|
||||||
|
}>;
|
||||||
readonly metrics: ElectronMetrics;
|
readonly metrics: ElectronMetrics;
|
||||||
readonly electronVersion: string;
|
readonly electronVersion: string;
|
||||||
readonly hookSnapshot: ElectronHookSnapshot;
|
readonly hookSnapshot: ElectronHookSnapshot;
|
||||||
@@ -297,7 +323,7 @@ export const readApplicationState = async (
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then((value) => z.array(electronActiveWindowSchema).parse(value));
|
.then((value) => z.array(windowMetadataSchema).parse(value));
|
||||||
const processMetricsPromise: Promise<ElectronMetrics> = application
|
const processMetricsPromise: Promise<ElectronMetrics> = application
|
||||||
.evaluate(({ app }) =>
|
.evaluate(({ app }) =>
|
||||||
app
|
app
|
||||||
@@ -316,7 +342,7 @@ export const readApplicationState = async (
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then((value) => z.array(electronActiveProcessMetricSchema).parse(value));
|
.then((value) => z.array(metricSchema).parse(value));
|
||||||
const electronVersionPromise: Promise<string> = application
|
const electronVersionPromise: Promise<string> = application
|
||||||
.evaluate(() => process.versions.electron)
|
.evaluate(() => process.versions.electron)
|
||||||
.then((value) => z.string().parse(value));
|
.then((value) => z.string().parse(value));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { access } from "node:fs/promises";
|
|||||||
import { expect, it } from "vitest";
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||||
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
|
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
|
||||||
import {
|
import {
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
AnalysisOutputError,
|
AnalysisOutputError,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|||||||
import { jsonParts, bufferedJsonParts } from "../domain/jsonSerialization.js";
|
import { jsonParts, bufferedJsonParts } from "../domain/jsonSerialization.js";
|
||||||
import { javaScriptRuntime } from "../domain/runtimeVersion.js";
|
import { javaScriptRuntime } from "../domain/runtimeVersion.js";
|
||||||
import { err, ok, type Result } from "../domain/result.js";
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
|
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
|
||||||
import {
|
import {
|
||||||
webBundleScriptAnalysisSchema,
|
webBundleScriptAnalysisSchema,
|
||||||
type WebBundleScriptAnalysis,
|
type WebBundleScriptAnalysis,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ vi.mock("node:v8", async (importOriginal) => {
|
|||||||
|
|
||||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||||
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
|
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
|
||||||
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
|
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
|
||||||
|
|
||||||
it("refuses before spawning or reading source when no host result capacity remains", async () => {
|
it("refuses before spawning or reading source when no host result capacity remains", async () => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||||
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
|
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
|
||||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||||
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createInterface } from "node:readline";
|
|||||||
import { expect, it } from "vitest";
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||||
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
|
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
|
||||||
import {
|
import {
|
||||||
webBundleWorkerMessageSchema,
|
webBundleWorkerMessageSchema,
|
||||||
type WebBundleWorkerMessage,
|
type WebBundleWorkerMessage,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { withArtifactFileHandle } from "../artifacts/ArtifactFileOperation.js";
|
|||||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||||
import { jsonParts } from "../domain/jsonSerialization.js";
|
import { jsonParts } from "../domain/jsonSerialization.js";
|
||||||
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
|
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
|
||||||
import {
|
import {
|
||||||
webBundleScriptAnalysisSchema,
|
webBundleScriptAnalysisSchema,
|
||||||
type WebBundleScriptAnalysis,
|
type WebBundleScriptAnalysis,
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { parseEvidence, type Evidence } from "../domain/evidence.js";
|
|||||||
import type { JsonValue } from "../domain/jsonValue.js";
|
import type { JsonValue } from "../domain/jsonValue.js";
|
||||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
import { ok, type Result } from "../domain/result.js";
|
import type { Result } from "../domain/result.js";
|
||||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||||
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
|
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
|
||||||
import { publishEvidenceDelivery } from "../application/EvidenceBundleCommands.js";
|
import { writeEvidenceBundle } from "../application/EvidenceBundleFiles.js";
|
||||||
import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js";
|
import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js";
|
||||||
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ export const publishCliAnalysisEvidence = async (
|
|||||||
value: JsonValue,
|
value: JsonValue,
|
||||||
input: CliEvidenceDelivery,
|
input: CliEvidenceDelivery,
|
||||||
) => {
|
) => {
|
||||||
const { operation, signal, detail, evidenceOutput } = input;
|
const { providerId, operation, signal, detail, evidenceOutput } = input;
|
||||||
if (!isEvidence(value)) return value;
|
if (!isEvidence(value)) return value;
|
||||||
const complete = parseEvidence(value);
|
const complete = parseEvidence(value);
|
||||||
const inputError = cliEvidenceDeliveryInputError(input);
|
const inputError = cliEvidenceDeliveryInputError(input);
|
||||||
@@ -114,39 +115,43 @@ export const publishCliAnalysisEvidence = async (
|
|||||||
return projectAnalysisError(inputError);
|
return projectAnalysisError(inputError);
|
||||||
}
|
}
|
||||||
if (evidenceOutput === undefined) return complete;
|
if (evidenceOutput === undefined) return complete;
|
||||||
// Project before publication so a view failure cannot commit an
|
const resources = new ArtifactResourceScope();
|
||||||
// unreported bundle; publication then owns every post-commit outcome.
|
let receipt: { readonly path: string; readonly bytes: number } | undefined;
|
||||||
const delivered =
|
const delivered = await finishAnalysisCommand(
|
||||||
detail === "summary"
|
async () => {
|
||||||
? inspectAnalysisView({
|
const written = await writeEvidenceBundle(
|
||||||
source: { kind: "inline", evidence: complete },
|
createEvidenceBundle([
|
||||||
view: input.view ?? { kind: "summary" },
|
...new Map(
|
||||||
})
|
[...(input.sourceEvidence ?? []), complete].map((record) => [
|
||||||
: ok(complete);
|
record.evidence_id,
|
||||||
if (!delivered.ok)
|
record,
|
||||||
return projectAnalysisError(
|
]),
|
||||||
delivered.error.retainPartialObservation(complete),
|
).values(),
|
||||||
);
|
|
||||||
const published = await publishEvidenceDelivery({
|
|
||||||
bundle: createEvidenceBundle([
|
|
||||||
...new Map(
|
|
||||||
[...(input.sourceEvidence ?? []), complete].map((record) => [
|
|
||||||
record.evidence_id,
|
|
||||||
record,
|
|
||||||
]),
|
]),
|
||||||
).values(),
|
absoluteCallerPath(evidenceOutput),
|
||||||
]),
|
false,
|
||||||
complete,
|
resources,
|
||||||
delivered: delivered.value,
|
signal,
|
||||||
outputPath: absoluteCallerPath(evidenceOutput),
|
);
|
||||||
|
if (!written.ok) {
|
||||||
|
if (written.error.partialObservation === undefined)
|
||||||
|
written.error.retainPartialObservation(complete);
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
receipt = written.value;
|
||||||
|
return detail === "summary"
|
||||||
|
? inspectAnalysisView({
|
||||||
|
source: { kind: "inline", evidence: complete },
|
||||||
|
view: input.view ?? { kind: "summary" },
|
||||||
|
})
|
||||||
|
: { ok: true as const, value: complete };
|
||||||
|
},
|
||||||
|
{ close: () => resources.close() },
|
||||||
|
providerId,
|
||||||
operation,
|
operation,
|
||||||
signal,
|
signal,
|
||||||
});
|
);
|
||||||
if (!published.ok) return projectAnalysisError(published.error);
|
return isEvidence(delivered) && detail === "summary"
|
||||||
return detail === "summary"
|
? { summary: delivered, evidence_bundle: receipt }
|
||||||
? {
|
: delivered;
|
||||||
summary: published.value.delivered,
|
|
||||||
evidence_bundle: published.value.evidenceBundle,
|
|
||||||
}
|
|
||||||
: published.value.delivered;
|
|
||||||
};
|
};
|
||||||
@@ -9,7 +9,6 @@ import type { Logger } from "pino";
|
|||||||
import {
|
import {
|
||||||
directAnalysisOptionsFromCli,
|
directAnalysisOptionsFromCli,
|
||||||
nativeOpenOptionFields,
|
nativeOpenOptionFields,
|
||||||
snapshotSelectionOption,
|
|
||||||
} from "./options.js";
|
} from "./options.js";
|
||||||
import type { CliInstance } from "./types.js";
|
import type { CliInstance } from "./types.js";
|
||||||
import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js";
|
import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js";
|
||||||
@@ -39,7 +38,11 @@ const registerOverviewCommands = (
|
|||||||
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
|
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
|
||||||
): void => {
|
): void => {
|
||||||
const overviewOptions = z.object({
|
const overviewOptions = z.object({
|
||||||
snapshot: snapshotSelectionOption,
|
snapshot: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Load and update a local analysis snapshot"),
|
||||||
...nativeOpenOptionFields,
|
...nativeOpenOptionFields,
|
||||||
});
|
});
|
||||||
// `analyze` declares the JavaScript integrity policy for its JavaScript
|
// `analyze` declares the JavaScript integrity policy for its JavaScript
|
||||||
@@ -176,7 +179,11 @@ const registerDecompileCommand = (
|
|||||||
.describe(
|
.describe(
|
||||||
"Ghidra source-file byte offset; mapped internally to an analysis address",
|
"Ghidra source-file byte offset; mapped internally to an analysis address",
|
||||||
),
|
),
|
||||||
snapshot: snapshotSelectionOption,
|
snapshot: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Load and update a local analysis snapshot"),
|
||||||
...nativeOpenOptionFields,
|
...nativeOpenOptionFields,
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
@@ -259,7 +266,11 @@ const registerXrefsCommand = (
|
|||||||
.describe(
|
.describe(
|
||||||
"Address or symbol name; use --address=<value> for leading dashes",
|
"Address or symbol name; use --address=<value> for leading dashes",
|
||||||
),
|
),
|
||||||
snapshot: snapshotSelectionOption,
|
snapshot: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Load and update a local analysis snapshot"),
|
||||||
...nativeOpenOptionFields,
|
...nativeOpenOptionFields,
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
@@ -304,7 +315,11 @@ const registerTraceCommand = (
|
|||||||
.boolean()
|
.boolean()
|
||||||
.default(false)
|
.default(false)
|
||||||
.describe("Match the query with exact letter case"),
|
.describe("Match the query with exact letter case"),
|
||||||
snapshot: snapshotSelectionOption,
|
snapshot: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Load and update a local analysis snapshot"),
|
||||||
...nativeOpenOptionFields,
|
...nativeOpenOptionFields,
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
@@ -350,7 +365,11 @@ const registerFunctionCommand = (
|
|||||||
.describe(
|
.describe(
|
||||||
"Procedure name or address; use --procedure=<value> for leading dashes",
|
"Procedure name or address; use --procedure=<value> for leading dashes",
|
||||||
),
|
),
|
||||||
snapshot: snapshotSelectionOption,
|
snapshot: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Load and update a local analysis snapshot"),
|
||||||
...nativeOpenOptionFields,
|
...nativeOpenOptionFields,
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
@@ -395,7 +414,11 @@ const registerInstructionsCommand = (
|
|||||||
.describe(
|
.describe(
|
||||||
"Procedure name or address; use --procedure=<value> for leading dashes",
|
"Procedure name or address; use --procedure=<value> for leading dashes",
|
||||||
),
|
),
|
||||||
snapshot: snapshotSelectionOption,
|
snapshot: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Load and update a local analysis snapshot"),
|
||||||
...nativeOpenOptionFields,
|
...nativeOpenOptionFields,
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
@@ -452,7 +475,11 @@ const registerSearchCommand = (
|
|||||||
.boolean()
|
.boolean()
|
||||||
.default(false)
|
.default(false)
|
||||||
.describe("Match the pattern with exact letter case"),
|
.describe("Match the pattern with exact letter case"),
|
||||||
snapshot: snapshotSelectionOption,
|
snapshot: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Load and update a local analysis snapshot"),
|
||||||
...nativeOpenOptionFields,
|
...nativeOpenOptionFields,
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { Logger } from "pino";
|
|||||||
import {
|
import {
|
||||||
directAnalysisOptionsFromCli,
|
directAnalysisOptionsFromCli,
|
||||||
nativeOpenOptionFields,
|
nativeOpenOptionFields,
|
||||||
snapshotSelectionOption,
|
|
||||||
} from "./options.js";
|
} from "./options.js";
|
||||||
import type { CliInstance } from "./types.js";
|
import type { CliInstance } from "./types.js";
|
||||||
|
|
||||||
@@ -272,9 +271,11 @@ const registerNativeDispatchMetadataCommand = (
|
|||||||
.max(20_000)
|
.max(20_000)
|
||||||
.default(5_000)
|
.default(5_000)
|
||||||
.describe("Maximum symbol records to inspect"),
|
.describe("Maximum symbol records to inspect"),
|
||||||
snapshot: snapshotSelectionOption.describe(
|
snapshot: z
|
||||||
"Load or update the local analysis snapshot",
|
.string()
|
||||||
),
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Load or update the local analysis snapshot"),
|
||||||
...nativeOpenOptionFields,
|
...nativeOpenOptionFields,
|
||||||
}),
|
}),
|
||||||
run: async ({ args, options }) =>
|
run: async ({ args, options }) =>
|
||||||
@@ -359,7 +360,11 @@ const registerNativeApiCommand = (
|
|||||||
address: z.string().describe("Procedure name or address"),
|
address: z.string().describe("Procedure name or address"),
|
||||||
}),
|
}),
|
||||||
options: z.object({
|
options: z.object({
|
||||||
snapshot: snapshotSelectionOption,
|
snapshot: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Load and update a local analysis snapshot"),
|
||||||
...nativeOpenOptionFields,
|
...nativeOpenOptionFields,
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { logCliCommand } from "../cliLogging.js";
|
|||||||
import { parseEnvironmentName } from "../config/environmentName.js";
|
import { parseEnvironmentName } from "../config/environmentName.js";
|
||||||
import {
|
import {
|
||||||
parseMcpHttpEndpoint,
|
parseMcpHttpEndpoint,
|
||||||
mcpHttpListenerPortSchema,
|
|
||||||
type McpHttpEndpoint,
|
type McpHttpEndpoint,
|
||||||
} from "../config/mcpHttpEndpoint.js";
|
} from "../config/mcpHttpEndpoint.js";
|
||||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
@@ -365,7 +364,11 @@ export const registerEnvironmentCommands = (
|
|||||||
.min(1)
|
.min(1)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Listener address; default MCP_HOST, else 127.0.0.1"),
|
.describe("Listener address; default MCP_HOST, else 127.0.0.1"),
|
||||||
port: mcpHttpListenerPortSchema
|
port: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(65535)
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"Listener port; default MCP_PORT, else 0 for an ephemeral port",
|
"Listener port; default MCP_PORT, else 0 for an ephemeral port",
|
||||||
|
|||||||
+10
-15
@@ -2,7 +2,6 @@ import { inspectGpuCodeObjectsInputSchema } from "../domain/gpu/gpuCodeObjects.j
|
|||||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||||
import { safeParseJson } from "../domain/safeJson.js";
|
|
||||||
import { z } from "incur";
|
import { z } from "incur";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { CliInstance } from "./types.js";
|
import type { CliInstance } from "./types.js";
|
||||||
@@ -60,23 +59,19 @@ export const registerGpuCommands = (cli: CliInstance, logger: Logger): void => {
|
|||||||
for (const name of ["object_ids", "targets", "view"] as const) {
|
for (const name of ["object_ids", "targets", "view"] as const) {
|
||||||
const value = options[name];
|
const value = options[name];
|
||||||
if (value === undefined) continue;
|
if (value === undefined) continue;
|
||||||
const decoded = safeParseJson(value);
|
try {
|
||||||
if (!decoded.ok) {
|
selectors[name] = JSON.parse(value);
|
||||||
|
} catch (cause) {
|
||||||
return projectAnalysisError(
|
return projectAnalysisError(
|
||||||
new AnalysisInputError(
|
new AnalysisInputError("inspect_gpu_code_objects", { cause }, [
|
||||||
"inspect_gpu_code_objects",
|
{
|
||||||
{ cause: decoded.cause },
|
path: [name],
|
||||||
[
|
reason: "invalid_format",
|
||||||
{
|
message: `${name} must be valid JSON`,
|
||||||
path: [name],
|
},
|
||||||
reason: "invalid_format",
|
]),
|
||||||
message: `${name} must be valid JSON`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
selectors[name] = decoded.value;
|
|
||||||
}
|
}
|
||||||
const rawInput = {
|
const rawInput = {
|
||||||
path: absoluteCallerPath(args.path),
|
path: absoluteCallerPath(args.path),
|
||||||
|
|||||||
+12
-18
@@ -2,7 +2,6 @@ import { inspectModelArtifactInputSchema } from "../domain/model/modelArtifactQu
|
|||||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||||
import { safeParseJson } from "../domain/safeJson.js";
|
|
||||||
import { z } from "incur";
|
import { z } from "incur";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { CliInstance } from "./types.js";
|
import type { CliInstance } from "./types.js";
|
||||||
@@ -45,26 +44,21 @@ export const registerModelCommands = (
|
|||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
withCommandCancellation((signal) =>
|
withCommandCancellation((signal) =>
|
||||||
logCliCommand(logger, CLI_COMMANDS.inspectModelArtifact, async () => {
|
logCliCommand(logger, CLI_COMMANDS.inspectModelArtifact, async () => {
|
||||||
const decoded =
|
let view: unknown;
|
||||||
options.view === undefined
|
try {
|
||||||
? { ok: true as const, value: undefined }
|
view =
|
||||||
: safeParseJson(options.view);
|
options.view === undefined ? undefined : JSON.parse(options.view);
|
||||||
if (!decoded.ok) {
|
} catch (cause) {
|
||||||
return projectAnalysisError(
|
return projectAnalysisError(
|
||||||
new AnalysisInputError(
|
new AnalysisInputError("inspect_model_artifact", { cause }, [
|
||||||
"inspect_model_artifact",
|
{
|
||||||
{ cause: decoded.cause },
|
path: ["view"],
|
||||||
[
|
reason: "invalid_format",
|
||||||
{
|
message: "view must be a JSON model page selection",
|
||||||
path: ["view"],
|
},
|
||||||
reason: "invalid_format",
|
]),
|
||||||
message: "view must be a JSON model page selection",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const view = decoded.value;
|
|
||||||
const selected = inspectModelArtifactInputSchema.safeParse({
|
const selected = inspectModelArtifactInputSchema.safeParse({
|
||||||
path: absoluteCallerPath(args.path),
|
path: absoluteCallerPath(args.path),
|
||||||
hash: options.hash,
|
hash: options.hash,
|
||||||
|
|||||||
@@ -26,13 +26,6 @@ export const providerSelectionOption = analysisProviderSelectorSchema
|
|||||||
"Bind deep analysis to a provider ID or use deterministic auto selection",
|
"Bind deep analysis to a provider ID or use deterministic auto selection",
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Optional path to the local analysis snapshot loaded or updated by a command. */
|
|
||||||
export const snapshotSelectionOption = z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.optional()
|
|
||||||
.describe("Load and update a local analysis snapshot");
|
|
||||||
|
|
||||||
/** Explicit interpretation for headerless native executable formats. */
|
/** Explicit interpretation for headerless native executable formats. */
|
||||||
export const formatSelectionOption = executableFormatHintSchema
|
export const formatSelectionOption = executableFormatHintSchema
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
+12
-18
@@ -10,7 +10,6 @@ import { inspectRustBinaryRequestSchema } from "../domain/rust/rustBinaryQuery.j
|
|||||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||||
import { safeParseJson } from "../domain/safeJson.js";
|
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { CliInstance } from "./types.js";
|
import type { CliInstance } from "./types.js";
|
||||||
import { withCommandCancellation } from "./commandCancellation.js";
|
import { withCommandCancellation } from "./commandCancellation.js";
|
||||||
@@ -45,26 +44,21 @@ export const registerRustCommands = (
|
|||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
withCommandCancellation((signal) =>
|
withCommandCancellation((signal) =>
|
||||||
logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => {
|
logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => {
|
||||||
const decoded =
|
let view: unknown;
|
||||||
options.view === undefined
|
try {
|
||||||
? { ok: true as const, value: undefined }
|
view =
|
||||||
: safeParseJson(options.view);
|
options.view === undefined ? undefined : JSON.parse(options.view);
|
||||||
if (!decoded.ok) {
|
} catch (cause) {
|
||||||
return projectAnalysisError(
|
return projectAnalysisError(
|
||||||
new AnalysisInputError(
|
new AnalysisInputError("inspect_rust_binary", { cause }, [
|
||||||
"inspect_rust_binary",
|
{
|
||||||
{ cause: decoded.cause },
|
path: ["view"],
|
||||||
[
|
reason: "invalid_format",
|
||||||
{
|
message: "view must be a JSON package or edge selection",
|
||||||
path: ["view"],
|
},
|
||||||
reason: "invalid_format",
|
]),
|
||||||
message: "view must be a JSON package or edge selection",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const view = decoded.value;
|
|
||||||
const input = inspectRustBinaryRequestSchema.safeParse({
|
const input = inspectRustBinaryRequestSchema.safeParse({
|
||||||
path: absoluteCallerPath(args.path),
|
path: absoluteCallerPath(args.path),
|
||||||
...(options.detail === undefined ? {} : { detail: options.detail }),
|
...(options.detail === undefined ? {} : { detail: options.detail }),
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js";
|
import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js";
|
||||||
import { withCommandCancellation } from "./cli/commandCancellation.js";
|
import { withCommandCancellation } from "./cli/commandCancellation.js";
|
||||||
|
import {
|
||||||
|
analysisEvidenceDeliveryOptions,
|
||||||
|
cliEvidenceDeliveryInputError,
|
||||||
|
publishCliAnalysisEvidence,
|
||||||
|
} from "./cli/analysisEvidenceDelivery.js";
|
||||||
import { CLI_COMMANDS } from "./cliCommandNames.js";
|
import { CLI_COMMANDS } from "./cliCommandNames.js";
|
||||||
import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js";
|
import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js";
|
||||||
import { logCliCommand } from "./cliLogging.js";
|
import { logCliCommand } from "./cliLogging.js";
|
||||||
@@ -16,6 +21,7 @@ import type { JsonValue } from "./domain/jsonValue.js";
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
const OPERATION = "capture_browser_scenario";
|
const OPERATION = "capture_browser_scenario";
|
||||||
|
const PROVIDER_ID = "rea-playwright-browser-scenario";
|
||||||
|
|
||||||
/** Register the one-shot controlled browser scenario command. */
|
/** Register the one-shot controlled browser scenario command. */
|
||||||
export const registerBrowserScenarioCommands = (
|
export const registerBrowserScenarioCommands = (
|
||||||
@@ -36,9 +42,17 @@ export const registerBrowserScenarioCommands = (
|
|||||||
.string()
|
.string()
|
||||||
.describe("Inline browser scenario JSON or JSON file path"),
|
.describe("Inline browser scenario JSON or JSON file path"),
|
||||||
}),
|
}),
|
||||||
run: ({ args }) =>
|
options: z.object(analysisEvidenceDeliveryOptions),
|
||||||
|
run: ({ args, options: delivery }) =>
|
||||||
withCommandCancellation((signal) =>
|
withCommandCancellation((signal) =>
|
||||||
logCliCommand(logger, CLI_COMMANDS.captureBrowserScenario, async () => {
|
logCliCommand(logger, CLI_COMMANDS.captureBrowserScenario, async () => {
|
||||||
|
const deliveryError = cliEvidenceDeliveryInputError({
|
||||||
|
detail: delivery.detail,
|
||||||
|
evidenceOutput: delivery.evidence_output,
|
||||||
|
operation: OPERATION,
|
||||||
|
});
|
||||||
|
// Refuse undeliverable summaries before launching a browser.
|
||||||
|
if (deliveryError !== undefined) return cliError(deliveryError);
|
||||||
const input = await parseCliJsonInput(args.inputJson, OPERATION);
|
const input = await parseCliJsonInput(args.inputJson, OPERATION);
|
||||||
if (!input.ok) return input.error;
|
if (!input.ok) return input.error;
|
||||||
const scenario = browserScenarioSchema.safeParse(
|
const scenario = browserScenarioSchema.safeParse(
|
||||||
@@ -68,7 +82,6 @@ export const registerBrowserScenarioCommands = (
|
|||||||
result = await captureBrowserScenario(provider, scenario.data, {
|
result = await captureBrowserScenario(provider, scenario.data, {
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
return result.ok ? result.value : cliError(result.error);
|
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await provider?.close?.();
|
await provider?.close?.();
|
||||||
@@ -97,6 +110,14 @@ export const registerBrowserScenarioCommands = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!result.ok) return cliError(result.error);
|
||||||
|
return publishCliAnalysisEvidence(result.value, {
|
||||||
|
providerId: PROVIDER_ID,
|
||||||
|
operation: OPERATION,
|
||||||
|
signal,
|
||||||
|
detail: delivery.detail,
|
||||||
|
evidenceOutput: delivery.evidence_output,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,15 +11,12 @@ export const DEFAULT_MCP_HTTP_HOST = "127.0.0.1";
|
|||||||
/** Port 0 asks the host for an ephemeral port, reported by the listening log line. */
|
/** Port 0 asks the host for an ephemeral port, reported by the listening log line. */
|
||||||
export const DEFAULT_MCP_HTTP_PORT = 0;
|
export const DEFAULT_MCP_HTTP_PORT = 0;
|
||||||
|
|
||||||
/** A listener may request an ephemeral port with 0; connected endpoints may not. */
|
|
||||||
export const mcpHttpListenerPortSchema = z.number().int().min(0).max(65535);
|
|
||||||
|
|
||||||
/** Decimal TCP port text, shared by the `--port` flag and `MCP_PORT`. */
|
/** Decimal TCP port text, shared by the `--port` flag and `MCP_PORT`. */
|
||||||
export const mcpHttpPortSchema = z
|
export const mcpHttpPortSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^(?:0|[1-9]\d*)$/u)
|
.regex(/^(?:0|[1-9]\d*)$/u)
|
||||||
.transform(Number)
|
.transform(Number)
|
||||||
.pipe(mcpHttpListenerPortSchema);
|
.pipe(z.number().int().min(0).max(65535));
|
||||||
|
|
||||||
const mcpHttpHostSchema = z.string().trim().min(1);
|
const mcpHttpHostSchema = z.string().trim().min(1);
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,11 @@ export const COMPACT_INPUT_SCHEMA_BUDGET_BYTES = 13 * 1024;
|
|||||||
|
|
||||||
export const mcpInputSchemaProfileSchema = z.enum(["full", "compact"]);
|
export const mcpInputSchemaProfileSchema = z.enum(["full", "compact"]);
|
||||||
|
|
||||||
/** Resolve the selected advertisement at the configuration boundary. */
|
|
||||||
export const resolveMcpInputSchemaProfile = (
|
|
||||||
profile: McpInputSchemaProfile | undefined,
|
|
||||||
): McpInputSchemaProfile => profile ?? "compact";
|
|
||||||
|
|
||||||
/** Select the default compact advertisement, preserving an explicit full choice. */
|
/** Select the default compact advertisement, preserving an explicit full choice. */
|
||||||
export const compactPresentationForMcpInputSchemaProfile = (
|
export const compactPresentationForMcpInputSchemaProfile = (
|
||||||
profile: "full" | "compact" | undefined,
|
profile: "full" | "compact" | undefined,
|
||||||
): { readonly budgetBytes: number } | undefined =>
|
): { readonly budgetBytes: number } | undefined =>
|
||||||
resolveMcpInputSchemaProfile(profile) === "full"
|
profile === "full"
|
||||||
? undefined
|
? undefined
|
||||||
: { budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES };
|
: { budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES };
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
|||||||
...toolContractMetadata("inspect_analysis_view"),
|
...toolContractMetadata("inspect_analysis_view"),
|
||||||
kind: "application",
|
kind: "application",
|
||||||
description:
|
description:
|
||||||
'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete EvidenceLine truncated
|
'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects, inspect_web_network_capture or capture_browser_scenario Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. capture_browser_scenario parents also support view {kind: "summary"} (step outcomes, focus accessibility tree, event and network counts) and view {kind: "browser-scenario", collection: "steps" | "events", offset: 0, limit: 64}: stLine truncated
|
||||||
inputSchema: inspectAnalysisViewInputSchema,
|
inputSchema: inspectAnalysisViewInputSchema,
|
||||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||||
examples: [
|
examples: [
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { ToolContract } from "./toolContractTypes.js";
|
import type { ToolContract } from "./toolContractTypes.js";
|
||||||
import { toolContractMetadata } from "./toolEffects.js";
|
import { toolContractMetadata } from "./toolEffects.js";
|
||||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
import {
|
||||||
|
analysisDetailOutputSchemaOf,
|
||||||
|
analysisDetailSchema,
|
||||||
|
} from "./analysisDetail.js";
|
||||||
|
import { browserScenarioSummaryViewResultSchema } from "../domain/analysisView/analysisView.js";
|
||||||
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
|
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
|
||||||
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
|
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
|
||||||
import type { JsonValue } from "../domain/jsonValue.js";
|
import type { JsonValue } from "../domain/jsonValue.js";
|
||||||
@@ -26,10 +30,15 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [
|
|||||||
name: "capture_browser_scenario",
|
name: "capture_browser_scenario",
|
||||||
...toolContractMetadata("capture_browser_scenario"),
|
...toolContractMetadata("capture_browser_scenario"),
|
||||||
description:
|
description:
|
||||||
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.",
|
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Default detail complete returns the capture inline. Detail summary retains it and returns per-step status, errors and URLs, the final or first failing step's accessibility tree, and event and network counts; page exact steps (with selected artifacts) or events through inspect_analysis_view kind browser-scenario.",
|
||||||
kind: "browser-provider",
|
kind: "browser-provider",
|
||||||
inputSchema: browserScenarioInputSchema,
|
inputSchema: browserScenarioInputSchema.extend({
|
||||||
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
|
detail: analysisDetailSchema,
|
||||||
|
}),
|
||||||
|
outputSchema: analysisDetailOutputSchemaOf(
|
||||||
|
browserScenarioCaptureSchema,
|
||||||
|
browserScenarioSummaryViewResultSchema,
|
||||||
|
),
|
||||||
examples: [
|
examples: [
|
||||||
{ title: "Capture a deterministic browser scenario", input: example },
|
{ title: "Capture a deterministic browser scenario", input: example },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { evidenceSchema } from "../../domain/evidence.js";
|
import { evidenceSchema } from "../../domain/evidence.js";
|
||||||
import { compareManagedMembersInputSchema } from "../../domain/managed/managedMemberComparison.js";
|
|
||||||
import { managedNativeVerificationInputSchema } from "../../domain/managed/managedNativeVerificationSchemas.js";
|
import { managedNativeVerificationInputSchema } from "../../domain/managed/managedNativeVerificationSchemas.js";
|
||||||
|
|
||||||
import { managedReconstructionImportInputSchema } from "../../domain/managed/managedReconstruction.js";
|
import { managedReconstructionImportInputSchema } from "../../domain/managed/managedReconstruction.js";
|
||||||
@@ -15,13 +14,31 @@ import {
|
|||||||
import { toolContractMetadata } from "../toolEffects.js";
|
import { toolContractMetadata } from "../toolEffects.js";
|
||||||
import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js";
|
import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js";
|
||||||
|
|
||||||
|
/** Inputs for comparing two managed observations carried inline. */
|
||||||
|
export const compareManagedMembersReferenceInputSchema = z
|
||||||
|
.strictObject({
|
||||||
|
left: evidenceSchema,
|
||||||
|
right: evidenceSchema,
|
||||||
|
})
|
||||||
|
.superRefine((input, context) => {
|
||||||
|
if (input.left.evidence_id === input.right.evidence_id)
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["right"],
|
||||||
|
message: "Managed member Evidence must be distinct",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/** Inline Evidence input for importing reconstruction. */
|
/** Inline Evidence input for importing reconstruction. */
|
||||||
export const managedReconstructionReferenceInputSchema =
|
export const managedReconstructionReferenceInputSchema =
|
||||||
managedReconstructionImportInputSchema.extend({
|
managedReconstructionImportInputSchema.extend({
|
||||||
static_members: evidenceSchema,
|
static_members: evidenceSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const managedApplicationGraphReferenceFacts = {} as const;
|
||||||
|
|
||||||
const managedApplicationGraphReferenceShape = z.strictObject({
|
const managedApplicationGraphReferenceShape = z.strictObject({
|
||||||
|
...managedApplicationGraphReferenceFacts,
|
||||||
managed_artifact: evidenceSchema.optional(),
|
managed_artifact: evidenceSchema.optional(),
|
||||||
managed_members: evidenceSchema.optional(),
|
managed_members: evidenceSchema.optional(),
|
||||||
managed_native_boundaries: evidenceSchema.optional(),
|
managed_native_boundaries: evidenceSchema.optional(),
|
||||||
@@ -83,7 +100,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [
|
|||||||
description:
|
description:
|
||||||
"Compare two authenticated inspect_managed_members Evidence records using unique-only tiers: exact declared type, name, and CIL/signature; exact declared type, name, and signature (decoded or not); exact declared type, name, and decoded structural method shape; then exact CIL/signature or decoded structural method shape alone, which never takes a member whose own declared type and name remain unpaired on the other side or whose declaring type is unresolved. Signatures compare type operands by name, not by build-local metadata row. Names alone are never a matching basis; a member whose signature was not decoded, or whose same-named counterpart's was not, stays unknown rather than added or removed. Metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.",
|
"Compare two authenticated inspect_managed_members Evidence records using unique-only tiers: exact declared type, name, and CIL/signature; exact declared type, name, and signature (decoded or not); exact declared type, name, and decoded structural method shape; then exact CIL/signature or decoded structural method shape alone, which never takes a member whose own declared type and name remain unpaired on the other side or whose declaring type is unresolved. Signatures compare type operands by name, not by build-local metadata row. Names alone are never a matching basis; a member whose signature was not decoded, or whose same-named counterpart's was not, stays unknown rather than added or removed. Metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.",
|
||||||
kind: "application",
|
kind: "application",
|
||||||
inputSchema: compareManagedMembersInputSchema,
|
inputSchema: compareManagedMembersReferenceInputSchema,
|
||||||
outputSchema: comparisonOutputSchema,
|
outputSchema: comparisonOutputSchema,
|
||||||
examples: [
|
examples: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
MANAGED_MEMBER_COMPARISON_EXAMPLE,
|
MANAGED_MEMBER_COMPARISON_EXAMPLE,
|
||||||
MANAGED_NATIVE_VERIFICATION_EXAMPLE,
|
MANAGED_NATIVE_VERIFICATION_EXAMPLE,
|
||||||
} from "./managed/managedWorkflowExamples.js";
|
} from "./managed/managedWorkflowExamples.js";
|
||||||
import { managedApplicationGraphReferenceInputSchema } from "./managed/managedWorkflowToolContracts.js";
|
import {
|
||||||
|
compareManagedMembersReferenceInputSchema,
|
||||||
|
managedApplicationGraphReferenceInputSchema,
|
||||||
|
} from "./managed/managedWorkflowToolContracts.js";
|
||||||
|
|
||||||
describe("workflow input contracts", () => {
|
describe("workflow input contracts", () => {
|
||||||
it("rejects application Evidence ID references and duplicate inline records", () => {
|
it("rejects application Evidence ID references and duplicate inline records", () => {
|
||||||
@@ -35,7 +38,7 @@ describe("workflow input contracts", () => {
|
|||||||
|
|
||||||
it("rejects duplicate inline managed Evidence records", () => {
|
it("rejects duplicate inline managed Evidence records", () => {
|
||||||
expect(
|
expect(
|
||||||
toolContract("compare_managed_members").inputSchema.safeParse({
|
compareManagedMembersReferenceInputSchema.safeParse({
|
||||||
left: MANAGED_MEMBER_COMPARISON_EXAMPLE.left,
|
left: MANAGED_MEMBER_COMPARISON_EXAMPLE.left,
|
||||||
right: MANAGED_MEMBER_COMPARISON_EXAMPLE.left,
|
right: MANAGED_MEMBER_COMPARISON_EXAMPLE.left,
|
||||||
}).success,
|
}).success,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
|
||||||
import { jsonObjectSchema, type JsonValue } from "./jsonValue.js";
|
import { jsonObjectSchema, type JsonValue } from "./jsonValue.js";
|
||||||
import { digestSchema } from "./../domain/digests.js";
|
import { digestSchema } from "./../domain/digests.js";
|
||||||
|
|
||||||
@@ -63,4 +65,11 @@ export const analysisProfilesEqual = (
|
|||||||
|
|
||||||
const profileDigest = (
|
const profileDigest = (
|
||||||
profile: z.infer<typeof unsignedAnalysisProfileSchema>,
|
profile: z.infer<typeof unsignedAnalysisProfileSchema>,
|
||||||
): string => digestCanonicalValue(profile, "Analysis profile");
|
): string => createHash("sha256").update(canonicalJson(profile)).digest("hex");
|
||||||
|
|
||||||
|
const canonicalJson = (value: JsonValue): string => {
|
||||||
|
const encoded = canonicalize(value);
|
||||||
|
if (encoded === undefined)
|
||||||
|
throw new TypeError("RFC 8785 canonicalization rejected analysis profile");
|
||||||
|
return encoded;
|
||||||
|
};
|
||||||
@@ -100,10 +100,7 @@ import { evidenceSchema, type Evidence } from "../evidence.js";
|
|||||||
import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js";
|
import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js";
|
||||||
import { err, ok, type Result } from "../result.js";
|
import { err, ok, type Result } from "../result.js";
|
||||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||||
import {
|
import { safeParseOwnedBinaryLayout } from "../native/binaryLayout.js";
|
||||||
binaryLayoutSchema,
|
|
||||||
safeParseOwnedBinaryLayout,
|
|
||||||
} from "../native/binaryLayout.js";
|
|
||||||
import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js";
|
import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js";
|
||||||
import { functionDossierSchema } from "../hopperValues.js";
|
import { functionDossierSchema } from "../hopperValues.js";
|
||||||
import {
|
import {
|
||||||
@@ -170,6 +167,13 @@ import {
|
|||||||
interfaceBuilderViewRequestSchema,
|
interfaceBuilderViewRequestSchema,
|
||||||
projectInterfaceBuilderView,
|
projectInterfaceBuilderView,
|
||||||
} from "./interfaceBuilderView.js";
|
} from "./interfaceBuilderView.js";
|
||||||
|
import {
|
||||||
|
browserScenarioSummarySchema,
|
||||||
|
browserScenarioViewContextSchema,
|
||||||
|
browserScenarioViewItemsSchema,
|
||||||
|
browserScenarioViewRequestSchema,
|
||||||
|
projectBrowserScenarioView,
|
||||||
|
} from "./browserScenarioView.js";
|
||||||
import {
|
import {
|
||||||
projectWebNetworkCaptureView,
|
projectWebNetworkCaptureView,
|
||||||
captureViewRequestSchema,
|
captureViewRequestSchema,
|
||||||
@@ -325,6 +329,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
|||||||
environmentManifestViewRequestSchema,
|
environmentManifestViewRequestSchema,
|
||||||
environmentStateViewRequestSchema,
|
environmentStateViewRequestSchema,
|
||||||
captureViewRequestSchema,
|
captureViewRequestSchema,
|
||||||
|
browserScenarioViewRequestSchema,
|
||||||
protobufViewRequestSchema,
|
protobufViewRequestSchema,
|
||||||
qlogViewRequestSchema,
|
qlogViewRequestSchema,
|
||||||
pcapViewRequestSchema,
|
pcapViewRequestSchema,
|
||||||
@@ -383,10 +388,14 @@ const artifactSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const layoutSummarySchema = z.strictObject({
|
const layoutSummarySchema = z.strictObject({
|
||||||
bytes: binaryLayoutSchema.shape.artifact.shape.bytes,
|
bytes: z.number().int().nonnegative(),
|
||||||
format: binaryLayoutSchema.shape.format,
|
format: z.literal("elf"),
|
||||||
architecture: binaryLayoutSchema.shape.architecture,
|
architecture: z.strictObject({
|
||||||
image_type: binaryLayoutSchema.shape.image_type,
|
machine: z.literal("EM_X86_64"),
|
||||||
|
bits: z.literal(64),
|
||||||
|
byte_order: z.literal("little"),
|
||||||
|
}),
|
||||||
|
image_type: z.enum(["ET_EXEC", "ET_DYN", "ET_REL"]),
|
||||||
entry: z.strictObject({
|
entry: z.strictObject({
|
||||||
reported_value: z.string().min(1),
|
reported_value: z.string().min(1),
|
||||||
meaning: z.enum(["linked-virtual-address", "not-applicable", "absent"]),
|
meaning: z.enum(["linked-virtual-address", "not-applicable", "absent"]),
|
||||||
@@ -603,6 +612,13 @@ export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({
|
|||||||
summary: captureSummarySchema,
|
summary: captureSummarySchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Step outcomes, focus accessibility tree and event counts for selected scenario delivery. */
|
||||||
|
export const browserScenarioSummaryViewResultSchema = z.strictObject({
|
||||||
|
...summaryViewResultBase,
|
||||||
|
parent_operation: z.literal("capture_browser_scenario"),
|
||||||
|
summary: browserScenarioSummarySchema,
|
||||||
|
});
|
||||||
|
|
||||||
/** Exact package declarations with retained decoded manifest text. */
|
/** Exact package declarations with retained decoded manifest text. */
|
||||||
export const androidPackageSummaryViewResultSchema = z.strictObject({
|
export const androidPackageSummaryViewResultSchema = z.strictObject({
|
||||||
...summaryViewResultBase,
|
...summaryViewResultBase,
|
||||||
@@ -753,6 +769,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
|||||||
managedArtifactSummaryViewResultSchema,
|
managedArtifactSummaryViewResultSchema,
|
||||||
managedMemberSummaryViewResultSchema,
|
managedMemberSummaryViewResultSchema,
|
||||||
webNetworkCaptureSummaryViewResultSchema,
|
webNetworkCaptureSummaryViewResultSchema,
|
||||||
|
browserScenarioSummaryViewResultSchema,
|
||||||
androidPackageSummaryViewResultSchema,
|
androidPackageSummaryViewResultSchema,
|
||||||
pcapCaptureSummaryViewResultSchema,
|
pcapCaptureSummaryViewResultSchema,
|
||||||
dartAotSummaryViewResultSchema,
|
dartAotSummaryViewResultSchema,
|
||||||
@@ -787,6 +804,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
|||||||
...viewResultBase,
|
...viewResultBase,
|
||||||
parent_operation: z.literal("inspect_web_network_capture"),
|
parent_operation: z.literal("inspect_web_network_capture"),
|
||||||
}),
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
kind: z.literal("browser-scenario"),
|
||||||
|
view: browserScenarioViewRequestSchema,
|
||||||
|
items: browserScenarioViewItemsSchema,
|
||||||
|
...viewResultBase,
|
||||||
|
...browserScenarioViewContextSchema.shape,
|
||||||
|
parent_operation: z.literal("capture_browser_scenario"),
|
||||||
|
}),
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
kind: z.literal("qlog"),
|
kind: z.literal("qlog"),
|
||||||
view: qlogViewRequestSchema,
|
view: qlogViewRequestSchema,
|
||||||
@@ -1003,9 +1028,7 @@ const unsupportedParent = (
|
|||||||
new AnalysisUnsupportedTargetError(
|
new AnalysisUnsupportedTargetError(
|
||||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||||
parentArtifactPath(parent),
|
parentArtifactPath(parent),
|
||||||
parent.operation === "capture_browser_scenario"
|
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function/inventory, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, IL2CPP metadata, web network capture or browser scenario Evidence.`,
|
||||||
? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest and environment-state views.`
|
|
||||||
: `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function/inventory, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, IL2CPP metadata, or web network capture Evidence.`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||||
@@ -1047,6 +1070,7 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
|||||||
["inspect_managed_artifact", projectManagedArtifactView],
|
["inspect_managed_artifact", projectManagedArtifactView],
|
||||||
["inspect_managed_members", projectManagedMemberView],
|
["inspect_managed_members", projectManagedMemberView],
|
||||||
["inspect_android_package", projectAndroidPackageView],
|
["inspect_android_package", projectAndroidPackageView],
|
||||||
|
["capture_browser_scenario", projectBrowserScenarioView],
|
||||||
["inspect_dart_aot", projectDartAotView],
|
["inspect_dart_aot", projectDartAotView],
|
||||||
["inspect_il2cpp_metadata", projectIl2cppMetadataView],
|
["inspect_il2cpp_metadata", projectIl2cppMetadataView],
|
||||||
["inspect_model_artifact", projectModelArtifactView],
|
["inspect_model_artifact", projectModelArtifactView],
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||||
|
import { sanitizedBrowserUrlSchema } from "../browserObservation.js";
|
||||||
|
import {
|
||||||
|
browserScenarioCaptureSchema,
|
||||||
|
type BrowserScenarioCapture,
|
||||||
|
} from "../browserScenarioCapture.js";
|
||||||
|
import {
|
||||||
|
browserScenarioCompletenessSchema,
|
||||||
|
browserScenarioEventKindSchema,
|
||||||
|
browserScenarioEventSchema,
|
||||||
|
browserStepArtifactsSchema,
|
||||||
|
type BrowserScenarioStep,
|
||||||
|
} from "../browserScenarioCaptureValues.js";
|
||||||
|
import { compareAsciiStrings } from "../canonicalOrdering.js";
|
||||||
|
import { err, ok, type Result } from "../result.js";
|
||||||
|
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||||
|
import {
|
||||||
|
completeWithinViewCoverage,
|
||||||
|
pageViewCoverage,
|
||||||
|
} from "./analysisViewCoverage.js";
|
||||||
|
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||||
|
import type {
|
||||||
|
AnalysisViewCoverage,
|
||||||
|
AnalysisViewParent,
|
||||||
|
AnalysisViewRequest,
|
||||||
|
UnsignedAnalysisView,
|
||||||
|
} from "./analysisView.js";
|
||||||
|
|
||||||
|
const OPERATION = "capture_browser_scenario" as const;
|
||||||
|
const ARTIFACT_SECTIONS = browserStepArtifactsSchema.keyof().options;
|
||||||
|
const artifactSectionSchema = browserStepArtifactsSchema.keyof();
|
||||||
|
type ArtifactSection = z.infer<typeof artifactSectionSchema>;
|
||||||
|
type Step = BrowserScenarioStep;
|
||||||
|
|
||||||
|
const countSchema = z.number().int().nonnegative();
|
||||||
|
const statusClassSchema = z.enum([
|
||||||
|
"1xx",
|
||||||
|
"2xx",
|
||||||
|
"3xx",
|
||||||
|
"4xx",
|
||||||
|
"5xx",
|
||||||
|
"failed",
|
||||||
|
"unknown",
|
||||||
|
]);
|
||||||
|
type StatusClass = z.infer<typeof statusClassSchema>;
|
||||||
|
|
||||||
|
/** Section names by recorded state; not_requested sections are omitted. */
|
||||||
|
const stepSectionsSchema = z.strictObject({
|
||||||
|
captured: z.array(artifactSectionSchema),
|
||||||
|
missing: z.array(artifactSectionSchema),
|
||||||
|
truncated: z.array(artifactSectionSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryStepSchema = z.strictObject({
|
||||||
|
step_index: countSchema,
|
||||||
|
step_id: z.string().min(1),
|
||||||
|
action: z.string().min(1),
|
||||||
|
status: z.enum(["completed", "failed", "cancelled"]),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
elapsed_ms: countSchema,
|
||||||
|
before_url: sanitizedBrowserUrlSchema
|
||||||
|
.exactOptional()
|
||||||
|
.describe(
|
||||||
|
"Present only when it differs from the previous step's after_url (always for step 0).",
|
||||||
|
),
|
||||||
|
after_url: sanitizedBrowserUrlSchema,
|
||||||
|
events: z.strictObject({
|
||||||
|
sequence_start: z.number().int().min(1),
|
||||||
|
sequence_end: countSchema,
|
||||||
|
}),
|
||||||
|
sections: stepSectionsSchema,
|
||||||
|
completeness: z.enum(["complete", "incomplete", "truncated"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const focusAccessibilitySchema = z.strictObject({
|
||||||
|
focus_step_index: countSchema,
|
||||||
|
focus: z.enum(["first-unsuccessful-step", "last-step"]),
|
||||||
|
/** The step whose tree is returned: the focus step, or the nearest earlier step that captured one. */
|
||||||
|
step_index: countSchema.nullable(),
|
||||||
|
accessibility: browserStepArtifactsSchema.shape.accessibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Compact capture overview; complete records stay in the retained parent. */
|
||||||
|
export const browserScenarioSummarySchema = z.strictObject({
|
||||||
|
browser: browserScenarioCaptureSchema.shape.browser,
|
||||||
|
scenario: browserScenarioCaptureSchema.shape.scenario,
|
||||||
|
duration_ms: countSchema,
|
||||||
|
completeness: browserScenarioCompletenessSchema,
|
||||||
|
steps: z.array(summaryStepSchema),
|
||||||
|
final_accessibility: focusAccessibilitySchema,
|
||||||
|
events: z.strictObject({
|
||||||
|
retained: countSchema,
|
||||||
|
dropped: countSchema,
|
||||||
|
by_kind: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
kind: browserScenarioEventKindSchema,
|
||||||
|
count: countSchema,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
omissions: z.strictObject({
|
||||||
|
by_kind:
|
||||||
|
browserScenarioCaptureSchema.shape.events.shape.omissions.shape.by_kind,
|
||||||
|
network_transactions: countSchema,
|
||||||
|
network_transaction_range:
|
||||||
|
browserScenarioCaptureSchema.shape.events.shape.omissions.shape
|
||||||
|
.network_transaction_range,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
network: z.strictObject({
|
||||||
|
transactions: countSchema,
|
||||||
|
by_origin: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
origin: z.string().nullable(),
|
||||||
|
transactions: countSchema,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
by_resource_type: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
resource_type: z.string().min(1),
|
||||||
|
transactions: countSchema,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
by_status_class: z
|
||||||
|
.array(
|
||||||
|
z.strictObject({
|
||||||
|
status_class: statusClassSchema,
|
||||||
|
transactions: countSchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.describe(
|
||||||
|
"Final retained outcome per transaction: the first response status class, failed, or unknown when neither was retained.",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
limitation_count: countSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const indicesSchema = z.array(countSchema);
|
||||||
|
|
||||||
|
/** Pages of exact retained steps or events, in original order. */
|
||||||
|
export const browserScenarioViewRequestSchema = z.strictObject({
|
||||||
|
kind: z.literal("browser-scenario"),
|
||||||
|
collection: z.enum(["steps", "events"]),
|
||||||
|
step_indices: indicesSchema
|
||||||
|
.optional()
|
||||||
|
.describe("Exact step indices; omitted selects all, [] selects none."),
|
||||||
|
artifacts: z
|
||||||
|
.array(artifactSectionSchema)
|
||||||
|
.default([])
|
||||||
|
.describe(
|
||||||
|
"Steps only: captured sections returned in full. Other recorded sections are listed in omitted_artifacts with their state.",
|
||||||
|
),
|
||||||
|
event_kinds: z
|
||||||
|
.array(browserScenarioEventKindSchema)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Events only: exact kinds; omitted selects all, [] selects none.",
|
||||||
|
),
|
||||||
|
transaction_ids: z
|
||||||
|
.array(z.string().min(1))
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Events only: exact network transaction IDs; omitted selects all, [] selects none. Events without a transaction never match a transaction selection.",
|
||||||
|
),
|
||||||
|
offset: countSchema.default(0),
|
||||||
|
limit: z.number().int().positive().default(64),
|
||||||
|
});
|
||||||
|
export type BrowserScenarioViewRequest = z.output<
|
||||||
|
typeof browserScenarioViewRequestSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** One exact step whose unselected captured sections are named rather than returned. */
|
||||||
|
const stepRowSchema = z.strictObject({
|
||||||
|
step_index: countSchema,
|
||||||
|
step_id: z.string().min(1),
|
||||||
|
action: z.string().min(1),
|
||||||
|
status: z.enum(["completed", "failed", "cancelled"]),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
elapsed_ms: countSchema,
|
||||||
|
before_url: sanitizedBrowserUrlSchema,
|
||||||
|
after_url: sanitizedBrowserUrlSchema,
|
||||||
|
event_sequence_start: z.number().int().min(1),
|
||||||
|
event_sequence_end: countSchema,
|
||||||
|
completeness: browserScenarioCompletenessSchema,
|
||||||
|
artifacts: browserStepArtifactsSchema.partial(),
|
||||||
|
omitted_artifacts: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
section: artifactSectionSchema,
|
||||||
|
state: z.enum(["captured", "missing", "truncated"]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const eventRowSchema = z.strictObject({
|
||||||
|
item_index: countSchema,
|
||||||
|
event: browserScenarioEventSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const browserScenarioViewItemsSchema = z.discriminatedUnion(
|
||||||
|
"collection",
|
||||||
|
[
|
||||||
|
z.strictObject({
|
||||||
|
collection: z.literal("steps"),
|
||||||
|
rows: z.array(stepRowSchema),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
collection: z.literal("events"),
|
||||||
|
rows: z.array(eventRowSchema),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Capture-wide facts every page repeats so exhaustion is not read as completeness. */
|
||||||
|
export const browserScenarioViewContextSchema = z.strictObject({
|
||||||
|
source_completeness: browserScenarioCompletenessSchema,
|
||||||
|
source_events: z.strictObject({
|
||||||
|
retained: countSchema,
|
||||||
|
dropped: countSchema,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsupportedView = (): AnalysisError =>
|
||||||
|
analysisViewInputError([
|
||||||
|
{
|
||||||
|
path: ["view"],
|
||||||
|
reason: "invalid_value",
|
||||||
|
message:
|
||||||
|
'Parent Evidence operation "capture_browser_scenario" supports the summary, browser-scenario, environment-manifest and environment-state views.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sameUrl = (
|
||||||
|
left: Step["after_url"] | undefined,
|
||||||
|
right: Step["before_url"],
|
||||||
|
): boolean =>
|
||||||
|
left !== undefined &&
|
||||||
|
left.url === right.url &&
|
||||||
|
left.redacted === right.redacted;
|
||||||
|
|
||||||
|
const stepSections = (step: Step): z.infer<typeof stepSectionsSchema> => {
|
||||||
|
const sections: z.infer<typeof stepSectionsSchema> = {
|
||||||
|
captured: [],
|
||||||
|
missing: [],
|
||||||
|
truncated: [],
|
||||||
|
};
|
||||||
|
for (const section of ARTIFACT_SECTIONS) {
|
||||||
|
const { state } = step.artifacts[section];
|
||||||
|
if (state !== "not_requested") sections[state].push(section);
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryStep = (
|
||||||
|
step: Step,
|
||||||
|
previous: Step | undefined,
|
||||||
|
): z.infer<typeof summaryStepSchema> => ({
|
||||||
|
step_index: step.step_index,
|
||||||
|
step_id: step.step_id,
|
||||||
|
action: step.action,
|
||||||
|
status: step.status,
|
||||||
|
error: step.error,
|
||||||
|
elapsed_ms: step.elapsed_ms,
|
||||||
|
...(sameUrl(previous?.after_url, step.before_url)
|
||||||
|
? {}
|
||||||
|
: { before_url: step.before_url }),
|
||||||
|
after_url: step.after_url,
|
||||||
|
events: {
|
||||||
|
sequence_start: step.event_sequence_start,
|
||||||
|
sequence_end: step.event_sequence_end,
|
||||||
|
},
|
||||||
|
sections: stepSections(step),
|
||||||
|
completeness: step.completeness.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The first unsuccessful step explains a failure; otherwise the final screen does. */
|
||||||
|
const focusAccessibility = (
|
||||||
|
steps: readonly Step[],
|
||||||
|
): z.infer<typeof focusAccessibilitySchema> => {
|
||||||
|
const unsuccessful = steps.findIndex(({ status }) => status !== "completed");
|
||||||
|
const focusIndex = unsuccessful === -1 ? steps.length - 1 : unsuccessful;
|
||||||
|
const focus = steps[focusIndex];
|
||||||
|
if (focus === undefined) throw new Error("Capture has no steps");
|
||||||
|
const kind =
|
||||||
|
unsuccessful === -1 ? ("last-step" as const) : "first-unsuccessful-step";
|
||||||
|
for (let index = focusIndex; index >= 0; index -= 1) {
|
||||||
|
const accessibility = steps[index]?.artifacts.accessibility;
|
||||||
|
if (accessibility?.state === "captured")
|
||||||
|
return {
|
||||||
|
focus_step_index: focusIndex,
|
||||||
|
focus: kind,
|
||||||
|
step_index: index,
|
||||||
|
accessibility,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
focus_step_index: focusIndex,
|
||||||
|
focus: kind,
|
||||||
|
step_index: null,
|
||||||
|
accessibility: focus.artifacts.accessibility,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Counts in canonical key order; a null origin sorts first. */
|
||||||
|
const sortedCounts = <Key extends string | null>(
|
||||||
|
counts: ReadonlyMap<Key, number>,
|
||||||
|
): [Key, number][] =>
|
||||||
|
[...counts].sort(([left], [right]) =>
|
||||||
|
left === null ? -1 : right === null ? 1 : compareAsciiStrings(left, right),
|
||||||
|
);
|
||||||
|
|
||||||
|
const increment = <Key>(counts: Map<Key, number>, key: Key): void => {
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Count retained network transactions once each, from their first retained request event. */
|
||||||
|
const networkCounts = (
|
||||||
|
capture: BrowserScenarioCapture,
|
||||||
|
): z.infer<typeof browserScenarioSummarySchema>["network"] => {
|
||||||
|
const origins = new Map<string | null, number>();
|
||||||
|
const resourceTypes = new Map<string, number>();
|
||||||
|
const outcomes = new Map<string, StatusClass>();
|
||||||
|
for (const event of capture.events.items) {
|
||||||
|
if (event.kind === "request") {
|
||||||
|
if (outcomes.has(event.transaction_id)) continue;
|
||||||
|
outcomes.set(event.transaction_id, "unknown");
|
||||||
|
increment(origins, event.url.origin);
|
||||||
|
increment(resourceTypes, event.resource_type);
|
||||||
|
} else if (event.kind === "response") {
|
||||||
|
if (outcomes.get(event.transaction_id) === "unknown")
|
||||||
|
outcomes.set(
|
||||||
|
event.transaction_id,
|
||||||
|
`${String(Math.floor(event.status / 100))}xx` as StatusClass,
|
||||||
|
);
|
||||||
|
} else if (event.kind === "request-failed") {
|
||||||
|
if (outcomes.get(event.transaction_id) === "unknown")
|
||||||
|
outcomes.set(event.transaction_id, "failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statusClasses = new Map<StatusClass, number>();
|
||||||
|
for (const outcome of outcomes.values()) increment(statusClasses, outcome);
|
||||||
|
return {
|
||||||
|
transactions: outcomes.size,
|
||||||
|
by_origin: sortedCounts(origins).map(([origin, transactions]) => ({
|
||||||
|
origin,
|
||||||
|
transactions,
|
||||||
|
})),
|
||||||
|
by_resource_type: sortedCounts(resourceTypes).map(
|
||||||
|
([resource_type, transactions]) => ({ resource_type, transactions }),
|
||||||
|
),
|
||||||
|
by_status_class: sortedCounts(statusClasses).map(
|
||||||
|
([status_class, transactions]) => ({ status_class, transactions }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventCounts = (
|
||||||
|
capture: BrowserScenarioCapture,
|
||||||
|
): z.infer<typeof browserScenarioSummarySchema>["events"] => {
|
||||||
|
const kinds = new Map<
|
||||||
|
BrowserScenarioCapture["events"]["items"][number]["kind"],
|
||||||
|
number
|
||||||
|
>();
|
||||||
|
for (const event of capture.events.items) increment(kinds, event.kind);
|
||||||
|
const { omissions } = capture.events;
|
||||||
|
return {
|
||||||
|
retained: capture.events.retained,
|
||||||
|
dropped: capture.events.dropped,
|
||||||
|
by_kind: sortedCounts(kinds).map(([kind, count]) => ({ kind, count })),
|
||||||
|
omissions: {
|
||||||
|
by_kind: omissions.by_kind,
|
||||||
|
network_transactions: omissions.network_transactions.length,
|
||||||
|
network_transaction_range: omissions.network_transaction_range,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const selected = <Value>(
|
||||||
|
filter: readonly Value[] | undefined,
|
||||||
|
): ((value: Value) => boolean) => {
|
||||||
|
if (filter === undefined) return () => true;
|
||||||
|
const values = new Set(filter);
|
||||||
|
return (value) => values.has(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepRow = (
|
||||||
|
{ artifacts: recorded, ...step }: Step,
|
||||||
|
selection: ReadonlySet<ArtifactSection>,
|
||||||
|
): z.infer<typeof stepRowSchema> => {
|
||||||
|
const artifacts: z.infer<typeof stepRowSchema>["artifacts"] = {};
|
||||||
|
const omitted: z.infer<typeof stepRowSchema>["omitted_artifacts"] = [];
|
||||||
|
for (const section of ARTIFACT_SECTIONS) {
|
||||||
|
const artifact = recorded[section];
|
||||||
|
if (selection.has(section))
|
||||||
|
Object.assign(artifacts, { [section]: artifact });
|
||||||
|
else if (artifact.state !== "not_requested")
|
||||||
|
omitted.push({ section, state: artifact.state });
|
||||||
|
}
|
||||||
|
return { ...step, artifacts, omitted_artifacts: omitted };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One stable page of matching steps or events, in original order. */
|
||||||
|
const pageRecords = (
|
||||||
|
capture: BrowserScenarioCapture,
|
||||||
|
view: BrowserScenarioViewRequest,
|
||||||
|
): {
|
||||||
|
readonly items: z.infer<typeof browserScenarioViewItemsSchema>;
|
||||||
|
readonly coverage: AnalysisViewCoverage;
|
||||||
|
} => {
|
||||||
|
const stepSelected = selected(view.step_indices);
|
||||||
|
const page = <Row>(rows: readonly Row[]) => {
|
||||||
|
const returned = rows.slice(view.offset, view.offset + view.limit);
|
||||||
|
return {
|
||||||
|
rows: returned,
|
||||||
|
coverage: pageViewCoverage(view.offset, returned.length, rows.length),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (view.collection === "steps") {
|
||||||
|
const artifacts = new Set(view.artifacts);
|
||||||
|
const { rows, coverage } = page(
|
||||||
|
capture.steps.filter(({ step_index }) => stepSelected(step_index)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
items: {
|
||||||
|
collection: "steps",
|
||||||
|
rows: rows.map((step) => stepRow(step, artifacts)),
|
||||||
|
},
|
||||||
|
coverage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const kindSelected = selected(view.event_kinds);
|
||||||
|
const transactionSelected = selected(view.transaction_ids);
|
||||||
|
const { rows, coverage } = page(
|
||||||
|
capture.events.items.flatMap((event, item_index) =>
|
||||||
|
stepSelected(event.step_index) &&
|
||||||
|
kindSelected(event.kind) &&
|
||||||
|
(view.transaction_ids === undefined ||
|
||||||
|
("transaction_id" in event &&
|
||||||
|
transactionSelected(event.transaction_id)))
|
||||||
|
? [{ item_index, event }]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { items: { collection: "events", rows }, coverage };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Project retained scenario Evidence without relaunching the browser or replaying actions. */
|
||||||
|
export const projectBrowserScenarioView = (
|
||||||
|
parent: AnalysisViewParent,
|
||||||
|
view: AnalysisViewRequest,
|
||||||
|
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||||
|
if (view.kind !== "summary" && view.kind !== "browser-scenario")
|
||||||
|
return err(unsupportedView());
|
||||||
|
const parsed = parseAnalysisViewResult(browserScenarioCaptureSchema, parent);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
const capture = parsed.value;
|
||||||
|
const shared = {
|
||||||
|
parent_evidence_id: parent.evidenceId,
|
||||||
|
parent_operation: OPERATION,
|
||||||
|
parent_digest: parent.evidenceId.slice(3),
|
||||||
|
artifact: { path: "", sha256: null },
|
||||||
|
limitations: [...parent.limitations],
|
||||||
|
unknowns: [
|
||||||
|
"Step states record one controlled replay; they do not establish the application's current state.",
|
||||||
|
...(capture.events.dropped > 0
|
||||||
|
? [
|
||||||
|
`${String(capture.events.dropped)} events were dropped by capture budgets; counts and pages cover retained events only.`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (view.kind === "summary")
|
||||||
|
return ok({
|
||||||
|
kind: "summary",
|
||||||
|
view,
|
||||||
|
...shared,
|
||||||
|
summary: {
|
||||||
|
browser: capture.browser,
|
||||||
|
scenario: capture.scenario,
|
||||||
|
duration_ms: capture.duration_ms,
|
||||||
|
completeness: capture.completeness,
|
||||||
|
steps: capture.steps.map((step, index) =>
|
||||||
|
summaryStep(step, capture.steps[index - 1]),
|
||||||
|
),
|
||||||
|
final_accessibility: focusAccessibility(capture.steps),
|
||||||
|
events: eventCounts(capture),
|
||||||
|
network: networkCounts(capture),
|
||||||
|
limitation_count: capture.limitations.length,
|
||||||
|
},
|
||||||
|
coverage: completeWithinViewCoverage(1, 1),
|
||||||
|
});
|
||||||
|
return ok({
|
||||||
|
kind: "browser-scenario",
|
||||||
|
view,
|
||||||
|
...shared,
|
||||||
|
source_completeness: capture.completeness,
|
||||||
|
source_events: {
|
||||||
|
retained: capture.events.retained,
|
||||||
|
dropped: capture.events.dropped,
|
||||||
|
},
|
||||||
|
...pageRecords(capture, view),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from "../artifactInventoryEvidence.js";
|
} from "../artifactInventoryEvidence.js";
|
||||||
import { digestSchema } from "../digests.js";
|
import { digestSchema } from "../digests.js";
|
||||||
import { prefixedDigestSchema } from "../digests.js";
|
import { prefixedDigestSchema } from "../digests.js";
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
|
||||||
import { nativeRuntimeConvention } from "../nativeConvention.js";
|
import { nativeRuntimeConvention } from "../nativeConvention.js";
|
||||||
import {
|
import {
|
||||||
applicationInventoryProjectionInputSchema,
|
applicationInventoryProjectionInputSchema,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
projectedInventoryEntries,
|
projectedInventoryEntries,
|
||||||
projectedFileComponents,
|
projectedFileComponents,
|
||||||
projectionCoverage,
|
projectionCoverage,
|
||||||
|
projectionDigest,
|
||||||
projectionEvidenceIdSchema,
|
projectionEvidenceIdSchema,
|
||||||
unavailableComponentSchema,
|
unavailableComponentSchema,
|
||||||
} from "../mobileApplicationGraphProjection.js";
|
} from "../mobileApplicationGraphProjection.js";
|
||||||
@@ -155,7 +155,7 @@ export const projectAndroidApplication = (
|
|||||||
return {
|
return {
|
||||||
projection: androidApplicationProjectionResultSchema.parse({
|
projection: androidApplicationProjectionResultSchema.parse({
|
||||||
...withoutId,
|
...withoutId,
|
||||||
projection_id: `adp_${digestCanonicalValue(withoutId, "Android application projection")}`,
|
projection_id: `adp_${projectionDigest(withoutId, "Android application projection")}`,
|
||||||
}),
|
}),
|
||||||
sources: evidence,
|
sources: evidence,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
import { evidenceSchema } from "../evidence.js";
|
import { evidenceSchema } from "../evidence.js";
|
||||||
import { digestSchema } from "../digests.js";
|
import { digestSchema } from "../digests.js";
|
||||||
import { prefixedDigestSchema } from "../digests.js";
|
import { prefixedDigestSchema } from "../digests.js";
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
|
||||||
import {
|
import {
|
||||||
bridgeCandidateCoverageSchema,
|
bridgeCandidateCoverageSchema,
|
||||||
projectCartesianCandidates,
|
projectCartesianCandidates,
|
||||||
@@ -31,6 +30,7 @@ import {
|
|||||||
projectedComponentSchema,
|
projectedComponentSchema,
|
||||||
projectedInventoryEntries,
|
projectedInventoryEntries,
|
||||||
projectedFileComponents,
|
projectedFileComponents,
|
||||||
|
projectionDigest,
|
||||||
unavailableComponentSchema,
|
unavailableComponentSchema,
|
||||||
type ProjectedInventoryEntry,
|
type ProjectedInventoryEntry,
|
||||||
} from "../mobileApplicationGraphProjection.js";
|
} from "../mobileApplicationGraphProjection.js";
|
||||||
@@ -212,7 +212,7 @@ export const projectAppleApplication = (
|
|||||||
|
|
||||||
/** Identify the complete projection, including source Evidence provenance. */
|
/** Identify the complete projection, including source Evidence provenance. */
|
||||||
export const appleProjectionId = (projection: unknown): string =>
|
export const appleProjectionId = (projection: unknown): string =>
|
||||||
`aap_${digestCanonicalValue(projection, "Apple application projection")}`;
|
`aap_${digest(projection)}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppleDouble sidecars describe neighbouring files and are never components.
|
* AppleDouble sidecars describe neighbouring files and are never components.
|
||||||
@@ -442,3 +442,6 @@ const projectionLimitations = (facts: {
|
|||||||
"Bridge candidates are path-based hypotheses, not observed runtime calls.",
|
"Bridge candidates are path-based hypotheses, not observed runtime calls.",
|
||||||
"A bridge basis is inferred from the native path and is repeated for every managed component.",
|
"A bridge basis is inferred from the native path and is repeated for every managed component.",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const digest = (value: unknown): string =>
|
||||||
|
projectionDigest(value, "Apple application projection");
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
|
||||||
import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
|
import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
|
||||||
import {
|
import {
|
||||||
compareAsciiStrings,
|
compareAsciiStrings,
|
||||||
@@ -123,9 +125,19 @@ const completionManifestObjectSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
type CompletionManifestValue = z.infer<typeof completionManifestObjectSchema>;
|
type CompletionManifestValue = z.infer<typeof completionManifestObjectSchema>;
|
||||||
|
|
||||||
|
const canonicalJson = (value: unknown): string => {
|
||||||
|
const serialized = canonicalize(value);
|
||||||
|
if (serialized === undefined)
|
||||||
|
throw new TypeError(
|
||||||
|
"RFC 8785 canonicalization rejected completion metadata",
|
||||||
|
);
|
||||||
|
return serialized;
|
||||||
|
};
|
||||||
|
|
||||||
const computeManifestId = (
|
const computeManifestId = (
|
||||||
manifest: Omit<CompletionManifestValue, "manifest_id">,
|
manifest: Omit<CompletionManifestValue, "manifest_id">,
|
||||||
): string => `ecm_${digestCanonicalValue(manifest, "Completion metadata")}`;
|
): string =>
|
||||||
|
`ecm_${createHash("sha256").update(canonicalJson(manifest)).digest("hex")}`;
|
||||||
|
|
||||||
const manifestIssues = (
|
const manifestIssues = (
|
||||||
manifest: CompletionManifestValue,
|
manifest: CompletionManifestValue,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
|
import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
|
||||||
@@ -6,7 +9,6 @@ import { evidenceSchema } from "./evidence.js";
|
|||||||
import { evidenceBundleSchema } from "./evidenceBundle.js";
|
import { evidenceBundleSchema } from "./evidenceBundle.js";
|
||||||
import { err, ok, type Result } from "./result.js";
|
import { err, ok, type Result } from "./result.js";
|
||||||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
|
||||||
|
|
||||||
const conformancePackageIdSchema = prefixedDigestSchema("cp");
|
const conformancePackageIdSchema = prefixedDigestSchema("cp");
|
||||||
|
|
||||||
@@ -315,5 +317,8 @@ const packageContents = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const computePackageId = (contents: ConformancePackageContents): string => {
|
const computePackageId = (contents: ConformancePackageContents): string => {
|
||||||
return `cp_${digestCanonicalValue(contents, "Conformance package")}`;
|
const json = canonicalize(contents);
|
||||||
|
if (json === undefined)
|
||||||
|
throw new TypeError("Could not canonicalize parsed conformance package");
|
||||||
|
return `cp_${createHash("sha256").update(json).digest("hex")}`;
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
|
||||||
import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
|
import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
|
||||||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||||
import {
|
import {
|
||||||
@@ -77,9 +79,21 @@ const summarize = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canonicalJson = (value: unknown): string => {
|
||||||
|
const serialized = canonicalize(value);
|
||||||
|
if (serialized === undefined)
|
||||||
|
throw new TypeError(
|
||||||
|
"RFC 8785 canonicalization rejected the completion ledger",
|
||||||
|
);
|
||||||
|
return serialized;
|
||||||
|
};
|
||||||
|
|
||||||
const computeLedgerId = (
|
const computeLedgerId = (
|
||||||
records: readonly EvidenceCompletionRecord[],
|
records: readonly EvidenceCompletionRecord[],
|
||||||
): string => `ecl_${digestCanonicalValue({ records }, "Completion ledger")}`;
|
): string =>
|
||||||
|
`ecl_${createHash("sha256")
|
||||||
|
.update(canonicalJson({ records }))
|
||||||
|
.digest("hex")}`;
|
||||||
|
|
||||||
const ledgerIssues = (
|
const ledgerIssues = (
|
||||||
ledger: EvidenceCompletionLedgerValue,
|
ledger: EvidenceCompletionLedgerValue,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from "../artifactInventoryEvidence.js";
|
} from "../artifactInventoryEvidence.js";
|
||||||
import { digestSchema } from "../digests.js";
|
import { digestSchema } from "../digests.js";
|
||||||
import { prefixedDigestSchema } from "../digests.js";
|
import { prefixedDigestSchema } from "../digests.js";
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
|
||||||
import { nativeRuntimeConvention } from "../nativeConvention.js";
|
import { nativeRuntimeConvention } from "../nativeConvention.js";
|
||||||
import {
|
import {
|
||||||
applicationInventoryProjectionInputSchema,
|
applicationInventoryProjectionInputSchema,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
projectedInventoryEntries,
|
projectedInventoryEntries,
|
||||||
projectedFileComponents,
|
projectedFileComponents,
|
||||||
projectionCoverage,
|
projectionCoverage,
|
||||||
|
projectionDigest,
|
||||||
projectionEvidenceIdSchema,
|
projectionEvidenceIdSchema,
|
||||||
unavailableComponentSchema,
|
unavailableComponentSchema,
|
||||||
} from "../mobileApplicationGraphProjection.js";
|
} from "../mobileApplicationGraphProjection.js";
|
||||||
@@ -151,7 +151,7 @@ export const projectHarmonyApplication = (
|
|||||||
return {
|
return {
|
||||||
projection: harmonyApplicationProjectionResultSchema.parse({
|
projection: harmonyApplicationProjectionResultSchema.parse({
|
||||||
...withoutId,
|
...withoutId,
|
||||||
projection_id: `hmp_${digestCanonicalValue(withoutId, "HarmonyOS application projection")}`,
|
projection_id: `hmp_${projectionDigest(withoutId, "HarmonyOS application projection")}`,
|
||||||
}),
|
}),
|
||||||
sources: evidence,
|
sources: evidence,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -156,16 +156,14 @@ const ipcEventSchema = electronActiveTimelineEventSchema.extend({
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Process facts shared by the runtime boundary and retained active observation. */
|
const processMetricSchema = z.strictObject({
|
||||||
export const electronActiveProcessMetricSchema = z.strictObject({
|
|
||||||
pid: z.number().int().min(1),
|
pid: z.number().int().min(1),
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
name: z.string().nullable(),
|
name: z.string().nullable(),
|
||||||
service_name: z.string().nullable(),
|
service_name: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Window facts shared by the runtime boundary and retained active observation. */
|
const windowResultSchema = z.strictObject({
|
||||||
export const electronActiveWindowSchema = z.strictObject({
|
|
||||||
window_id: z.string(),
|
window_id: z.string(),
|
||||||
web_contents_id: z.string(),
|
web_contents_id: z.string(),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
@@ -225,9 +223,9 @@ export const electronActiveObservationResultSchema = z.strictObject({
|
|||||||
cleanup: z.literal("terminated-owned-process"),
|
cleanup: z.literal("terminated-owned-process"),
|
||||||
}),
|
}),
|
||||||
actions: z.array(actionResultSchema),
|
actions: z.array(actionResultSchema),
|
||||||
windows: z.array(electronActiveWindowSchema),
|
windows: z.array(windowResultSchema),
|
||||||
processes: z.strictObject({
|
processes: z.strictObject({
|
||||||
items: z.array(electronActiveProcessMetricSchema),
|
items: z.array(processMetricSchema),
|
||||||
}),
|
}),
|
||||||
ipc: z.strictObject({
|
ipc: z.strictObject({
|
||||||
events: z.array(ipcEventSchema),
|
events: z.array(ipcEventSchema),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createJavaScriptApplicationEdge,
|
createJavaScriptApplicationEdge,
|
||||||
createImmutableJavaScriptApplicationGraphSteps,
|
createImmutableJavaScriptApplicationGraphSteps,
|
||||||
mergeJavaScriptApplicationNodes,
|
createJavaScriptApplicationNode,
|
||||||
type JavaScriptApplicationGraph,
|
type JavaScriptApplicationGraph,
|
||||||
} from "./javascriptApplicationGraph.js";
|
} from "./javascriptApplicationGraph.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -30,16 +30,22 @@ export interface ApplicationChangeGraphProjection {
|
|||||||
export const buildJavaScriptApplicationChangeGraph = (
|
export const buildJavaScriptApplicationChangeGraph = (
|
||||||
input: ChangeGraphInput,
|
input: ChangeGraphInput,
|
||||||
): ApplicationChangeGraphProjection => {
|
): ApplicationChangeGraphProjection => {
|
||||||
const mergedNodes = mergeJavaScriptApplicationNodes(
|
const allNodes = nodeCandidates(input);
|
||||||
nodeCandidates(input),
|
|
||||||
).sort((left, right) =>
|
|
||||||
compareUnicodeCodePoints(left.node_id, right.node_id),
|
|
||||||
);
|
|
||||||
const retained = new Set(mergedNodes.map(({ node_id }) => node_id));
|
|
||||||
const preferredRoots = uniqueSorted([
|
const preferredRoots = uniqueSorted([
|
||||||
...input.right.root_node_ids,
|
...input.right.root_node_ids,
|
||||||
...input.left.root_node_ids,
|
...input.left.root_node_ids,
|
||||||
]).filter((nodeId) => retained.has(nodeId));
|
]).filter((nodeId) => allNodes.has(nodeId));
|
||||||
|
const orderedNodeIds = uniqueSorted([
|
||||||
|
...preferredRoots,
|
||||||
|
...input.items.flatMap(({ left_node_id: left, right_node_id: right }) => [
|
||||||
|
...(right === null ? [] : [right]),
|
||||||
|
...(left === null ? [] : [left]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const retained = new Set(orderedNodeIds);
|
||||||
|
const mergedNodes = mergeNodes(
|
||||||
|
orderedNodeIds.flatMap((nodeId) => allNodes.get(nodeId) ?? []),
|
||||||
|
);
|
||||||
const sourceEdges = uniqueEdges([
|
const sourceEdges = uniqueEdges([
|
||||||
...input.left.edges,
|
...input.left.edges,
|
||||||
...input.right.edges,
|
...input.right.edges,
|
||||||
@@ -82,7 +88,9 @@ export const buildJavaScriptApplicationChangeGraph = (
|
|||||||
return { graph: next.value };
|
return { graph: next.value };
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeCandidates = (input: ChangeGraphInput): ApplicationNode[] => {
|
const nodeCandidates = (
|
||||||
|
input: ChangeGraphInput,
|
||||||
|
): Map<string, ApplicationNode[]> => {
|
||||||
const required = new Set([
|
const required = new Set([
|
||||||
...input.left.root_node_ids,
|
...input.left.root_node_ids,
|
||||||
...input.right.root_node_ids,
|
...input.right.root_node_ids,
|
||||||
@@ -91,9 +99,11 @@ const nodeCandidates = (input: ChangeGraphInput): ApplicationNode[] => {
|
|||||||
...(right === null ? [] : [right]),
|
...(right === null ? [] : [right]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
return [...input.left.nodes, ...input.right.nodes].filter((node) =>
|
const output = new Map<string, ApplicationNode[]>();
|
||||||
required.has(node.node_id),
|
for (const node of [...input.left.nodes, ...input.right.nodes])
|
||||||
);
|
if (required.has(node.node_id))
|
||||||
|
output.set(node.node_id, [...(output.get(node.node_id) ?? []), node]);
|
||||||
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
const changedFromEdge = (
|
const changedFromEdge = (
|
||||||
@@ -235,6 +245,37 @@ const sourceCoverageLimits = (input: ChangeGraphInput) =>
|
|||||||
const combineOmittedCounts = (left: number | null, right: number | null) =>
|
const combineOmittedCounts = (left: number | null, right: number | null) =>
|
||||||
left === null || right === null ? null : left + right;
|
left === null || right === null ? null : left + right;
|
||||||
|
|
||||||
|
const mergeNodes = (nodes: readonly ApplicationNode[]): ApplicationNode[] => {
|
||||||
|
const groups = new Map<string, ApplicationNode[]>();
|
||||||
|
for (const node of nodes)
|
||||||
|
groups.set(node.node_id, [...(groups.get(node.node_id) ?? []), node]);
|
||||||
|
const merged = [...groups.values()].map((group) => {
|
||||||
|
const first = group[0];
|
||||||
|
if (first === undefined)
|
||||||
|
throw new TypeError("Empty change-graph node group");
|
||||||
|
const observations = [
|
||||||
|
...new Map(
|
||||||
|
group
|
||||||
|
.flatMap(({ observations: values }) => values)
|
||||||
|
.map((observation) => [observation.observation_id, observation]),
|
||||||
|
).values(),
|
||||||
|
].sort((left, right) =>
|
||||||
|
compareUnicodeCodePoints(left.observation_id, right.observation_id),
|
||||||
|
);
|
||||||
|
return createJavaScriptApplicationNode({
|
||||||
|
kind: first.kind,
|
||||||
|
identity: first.identity,
|
||||||
|
observations: observations.map(
|
||||||
|
({ observation_id: _id, identifier_strategy: _strategy, ...value }) =>
|
||||||
|
value,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return merged.sort((left, right) =>
|
||||||
|
compareUnicodeCodePoints(left.node_id, right.node_id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const uniqueEdges = (edges: readonly ApplicationEdge[]): ApplicationEdge[] =>
|
const uniqueEdges = (edges: readonly ApplicationEdge[]): ApplicationEdge[] =>
|
||||||
[...new Map(edges.map((edge) => [edge.edge_id, edge])).values()].sort(
|
[...new Map(edges.map((edge) => [edge.edge_id, edge])).values()].sort(
|
||||||
(left, right) => compareUnicodeCodePoints(left.edge_id, right.edge_id),
|
(left, right) => compareUnicodeCodePoints(left.edge_id, right.edge_id),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
createJavaScriptApplicationEdge,
|
createJavaScriptApplicationEdge,
|
||||||
createJavaScriptApplicationGraph,
|
createJavaScriptApplicationGraph,
|
||||||
createJavaScriptApplicationNode,
|
createJavaScriptApplicationNode,
|
||||||
mergeJavaScriptApplicationNodes,
|
|
||||||
javascriptApplicationGraphSchema,
|
javascriptApplicationGraphSchema,
|
||||||
parseJavaScriptApplicationGraph,
|
parseJavaScriptApplicationGraph,
|
||||||
serializeJavaScriptApplicationGraph,
|
serializeJavaScriptApplicationGraph,
|
||||||
@@ -24,58 +23,6 @@ import {
|
|||||||
sourceMapReference,
|
sourceMapReference,
|
||||||
unknownEvidence,
|
unknownEvidence,
|
||||||
} from "./javascriptApplicationGraph.fixture.js";
|
} from "./javascriptApplicationGraph.fixture.js";
|
||||||
|
|
||||||
it("merges observations by entity identity without changing source nodes", () => {
|
|
||||||
const evidence = artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "main.js");
|
|
||||||
const first = contentNode(evidence, { selected: "first" });
|
|
||||||
const second = contentNode(evidence, { selected: "second" });
|
|
||||||
const reversedIdentity = {
|
|
||||||
sha256: APPLICATION_GRAPH_DIGESTS.asar,
|
|
||||||
stability: "global-exact" as const,
|
|
||||||
strategy: "content-digest" as const,
|
|
||||||
};
|
|
||||||
const merged = mergeJavaScriptApplicationNodes([
|
|
||||||
first,
|
|
||||||
{ ...second, identity: reversedIdentity },
|
|
||||||
first,
|
|
||||||
]);
|
|
||||||
expect(merged).toHaveLength(1);
|
|
||||||
const node = firstOf(merged, "merged node");
|
|
||||||
expect(node.node_id).toBe(first.node_id);
|
|
||||||
expect(node.observations.map(({ properties }) => properties)).toEqual(
|
|
||||||
expect.arrayContaining([{ selected: "first" }, { selected: "second" }]),
|
|
||||||
);
|
|
||||||
expect(node.observations).toHaveLength(2);
|
|
||||||
expect(first.observations).toHaveLength(1);
|
|
||||||
expect(second.observations).toHaveLength(1);
|
|
||||||
const graph = graphForNode(node);
|
|
||||||
expect(parseJavaScriptApplicationGraph(graph)).toEqual(graph);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["kind", "identity"] as const)(
|
|
||||||
"rejects colliding entity IDs with a different %s before merging observations",
|
|
||||||
(field) => {
|
|
||||||
const node = contentNode(
|
|
||||||
artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "main.js"),
|
|
||||||
);
|
|
||||||
const conflicting =
|
|
||||||
field === "kind"
|
|
||||||
? { ...node, kind: "javascript-asset" as const }
|
|
||||||
: {
|
|
||||||
...node,
|
|
||||||
identity: {
|
|
||||||
strategy: "content-digest" as const,
|
|
||||||
stability: "global-exact" as const,
|
|
||||||
sha256: "f".repeat(64),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(() => mergeJavaScriptApplicationNodes([node, conflicting])).toThrow(
|
|
||||||
"Colliding JAG node identifiers disagree",
|
|
||||||
);
|
|
||||||
expect(node.observations).toHaveLength(1);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const createLargeNativeExportGraph = (): JavaScriptApplicationGraph => {
|
const createLargeNativeExportGraph = (): JavaScriptApplicationGraph => {
|
||||||
const properties = Object.fromEntries(
|
const properties = Object.fromEntries(
|
||||||
Array.from({ length: 1_001 }, (_, index) => [
|
Array.from({ length: 1_001 }, (_, index) => [
|
||||||
|
|||||||
@@ -502,51 +502,6 @@ export const createJavaScriptApplicationNode = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Merge authenticated entities, retaining every distinct observation. */
|
|
||||||
export const mergeJavaScriptApplicationNodes = (
|
|
||||||
nodes: readonly ApplicationNode[],
|
|
||||||
): ApplicationNode[] => {
|
|
||||||
const groups = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
node: ApplicationNode;
|
|
||||||
observations: Map<string, ApplicationNode["observations"][number]>;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
for (const node of nodes) {
|
|
||||||
const group = groups.get(node.node_id);
|
|
||||||
if (group === undefined) {
|
|
||||||
groups.set(node.node_id, {
|
|
||||||
node,
|
|
||||||
observations: new Map(
|
|
||||||
node.observations.map((observation) => [
|
|
||||||
observation.observation_id,
|
|
||||||
observation,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
node.kind !== group.node.kind ||
|
|
||||||
canonicalJson(node.identity) !== canonicalJson(group.node.identity)
|
|
||||||
)
|
|
||||||
throw new TypeError("Colliding JAG node identifiers disagree");
|
|
||||||
for (const observation of node.observations)
|
|
||||||
group.observations.set(observation.observation_id, observation);
|
|
||||||
}
|
|
||||||
return [...groups.values()].map(({ node, observations }) =>
|
|
||||||
createJavaScriptApplicationNode({
|
|
||||||
kind: node.kind,
|
|
||||||
identity: node.identity,
|
|
||||||
observations: [...observations.values()].map(
|
|
||||||
({ observation_id: _id, identifier_strategy: _strategy, ...value }) =>
|
|
||||||
value,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Build one normalized directed relationship and derive its semantic ID. */
|
/** Build one normalized directed relationship and derive its semantic ID. */
|
||||||
export const createJavaScriptApplicationEdge = (
|
export const createJavaScriptApplicationEdge = (
|
||||||
input: unknown,
|
input: unknown,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||||
import { canonicalJson } from "../comparisonSemantics.js";
|
import {
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
canonicalExportShapeValue,
|
||||||
|
digestExportShapeValue,
|
||||||
|
} from "./javascriptExportShapeComparisonIdentity.js";
|
||||||
import {
|
import {
|
||||||
javaScriptExportShapeComparisonResultSchema,
|
javaScriptExportShapeComparisonResultSchema,
|
||||||
type JavaScriptExportShapeComparisonChange,
|
type JavaScriptExportShapeComparisonChange,
|
||||||
@@ -84,7 +86,7 @@ export const compareJavaScriptExportShapes = (
|
|||||||
};
|
};
|
||||||
return javaScriptExportShapeComparisonResultSchema.parse({
|
return javaScriptExportShapeComparisonResultSchema.parse({
|
||||||
...semantic,
|
...semantic,
|
||||||
comparison_id: `jesc_${digestCanonicalValue(semantic, "JavaScript export shape comparison")}`,
|
comparison_id: `jesc_${digestExportShapeValue(semantic)}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,17 +97,14 @@ const sortChanges = (
|
|||||||
.map((change, index) => ({
|
.map((change, index) => ({
|
||||||
change,
|
change,
|
||||||
index,
|
index,
|
||||||
sortKey: canonicalJson(
|
sortKey: canonicalExportShapeValue({
|
||||||
{
|
discriminant: change.discriminant,
|
||||||
discriminant: change.discriminant,
|
path: change.path,
|
||||||
path: change.path,
|
status: change.status,
|
||||||
status: change.status,
|
left_source_range: change.left_source_range,
|
||||||
left_source_range: change.left_source_range,
|
right_source_range: change.right_source_range,
|
||||||
right_source_range: change.right_source_range,
|
change_id: change.change_id,
|
||||||
change_id: change.change_id,
|
}),
|
||||||
},
|
|
||||||
"JavaScript export shape comparison",
|
|
||||||
),
|
|
||||||
}))
|
}))
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonically encode one export-shape comparison value.
|
||||||
|
*
|
||||||
|
* Comparison identifiers hash the full result semantics, including each
|
||||||
|
* change's presence object and the per-variant property inventories.
|
||||||
|
*/
|
||||||
|
export const canonicalExportShapeValue = (value: unknown): string => {
|
||||||
|
const encoded = canonicalize(value);
|
||||||
|
if (encoded === undefined)
|
||||||
|
throw new TypeError(
|
||||||
|
"JavaScript export shape comparison could not canonicalize data",
|
||||||
|
);
|
||||||
|
return encoded;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Derive one deterministic SHA-256 comparison identifier component. */
|
||||||
|
export const digestExportShapeValue = (value: unknown): string =>
|
||||||
|
createHash("sha256").update(canonicalExportShapeValue(value)).digest("hex");
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { canonicalJson } from "../comparisonSemantics.js";
|
import {
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
canonicalExportShapeValue,
|
||||||
|
digestExportShapeValue,
|
||||||
|
} from "./javascriptExportShapeComparisonIdentity.js";
|
||||||
import type {
|
import type {
|
||||||
JavaScriptExportShapeComparisonChange,
|
JavaScriptExportShapeComparisonChange,
|
||||||
JavaScriptExportShapeComparisonResult,
|
JavaScriptExportShapeComparisonResult,
|
||||||
@@ -193,10 +195,7 @@ const discriminantOccurrences = (
|
|||||||
const value = literalValue(field);
|
const value = literalValue(field);
|
||||||
if (!value.found) continue;
|
if (!value.found) continue;
|
||||||
const discriminant = { path: field.path, value: value.value };
|
const discriminant = { path: field.path, value: value.value };
|
||||||
const key = canonicalJson(
|
const key = canonicalExportShapeValue(discriminant);
|
||||||
discriminant,
|
|
||||||
"JavaScript export shape comparison",
|
|
||||||
);
|
|
||||||
const current = output.get(key);
|
const current = output.get(key);
|
||||||
output.set(key, {
|
output.set(key, {
|
||||||
discriminant,
|
discriminant,
|
||||||
@@ -255,8 +254,8 @@ const compareDiscriminants = (
|
|||||||
right: Discriminant,
|
right: Discriminant,
|
||||||
): number =>
|
): number =>
|
||||||
compareUnicodeCodePoints(
|
compareUnicodeCodePoints(
|
||||||
canonicalJson(left, "JavaScript export shape comparison"),
|
canonicalExportShapeValue(left),
|
||||||
canonicalJson(right, "JavaScript export shape comparison"),
|
canonicalExportShapeValue(right),
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectionUnknownChange = (
|
const selectionUnknownChange = (
|
||||||
@@ -386,8 +385,8 @@ const fieldChangeStatus = ({
|
|||||||
if (
|
if (
|
||||||
leftField !== undefined &&
|
leftField !== undefined &&
|
||||||
rightField !== undefined &&
|
rightField !== undefined &&
|
||||||
canonicalJson(leftField, "JavaScript export shape comparison") ===
|
canonicalExportShapeValue(leftField) ===
|
||||||
canonicalJson(rightField, "JavaScript export shape comparison")
|
canonicalExportShapeValue(rightField)
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
if (leftPresence === "absent" && rightPresence === "absent") return null;
|
if (leftPresence === "absent" && rightPresence === "absent") return null;
|
||||||
@@ -503,7 +502,7 @@ const changeWithId = (
|
|||||||
semantic: Omit<JavaScriptExportShapeComparisonChange, "change_id">,
|
semantic: Omit<JavaScriptExportShapeComparisonChange, "change_id">,
|
||||||
): JavaScriptExportShapeComparisonChange => ({
|
): JavaScriptExportShapeComparisonChange => ({
|
||||||
...semantic,
|
...semantic,
|
||||||
change_id: `jesc_change_${digestCanonicalValue(semantic, "JavaScript export shape comparison")}`,
|
change_id: `jesc_change_${digestExportShapeValue(semantic)}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const literalValue = (
|
const literalValue = (
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
parseAtPath,
|
parseAtPath,
|
||||||
} from "./javascriptRuntimeReconciliationInputValidation.js";
|
} from "./javascriptRuntimeReconciliationInputValidation.js";
|
||||||
|
|
||||||
import { classifyBrowserCompleteness } from "../browserCompleteness.js";
|
import {
|
||||||
|
classifyBrowserCompleteness,
|
||||||
|
type BrowserCompleteness,
|
||||||
|
} from "../browserCompleteness.js";
|
||||||
import {
|
import {
|
||||||
electronActiveObservationResultSchema,
|
electronActiveObservationResultSchema,
|
||||||
type ElectronActiveObservationResult,
|
type ElectronActiveObservationResult,
|
||||||
@@ -78,10 +81,19 @@ const assertIdentity = (evidence: Evidence): void => {
|
|||||||
};
|
};
|
||||||
const normalizeInspection = (
|
const normalizeInspection = (
|
||||||
result: ElectronActiveObservationResult,
|
result: ElectronActiveObservationResult,
|
||||||
): Extract<
|
): {
|
||||||
ParsedRuntimeCapture,
|
readonly target: {
|
||||||
{ kind: "electron-active" }
|
readonly target_id: string;
|
||||||
>["inspection"] => ({
|
readonly type: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly attached: boolean;
|
||||||
|
readonly file_path: string;
|
||||||
|
};
|
||||||
|
readonly frames: readonly [];
|
||||||
|
readonly scripts: { readonly items: readonly [] };
|
||||||
|
readonly workers: readonly [];
|
||||||
|
readonly completeness: BrowserCompleteness;
|
||||||
|
} => ({
|
||||||
target: {
|
target: {
|
||||||
target_id: `electron-active:${digestCanonicalValue(result.application.application_path, "Runtime reconciliation").slice(0, 32)}`,
|
target_id: `electron-active:${digestCanonicalValue(result.application.application_path, "Runtime reconciliation").slice(0, 32)}`,
|
||||||
type: "electron-application",
|
type: "electron-application",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
createJavaScriptApplicationGraph,
|
createJavaScriptApplicationGraph,
|
||||||
mergeJavaScriptApplicationNodes,
|
createJavaScriptApplicationNode,
|
||||||
type JavaScriptApplicationGraph,
|
type JavaScriptApplicationGraph,
|
||||||
} from "./javascriptApplicationGraph.js";
|
} from "./javascriptApplicationGraph.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -39,7 +39,7 @@ export interface ReconciledApplicationGraphProjection {
|
|||||||
export const buildReconciledApplicationGraph = (
|
export const buildReconciledApplicationGraph = (
|
||||||
input: ReconciledGraphInput,
|
input: ReconciledGraphInput,
|
||||||
): ReconciledApplicationGraphProjection => {
|
): ReconciledApplicationGraphProjection => {
|
||||||
const merged = mergeJavaScriptApplicationNodes([
|
const merged = mergeNodes([
|
||||||
...input.layers.flatMap(({ graph }) => graph.nodes),
|
...input.layers.flatMap(({ graph }) => graph.nodes),
|
||||||
...input.runtime.nodes,
|
...input.runtime.nodes,
|
||||||
]);
|
]);
|
||||||
@@ -210,6 +210,45 @@ const graphCoverage = (
|
|||||||
limits: [],
|
limits: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeNodes = (nodes: readonly ApplicationNode[]): ApplicationNode[] => {
|
||||||
|
const grouped = new Map<string, ApplicationNode[]>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
const group = grouped.get(node.node_id);
|
||||||
|
if (group === undefined) grouped.set(node.node_id, [node]);
|
||||||
|
else group.push(node);
|
||||||
|
}
|
||||||
|
const merged = [...grouped.values()].map((group) => {
|
||||||
|
const first = group[0];
|
||||||
|
if (first === undefined) throw new TypeError("Empty JAG node group");
|
||||||
|
if (
|
||||||
|
group.some(
|
||||||
|
({ kind, identity }) =>
|
||||||
|
kind !== first.kind ||
|
||||||
|
JSON.stringify(identity) !== JSON.stringify(first.identity),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new TypeError("Colliding JAG node identifiers disagree");
|
||||||
|
const observations = [
|
||||||
|
...new Map(
|
||||||
|
group
|
||||||
|
.flatMap(({ observations: values }) => values)
|
||||||
|
.map((observation) => [observation.observation_id, observation]),
|
||||||
|
).values(),
|
||||||
|
].sort((left, right) =>
|
||||||
|
compareUnicodeCodePoints(left.observation_id, right.observation_id),
|
||||||
|
);
|
||||||
|
return createJavaScriptApplicationNode({
|
||||||
|
kind: first.kind,
|
||||||
|
identity: first.identity,
|
||||||
|
observations: observations.map(
|
||||||
|
({ observation_id: _id, identifier_strategy: _strategy, ...value }) =>
|
||||||
|
value,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
const uniqueEdges = (edges: readonly ApplicationEdge[]): ApplicationEdge[] => [
|
const uniqueEdges = (edges: readonly ApplicationEdge[]): ApplicationEdge[] => [
|
||||||
...new Map(edges.map((edge) => [edge.edge_id, edge])).values(),
|
...new Map(edges.map((edge) => [edge.edge_id, edge])).values(),
|
||||||
];
|
];
|
||||||
@@ -77,13 +77,14 @@ const reconcileEntity = (
|
|||||||
reason: "runtime-location-unresolved",
|
reason: "runtime-location-unresolved",
|
||||||
candidates: [],
|
candidates: [],
|
||||||
});
|
});
|
||||||
const { locations, mapped } = locateEntity(entity, index, layers);
|
const locations = candidatesWithLocation(entity, index, layers);
|
||||||
if (entity.sourceSha256 !== null)
|
if (entity.sourceSha256 !== null)
|
||||||
return reconcileCapturedSource(entity, entity.sourceSha256, {
|
return reconcileCapturedSource(
|
||||||
|
entity,
|
||||||
|
entity.sourceSha256,
|
||||||
locations,
|
locations,
|
||||||
mapped,
|
|
||||||
index,
|
index,
|
||||||
});
|
);
|
||||||
|
|
||||||
const location = locations.at(0);
|
const location = locations.at(0);
|
||||||
if (locations.length === 1 && location !== undefined) {
|
if (locations.length === 1 && location !== undefined) {
|
||||||
@@ -102,39 +103,19 @@ const reconcileEntity = (
|
|||||||
reason: "ambiguous-static-candidates",
|
reason: "ambiguous-static-candidates",
|
||||||
candidates: locations.map(({ candidate }) => candidate),
|
candidates: locations.map(({ candidate }) => candidate),
|
||||||
});
|
});
|
||||||
return withoutStaticCandidate(entity, mapped, "unknown");
|
const mapped = layers.some(
|
||||||
};
|
(layer) => mappedRuntimePaths(layer, entity).length > 0,
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify an entity with no qualifying static candidate. Absence is claimed
|
|
||||||
* only beneath an automatic or caller-declared mapping; without one, the
|
|
||||||
* runtime location has no authorized static counterpart and stays unknown.
|
|
||||||
* Captured bytes compared against every static digest support a high-confidence
|
|
||||||
* absence; a location-only absence does not.
|
|
||||||
*/
|
|
||||||
const withoutStaticCandidate = (
|
|
||||||
entity: RuntimeReconciliationEntity,
|
|
||||||
mapped: boolean,
|
|
||||||
absenceConfidence: JavaScriptRuntimeReconciliationItem["confidence"],
|
|
||||||
): EvaluatedMatch =>
|
|
||||||
unresolved(
|
|
||||||
entity,
|
|
||||||
mapped
|
|
||||||
? {
|
|
||||||
status: "unmatched",
|
|
||||||
basis: "none",
|
|
||||||
confidence: absenceConfidence,
|
|
||||||
reason: "no-static-content-match",
|
|
||||||
candidates: [],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
status: "unknown",
|
|
||||||
basis: "none",
|
|
||||||
confidence: "unknown",
|
|
||||||
reason: "no-authorized-location-mapping",
|
|
||||||
candidates: [],
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
return unresolved(entity, {
|
||||||
|
status: mapped ? "unmatched" : "unknown",
|
||||||
|
basis: "none",
|
||||||
|
confidence: "unknown",
|
||||||
|
reason: mapped
|
||||||
|
? "no-static-content-match"
|
||||||
|
: "no-authorized-location-mapping",
|
||||||
|
candidates: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface LocatedCandidate {
|
interface LocatedCandidate {
|
||||||
readonly candidate: StaticRuntimeCandidate;
|
readonly candidate: StaticRuntimeCandidate;
|
||||||
@@ -144,15 +125,8 @@ interface LocatedCandidate {
|
|||||||
const reconcileCapturedSource = (
|
const reconcileCapturedSource = (
|
||||||
entity: RuntimeReconciliationEntity,
|
entity: RuntimeReconciliationEntity,
|
||||||
sourceSha256: string,
|
sourceSha256: string,
|
||||||
{
|
locations: readonly LocatedCandidate[],
|
||||||
locations,
|
index: CandidateIndex,
|
||||||
mapped,
|
|
||||||
index,
|
|
||||||
}: {
|
|
||||||
readonly locations: readonly LocatedCandidate[];
|
|
||||||
readonly mapped: boolean;
|
|
||||||
readonly index: CandidateIndex;
|
|
||||||
},
|
|
||||||
): EvaluatedMatch => {
|
): EvaluatedMatch => {
|
||||||
// A known mapped candidate owns the comparison. Unrelated equal bytes cannot
|
// A known mapped candidate owns the comparison. Unrelated equal bytes cannot
|
||||||
// repair a disagreement or establish the content of a candidate without a digest.
|
// repair a disagreement or establish the content of a candidate without a digest.
|
||||||
@@ -202,19 +176,23 @@ const reconcileCapturedSource = (
|
|||||||
candidates: locations.map(({ candidate: located }) => located),
|
candidates: locations.map(({ candidate: located }) => located),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return withoutStaticCandidate(entity, mapped, "high");
|
return unresolved(entity, {
|
||||||
|
status: "unmatched",
|
||||||
|
basis: "none",
|
||||||
|
confidence: "high",
|
||||||
|
reason: "no-static-content-match",
|
||||||
|
candidates: [],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const locateEntity = (
|
const candidatesWithLocation = (
|
||||||
entity: RuntimeReconciliationEntity,
|
entity: RuntimeReconciliationEntity,
|
||||||
index: CandidateIndex,
|
index: CandidateIndex,
|
||||||
layers: readonly ParsedStaticLayer[],
|
layers: readonly ParsedStaticLayer[],
|
||||||
): { readonly locations: LocatedCandidate[]; readonly mapped: boolean } => {
|
): LocatedCandidate[] => {
|
||||||
const located = new Map<string, LocatedCandidate>();
|
const located = new Map<string, LocatedCandidate>();
|
||||||
let mapped = false;
|
|
||||||
for (const layer of layers)
|
for (const layer of layers)
|
||||||
for (const mapping of mappedRuntimePaths(layer, entity)) {
|
for (const mapping of mappedRuntimePaths(layer, entity))
|
||||||
mapped = true;
|
|
||||||
for (const candidate of [
|
for (const candidate of [
|
||||||
...(index.byLayerCategoryPath.get(
|
...(index.byLayerCategoryPath.get(
|
||||||
layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.path),
|
layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.path),
|
||||||
@@ -237,16 +215,12 @@ const locateEntity = (
|
|||||||
const key = candidateKey(candidate);
|
const key = candidateKey(candidate);
|
||||||
if (!located.has(key)) located.set(key, { candidate, mapping });
|
if (!located.has(key)) located.set(key, { candidate, mapping });
|
||||||
}
|
}
|
||||||
}
|
return [...located.values()].sort((left, right) =>
|
||||||
return {
|
compareUnicodeCodePoints(
|
||||||
locations: [...located.values()].sort((left, right) =>
|
candidateKey(left.candidate),
|
||||||
compareUnicodeCodePoints(
|
candidateKey(right.candidate),
|
||||||
candidateKey(left.candidate),
|
|
||||||
candidateKey(right.candidate),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
mapped,
|
);
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const indexCandidates = (
|
const indexCandidates = (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import * as t from "@babel/types";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
JavaScriptSemanticCallable,
|
JavaScriptSemanticCallable,
|
||||||
JavaScriptSemanticProjectedResultBinding,
|
|
||||||
JavaScriptSemanticEventOperation,
|
JavaScriptSemanticEventOperation,
|
||||||
JavaScriptSemanticTimerOperation,
|
JavaScriptSemanticTimerOperation,
|
||||||
} from "./javascriptSemanticIr.js";
|
} from "./javascriptSemanticIr.js";
|
||||||
@@ -19,7 +18,6 @@ import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
|||||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||||
import {
|
import {
|
||||||
builtinDataEffectMethod,
|
builtinDataEffectMethod,
|
||||||
dataEffectArgumentBindingId,
|
|
||||||
dataEffectLiteralString,
|
dataEffectLiteralString,
|
||||||
dataEffectMemberCallee,
|
dataEffectMemberCallee,
|
||||||
assignedSemanticResultBindings,
|
assignedSemanticResultBindings,
|
||||||
@@ -73,7 +71,11 @@ interface TimerCandidate {
|
|||||||
readonly kind: JavaScriptSemanticTimerOperation["kind"];
|
readonly kind: JavaScriptSemanticTimerOperation["kind"];
|
||||||
readonly ownerCallableId: string | null;
|
readonly ownerCallableId: string | null;
|
||||||
readonly handleBindingId: string | null;
|
readonly handleBindingId: string | null;
|
||||||
readonly projectedResultBindings: readonly JavaScriptSemanticProjectedResultBinding[];
|
readonly projectedResultBindings: readonly {
|
||||||
|
readonly bindingId: string;
|
||||||
|
readonly projectionPath: readonly (string | number | null)[];
|
||||||
|
readonly resolution: "complete" | "partial";
|
||||||
|
}[];
|
||||||
readonly handleRead: {
|
readonly handleRead: {
|
||||||
readonly origins: readonly t.Node[];
|
readonly origins: readonly t.Node[];
|
||||||
readonly complete: boolean;
|
readonly complete: boolean;
|
||||||
@@ -156,7 +158,7 @@ const collectEvent = (
|
|||||||
emitterKey: emitter.key,
|
emitterKey: emitter.key,
|
||||||
emitterBindingId: emitter.bindingId,
|
emitterBindingId: emitter.bindingId,
|
||||||
eventName,
|
eventName,
|
||||||
listenerBindingId: dataEffectArgumentBindingId(listenerArgument, state),
|
listenerBindingId: bindingId(listenerArgument, state),
|
||||||
listenerLocation: t.isNode(listenerArgument)
|
listenerLocation: t.isNode(listenerArgument)
|
||||||
? range(listenerArgument)
|
? range(listenerArgument)
|
||||||
: null,
|
: null,
|
||||||
@@ -262,7 +264,7 @@ const collectTimer = (input: TimerCollectionInput): void => {
|
|||||||
handleBindingId:
|
handleBindingId:
|
||||||
kind === "schedule"
|
kind === "schedule"
|
||||||
? scalarAssignedResultBinding(projectedResultBindings)
|
? scalarAssignedResultBinding(projectedResultBindings)
|
||||||
: dataEffectArgumentBindingId(node.arguments[0], state),
|
: bindingId(node.arguments[0], state),
|
||||||
projectedResultBindings:
|
projectedResultBindings:
|
||||||
kind === "schedule"
|
kind === "schedule"
|
||||||
? semanticProjectedBindingFacts(
|
? semanticProjectedBindingFacts(
|
||||||
@@ -349,6 +351,19 @@ const admittedTimerMethod = (
|
|||||||
(candidate) => candidate === value,
|
(candidate) => candidate === value,
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
|
||||||
|
const bindingId = (
|
||||||
|
node:
|
||||||
|
| t.Expression
|
||||||
|
| t.SpreadElement
|
||||||
|
| t.JSXNamespacedName
|
||||||
|
| t.ArgumentPlaceholder
|
||||||
|
| undefined,
|
||||||
|
state: JavaScriptSemanticAnalysisState,
|
||||||
|
): string | null =>
|
||||||
|
t.isIdentifier(node)
|
||||||
|
? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const timerDelay = (candidate: TimerCandidate): number | null => {
|
const timerDelay = (candidate: TimerCandidate): number | null => {
|
||||||
if (candidate.kind !== "schedule" || candidate.method === "setImmediate")
|
if (candidate.kind !== "schedule" || candidate.method === "setImmediate")
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { javascriptSourceRangeContains } from "./javascriptSourceCoordinates.js";
|
|
||||||
import * as t from "@babel/types";
|
import * as t from "@babel/types";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -271,7 +270,7 @@ const parameterAcceptsArgument = (
|
|||||||
if (!t.isFunction(node)) return false;
|
if (!t.isFunction(node)) return false;
|
||||||
const parameters = runtimeFunctionParameters(node);
|
const parameters = runtimeFunctionParameters(node);
|
||||||
const parameterIndex = parameters.findIndex((parameter) =>
|
const parameterIndex = parameters.findIndex((parameter) =>
|
||||||
javascriptSourceRangeContains(range(parameter), location),
|
rangeContains(range(parameter), location),
|
||||||
);
|
);
|
||||||
if (parameterIndex === argumentIndex) return true;
|
if (parameterIndex === argumentIndex) return true;
|
||||||
return (
|
return (
|
||||||
@@ -281,5 +280,16 @@ const parameterAcceptsArgument = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rangeContains = (
|
||||||
|
outer: JavaScriptSemanticArgumentFlow["parameterLocation"],
|
||||||
|
inner: JavaScriptSemanticArgumentFlow["parameterLocation"],
|
||||||
|
): boolean =>
|
||||||
|
(outer.start.line < inner.start.line ||
|
||||||
|
(outer.start.line === inner.start.line &&
|
||||||
|
outer.start.column <= inner.start.column)) &&
|
||||||
|
(outer.end.line > inner.end.line ||
|
||||||
|
(outer.end.line === inner.end.line &&
|
||||||
|
outer.end.column >= inner.end.column));
|
||||||
|
|
||||||
const uniqueCallableIds = (values: readonly string[]): string[] =>
|
const uniqueCallableIds = (values: readonly string[]): string[] =>
|
||||||
[...new Set(values)].sort(compareUnicodeCodePoints);
|
[...new Set(values)].sort(compareUnicodeCodePoints);
|
||||||
@@ -2,7 +2,6 @@ import * as t from "@babel/types";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
JavaScriptSemanticCallable,
|
JavaScriptSemanticCallable,
|
||||||
JavaScriptSemanticProjectedResultBinding,
|
|
||||||
JavaScriptSemanticChildProcessInteraction,
|
JavaScriptSemanticChildProcessInteraction,
|
||||||
JavaScriptSemanticChildProcessSpawn,
|
JavaScriptSemanticChildProcessSpawn,
|
||||||
} from "./javascriptSemanticIr.js";
|
} from "./javascriptSemanticIr.js";
|
||||||
@@ -50,7 +49,11 @@ interface SpawnCandidate {
|
|||||||
readonly method: JavaScriptSemanticChildProcessSpawn["method"];
|
readonly method: JavaScriptSemanticChildProcessSpawn["method"];
|
||||||
readonly ownerCallableId: string | null;
|
readonly ownerCallableId: string | null;
|
||||||
readonly resultBindingId: string | null;
|
readonly resultBindingId: string | null;
|
||||||
readonly projectedResultBindings: readonly JavaScriptSemanticProjectedResultBinding[];
|
readonly projectedResultBindings: readonly {
|
||||||
|
readonly bindingId: string;
|
||||||
|
readonly projectionPath: readonly (string | number | null)[];
|
||||||
|
readonly resolution: "complete" | "partial";
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChildInteractionContext {
|
interface ChildInteractionContext {
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import {
|
|||||||
semanticGlobalProcessOrigin,
|
semanticGlobalProcessOrigin,
|
||||||
semanticMutableBuiltinOrigin,
|
semanticMutableBuiltinOrigin,
|
||||||
} from "./javascriptSemanticRequireOrigin.js";
|
} from "./javascriptSemanticRequireOrigin.js";
|
||||||
import type {
|
import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js";
|
||||||
JavaScriptModuleOrigin,
|
|
||||||
JavaScriptSemanticProjectedResultBinding,
|
|
||||||
} from "./javascriptSemanticIr.js";
|
|
||||||
import {
|
import {
|
||||||
evaluateSemanticProvenance,
|
evaluateSemanticProvenance,
|
||||||
evaluateSemanticProvenanceAtRead,
|
evaluateSemanticProvenanceAtRead,
|
||||||
@@ -38,8 +35,11 @@ import {
|
|||||||
unwrapJavaScriptExpression,
|
unwrapJavaScriptExpression,
|
||||||
} from "./javascriptAstValues.js";
|
} from "./javascriptAstValues.js";
|
||||||
|
|
||||||
export interface SemanticProjectedBinding extends JavaScriptSemanticProjectedResultBinding {
|
export interface SemanticProjectedBinding {
|
||||||
|
readonly bindingId: string;
|
||||||
readonly identifier: t.Identifier;
|
readonly identifier: t.Identifier;
|
||||||
|
readonly projectionPath: readonly (string | number | null)[];
|
||||||
|
readonly resolution: "complete" | "partial";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve bindings written by the nearest variable or assignment owner. */
|
/** Resolve bindings written by the nearest variable or assignment owner. */
|
||||||
@@ -136,7 +136,7 @@ const projectedPatternBindings = (
|
|||||||
|
|
||||||
export const semanticProjectedBindingFacts = (
|
export const semanticProjectedBindingFacts = (
|
||||||
bindings: readonly SemanticProjectedBinding[],
|
bindings: readonly SemanticProjectedBinding[],
|
||||||
): JavaScriptSemanticProjectedResultBinding[] =>
|
): Omit<SemanticProjectedBinding, "identifier">[] =>
|
||||||
bindings.map(({ identifier: _identifier, ...fact }) => fact);
|
bindings.map(({ identifier: _identifier, ...fact }) => fact);
|
||||||
|
|
||||||
/** Return a scalar owner only for an unprojected identifier result. */
|
/** Return a scalar owner only for an unprojected identifier result. */
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { canonicalJson } from "../comparisonSemantics.js";
|
import canonicalize from "canonicalize";
|
||||||
import {
|
import {
|
||||||
javaScriptSemanticGraphSchema,
|
javaScriptSemanticGraphSchema,
|
||||||
type JavaScriptSemanticGraph,
|
type JavaScriptSemanticGraph,
|
||||||
} from "./javascriptSemanticGraph.js";
|
} from "./javascriptSemanticGraph.js";
|
||||||
|
|
||||||
|
const canonicalJson = (value: unknown): string => {
|
||||||
|
const encoded = canonicalize(value);
|
||||||
|
if (encoded === undefined)
|
||||||
|
throw new TypeError(
|
||||||
|
"JavaScript semantic graph could not canonicalize data",
|
||||||
|
);
|
||||||
|
return encoded;
|
||||||
|
};
|
||||||
|
|
||||||
/** Parse a stored semantic graph and reject stale IDs. */
|
/** Parse a stored semantic graph and reject stale IDs. */
|
||||||
export const parseJavaScriptSemanticGraph = (
|
export const parseJavaScriptSemanticGraph = (
|
||||||
input: unknown,
|
input: unknown,
|
||||||
@@ -11,7 +20,4 @@ export const parseJavaScriptSemanticGraph = (
|
|||||||
|
|
||||||
/** Serialize a verified semantic graph as canonical JSON. */
|
/** Serialize a verified semantic graph as canonical JSON. */
|
||||||
export const serializeJavaScriptSemanticGraph = (input: unknown): string =>
|
export const serializeJavaScriptSemanticGraph = (input: unknown): string =>
|
||||||
canonicalJson(
|
canonicalJson(parseJavaScriptSemanticGraph(input));
|
||||||
parseJavaScriptSemanticGraph(input),
|
|
||||||
"JavaScript semantic graph",
|
|
||||||
);
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { javascriptSourceRangeKey } from "./javascriptSourceCoordinates.js";
|
|
||||||
import * as t from "@babel/types";
|
import * as t from "@babel/types";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -341,7 +340,7 @@ const indexReturnSites = (
|
|||||||
): ReadonlyMap<string, string> => {
|
): ReadonlyMap<string, string> => {
|
||||||
const output = new Map<string, string>();
|
const output = new Map<string, string>();
|
||||||
for (const site of callable.returnSites) {
|
for (const site of callable.returnSites) {
|
||||||
const key = javascriptSourceRangeKey(site.location);
|
const key = returnSiteKey(site.location);
|
||||||
if (!output.has(key)) output.set(key, site.returnSiteId);
|
if (!output.has(key)) output.set(key, site.returnSiteId);
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
@@ -367,7 +366,7 @@ const createReturnSiteLookup = (
|
|||||||
returnSiteIds = indexReturnSites(callable);
|
returnSiteIds = indexReturnSites(callable);
|
||||||
returnSiteIdsByCallable.set(callableId, returnSiteIds);
|
returnSiteIdsByCallable.set(callableId, returnSiteIds);
|
||||||
}
|
}
|
||||||
return returnSiteIds.get(javascriptSourceRangeKey(location)) ?? null;
|
return returnSiteIds.get(returnSiteKey(location)) ?? null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -382,6 +381,11 @@ const indexCallablesById = (
|
|||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const returnSiteKey = (
|
||||||
|
location: JavaScriptSemanticPromiseOperation["location"],
|
||||||
|
): string =>
|
||||||
|
`${location.start.line}:${location.start.column}:${location.end.line}:${location.end.column}`;
|
||||||
|
|
||||||
const promiseSources = (
|
const promiseSources = (
|
||||||
candidate: PromiseCandidate,
|
candidate: PromiseCandidate,
|
||||||
candidateByNode: WeakMap<t.Node, PromiseCandidate>,
|
candidateByNode: WeakMap<t.Node, PromiseCandidate>,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
|
|
||||||
import type { JavaScriptSemanticGraph } from "./javascriptSemanticGraph.js";
|
import type { JavaScriptSemanticGraph } from "./javascriptSemanticGraph.js";
|
||||||
import type { JavaScriptSemanticGraphNode } from "./javascriptSemanticGraphSchemas.js";
|
import type { JavaScriptSemanticGraphNode } from "./javascriptSemanticGraphSchemas.js";
|
||||||
@@ -6,30 +8,44 @@ import { uniqueSorted } from "../canonicalOrdering.js";
|
|||||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||||
import type { JavaScriptSemanticQueryInput } from "./javascriptSemanticQuerySchemas.js";
|
import type { JavaScriptSemanticQueryInput } from "./javascriptSemanticQuerySchemas.js";
|
||||||
|
|
||||||
|
/** Canonical JSON used by semantic query commitments and ordering. */
|
||||||
|
export const canonicalJavaScriptSemanticQueryJson = (
|
||||||
|
value: unknown,
|
||||||
|
): string => {
|
||||||
|
const encoded = canonicalize(value);
|
||||||
|
if (encoded === undefined)
|
||||||
|
throw new TypeError(
|
||||||
|
"JavaScript semantic query could not canonicalize data",
|
||||||
|
);
|
||||||
|
return encoded;
|
||||||
|
};
|
||||||
|
|
||||||
|
const digest = (value: unknown): string =>
|
||||||
|
createHash("sha256")
|
||||||
|
.update(canonicalJavaScriptSemanticQueryJson(value))
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
/** Derive the stable identity for one graph-bound query. */
|
/** Derive the stable identity for one graph-bound query. */
|
||||||
export const javaScriptSemanticQueryIdentifier = (
|
export const javaScriptSemanticQueryIdentifier = (
|
||||||
graph: JavaScriptSemanticGraph,
|
graph: JavaScriptSemanticGraph,
|
||||||
input: JavaScriptSemanticQueryInput,
|
input: JavaScriptSemanticQueryInput,
|
||||||
): string => {
|
): string => {
|
||||||
const semantic = input;
|
const semantic = input;
|
||||||
return `jsrq_${digestCanonicalValue(
|
return `jsrq_${digest({
|
||||||
{
|
source_graph_id: graph.graph_id,
|
||||||
source_graph_id: graph.graph_id,
|
...semantic,
|
||||||
...semantic,
|
allowed_relations:
|
||||||
allowed_relations:
|
semantic.allowed_relations === undefined
|
||||||
semantic.allowed_relations === undefined
|
? undefined
|
||||||
? undefined
|
: uniqueSorted(semantic.allowed_relations),
|
||||||
: uniqueSorted(semantic.allowed_relations),
|
expected:
|
||||||
expected:
|
semantic.expected === null
|
||||||
semantic.expected === null
|
? null
|
||||||
? null
|
: {
|
||||||
: {
|
...semantic.expected,
|
||||||
...semantic.expected,
|
classes: uniqueSorted(semantic.expected.classes),
|
||||||
classes: uniqueSorted(semantic.expected.classes),
|
},
|
||||||
},
|
})}`;
|
||||||
},
|
|
||||||
"JavaScript semantic query",
|
|
||||||
)}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Resolve deterministic seed candidates without applying traversal limits. */
|
/** Resolve deterministic seed candidates without applying traversal limits. */
|
||||||
|
|||||||
@@ -1,28 +1,4 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type {
|
|
||||||
JavaScriptSourcePoint,
|
|
||||||
JavaScriptSourceRange,
|
|
||||||
} from "./javascriptStaticAnalysisTypes.js";
|
|
||||||
|
|
||||||
/** Compare parser coordinates by line, then UTF-16 column. */
|
|
||||||
export const compareJavaScriptSourcePoints = (
|
|
||||||
left: JavaScriptSourcePoint,
|
|
||||||
right: JavaScriptSourcePoint,
|
|
||||||
): number => left.line - right.line || left.column - right.column;
|
|
||||||
|
|
||||||
/** Include equal endpoints without implying runtime reachability. */
|
|
||||||
export const javascriptSourceRangeContains = (
|
|
||||||
outer: JavaScriptSourceRange,
|
|
||||||
inner: JavaScriptSourceRange,
|
|
||||||
): boolean =>
|
|
||||||
compareJavaScriptSourcePoints(outer.start, inner.start) <= 0 &&
|
|
||||||
compareJavaScriptSourcePoints(outer.end, inner.end) >= 0;
|
|
||||||
|
|
||||||
/** Exact artifact-local coordinate key; callers supply the artifact identity. */
|
|
||||||
export const javascriptSourceRangeKey = (
|
|
||||||
range: JavaScriptSourceRange,
|
|
||||||
): string =>
|
|
||||||
`${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`;
|
|
||||||
|
|
||||||
const sourcePointSchema = z.strictObject({
|
const sourcePointSchema = z.strictObject({
|
||||||
line: z.number().int().min(1),
|
line: z.number().int().min(1),
|
||||||
@@ -33,7 +9,11 @@ const sourcePointSchema = z.strictObject({
|
|||||||
export const javascriptSourceRangeSchema = z
|
export const javascriptSourceRangeSchema = z
|
||||||
.strictObject({ start: sourcePointSchema, end: sourcePointSchema })
|
.strictObject({ start: sourcePointSchema, end: sourcePointSchema })
|
||||||
.superRefine((range, context) => {
|
.superRefine((range, context) => {
|
||||||
if (compareJavaScriptSourcePoints(range.end, range.start) < 0)
|
if (
|
||||||
|
range.end.line < range.start.line ||
|
||||||
|
(range.end.line === range.start.line &&
|
||||||
|
range.end.column < range.start.column)
|
||||||
|
)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
path: ["end"],
|
path: ["end"],
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||||
import { safeParseJson } from "./safeJson.js";
|
|
||||||
|
|
||||||
const jsonValueTypeSchema = z.enum([
|
const jsonValueTypeSchema = z.enum([
|
||||||
"object",
|
"object",
|
||||||
@@ -134,8 +133,13 @@ export const inferJsonShape = (text: string): JsonShape | null => {
|
|||||||
export const parseJsonObject = (
|
export const parseJsonObject = (
|
||||||
text: string,
|
text: string,
|
||||||
): Readonly<Record<string, unknown>> | null => {
|
): Readonly<Record<string, unknown>> | null => {
|
||||||
const parsed = safeParseJson(text);
|
let root: unknown;
|
||||||
return parsed.ok && isRecord(parsed.value) ? parsed.value : null;
|
try {
|
||||||
|
root = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return isRecord(root) ? root : null;
|
||||||
};
|
};
|
||||||
/** The JSON type name of one parsed value. */
|
/** The JSON type name of one parsed value. */
|
||||||
export const jsonValueType = (value: unknown): JsonValueType => {
|
export const jsonValueType = (value: unknown): JsonValueType => {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { evidenceSchema, type Evidence } from "../evidence.js";
|
import { evidenceSchema, type Evidence } from "../evidence.js";
|
||||||
@@ -17,7 +20,7 @@ import {
|
|||||||
type ManagedMemberInspection,
|
type ManagedMemberInspection,
|
||||||
type ManagedNativeBoundaryInspection,
|
type ManagedNativeBoundaryInspection,
|
||||||
} from "./managedArtifact.js";
|
} from "./managedArtifact.js";
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
import type { JsonValue } from "../jsonValue.js";
|
||||||
import {
|
import {
|
||||||
assessManagedGraphOmissions,
|
assessManagedGraphOmissions,
|
||||||
managedGraphEvidenceCoverage,
|
managedGraphEvidenceCoverage,
|
||||||
@@ -117,6 +120,13 @@ export interface GraphBuildState {
|
|||||||
readonly fieldNodes: Map<string, ApplicationNode>;
|
readonly fieldNodes: Map<string, ApplicationNode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sha256 = (value: JsonValue): string => {
|
||||||
|
const serialized = canonicalize(value);
|
||||||
|
if (serialized === undefined)
|
||||||
|
throw new TypeError("Managed application graph canonicalization failed");
|
||||||
|
return createHash("sha256").update(serialized).digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
/** Project authenticated managed Evidence records into the application graph. */
|
/** Project authenticated managed Evidence records into the application graph. */
|
||||||
export const projectManagedApplicationGraph = (
|
export const projectManagedApplicationGraph = (
|
||||||
input: ProjectManagedApplicationGraphInput,
|
input: ProjectManagedApplicationGraphInput,
|
||||||
@@ -184,10 +194,7 @@ export const projectManagedApplicationGraph = (
|
|||||||
};
|
};
|
||||||
return managedApplicationGraphResultSchema.parse({
|
return managedApplicationGraphResultSchema.parse({
|
||||||
...withoutId,
|
...withoutId,
|
||||||
projection_id: `magp_${digestCanonicalValue(
|
projection_id: `magp_${sha256(withoutId)}`,
|
||||||
withoutId,
|
|
||||||
"Managed application graph",
|
|
||||||
)}`,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { digestSchema } from "../digests.js";
|
import { digestSchema } from "../digests.js";
|
||||||
|
|
||||||
/** Build-local ECMA-335 metadata token emitted as an eight-digit lowercase hex value. */
|
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
|
||||||
export const managedMetadataTokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
|
|
||||||
const offsetSchema = z.number().int().min(0);
|
const offsetSchema = z.number().int().min(0);
|
||||||
|
|
||||||
/** A 16-byte CLI metadata GUID without RFC 4122 version/variant restrictions. */
|
/** A 16-byte CLI metadata GUID without RFC 4122 version/variant restrictions. */
|
||||||
@@ -27,7 +26,7 @@ const assemblyIdentitySchema = z.object({
|
|||||||
flags: z.number().int().min(0).max(0xffff_ffff),
|
flags: z.number().int().min(0).max(0xffff_ffff),
|
||||||
hash_algorithm: z.number().int().min(0).max(0xffff_ffff),
|
hash_algorithm: z.number().int().min(0).max(0xffff_ffff),
|
||||||
public_key: publicKeyIdentitySchema,
|
public_key: publicKeyIdentitySchema,
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ const moduleIdentitySchema = z.object({
|
|||||||
mvid: cliMetadataGuidSchema.nullable(),
|
mvid: cliMetadataGuidSchema.nullable(),
|
||||||
enc_id: cliMetadataGuidSchema.nullable(),
|
enc_id: cliMetadataGuidSchema.nullable(),
|
||||||
enc_base_id: cliMetadataGuidSchema.nullable(),
|
enc_base_id: cliMetadataGuidSchema.nullable(),
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +70,7 @@ const assemblyReferenceSchema = z.object({
|
|||||||
public_key_or_token: publicKeyIdentitySchema,
|
public_key_or_token: publicKeyIdentitySchema,
|
||||||
hash_value_sha256: digestSchema.nullable(),
|
hash_value_sha256: digestSchema.nullable(),
|
||||||
hash_value_length: z.number().int().min(0),
|
hash_value_length: z.number().int().min(0),
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,23 +78,23 @@ const managedResourceSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
flags: z.number().int().min(0).max(0xffff_ffff),
|
flags: z.number().int().min(0).max(0xffff_ffff),
|
||||||
visibility: z.enum(["public", "private", "unknown"]),
|
visibility: z.enum(["public", "private", "unknown"]),
|
||||||
implementation_token: managedMetadataTokenSchema.nullable(),
|
implementation_token: tokenSchema.nullable(),
|
||||||
embedded: z.boolean(),
|
embedded: z.boolean(),
|
||||||
declared_offset: z.number().int().min(0).max(0xffff_ffff),
|
declared_offset: z.number().int().min(0).max(0xffff_ffff),
|
||||||
data_length: z.number().int().min(0).nullable(),
|
data_length: z.number().int().min(0).nullable(),
|
||||||
data_sha256: digestSchema.nullable(),
|
data_sha256: digestSchema.nullable(),
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const customAttributeSchema = z.object({
|
const customAttributeSchema = z.object({
|
||||||
parent_token: managedMetadataTokenSchema,
|
parent_token: tokenSchema,
|
||||||
constructor_token: managedMetadataTokenSchema.nullable(),
|
constructor_token: tokenSchema.nullable(),
|
||||||
type_name: z.string().nullable(),
|
type_name: z.string().nullable(),
|
||||||
value_length: z.number().int().min(0),
|
value_length: z.number().int().min(0),
|
||||||
value_sha256: digestSchema,
|
value_sha256: digestSchema,
|
||||||
decoded_fixed_string: z.string().nullable(),
|
decoded_fixed_string: z.string().nullable(),
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ const managedSignatureSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const managedTypeSchema = z.object({
|
const managedTypeSchema = z.object({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
namespace: z.string(),
|
namespace: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -133,8 +132,8 @@ const managedTypeSchema = z.object({
|
|||||||
"Framed nominal namespace/name/enclosing-name digest, independent of build-local rows. Null for historical Evidence or unresolved nesting; defining assemblies and CLR runtime identity are not established.",
|
"Framed nominal namespace/name/enclosing-name digest, independent of build-local rows. Null for historical Evidence or unresolved nesting; defining assemblies and CLR runtime identity are not established.",
|
||||||
),
|
),
|
||||||
flags: z.number().int().min(0).max(0xffff_ffff),
|
flags: z.number().int().min(0).max(0xffff_ffff),
|
||||||
extends_token: managedMetadataTokenSchema.nullable(),
|
extends_token: tokenSchema.nullable(),
|
||||||
enclosing_type_token: managedMetadataTokenSchema
|
enclosing_type_token: tokenSchema
|
||||||
.nullable()
|
.nullable()
|
||||||
.default(null)
|
.default(null)
|
||||||
.describe(
|
.describe(
|
||||||
@@ -154,20 +153,20 @@ const managedTypeSchema = z.object({
|
|||||||
|
|
||||||
/** One ExportedType row: a type this assembly exports from another module or forwards. */
|
/** One ExportedType row: a type this assembly exports from another module or forwards. */
|
||||||
const managedExportedTypeSchema = z.object({
|
const managedExportedTypeSchema = z.object({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
namespace: z.string(),
|
namespace: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
flags: z.number().int().min(0).max(0xffff_ffff),
|
flags: z.number().int().min(0).max(0xffff_ffff),
|
||||||
forwarder: z.boolean(),
|
forwarder: z.boolean(),
|
||||||
type_def_id: z.number().int().min(0).max(0xffff_ffff),
|
type_def_id: z.number().int().min(0).max(0xffff_ffff),
|
||||||
implementation_token: managedMetadataTokenSchema.nullable(),
|
implementation_token: tokenSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const managedFieldSchema = z.object({
|
const managedFieldSchema = z.object({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
declaring_type_token: managedMetadataTokenSchema.nullable(),
|
declaring_type_token: tokenSchema.nullable(),
|
||||||
declaring_type: z.string().nullable(),
|
declaring_type: z.string().nullable(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
flags: z.number().int().min(0).max(0xffff),
|
flags: z.number().int().min(0).max(0xffff),
|
||||||
@@ -180,7 +179,7 @@ const managedExceptionRegionSchema = z.object({
|
|||||||
try_length: z.number().int().min(0),
|
try_length: z.number().int().min(0),
|
||||||
handler_offset: z.number().int().min(0),
|
handler_offset: z.number().int().min(0),
|
||||||
handler_length: z.number().int().min(0),
|
handler_length: z.number().int().min(0),
|
||||||
class_token: managedMetadataTokenSchema.nullable(),
|
class_token: tokenSchema.nullable(),
|
||||||
filter_offset: z.number().int().min(0).nullable(),
|
filter_offset: z.number().int().min(0).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,7 +210,7 @@ const managedMethodBodySchema = z.object({
|
|||||||
file_offset: offsetSchema.nullable(),
|
file_offset: offsetSchema.nullable(),
|
||||||
max_stack: z.number().int().min(0).nullable(),
|
max_stack: z.number().int().min(0).nullable(),
|
||||||
init_locals: z.boolean().nullable(),
|
init_locals: z.boolean().nullable(),
|
||||||
local_var_sig_token: managedMetadataTokenSchema.nullable(),
|
local_var_sig_token: tokenSchema.nullable(),
|
||||||
il_size: z.number().int().min(0),
|
il_size: z.number().int().min(0),
|
||||||
il_sha256: digestSchema
|
il_sha256: digestSchema
|
||||||
.nullable()
|
.nullable()
|
||||||
@@ -233,9 +232,9 @@ const managedMethodBodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const managedMethodSchema = z.object({
|
const managedMethodSchema = z.object({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
declaring_type_token: managedMetadataTokenSchema.nullable(),
|
declaring_type_token: tokenSchema.nullable(),
|
||||||
declaring_type: z.string().nullable(),
|
declaring_type: z.string().nullable(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
rva: z.number().int().min(0).max(0xffff_ffff),
|
rva: z.number().int().min(0).max(0xffff_ffff),
|
||||||
@@ -251,9 +250,9 @@ const managedMethodSchema = z.object({
|
|||||||
const declaringTypeNameSchema = z.string().nullable().default(null);
|
const declaringTypeNameSchema = z.string().nullable().default(null);
|
||||||
|
|
||||||
const managedMemberReferenceSchema = z.object({
|
const managedMemberReferenceSchema = z.object({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
parent_token: managedMetadataTokenSchema.nullable(),
|
parent_token: tokenSchema.nullable(),
|
||||||
parent_name: declaringTypeNameSchema.describe(
|
parent_name: declaringTypeNameSchema.describe(
|
||||||
"Name of a TypeDef, TypeRef, or generic-instantiation TypeSpec parent; null for other parents or when unknown",
|
"Name of a TypeDef, TypeRef, or generic-instantiation TypeSpec parent; null for other parents or when unknown",
|
||||||
),
|
),
|
||||||
@@ -262,7 +261,7 @@ const managedMemberReferenceSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const managedUserStringSchema = z.object({
|
const managedUserStringSchema = z.object({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
offset: offsetSchema.describe("File offset of the #US item"),
|
offset: offsetSchema.describe("File offset of the #US item"),
|
||||||
value: z
|
value: z
|
||||||
.string()
|
.string()
|
||||||
@@ -272,21 +271,21 @@ const managedUserStringSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const managedModuleReferenceSchema = z.object({
|
const managedModuleReferenceSchema = z.object({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const managedNativeImportSchema = z.object({
|
const managedNativeImportSchema = z.object({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
mapping_flags: z.number().int().min(0).max(0xffff),
|
mapping_flags: z.number().int().min(0).max(0xffff),
|
||||||
mapping_flags_hex: z.string().regex(/^0x[0-9a-f]{4}$/u),
|
mapping_flags_hex: z.string().regex(/^0x[0-9a-f]{4}$/u),
|
||||||
member_token: managedMetadataTokenSchema.nullable(),
|
member_token: tokenSchema.nullable(),
|
||||||
member_kind: z.enum(["method", "field", "unknown"]),
|
member_kind: z.enum(["method", "field", "unknown"]),
|
||||||
member_name: z.string().nullable(),
|
member_name: z.string().nullable(),
|
||||||
import_name: z.string(),
|
import_name: z.string(),
|
||||||
import_scope_token: managedMetadataTokenSchema.nullable(),
|
import_scope_token: tokenSchema.nullable(),
|
||||||
import_scope_name: z.string().nullable(),
|
import_scope_name: z.string().nullable(),
|
||||||
no_mangle: z.boolean(),
|
no_mangle: z.boolean(),
|
||||||
char_set: z.enum(["not-specified", "ansi", "unicode", "auto", "unknown"]),
|
char_set: z.enum(["not-specified", "ansi", "unicode", "auto", "unknown"]),
|
||||||
@@ -311,7 +310,7 @@ export const managedNativeImportSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const managedNativeImplementationSchema = z.object({
|
const managedNativeImplementationSchema = z.object({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
row_offset: offsetSchema,
|
row_offset: offsetSchema,
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
rva: z.number().int().min(0).max(0xffff_ffff),
|
rva: z.number().int().min(0).max(0xffff_ffff),
|
||||||
@@ -336,10 +335,10 @@ const managedNativeImplementationSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const managedCallEdgeSchema = z.object({
|
const managedCallEdgeSchema = z.object({
|
||||||
caller_token: managedMetadataTokenSchema,
|
caller_token: tokenSchema,
|
||||||
caller: z.string().nullable(),
|
caller: z.string().nullable(),
|
||||||
opcode: z.string(),
|
opcode: z.string(),
|
||||||
target_token: managedMetadataTokenSchema,
|
target_token: tokenSchema,
|
||||||
target_kind: z.enum(["method-def", "member-ref", "method-spec", "unknown"]),
|
target_kind: z.enum(["method-def", "member-ref", "method-spec", "unknown"]),
|
||||||
target_name: z
|
target_name: z
|
||||||
.string()
|
.string()
|
||||||
@@ -351,10 +350,10 @@ const managedCallEdgeSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const managedFieldAccessSchema = z.object({
|
const managedFieldAccessSchema = z.object({
|
||||||
method_token: managedMetadataTokenSchema,
|
method_token: tokenSchema,
|
||||||
method: z.string().nullable(),
|
method: z.string().nullable(),
|
||||||
opcode: z.string(),
|
opcode: z.string(),
|
||||||
field_token: managedMetadataTokenSchema,
|
field_token: tokenSchema,
|
||||||
field_name: z.string().nullable(),
|
field_name: z.string().nullable(),
|
||||||
field_declaring_type: declaringTypeNameSchema.describe(
|
field_declaring_type: declaringTypeNameSchema.describe(
|
||||||
"Declaring type of the accessed field; null when unknown",
|
"Declaring type of the accessed field; null when unknown",
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
|
||||||
import { emptyArraySchema } from "../emptyArraySchema.js";
|
import { emptyArraySchema } from "../emptyArraySchema.js";
|
||||||
import { evidenceSchema } from "../evidence.js";
|
import { evidenceSchema } from "../evidence.js";
|
||||||
import {
|
import {
|
||||||
cliMetadataGuidSchema,
|
cliMetadataGuidSchema,
|
||||||
managedMetadataTokenSchema,
|
|
||||||
type ManagedMemberInspection,
|
type ManagedMemberInspection,
|
||||||
} from "./managedArtifact.js";
|
} from "./managedArtifact.js";
|
||||||
import {
|
import {
|
||||||
@@ -19,12 +17,13 @@ import {
|
|||||||
buildFieldItems,
|
buildFieldItems,
|
||||||
buildMethodItems,
|
buildMethodItems,
|
||||||
} from "./managedMemberComparisonItems.js";
|
} from "./managedMemberComparisonItems.js";
|
||||||
import { keyMembers } from "./managedMemberComparisonMatch.js";
|
import { keyMembers, sha256 } from "./managedMemberComparisonMatch.js";
|
||||||
import { hasSignatureResolution } from "./managedMemberSignatureIdentity.js";
|
import { hasSignatureResolution } from "./managedMemberSignatureIdentity.js";
|
||||||
import { digestSchema } from "../digests.js";
|
import { digestSchema } from "../digests.js";
|
||||||
import { prefixedDigestSchema } from "../digests.js";
|
import { prefixedDigestSchema } from "../digests.js";
|
||||||
|
|
||||||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||||
|
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
|
||||||
const boundedTextSchema = z.string().min(1);
|
const boundedTextSchema = z.string().min(1);
|
||||||
|
|
||||||
/** Two authenticated managed member observations. */
|
/** Two authenticated managed member observations. */
|
||||||
@@ -48,7 +47,7 @@ const concreteMatchBasisSchema = z.enum([
|
|||||||
"structural-method-shape",
|
"structural-method-shape",
|
||||||
"field-signature",
|
"field-signature",
|
||||||
]);
|
]);
|
||||||
const candidateTokensSchema = z.array(managedMetadataTokenSchema).min(1);
|
const candidateTokensSchema = z.array(tokenSchema).min(1);
|
||||||
const matchedComparisonSchema = z.strictObject({
|
const matchedComparisonSchema = z.strictObject({
|
||||||
status: z.literal("matched"),
|
status: z.literal("matched"),
|
||||||
basis: concreteMatchBasisSchema,
|
basis: concreteMatchBasisSchema,
|
||||||
@@ -71,7 +70,7 @@ const ambiguousComparisonSchema = z.strictObject({
|
|||||||
candidate_right_tokens: candidateTokensSchema,
|
candidate_right_tokens: candidateTokensSchema,
|
||||||
});
|
});
|
||||||
const memberIdentitySchema = z.strictObject({
|
const memberIdentitySchema = z.strictObject({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
declaring_type: z.string().nullable(),
|
declaring_type: z.string().nullable(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
signature_sha256: digestSchema,
|
signature_sha256: digestSchema,
|
||||||
@@ -248,15 +247,12 @@ export const compareManagedMembers = (
|
|||||||
});
|
});
|
||||||
const limitations = comparisonLimitations(left.result, right.result);
|
const limitations = comparisonLimitations(left.result, right.result);
|
||||||
const result = {
|
const result = {
|
||||||
comparison_id: `mmc_${digestCanonicalValue(
|
comparison_id: `mmc_${sha256({
|
||||||
{
|
left: left.evidenceId,
|
||||||
left: left.evidenceId,
|
right: right.evidenceId,
|
||||||
right: right.evidenceId,
|
methods: [...methodItems],
|
||||||
methods: [...methodItems],
|
fields: [...fieldItems],
|
||||||
fields: [...fieldItems],
|
})}`,
|
||||||
},
|
|
||||||
"Managed comparison",
|
|
||||||
)}`,
|
|
||||||
algorithm: {
|
algorithm: {
|
||||||
name: "rea-managed-member-comparison" as const,
|
name: "rea-managed-member-comparison" as const,
|
||||||
token_identity: "build-local" as const,
|
token_identity: "build-local" as const,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import canonicalize from "canonicalize";
|
import canonicalize from "canonicalize";
|
||||||
|
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
|
||||||
import type { ManagedMemberInspection } from "./managedArtifact.js";
|
import type { ManagedMemberInspection } from "./managedArtifact.js";
|
||||||
import type { ManagedMemberComparisonResult } from "./managedMemberComparison.js";
|
import type { ManagedMemberComparisonResult } from "./managedMemberComparison.js";
|
||||||
import type {
|
import {
|
||||||
ManagedFieldMatches,
|
type ManagedFieldMatches,
|
||||||
ManagedMethodMatches,
|
type ManagedMethodMatches,
|
||||||
|
sha256,
|
||||||
} from "./managedMemberComparisonMatch.js";
|
} from "./managedMemberComparisonMatch.js";
|
||||||
import { signatureIdentity } from "./managedMemberSignatureIdentity.js";
|
import { signatureIdentity } from "./managedMemberSignatureIdentity.js";
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ const methodOnlyItem = (
|
|||||||
inventory: "method",
|
inventory: "method",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
item_id: `mmc_method_${digestCanonicalValue({ token: member.left.token, side: "left" }, "Managed comparison")}`,
|
item_id: `mmc_method_${sha256({ token: member.left.token, side: "left" })}`,
|
||||||
status: limitations.length === 0 ? "removed" : "unknown",
|
status: limitations.length === 0 ? "removed" : "unknown",
|
||||||
left: methodIdentity(member.left),
|
left: methodIdentity(member.left),
|
||||||
right: null,
|
right: null,
|
||||||
@@ -203,7 +203,7 @@ const methodOnlyItem = (
|
|||||||
inventory: "method",
|
inventory: "method",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
item_id: `mmc_method_${digestCanonicalValue({ token: member.right.token, side: "right" }, "Managed comparison")}`,
|
item_id: `mmc_method_${sha256({ token: member.right.token, side: "right" })}`,
|
||||||
status: limitations.length === 0 ? "added" : "unknown",
|
status: limitations.length === 0 ? "added" : "unknown",
|
||||||
left: null,
|
left: null,
|
||||||
right: methodIdentity(member.right),
|
right: methodIdentity(member.right),
|
||||||
@@ -377,7 +377,7 @@ const fieldOnlyItem = (
|
|||||||
inventory: "field",
|
inventory: "field",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
item_id: `mmc_field_${digestCanonicalValue({ token: member.left.token, side: "left" }, "Managed comparison")}`,
|
item_id: `mmc_field_${sha256({ token: member.left.token, side: "left" })}`,
|
||||||
status: limitations.length === 0 ? "removed" : "unknown",
|
status: limitations.length === 0 ? "removed" : "unknown",
|
||||||
left: fieldIdentity(member.left),
|
left: fieldIdentity(member.left),
|
||||||
right: null,
|
right: null,
|
||||||
@@ -399,7 +399,7 @@ const fieldOnlyItem = (
|
|||||||
inventory: "field",
|
inventory: "field",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
item_id: `mmc_field_${digestCanonicalValue({ token: member.right.token, side: "right" }, "Managed comparison")}`,
|
item_id: `mmc_field_${sha256({ token: member.right.token, side: "right" })}`,
|
||||||
status: limitations.length === 0 ? "added" : "unknown",
|
status: limitations.length === 0 ? "added" : "unknown",
|
||||||
left: null,
|
left: null,
|
||||||
right: fieldIdentity(member.right),
|
right: fieldIdentity(member.right),
|
||||||
@@ -448,14 +448,11 @@ export const buildMethodItems = (
|
|||||||
: observedDimensions;
|
: observedDimensions;
|
||||||
const observedChange = observedDimensions.length > 0;
|
const observedChange = observedDimensions.length > 0;
|
||||||
items.push({
|
items.push({
|
||||||
item_id: `mmc_method_${digestCanonicalValue(
|
item_id: `mmc_method_${sha256({
|
||||||
{
|
left: pair.left.item.token,
|
||||||
left: pair.left.item.token,
|
right: pair.right.item.token,
|
||||||
right: pair.right.item.token,
|
basis: pair.basis,
|
||||||
basis: pair.basis,
|
})}`,
|
||||||
},
|
|
||||||
"Managed comparison",
|
|
||||||
)}`,
|
|
||||||
status: observedChange
|
status: observedChange
|
||||||
? "changed"
|
? "changed"
|
||||||
: bodyUnknown || signatureLimitations.length > 0
|
: bodyUnknown || signatureLimitations.length > 0
|
||||||
@@ -484,14 +481,11 @@ export const buildMethodItems = (
|
|||||||
}
|
}
|
||||||
for (const ambiguous of matches.ambiguous) {
|
for (const ambiguous of matches.ambiguous) {
|
||||||
items.push({
|
items.push({
|
||||||
item_id: `mmc_method_${digestCanonicalValue(
|
item_id: `mmc_method_${sha256({
|
||||||
{
|
left: ambiguous.left.map(({ item }) => item.token),
|
||||||
left: ambiguous.left.map(({ item }) => item.token),
|
right: ambiguous.right.map(({ item }) => item.token),
|
||||||
right: ambiguous.right.map(({ item }) => item.token),
|
basis: ambiguous.basis,
|
||||||
basis: ambiguous.basis,
|
})}`,
|
||||||
},
|
|
||||||
"Managed comparison",
|
|
||||||
)}`,
|
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
left: null,
|
left: null,
|
||||||
right: null,
|
right: null,
|
||||||
@@ -557,13 +551,10 @@ export const buildFieldItems = (
|
|||||||
signatureIdentity(pair.right.item.signature) ||
|
signatureIdentity(pair.right.item.signature) ||
|
||||||
pair.left.item.flags !== pair.right.item.flags;
|
pair.left.item.flags !== pair.right.item.flags;
|
||||||
items.push({
|
items.push({
|
||||||
item_id: `mmc_field_${digestCanonicalValue(
|
item_id: `mmc_field_${sha256({
|
||||||
{
|
left: pair.left.item.token,
|
||||||
left: pair.left.item.token,
|
right: pair.right.item.token,
|
||||||
right: pair.right.item.token,
|
})}`,
|
||||||
},
|
|
||||||
"Managed comparison",
|
|
||||||
)}`,
|
|
||||||
status: changed
|
status: changed
|
||||||
? "changed"
|
? "changed"
|
||||||
: signatureLimitations.length > 0
|
: signatureLimitations.length > 0
|
||||||
@@ -584,13 +575,10 @@ export const buildFieldItems = (
|
|||||||
}
|
}
|
||||||
for (const ambiguous of matches.ambiguous)
|
for (const ambiguous of matches.ambiguous)
|
||||||
items.push({
|
items.push({
|
||||||
item_id: `mmc_field_${digestCanonicalValue(
|
item_id: `mmc_field_${sha256({
|
||||||
{
|
left: ambiguous.left.map(({ item }) => item.token),
|
||||||
left: ambiguous.left.map(({ item }) => item.token),
|
right: ambiguous.right.map(({ item }) => item.token),
|
||||||
right: ambiguous.right.map(({ item }) => item.token),
|
})}`,
|
||||||
},
|
|
||||||
"Managed comparison",
|
|
||||||
)}`,
|
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
left: null,
|
left: null,
|
||||||
right: null,
|
right: null,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
managedMemberInspectionSchema,
|
managedMemberInspectionSchema,
|
||||||
type ManagedMemberInspection,
|
type ManagedMemberInspection,
|
||||||
@@ -12,6 +15,15 @@ import {
|
|||||||
comparableSides,
|
comparableSides,
|
||||||
signatureIdentity,
|
signatureIdentity,
|
||||||
} from "./managedMemberSignatureIdentity.js";
|
} from "./managedMemberSignatureIdentity.js";
|
||||||
|
import type { JsonValue } from "../jsonValue.js";
|
||||||
|
|
||||||
|
export const sha256 = (value: JsonValue): string => {
|
||||||
|
const serialized = canonicalize(value);
|
||||||
|
if (serialized === undefined)
|
||||||
|
throw new TypeError("Managed comparison canonicalization failed");
|
||||||
|
return createHash("sha256").update(serialized).digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
type Method = ManagedMemberInspection["methods"][number];
|
type Method = ManagedMemberInspection["methods"][number];
|
||||||
type Field = ManagedMemberInspection["fields"][number];
|
type Field = ManagedMemberInspection["fields"][number];
|
||||||
|
|
||||||
@@ -57,58 +69,49 @@ const keyMethod = (item: Method, declared: DeclaredNameOf): Keyed<Method> => ({
|
|||||||
item.body.status === "present" &&
|
item.body.status === "present" &&
|
||||||
item.body.il_sha256 !== null &&
|
item.body.il_sha256 !== null &&
|
||||||
item.body.normalized_il_sha256 !== null
|
item.body.normalized_il_sha256 !== null
|
||||||
? digestCanonicalValue(
|
? sha256([
|
||||||
[
|
"method-exact",
|
||||||
"method-exact",
|
signatureIdentity(item.signature),
|
||||||
signatureIdentity(item.signature),
|
item.body.il_sha256,
|
||||||
item.body.il_sha256,
|
item.body.normalized_il_sha256,
|
||||||
item.body.normalized_il_sha256,
|
])
|
||||||
],
|
|
||||||
"Managed comparison",
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
// The blob, with type operands named, is the exact signature; decoding
|
// The blob, with type operands named, is the exact signature; decoding
|
||||||
// only gates the shape keys.
|
// only gates the shape keys.
|
||||||
signatureKey:
|
signatureKey:
|
||||||
declared(item) === null
|
declared(item) === null
|
||||||
? null
|
? null
|
||||||
: digestCanonicalValue(
|
: sha256([
|
||||||
[
|
"method-signature",
|
||||||
"method-signature",
|
declared(item),
|
||||||
declared(item),
|
item.name,
|
||||||
item.name,
|
signatureIdentity(item.signature),
|
||||||
signatureIdentity(item.signature),
|
]),
|
||||||
],
|
|
||||||
"Managed comparison",
|
|
||||||
),
|
|
||||||
structuralKey:
|
structuralKey:
|
||||||
item.signature.parse_status === "decoded" && item.body.status === "present"
|
item.signature.parse_status === "decoded" && item.body.status === "present"
|
||||||
? digestCanonicalValue(
|
? sha256([
|
||||||
[
|
"method-structural",
|
||||||
"method-structural",
|
// Decoded type strings carry build-local rows; the identity names them.
|
||||||
// Decoded type strings carry build-local rows; the identity names them.
|
signatureIdentity(item.signature),
|
||||||
signatureIdentity(item.signature),
|
item.body.status,
|
||||||
item.body.status,
|
item.body.header_format,
|
||||||
item.body.header_format,
|
item.body.il_size,
|
||||||
item.body.il_size,
|
item.body.max_stack,
|
||||||
item.body.max_stack,
|
item.body.init_locals,
|
||||||
item.body.init_locals,
|
item.body.opcode_counts,
|
||||||
item.body.opcode_counts,
|
item.body.anchors.map(({ opcode, operand_kind, operand }) => [
|
||||||
item.body.anchors.map(({ opcode, operand_kind, operand }) => [
|
opcode,
|
||||||
opcode,
|
operand_kind,
|
||||||
operand_kind,
|
operand_kind === "constant" ? operand : null,
|
||||||
operand_kind === "constant" ? operand : null,
|
]),
|
||||||
]),
|
item.body.exception_regions.map((region) => [
|
||||||
item.body.exception_regions.map((region) => [
|
region.flags,
|
||||||
region.flags,
|
region.try_length,
|
||||||
region.try_length,
|
region.handler_length,
|
||||||
region.handler_length,
|
region.class_token === null ? null : "type-token",
|
||||||
region.class_token === null ? null : "type-token",
|
region.filter_offset === null ? null : "filter",
|
||||||
region.filter_offset === null ? null : "filter",
|
]),
|
||||||
]),
|
])
|
||||||
],
|
|
||||||
"Managed comparison",
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,34 +122,25 @@ const keyField = (item: Field, declared: DeclaredNameOf): Keyed<Field> => ({
|
|||||||
signatureId: signatureIdentity(item.signature),
|
signatureId: signatureIdentity(item.signature),
|
||||||
exactKey:
|
exactKey:
|
||||||
item.signature.parse_status === "decoded"
|
item.signature.parse_status === "decoded"
|
||||||
? digestCanonicalValue(
|
? sha256(["field-exact", signatureIdentity(item.signature)])
|
||||||
["field-exact", signatureIdentity(item.signature)],
|
|
||||||
"Managed comparison",
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
// Only an undecoded field reaches this round: a decoded field with the
|
// Only an undecoded field reaches this round: a decoded field with the
|
||||||
// same signature already met its counterpart in the exact round.
|
// same signature already met its counterpart in the exact round.
|
||||||
signatureKey:
|
signatureKey:
|
||||||
declared(item) === null
|
declared(item) === null
|
||||||
? null
|
? null
|
||||||
: digestCanonicalValue(
|
: sha256([
|
||||||
[
|
"field-signature",
|
||||||
"field-signature",
|
declared(item),
|
||||||
declared(item),
|
item.name,
|
||||||
item.name,
|
signatureIdentity(item.signature),
|
||||||
signatureIdentity(item.signature),
|
]),
|
||||||
],
|
structuralKey: sha256([
|
||||||
"Managed comparison",
|
"field-structural",
|
||||||
),
|
item.signature.kind,
|
||||||
structuralKey: digestCanonicalValue(
|
item.signature.field_type,
|
||||||
[
|
item.flags,
|
||||||
"field-structural",
|
]),
|
||||||
item.signature.kind,
|
|
||||||
item.signature.field_type,
|
|
||||||
item.flags,
|
|
||||||
],
|
|
||||||
"Managed comparison",
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const matchMethods = (
|
const matchMethods = (
|
||||||
@@ -243,8 +237,7 @@ export const identityOf = ({
|
|||||||
}: {
|
}: {
|
||||||
readonly declaring_type: DeclaredName;
|
readonly declaring_type: DeclaredName;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
}): string =>
|
}): string => sha256([declaring_type, name]);
|
||||||
digestCanonicalValue([declaring_type, name], "Managed comparison");
|
|
||||||
|
|
||||||
/** Narrow a key to members of one declared type and name. */
|
/** Narrow a key to members of one declared type and name. */
|
||||||
const declaredAs =
|
const declaredAs =
|
||||||
@@ -253,7 +246,7 @@ const declaredAs =
|
|||||||
const value = key(item);
|
const value = key(item);
|
||||||
return value === null || item.identityUnavailable
|
return value === null || item.identityUnavailable
|
||||||
? null
|
? null
|
||||||
: digestCanonicalValue([item.identity, value], "Managed comparison");
|
: sha256([item.identity, value]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchByKeys = <Item extends Declared>({
|
const matchByKeys = <Item extends Declared>({
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
|
|
||||||
import type { Evidence } from "../evidence.js";
|
import type { Evidence } from "../evidence.js";
|
||||||
import {
|
import {
|
||||||
managedNativeBoundaryInspectionSchema,
|
managedNativeBoundaryInspectionSchema,
|
||||||
type ManagedNativeBoundaryInspection,
|
type ManagedNativeBoundaryInspection,
|
||||||
} from "./managedArtifact.js";
|
} from "./managedArtifact.js";
|
||||||
import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js";
|
import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js";
|
||||||
|
import type { JsonValue } from "../jsonValue.js";
|
||||||
import {
|
import {
|
||||||
managedNativeVerificationResultSchema,
|
managedNativeVerificationResultSchema,
|
||||||
type ManagedNativeVerificationInput,
|
type ManagedNativeVerificationInput,
|
||||||
@@ -16,6 +20,13 @@ import {
|
|||||||
verifyPinvoke,
|
verifyPinvoke,
|
||||||
} from "./managedNativeVerificationMatch.js";
|
} from "./managedNativeVerificationMatch.js";
|
||||||
|
|
||||||
|
const sha256 = (value: JsonValue): string => {
|
||||||
|
const serialized = canonicalize(value);
|
||||||
|
if (serialized === undefined)
|
||||||
|
throw new TypeError("Managed/native verification canonicalization failed");
|
||||||
|
return createHash("sha256").update(serialized).digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
const parseManagedBoundary = (
|
const parseManagedBoundary = (
|
||||||
input: ManagedNativeVerificationInput,
|
input: ManagedNativeVerificationInput,
|
||||||
): {
|
): {
|
||||||
@@ -54,9 +65,6 @@ export const verifyManagedNativeBoundaries = (
|
|||||||
});
|
});
|
||||||
return managedNativeVerificationResultSchema.parse({
|
return managedNativeVerificationResultSchema.parse({
|
||||||
...withoutId,
|
...withoutId,
|
||||||
verification_id: `mnv_${digestCanonicalValue(
|
verification_id: `mnv_${sha256(withoutId)}`,
|
||||||
withoutId,
|
|
||||||
"Managed/native verification",
|
|
||||||
)}`,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { basename } from "node:path";
|
import { basename } from "node:path";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
|
|
||||||
import { parseEvidence } from "../evidence.js";
|
import { parseEvidence } from "../evidence.js";
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
|
||||||
import { functionDossierSchema } from "../hopperValues.js";
|
import { functionDossierSchema } from "../hopperValues.js";
|
||||||
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
||||||
import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js";
|
import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js";
|
||||||
|
import type { JsonValue } from "../jsonValue.js";
|
||||||
import {
|
import {
|
||||||
pinvokeVerificationSchema,
|
pinvokeVerificationSchema,
|
||||||
type ManagedNativeVerificationInput,
|
type ManagedNativeVerificationInput,
|
||||||
@@ -13,6 +16,13 @@ import {
|
|||||||
type PinvokeVerification,
|
type PinvokeVerification,
|
||||||
} from "./managedNativeVerificationSchemas.js";
|
} from "./managedNativeVerificationSchemas.js";
|
||||||
|
|
||||||
|
const digest = (value: JsonValue): string => {
|
||||||
|
const serialized = canonicalize(value);
|
||||||
|
if (serialized === undefined)
|
||||||
|
throw new TypeError("Managed/native verification canonicalization failed");
|
||||||
|
return createHash("sha256").update(serialized).digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
type PinvokeImport = ManagedNativeBoundaryInspection["pinvoke_imports"][number];
|
type PinvokeImport = ManagedNativeBoundaryInspection["pinvoke_imports"][number];
|
||||||
type VerifiedPinvoke = {
|
type VerifiedPinvoke = {
|
||||||
readonly verification: PinvokeVerification;
|
readonly verification: PinvokeVerification;
|
||||||
@@ -316,16 +326,13 @@ export const verifyPinvoke = ({
|
|||||||
const limitations = pinvokeLimitations(selection.match, item);
|
const limitations = pinvokeLimitations(selection.match, item);
|
||||||
return {
|
return {
|
||||||
verification: pinvokeVerificationSchema.parse({
|
verification: pinvokeVerificationSchema.parse({
|
||||||
item_id: `mnv_pinvoke_${digestCanonicalValue(
|
item_id: `mnv_pinvoke_${digest({
|
||||||
{
|
managedEvidenceId,
|
||||||
managedEvidenceId,
|
token: item.token,
|
||||||
token: item.token,
|
importName: item.import_name,
|
||||||
importName: item.import_name,
|
importScope: item.import_scope_name,
|
||||||
importScope: item.import_scope_name,
|
candidates: selection.match.candidates,
|
||||||
candidates: selection.match.candidates,
|
})}`,
|
||||||
},
|
|
||||||
"Managed/native verification",
|
|
||||||
)}`,
|
|
||||||
managed,
|
managed,
|
||||||
...selection.match,
|
...selection.match,
|
||||||
evidence_links: [
|
evidence_links: [
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { emptyArraySchema } from "../emptyArraySchema.js";
|
import { emptyArraySchema } from "../emptyArraySchema.js";
|
||||||
import { evidenceSchema } from "../evidence.js";
|
import { evidenceSchema } from "../evidence.js";
|
||||||
import {
|
import { cliMetadataGuidSchema } from "./managedArtifact.js";
|
||||||
cliMetadataGuidSchema,
|
|
||||||
managedNativeImportSchema,
|
|
||||||
} from "./managedArtifact.js";
|
|
||||||
import { digestSchema } from "../digests.js";
|
import { digestSchema } from "../digests.js";
|
||||||
import { prefixedDigestSchema } from "../digests.js";
|
import { prefixedDigestSchema } from "../digests.js";
|
||||||
|
|
||||||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||||
|
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
|
||||||
const textSchema = z.string().min(1);
|
const textSchema = z.string().min(1);
|
||||||
|
|
||||||
const nativeSymbolSchema = z.strictObject({
|
const nativeSymbolSchema = z.strictObject({
|
||||||
@@ -24,26 +22,27 @@ const nativeSymbolSchema = z.strictObject({
|
|||||||
|
|
||||||
export type NativeSymbol = z.infer<typeof nativeSymbolSchema>;
|
export type NativeSymbol = z.infer<typeof nativeSymbolSchema>;
|
||||||
|
|
||||||
const verifiedManagedDeclarationSchema = managedNativeImportSchema
|
|
||||||
.pick({
|
|
||||||
token: true,
|
|
||||||
member_token: true,
|
|
||||||
member_name: true,
|
|
||||||
import_name: true,
|
|
||||||
import_scope_name: true,
|
|
||||||
no_mangle: true,
|
|
||||||
char_set: true,
|
|
||||||
call_convention: true,
|
|
||||||
})
|
|
||||||
.extend({
|
|
||||||
import_name: z.string().min(1),
|
|
||||||
declaration_verification: managedNativeImportSchema.shape.verification,
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const pinvokeVerificationContextShape = {
|
const pinvokeVerificationContextShape = {
|
||||||
item_id: prefixedDigestSchema("mnv_pinvoke"),
|
item_id: prefixedDigestSchema("mnv_pinvoke"),
|
||||||
managed: verifiedManagedDeclarationSchema,
|
managed: z.strictObject({
|
||||||
|
token: tokenSchema,
|
||||||
|
member_token: tokenSchema.nullable(),
|
||||||
|
member_name: z.string().nullable(),
|
||||||
|
import_name: z.string().min(1),
|
||||||
|
import_scope_name: z.string().nullable(),
|
||||||
|
no_mangle: z.boolean(),
|
||||||
|
char_set: z.enum(["not-specified", "ansi", "unicode", "auto", "unknown"]),
|
||||||
|
call_convention: z.enum([
|
||||||
|
"not-specified",
|
||||||
|
"winapi",
|
||||||
|
"cdecl",
|
||||||
|
"stdcall",
|
||||||
|
"thiscall",
|
||||||
|
"fastcall",
|
||||||
|
"unknown",
|
||||||
|
]),
|
||||||
|
declaration_verification: z.literal("managed-declaration-only"),
|
||||||
|
}),
|
||||||
evidence_links: z.array(evidenceIdSchema).min(1),
|
evidence_links: z.array(evidenceIdSchema).min(1),
|
||||||
limitations: z.array(textSchema),
|
limitations: z.array(textSchema),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { evidenceSchema, type Evidence } from "../evidence.js";
|
import { evidenceSchema, type Evidence } from "../evidence.js";
|
||||||
import {
|
import {
|
||||||
cliMetadataGuidSchema,
|
cliMetadataGuidSchema,
|
||||||
managedMetadataTokenSchema,
|
|
||||||
managedMemberInspectionSchema,
|
managedMemberInspectionSchema,
|
||||||
type ManagedMemberInspection,
|
type ManagedMemberInspection,
|
||||||
} from "./managedArtifact.js";
|
} from "./managedArtifact.js";
|
||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
import type { JsonValue } from "../jsonValue.js";
|
||||||
import { digestSchema, prefixedDigestSchema } from "../digests.js";
|
import { digestSchema, prefixedDigestSchema } from "../digests.js";
|
||||||
import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js";
|
import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js";
|
||||||
|
|
||||||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||||
|
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
|
||||||
const textSchema = z.string().min(1);
|
const textSchema = z.string().min(1);
|
||||||
|
|
||||||
const decompilerSchema = z.strictObject({
|
const decompilerSchema = z.strictObject({
|
||||||
@@ -25,7 +26,7 @@ const decompilerSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const managedReconstructionMethodInputSchema = z.strictObject({
|
const managedReconstructionMethodInputSchema = z.strictObject({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
signature_sha256: digestSchema,
|
signature_sha256: digestSchema,
|
||||||
normalized_il_sha256: digestSchema.nullable(),
|
normalized_il_sha256: digestSchema.nullable(),
|
||||||
reconstruction: z.strictObject({
|
reconstruction: z.strictObject({
|
||||||
@@ -51,7 +52,7 @@ export const managedReconstructionImportInputSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const importedMethodSchema = z.strictObject({
|
const importedMethodSchema = z.strictObject({
|
||||||
token: managedMetadataTokenSchema,
|
token: tokenSchema,
|
||||||
declaring_type: z.string().nullable(),
|
declaring_type: z.string().nullable(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
signature_sha256: digestSchema,
|
signature_sha256: digestSchema,
|
||||||
@@ -121,6 +122,13 @@ type StaticObservation =
|
|||||||
const sha256Text = (value: string): string =>
|
const sha256Text = (value: string): string =>
|
||||||
createHash("sha256").update(value).digest("hex");
|
createHash("sha256").update(value).digest("hex");
|
||||||
|
|
||||||
|
const sha256Json = (value: JsonValue): string => {
|
||||||
|
const serialized = canonicalize(value);
|
||||||
|
if (serialized === undefined)
|
||||||
|
throw new TypeError("Managed reconstruction canonicalization failed");
|
||||||
|
return createHash("sha256").update(serialized).digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
const countLines = (text: string): number =>
|
const countLines = (text: string): number =>
|
||||||
text.length === 0 ? 0 : text.split(/\r\n|\r|\n/u).length;
|
text.length === 0 ? 0 : text.split(/\r\n|\r|\n/u).length;
|
||||||
|
|
||||||
@@ -219,10 +227,7 @@ const buildDecompilerRecord = (
|
|||||||
decompiler: ManagedReconstructionImportInput["decompiler"],
|
decompiler: ManagedReconstructionImportInput["decompiler"],
|
||||||
): ManagedReconstructionImportResult["decompiler"] => ({
|
): ManagedReconstructionImportResult["decompiler"] => ({
|
||||||
...decompiler,
|
...decompiler,
|
||||||
options_sha256: digestCanonicalValue(
|
options_sha256: sha256Json(decompiler.options),
|
||||||
decompiler.options,
|
|
||||||
"Managed reconstruction",
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const RECONSTRUCTION_LIMITATIONS = [
|
const RECONSTRUCTION_LIMITATIONS = [
|
||||||
@@ -277,9 +282,6 @@ export const importManagedReconstruction = (
|
|||||||
);
|
);
|
||||||
return managedReconstructionImportResultSchema.parse({
|
return managedReconstructionImportResultSchema.parse({
|
||||||
...withoutId,
|
...withoutId,
|
||||||
reconstruction_id: `mre_${digestCanonicalValue(
|
reconstruction_id: `mre_${sha256Json(withoutId)}`,
|
||||||
withoutId,
|
|
||||||
"Managed reconstruction",
|
|
||||||
)}`,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import canonicalize from "canonicalize";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -138,3 +141,11 @@ export const projectedFileComponents = (
|
|||||||
? [component]
|
? [component]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Digest a canonical JSON projection payload for its stable identifier. */
|
||||||
|
export const projectionDigest = (value: unknown, label: string): string => {
|
||||||
|
const encoded = canonicalize(value);
|
||||||
|
if (encoded === undefined)
|
||||||
|
throw new TypeError(`${label} is not canonical JSON`);
|
||||||
|
return createHash("sha256").update(encoded).digest("hex");
|
||||||
|
};
|
||||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user