fix!: repair boundary contracts across CLI and MCP (#597)

* fix(comparisons)!: preserve unknowns across partial observations

Share absence and digest semantics, keep unobserved managed bodies unknown, and match unique managed type/name/signatures before structural fallback.

BREAKING CHANGE: Managed comparison metadata adds exact-signature matching and its count, and name_matching now reports exact-signature-fallback.

* fix(javascript): preserve projected result binding semantics

Share syntax-aware property reads and assigned-result resolution. Retain destructuring paths and downgrade downstream whole-result relationships instead of resolving nested or wrapped calls as aliases.

* fix(native)!: admit complete parser values and preserve typed failures

Decode Mach headers by named columns, validate complete slice keys and lipo alignment, and preserve codesign runtime flags and tagged parser errors. Rename the Ghidra test dossier fixture and connect fake-backed suites to real verification lanes.

BREAKING CHANGE: Native load-command results no longer contain the unused, always-null file_offset field.

* fix(process): preserve host identity and actionable boundary failures

Thread selected platform and environment through process and Doctor seams, stamp observed producer identity in Evidence, and retain PTY, filesystem, lease and lazy-provider failure causes. Drain cancelled lazy loads and omit exited descendants from verified lineage.

* fix(browser): preserve cleanup and document-transition evidence

Make V8 and Playwright teardown cancellation-aware, expose primary failures alongside incomplete cleanup, share frame-tree and navigation rules, and clear stale WebMCP registrations without pruning unrelated child frames.

* fix(adapters): align managed targets and typed CLI outcomes

Inspect managed PE/CLI targets independently of native provider selection, reuse successful managed selections, and keep evidence bound to the actual inspected target. Classify canonical CLI error envelopes and exhaustive setup/update/uninstall outcomes, audit registered CLI coverage, and assert platform-specific runtime results.

* docs: refresh boundary contracts and shipped roadmap behavior
This commit is contained in:
morluto
2026-10-06 00:50:38 +08:00
committed by GitHub
parent f3e6582e9e
commit 9c25dc4ed2
134 changed files with 3547 additions and 812 deletions
@@ -259,10 +259,13 @@ exception/local commitments, and an explicit algorithm version require a new
normalized-CIL schema.
Metadata tokens are reported because they are precise coordinates inside one
module. Cross-version identity never consists of a token alone. A structural
match cites both build commitments, both local tokens, the v1 tuple inputs, and
all competing candidates. V1 cannot cite a
separate matching-algorithm version because none is exposed.
module. Cross-version identity never consists of a token alone. Matching tries
exact CIL/signature identity, then an exact declared-type/name/signature tuple,
then structural shape; a name by itself never selects a pair. Partial or
malformed bodies leave body facets unknown after a signature tuple pairs the
methods. Matches cite both build commitments, both local tokens, and all
competing candidates. V1 cannot cite a separate matching-algorithm version
because none is exposed.
### 5. Separate five evidence layers
+11 -8
View File
@@ -221,15 +221,18 @@ The workflow produces separate observation, inference, validation, and unknown
tables. It may say that a method is a strong candidate for a role; it cannot
turn that role into the method's durable identity.
Cross-version matching is two-stage:
Cross-version matching first prefers exact CIL/signature identity. When that
does not match, it pairs an exact declared type, method name, and raw signature
before trying structural body shape. The exact-signature key uses names only
as part of that full tuple; names alone never select a pair. Duplicate tuples
remain ambiguous rather than being paired by token order.
1. Exact identity requires compatible artifact/module commitments and exact
raw CIL identity; no standalone complete-method-body digest is exposed.
2. Structural identity compares normalized signatures, the limited
decoded-CIL tuple fingerprint, constants, and bounded shape context. It
reports all candidates at the winning score and remains ambiguous when the
evidence does not distinguish them. The digest does not itself remap
metadata tokens.
For a matched method, an unavailable or partial body makes body-shape facets
unknown while preserving observed signature differences. Structural identity
compares normalized signatures, the limited decoded-CIL tuple fingerprint,
constants, and bounded shape context. It reports all candidates at the winning
score and remains ambiguous when the evidence does not distinguish them. The
digest does not itself remap metadata tokens.
Tokens are always remapped through observed structure. A caller cannot carry
`0x06001234` into a new MVID and assume it names the same method.
+2 -2
View File
@@ -545,9 +545,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "dc481fd931e612386c83cea7afe609ff4b1ed57b217f4ed877e5210d7ff3da16",
"tools_sha256": "0238e00bcc841db0a7922f68b0f3acb5920e2bdff754a58a962f5dba811516dc",
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
"combined_sha256": "9a38caecda4d0d5bae02db49909e2ae3b2560b8bcf0fac8885345097d856f931",
"combined_sha256": "08ce24418a9d9490c642cf864f395d2f846b9b47bdc48c7674f0f826224fcc40",
"providers_sha256": "2ae8b85b20be338739e500549d275e78e875aaa10235d2ca283f0ade7787da90"
}
}
+11 -10
View File
@@ -28,21 +28,22 @@ and [browser tracker](https://github.com/morluto/rea/issues/39) track remaining
capabilities and proof. Their scope follows direct tools and agent-composed
experiments; custom replay languages and grant systems are not completion gates.
Current boundary work includes
[inline Evidence composition](https://github.com/morluto/rea/issues/551),
[CLI/MCP managed-tool and seed parity](https://github.com/morluto/rea/issues/553),
[partial-evidence comparison](https://github.com/morluto/rea/issues/558), and
[native PTY failure diagnostics](https://github.com/morluto/rea/issues/568).
Removing replay does not resolve these defects. Real-provider and platform
claims still require their corresponding verification lanes.
[Inline Evidence composition](https://github.com/morluto/rea/pull/585) now returns
the complete record accepted by comparison tools. Managed inspection uses an
explicit PE/CLI target independently of the native provider selection. Comparisons
preserve unknowns when observation is incomplete, and PTY diagnostics retain the
actual loader or startup failure. Browser cleanup reports incomplete teardown
alongside the primary failure; transient documents invalidate only the affected
frame's WebMCP registrations. Real-provider and platform claims still require
their corresponding verification lanes.
## Shipped behavior
REA setup lets you select agent integration and optional Hopper installation.
It installs the bundled workflow, configures detected agents, and can save
verified paths for an existing Ghidra installation. It detects Claude Code,
Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. The first six
can be configured; Devin is reported but left unchanged.
verified paths for an existing Ghidra installation. It configures Claude Code,
Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity,
GitHub Copilot CLI, and VS Code using each client's configuration format.
Ghidra analysis supports Linux x64 and macOS x64/arm64 with Ghidra 12.1.4 and a
64-bit full JDK 21. macOS also requires the matching native decompiler. The
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_4d23d0a47c714a48078502042f6f7f902e8c591375e5f4c06dbc92d0263a65db",
"manifest_id": "ecm_87a5bbeaf15d8c334615ea74f94bb073c603c98d2261bc3355d31d21a8955ed7",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "dcfaef23bdaf8bc6defab4a2f65d419f53f35c968b83f8abc665dce49453df23"
"sha256": "22e5301421d35d11c6dd101e43e743b86956801ad676bc8b168226874efa8bea"
}
],
"claims": [
+28 -1
View File
@@ -312,7 +312,10 @@ try {
});
assert.equal(comparison.ok, true);
const comparisonResult = comparison.value.normalized_result;
assert.equal(comparisonResult.algorithm.name_matching, "not-used");
assert.equal(
comparisonResult.algorithm.name_matching,
"exact-signature-fallback",
);
assert.equal(comparisonResult.matching.exact_il_signature, 1);
assert.equal(
comparisonResult.methods.some(
@@ -324,6 +327,30 @@ try {
true,
);
const unavailableBodyLeft = await fixture("partial-body-left.exe", {
methods: [{ name: "PartialBodyTarget", body: Buffer.from([0xff]) }],
});
const unavailableBodyRight = await fixture("partial-body-right.exe", {
methods: [{ name: "PartialBodyTarget", body: defaultIlBody }],
});
const partialBodyComparison = await compareManagedMemberPaths({
leftPath: unavailableBodyLeft.path,
rightPath: unavailableBodyRight.path,
memberLimits: {
maxFileBytes: 1024 * 1024,
...memberLimits,
},
comparisonLimits,
});
assert.equal(partialBodyComparison.ok, true);
const partialBodyMethod =
partialBodyComparison.value.normalized_result.methods.find(
({ left }) => left?.name === "PartialBodyTarget",
);
assert.equal(partialBodyMethod?.status, "unknown");
assert.equal(partialBodyMethod?.match.basis, "exact-signature");
assert.equal(partialBodyMethod?.dimensions.includes("body-coverage"), true);
const nativeOnly = await fixtureBytes(
"native-only.exe",
buildNativePeFixture(),
+2 -1
View File
@@ -274,7 +274,8 @@ const verifyManagedComparison = async ({
if (
managedComparison.operation !== "compare_managed_members" ||
managedComparison.provider?.id !== "rea-dotnet-workflows" ||
managedComparison.normalized_result?.algorithm?.name_matching !== "not-used"
managedComparison.normalized_result?.algorithm?.name_matching !==
"exact-signature-fallback"
)
throw new Error("packaged managed comparison CLI failed");
};
+1 -1
View File
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
metadata:
version: "23"
tool_count: 116
catalog_digest: "9a38caecda4d0d5bae02db49909e2ae3b2560b8bcf0fac8885345097d856f931"
catalog_digest: "08ce24418a9d9490c642cf864f395d2f846b9b47bdc48c7674f0f826224fcc40"
---
# REA
+28 -1
View File
@@ -87,12 +87,39 @@ export const parseBinaryTarget = async (
await handle.close();
}
} catch (cause: unknown) {
if (!(cause instanceof Error)) throw cause;
const code = filesystemErrorCode(cause);
if (code === undefined) throw cause;
return err(
new BinaryTargetError(candidate, "path is not readable", { cause }),
new BinaryTargetError(
candidate,
targetFailureReason(code, cause.message),
{ cause },
),
);
}
};
const filesystemErrorCode = (cause: Error): string | undefined => {
const code: unknown = Reflect.get(cause, "code");
return typeof code === "string" ? code : undefined;
};
const targetFailureReason = (code: string, detail: string): string => {
switch (code) {
case "ENOENT":
case "ENOTDIR":
return `target does not exist or a path component is not a directory: ${detail}`;
case "EACCES":
case "EPERM":
return `permission denied while reading target: ${detail}`;
case "EISDIR":
return `target is a directory, not a readable file: ${detail}`;
default:
return `target could not be read (${code}): ${detail}`;
}
};
const detectArtifactFormat = async (
path: string,
handle: FileHandle,
+8 -3
View File
@@ -82,12 +82,17 @@ export const createBrowserEvidence = (
"browser" in result
? {
id: `${result.browser.product}@${result.browser.revision}`,
platform: process.platform,
architecture: process.arch,
platform: "unknown",
architecture: "unknown",
isolation: "none",
}
: null,
limitations: result.limitations,
limitations: [
...result.limitations,
...("browser" in result
? ["Browser host platform and architecture were not observed."]
: []),
],
});
const browserParameters = (
+6 -3
View File
@@ -51,12 +51,15 @@ export const createBrowserScenarioEvidence = (
authority: "controlled-replay",
environment: {
id: `${capture.browser.product}@${capture.browser.version}`,
platform: process.platform,
architecture: process.arch,
platform: "unknown",
architecture: "unknown",
isolation:
capture.browser.process_ownership === "provider-owned"
? "process"
: "none",
},
limitations: capture.limitations,
limitations: [
...capture.limitations,
"Browser host platform and architecture were not observed.",
],
});
+18 -1
View File
@@ -57,9 +57,26 @@ const registrationSchema = z
export const readClientRegistrationStatuses = async (
home: string,
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
options: {
readonly platform?: NodeJS.Platform;
readonly environment?: NodeJS.ProcessEnv;
} = {},
): Promise<readonly ClientRegistrationStatus[]> => {
const statuses: ClientRegistrationStatus[] = [];
for (const client of supportedClients(home)) {
for (const client of supportedClients(
home,
options.platform,
options.environment === undefined
? undefined
: {
APPDATA: options.environment.APPDATA,
CLAUDE_CONFIG_DIR: options.environment.CLAUDE_CONFIG_DIR,
CODEX_HOME: options.environment.CODEX_HOME,
COPILOT_HOME: options.environment.COPILOT_HOME,
OPENCODE_CONFIG: options.environment.OPENCODE_CONFIG,
XDG_CONFIG_HOME: options.environment.XDG_CONFIG_HOME,
},
)) {
if (
client.format === "unsupported" ||
(!(await exists(client.markerPath)) && !(await exists(client.configPath)))
+50 -9
View File
@@ -1,13 +1,16 @@
import { parseConfig } from "../config.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { EnhancedTools } from "./EnhancedTools.js";
import { createBinarySession } from "./runtime.js";
import { createBinarySession, createManagedBinarySession } from "./runtime.js";
import { silentLogger, type Logger } from "../logger.js";
import { createEvidence } from "../domain/evidence.js";
import type { Evidence } from "../domain/evidence.js";
import type { NativeToolName } from "../contracts/nativeToolContracts.js";
import type { ArtifactAnalysisOperation } from "../contracts/artifactToolContracts.js";
import type { ManagedToolName } from "../contracts/managedToolContracts.js";
import {
isManagedToolName,
type ManagedToolName,
} from "../contracts/managedToolContracts.js";
import {
EvidenceIntegrityError,
projectAnalysisError,
@@ -27,6 +30,7 @@ import {
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import { err, ok, type Result } from "../domain/result.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import type { AnalysisExecution } from "./AnalysisProvider.js";
import {
REA_WORKFLOW_PROVIDER,
workflowAnalysisProfile,
@@ -93,13 +97,50 @@ export const runProviderAnalysis = async (
signal?: AbortSignal,
]
): Promise<JsonValue> =>
withProcessCancellation(signal, (operationSignal) =>
runAnalysis(path, tool, arguments_, {
logger,
snapshotPath: undefined,
signal: operationSignal,
}),
);
isManagedToolName(tool)
? runManagedProviderAnalysis(path, tool, signal)
: withProcessCancellation(signal, (operationSignal) =>
runAnalysis(path, tool, arguments_, {
logger,
snapshotPath: undefined,
signal: operationSignal,
}),
);
/** Execute managed metadata inspection in an isolated managed-only session. */
export const runManagedProviderExecution = async (
path: string,
tool: ManagedToolName,
signal?: AbortSignal,
): Promise<Result<AnalysisExecution, AnalysisError>> =>
withProcessCancellation(signal, async (operationSignal) => {
const session = createManagedBinarySession();
try {
const opened = await session.open(path, { signal: operationSignal });
if (!opened.ok) return opened;
return await session.execute(tool, {}, { signal: operationSignal });
} finally {
await session.close();
}
});
const runManagedProviderAnalysis = async (
path: string,
tool: ManagedToolName,
signal?: AbortSignal,
): Promise<JsonValue> => {
const execution = await runManagedProviderExecution(path, tool, signal);
if (!execution.ok) return cliError(execution.error);
const value = execution.value;
return createEvidence(value.subject ?? undefined, value.provider, {
operation: tool,
parameters: {},
result: value.result,
rawResult: value.rawResult,
limitations: value.limitations,
locations: value.locations,
});
};
const runAnalysis = async (
path: string,
+43
View File
@@ -0,0 +1,43 @@
import { expect, it } from "vitest";
import { systemDoctorHost } from "./Doctor.js";
it("threads the selected host and environment into system diagnostics", async () => {
const environment = {
HOME: "/selected/home",
HOPPER_LAUNCHER_PATH: "/selected/bin/hopper",
PATH: "/selected/bin",
};
const calls: Array<{
command: string;
arguments: readonly string[];
environment: NodeJS.ProcessEnv | undefined;
}> = [];
const host = systemDoctorHost({
platform: "linux",
architecture: "x64",
environment,
execFileOutput: async (command, arguments_, options) => {
calls.push({ command, arguments: arguments_, environment: options?.env });
if (command === "sw_vers") return { stdout: "15.0\n", stderr: "" };
if (command === "ldd") return { stdout: "linux-vdso.so.1", stderr: "" };
return { stdout: "/selected/bin/rea\n", stderr: "" };
},
});
expect(host.homeDirectory).toBe("/selected/home");
expect(host.platform).toBe("linux");
expect(host.architecture).toBe("x64");
await host.installationPaths?.();
await host.macosVersion();
await host.executable(process.execPath);
expect(calls.map(({ command }) => command)).toEqual([
"which",
"sw_vers",
"ldd",
]);
expect(
calls.every(({ environment: observed }) => observed === environment),
).toBe(true);
});
+22 -3
View File
@@ -33,7 +33,13 @@ describe("doctor", () => {
undefined,
host({ macosVersion: () => Promise.resolve(version) }),
);
expect(result.checks.find(({ name }) => name === "host")?.ok).toBe(false);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "host",
ok: false,
classification: "unsupported_host",
}),
);
},
);
@@ -450,7 +456,14 @@ describe("doctor Linux and Hopper discovery", () => {
Promise.resolve(path === "/Applications/Notes.app"),
}),
);
expect(result.checks.find(({ name }) => name === "target")?.ok).toBe(true);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "target",
ok: true,
classification: "healthy",
detail: "/Applications/Notes.app",
}),
);
});
it("rejects a readable Hopper launcher without execute permission", async () => {
@@ -464,6 +477,12 @@ describe("doctor Linux and Hopper discovery", () => {
}),
);
expect(result.hopperPath).toBeUndefined();
expect(result.checks.find(({ name }) => name === "hopper")?.ok).toBe(false);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "hopper",
ok: false,
classification: "config_drift",
}),
);
});
});
+128 -70
View File
@@ -72,6 +72,7 @@ export interface DoctorHost {
readonly platform: NodeJS.Platform;
readonly architecture: NodeJS.Architecture;
readonly nodeVersion: string;
readonly homeDirectory?: string;
readonly configuredHopperPath?: string;
readonly configuredIlspyCmdPath?: string;
macosVersion(): Promise<string | undefined>;
@@ -277,8 +278,27 @@ const installationState = (
): DoctorIdentity["installations"]["state"] =>
paths.length === 0 ? "unknown" : paths.length === 1 ? "single" : "multiple";
const homeFromEnvironment = (
environment: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
): string => {
const selected =
platform === "win32"
? (environment.USERPROFILE ??
(environment.HOMEDRIVE !== undefined &&
environment.HOMEPATH !== undefined
? `${environment.HOMEDRIVE}${environment.HOMEPATH}`
: environment.HOME))
: (environment.HOME ?? environment.USERPROFILE);
return selected ?? homedir();
};
/** Optional outer-adapter diagnostics composed without reversing dependencies. */
export interface SystemDoctorHostOptions {
readonly platform?: NodeJS.Platform;
readonly architecture?: NodeJS.Architecture;
readonly environment?: NodeJS.ProcessEnv;
readonly execFileOutput?: typeof execFileOutput;
readonly providerInspections?: () => Promise<
readonly DoctorProviderInspection[]
>;
@@ -288,81 +308,117 @@ export interface SystemDoctorHostOptions {
/** Create diagnostics backed by the current process and host commands. */
export const systemDoctorHost = (
options: SystemDoctorHostOptions = {},
): DoctorHost => ({
platform: process.platform,
architecture: process.arch,
nodeVersion: process.versions.node,
...(process.env.HOPPER_LAUNCHER_PATH === undefined
? {}
: { configuredHopperPath: process.env.HOPPER_LAUNCHER_PATH }),
...(process.env.REA_ILSPY_CMD_PATH === undefined
? {}
: { configuredIlspyCmdPath: process.env.REA_ILSPY_CMD_PATH }),
macosVersion: readMacosVersion,
linuxDistribution: readLinuxDistribution,
async validTarget(path) {
return (await parseBinaryTarget(path)).ok;
},
executable: executableAvailable,
linuxDemoRuntimeCheck:
options.linuxDemoRuntimeCheck ?? uncomposedLinuxDemoRuntimeCheck,
supportedLinuxHopper: linuxHopperBinarySupported,
async brewHopperPath() {
return probeHomebrew(async (command) => {
): DoctorHost => {
const platform = options.platform ?? process.platform;
const architecture = options.architecture ?? process.arch;
const environment = options.environment ?? process.env;
const hostExecFileOutput = options.execFileOutput ?? execFileOutput;
const homeDirectory = homeFromEnvironment(environment, platform);
const commandEnvironment = { env: environment };
return {
platform,
architecture,
nodeVersion: process.versions.node,
homeDirectory,
...(environment.HOPPER_LAUNCHER_PATH === undefined
? {}
: { configuredHopperPath: environment.HOPPER_LAUNCHER_PATH }),
...(environment.REA_ILSPY_CMD_PATH === undefined
? {}
: { configuredIlspyCmdPath: environment.REA_ILSPY_CMD_PATH }),
macosVersion: () =>
readMacosVersion(hostExecFileOutput, commandEnvironment),
linuxDistribution: readLinuxDistribution,
async validTarget(path) {
return (await parseBinaryTarget(path, process.cwd(), architecture)).ok;
},
executable: (path) =>
executableAvailable(
path,
platform,
hostExecFileOutput,
commandEnvironment,
),
linuxDemoRuntimeCheck:
options.linuxDemoRuntimeCheck ?? uncomposedLinuxDemoRuntimeCheck,
supportedLinuxHopper: linuxHopperBinarySupported,
async brewHopperPath() {
return probeHomebrew(async (command) => {
try {
const prefix = (
await hostExecFileOutput(
command,
["--prefix", "--cask", "hopper-disassembler"],
commandEnvironment,
)
).stdout.trim();
return `${prefix}/Hopper Disassembler.app/Contents/MacOS/hopper`;
} catch {
return undefined;
}
});
},
manualHopperPaths: () => manualHopperPaths(homeDirectory),
...(options.providerInspections === undefined
? {}
: { providerInspections: options.providerInspections }),
async ilspyCmdVersion(path) {
try {
const prefix = (
await execFileOutput(command, [
"--prefix",
"--cask",
"hopper-disassembler",
])
).stdout.trim();
return `${prefix}/Hopper Disassembler.app/Contents/MacOS/hopper`;
await access(path, constants.X_OK);
return firstIlspyVersionLine(
(
await hostExecFileOutput(path, ["--version"], {
timeout: 10_000,
...commandEnvironment,
})
).stdout,
);
} catch {
return undefined;
}
});
},
manualHopperPaths,
...(options.providerInspections === undefined
? {}
: { providerInspections: options.providerInspections }),
async ilspyCmdVersion(path) {
try {
await access(path, constants.X_OK);
return firstIlspyVersionLine(
(await execFileOutput(path, ["--version"], { timeout: 10_000 })).stdout,
);
} catch {
return undefined;
}
},
async installationPaths() {
try {
const command = process.platform === "win32" ? "where" : "which";
const arguments_ = process.platform === "win32" ? ["rea"] : ["-a", "rea"];
return uniqueLines((await execFileOutput(command, arguments_)).stdout);
} catch {
return [];
}
},
installedSkillIdentity,
clientRegistrations: () => readClientRegistrationStatuses(homedir()),
});
},
async installationPaths() {
try {
const command = platform === "win32" ? "where" : "which";
const arguments_ = platform === "win32" ? ["rea"] : ["-a", "rea"];
return uniqueLines(
(await hostExecFileOutput(command, arguments_, commandEnvironment))
.stdout,
);
} catch {
return [];
}
},
installedSkillIdentity: () => installedSkillIdentity(homeDirectory),
clientRegistrations: () =>
readClientRegistrationStatuses(homeDirectory, undefined, {
platform,
environment,
}),
};
};
const readMacosVersion = async (): Promise<string | undefined> => {
const readMacosVersion = async (
run: typeof execFileOutput = execFileOutput,
options: { readonly env: NodeJS.ProcessEnv } = { env: process.env },
): Promise<string | undefined> => {
try {
return (await execFileOutput("sw_vers", ["-productVersion"])).stdout.trim();
return (await run("sw_vers", ["-productVersion"], options)).stdout.trim();
} catch {
return undefined;
}
};
const executableAvailable = async (path: string): Promise<boolean> => {
const executableAvailable = async (
path: string,
platform: NodeJS.Platform = process.platform,
run: typeof execFileOutput = execFileOutput,
options: { readonly env: NodeJS.ProcessEnv } = { env: process.env },
): Promise<boolean> => {
try {
await access(path, constants.X_OK);
if (process.platform !== "linux") return true;
const linked = await execFileOutput("ldd", [path]);
if (platform !== "linux") return true;
const linked = await run("ldd", [path], options);
return linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`);
} catch {
return false;
@@ -378,9 +434,11 @@ const uncomposedLinuxDemoRuntimeCheck = (): Promise<DoctorCheck> =>
remediation: "Run rea doctor through the production CLI adapter.",
});
const manualHopperPaths = async (): Promise<readonly string[]> => {
const manualHopperPaths = async (
home: string = homedir(),
): Promise<readonly string[]> => {
const paths: string[] = [];
for (const root of ["/Applications", join(homedir(), "Applications")]) {
for (const root of ["/Applications", join(home, "Applications")]) {
try {
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name))
@@ -393,17 +451,17 @@ const manualHopperPaths = async (): Promise<readonly string[]> => {
return paths;
};
const readInstalledSkill = (): Promise<string> =>
const readInstalledSkill = (home: string): Promise<string> =>
readFile(
join(homedir(), ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md"),
join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md"),
"utf8",
);
const installedSkillIdentity = async (): Promise<
InstalledSkillIdentity | undefined
> => {
const installedSkillIdentity = async (
home: string,
): Promise<InstalledSkillIdentity | undefined> => {
try {
const content = await readInstalledSkill();
const content = await readInstalledSkill(home);
const countText = /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
return {
version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null,
+1 -1
View File
@@ -123,7 +123,7 @@ const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
? [
DEFAULT_HOPPER,
SYSTEM_LINUX_HOPPER,
linuxHopperLauncherPath(homedir()),
linuxHopperLauncherPath(host.homeDirectory ?? homedir()),
...(await host.manualHopperPaths()),
]
: [host.configuredHopperPath];
+7 -4
View File
@@ -27,12 +27,15 @@ export const createElectronActiveEvidence = (
confidence: "observed",
authority: "controlled-replay",
environment: {
id: `${result.application.electron_version}@${process.platform}`,
platform: process.platform,
architecture: process.arch,
id: `${result.application.electron_version}@unknown`,
platform: "unknown",
architecture: "unknown",
isolation: "none",
},
limitations: result.limitations,
limitations: [
...result.limitations,
"Application host platform and architecture were not observed.",
],
});
const scenarioProjection = (
+8 -3
View File
@@ -35,12 +35,17 @@ export const createElectronEvidence = (
"target" in result
? {
id: `${result.browser.product}@${result.browser.revision}`,
platform: process.platform,
architecture: process.arch,
platform: "unknown",
architecture: "unknown",
isolation: "none",
}
: null,
limitations: result.limitations,
limitations: [
...result.limitations,
...("target" in result
? ["Browser host platform and architecture were not observed."]
: []),
],
});
const parameters = (
@@ -1,3 +1,5 @@
// Fake-backed composition coverage; real provider verification lives in
// `npm run verify:hopper` and `npm run verify:ghidra`.
import { describe, expect, it } from "vitest";
import { z } from "zod";
@@ -36,11 +36,14 @@ export const createJavaScriptRuntimeObservationEvidence = (
authority: "external-service",
environment: {
id: `${result.runtime.product}@${result.runtime.protocol_version}`,
platform: process.platform,
architecture: process.arch,
platform: "unknown",
architecture: "unknown",
isolation: "none",
},
limitations: result.limitations,
limitations: [
...result.limitations,
"Runtime host platform and architecture were not observed.",
],
});
const parameters = (
@@ -202,6 +202,18 @@ const projectTimerSchedule = (
relation: "schedules-timer",
resolution: "resolved",
});
for (const projected of operation.projectedResultBindings)
addSemanticGraphRelation(context.state, {
source: timerNodes.get(operation.timerId),
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 = (
@@ -91,6 +91,18 @@ const projectSpawn = (
relation: "spawns",
resolution: spawn.resolution === "complete" ? "resolved" : "candidate",
});
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)
addSemanticGraphRelation(context.state, {
source: callSite,
@@ -180,6 +180,18 @@ const projectRequestConstruction = (
relation: "constructs-request",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
});
for (const projected of operation.projectedResultBindings)
addSemanticGraphRelation(context.state, {
source: request,
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)
addSemanticGraphRelation(context.state, {
source:
@@ -96,6 +96,67 @@ it("projects child-process creation, I/O, listeners, and signals", () => {
).toMatchObject({ status: "partial" });
});
it("keeps destructured request, spawn, and timer results as projections", () => {
const graph = graphFor(`
import { spawn } from "node:child_process";
async function run() {
const { data } = await fetch("https://example.test");
const [ child ] = spawn("worker", []);
child.on("exit", done);
const { handle } = setTimeout(done, 5);
clearTimeout(handle);
return data.json();
}
`);
const projections = graph.relations.filter(
({ relation }) => relation === "destructures",
);
expect(
projections.map(({ properties }) => properties.projection_path),
).toEqual(expect.arrayContaining([["data"], [0], ["handle"]]));
expect(
projections.every(
({ properties }) => properties.projection_resolution === "complete",
),
).toBe(true);
expect(
graph.relations.find(
({ relation, properties }) =>
relation === "consumed-by" && properties.method === "json",
),
).toMatchObject({ resolution: "candidate" });
expect(
graph.relations.find(({ relation }) => relation === "listens-exit"),
).toMatchObject({ resolution: "candidate" });
expect(
graph.relations.find(({ relation }) => relation === "cancels-timer"),
).toMatchObject({ resolution: "candidate" });
expect(
graph.relations.some(
({ relation, resolution }) =>
["consumed-by", "listens-exit", "cancels-timer"].includes(relation) &&
resolution === "resolved",
),
).toBe(false);
});
it("does not alias nested call arguments to an outer assigned result", () => {
const graph = graphFor(`
import { spawn } from "node:child_process";
function run() {
const child = wrap(spawn("worker", []));
child.on("exit", done);
const handle = String(setTimeout(done, 5));
clearTimeout(handle);
}
`);
expect(
graph.relations.some(({ relation }) =>
["listens-exit", "cancels-timer", "destructures"].includes(relation),
),
).toBe(false);
});
it("projects config precedence, request fields, and boundaries", () => {
const graph = graphFor(`
import { readFileSync } from "node:fs";
@@ -0,0 +1,105 @@
import { expect, it } from "vitest";
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
import type { AnalysisProvider, ProviderIdentity } from "./AnalysisProvider.js";
import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js";
import { AnalysisCancelledError } from "../domain/errors.js";
import { err } from "../domain/result.js";
const identity: ProviderIdentity = {
id: "test-provider",
name: "Test provider",
version: "1",
};
const providerWithClient = (
onCreate: () => void = () => undefined,
): AnalysisProvider => ({
identity: () => identity,
capabilities: () => [],
createClient: () => {
onCreate();
return {
execute: async (operation) => err(new AnalysisCancelledError(operation)),
close: async () => undefined,
};
},
});
const target = async () => {
const parsed = await parseBinaryTarget(process.execPath);
if (!parsed.ok) throw parsed.error;
return parsed.value;
};
it("returns a tagged provider error when lazy loading fails", async () => {
const provider = new LazyAnalysisProvider({
identity,
capabilities: [],
load: async () => {
throw new Error("provider module unavailable");
},
});
const result = await provider
.createClient(await target())
.execute("health", {});
expect(result).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
providerId: "test-provider",
operation: "health",
},
});
});
it("returns cancellation while an unabortable lazy load continues safely", async () => {
let finishLoading: ((provider: AnalysisProvider) => void) | undefined;
let markCreated: (() => void) | undefined;
const created = new Promise<void>((resolve) => {
markCreated = resolve;
});
const provider = new LazyAnalysisProvider({
identity,
capabilities: [],
load: () =>
new Promise<AnalysisProvider>((resolve) => {
finishLoading = resolve;
}),
});
const controller = new AbortController();
const client = provider.createClient(await target());
const pending = client.execute("health", {}, { signal: controller.signal });
await Promise.resolve();
controller.abort();
expect(await pending).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", operation: "health" },
});
finishLoading?.(providerWithClient(() => markCreated?.()));
await created;
await client.close();
});
it("reports that a lazy client was closed before its first operation", async () => {
const provider = new LazyAnalysisProvider({
identity,
capabilities: [],
load: async () => providerWithClient(),
});
const client = provider.createClient(await target());
await client.close();
expect(await client.execute("health", {})).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
operation: "health",
diagnostics: { reason: "client_closed" },
},
});
});
+36 -6
View File
@@ -1,5 +1,12 @@
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import {
AnalysisCancelledError,
AnalysisError,
ProviderAdapterError,
} from "../domain/errors.js";
import { err } from "../domain/result.js";
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
import type {
AnalysisClient,
AnalysisClientContext,
@@ -12,6 +19,9 @@ import type {
type LoadAnalysisProvider = () => Promise<AnalysisProvider>;
const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
/**
* Preserve synchronous provider discovery while loading implementation modules
* only when a target-bound client first executes.
@@ -51,7 +61,7 @@ export class LazyAnalysisProvider implements AnalysisProvider {
return new LazyAnalysisClient(async () => {
const provider = await this.#load();
return provider.createClient(target, profile, context);
});
}, this.#providerIdentity.id);
}
async #load(): Promise<AnalysisProvider> {
@@ -65,12 +75,14 @@ export class LazyAnalysisProvider implements AnalysisProvider {
class LazyAnalysisClient implements AnalysisClient {
readonly #loadClient: () => Promise<AnalysisClient>;
readonly #providerId: string;
#client: AnalysisClient | undefined;
#loading: Promise<AnalysisClient> | undefined;
#closed = false;
constructor(loadClient: () => Promise<AnalysisClient>) {
constructor(loadClient: () => Promise<AnalysisClient>, providerId: string) {
this.#loadClient = loadClient;
this.#providerId = providerId;
}
execute: AnalysisClient["execute"] = async (
@@ -78,8 +90,23 @@ class LazyAnalysisClient implements AnalysisClient {
parameters,
options,
) => {
const client = await this.#load();
return client.execute(operation, parameters, options);
try {
if (isAborted(options?.signal))
return err(new AnalysisCancelledError(operation));
const loaded = await waitForAbortable(
this.#load(operation),
options?.signal,
);
if (loaded === ABORTED) return err(new AnalysisCancelledError(operation));
return await loaded.execute(operation, parameters, options);
} catch (cause: unknown) {
if (cause instanceof AnalysisError) return err(cause);
return err(
new ProviderAdapterError(this.#providerId, operation, {
cause,
}),
);
}
};
runtimeLineageSnapshots(): readonly ProviderRuntimeLineageSnapshot[] {
@@ -97,8 +124,11 @@ class LazyAnalysisClient implements AnalysisClient {
await client.close();
}
async #load(): Promise<AnalysisClient> {
if (this.#closed) throw new Error("Lazy analysis client is closed");
async #load(operation: string): Promise<AnalysisClient> {
if (this.#closed)
throw new ProviderAdapterError(this.#providerId, operation, {
diagnostics: { reason: "client_closed" },
});
this.#loading ??= this.#loadClient()
.then((client) => {
this.#client = client;
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { functionDossierSchema } from "../domain/hopperValues.js";
import { ghidraFunctionDossier } from "../domain/hopperValues.fixture.js";
import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
import { nativeApiBoundarySchema } from "../domain/nativeApiBoundary.js";
import { projectNativeApiInspection } from "./NativeApiInspection.js";
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { readNativeCallRoutes } from "./NativeCallRoutes.js";
import { createAnalysisExecution } from "./AnalysisProvider.js";
import { ghidraReferenceEdge } from "../domain/hopperValues.fixture.js";
import { ghidraReferenceEdge } from "../domain/ghidraValues.fixture.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
import { AnalysisCancelledError } from "../domain/errors.js";
+1 -1
View File
@@ -4,7 +4,7 @@ import {
createAnalysisExecution,
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import { ghidraFunctionDossier } from "../domain/hopperValues.fixture.js";
import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { AnalysisCancelledError } from "../domain/errors.js";
+65 -35
View File
@@ -16,40 +16,70 @@ export const processCaptureOwnershipUnavailableReason = (
? "Windows process-tree ownership and cleanup are unavailable; REA cannot verify that descendants have stopped."
: undefined;
/** Dependencies that make the native PTY probe's host boundary testable. */
export interface ProcessCaptureProbeOptions {
readonly platform?: NodeJS.Platform;
readonly loadPty?: () => Promise<typeof import("@lydell/node-pty")>;
}
/** Probe the native PTY seam and explain loader failures at their source. */
export const processCaptureProbeFailureReason = (cause: unknown): string => {
const error = cause instanceof Error ? cause : new Error(String(cause));
const code = "code" in error ? String(error.code) : "";
const diagnostic = error.message;
if (
code === "ERR_MODULE_NOT_FOUND" ||
code === "MODULE_NOT_FOUND" ||
/cannot find module|cannot find package/iu.test(diagnostic)
)
return `the native PTY module is missing (${diagnostic}); reinstall REA for this host with optional dependencies enabled (for a source checkout, run npm ci)`;
if (
/NODE_MODULE_VERSION|compiled against a different Node\.js version|module version mismatch/iu.test(
diagnostic,
)
)
return `the native PTY module is incompatible with this Node.js runtime (${diagnostic}); reinstall REA for this host and active Node.js version with optional dependencies enabled (for a source checkout, run npm ci after selecting that Node.js version)`;
if (code === "ERR_DLOPEN_FAILED")
return `the native PTY library failed to load (${diagnostic}); check the loader error, then reinstall REA for this host with optional dependencies enabled (for a source checkout, run npm ci)`;
return `the native PTY probe failed (${diagnostic}); check the reported operating-system or process-startup error and reinstall REA for this host with optional dependencies enabled if the native binary is missing`;
};
/** Probe the actual native PTY seam instead of inferring support from the OS name. */
export const probeProcessCaptureCapability =
async (): Promise<ProcessCaptureCapability> => {
const ownershipReason = processCaptureOwnershipUnavailableReason(
process.platform,
export const probeProcessCaptureCapability = async (
options: ProcessCaptureProbeOptions = {},
): Promise<ProcessCaptureCapability> => {
const platform = options.platform ?? process.platform;
const ownershipReason = processCaptureOwnershipUnavailableReason(platform);
if (ownershipReason !== undefined)
return {
available: false,
backend: "node-pty",
reason: ownershipReason,
};
try {
const { spawn } = await (
options.loadPty ?? (() => import("@lydell/node-pty"))
)();
const terminal = spawn(
platform === "win32" ? "cmd.exe" : "/bin/sh",
platform === "win32" ? ["/c", "exit", "0"] : ["-c", "exit 0"],
{
cwd: tmpdir(),
env: { HOME: tmpdir(), TERM: "xterm-256color" },
cols: 80,
rows: 24,
name: "xterm-256color",
},
);
if (ownershipReason !== undefined)
return {
available: false,
backend: "node-pty",
reason: ownershipReason,
};
try {
const { spawn } = await import("@lydell/node-pty");
const terminal = spawn(
process.platform === "win32" ? "cmd.exe" : "/bin/sh",
process.platform === "win32" ? ["/c", "exit", "0"] : ["-c", "exit 0"],
{
cwd: tmpdir(),
env: { HOME: tmpdir(), TERM: "xterm-256color" },
cols: 80,
rows: 24,
name: "xterm-256color",
},
);
await new Promise<void>((resolveExit) =>
terminal.onExit(() => resolveExit()),
);
return { available: true, backend: "node-pty" };
} catch {
return {
available: false,
backend: "node-pty",
reason: "the native PTY backend could not start a probe process",
};
}
};
await new Promise<void>((resolveExit) =>
terminal.onExit(() => resolveExit()),
);
return { available: true, backend: "node-pty" };
} catch (cause: unknown) {
return {
available: false,
backend: "node-pty",
reason: processCaptureProbeFailureReason(cause),
};
}
};
+11 -3
View File
@@ -202,6 +202,13 @@ export const createRunManifest = async (
scenario: ProcessScenario,
startedAt: Date,
completedAt: Date,
host: {
readonly platform: NodeJS.Platform;
readonly architecture: NodeJS.Architecture;
} = {
platform: process.platform,
architecture: process.arch,
},
): Promise<UnverifiedProcessCapture["manifest"]> => {
const executableSha256 = await hashFile(scenario.executable);
const scenarioCommitment = processScenarioCommitment(
@@ -212,8 +219,8 @@ export const createRunManifest = async (
return {
rea_version: PRODUCT_IDENTITY.packageVersion,
provider_version: PROCESS_PROVIDER.version,
platform: process.platform,
architecture: process.arch,
platform: host.platform,
architecture: host.architecture,
pty_backend: "node-pty",
started_at: startedAt.toISOString(),
completed_at: completedAt.toISOString(),
@@ -231,8 +238,9 @@ export const observeSettlement = async (
processGroupIds: readonly number[],
settleMs: number,
recordEvent: RecordProcessCaptureEvent = () => undefined,
platform: NodeJS.Platform = process.platform,
): Promise<ObservedProcessSettlement> => {
if (process.platform === "win32") {
if (platform === "win32") {
recordEvent("lifecycle", 1);
return { state: "unverifiable", elapsed_ms: 0 };
}
+13
View File
@@ -27,6 +27,19 @@ export interface ProcessCliErrorOutput {
readonly message: string;
}
/** Identify the process workflow's typed diagnostic result for CLI exit status. */
export const isProcessCliFailure = (
value: unknown,
): value is ProcessCliErrorOutput =>
typeof value === "object" &&
value !== null &&
"error" in value &&
value.error === "Process command failed" &&
"category" in value &&
typeof value.category === "string" &&
"message" in value &&
typeof value.message === "string";
/** Capture one JSON scenario through the shared process harness and Evidence contract. */
export const captureProcessScenarioFile = async (
path: string,
@@ -0,0 +1,46 @@
import { expect, it } from "vitest";
import { emptyUnverifiedProcessCapture } from "../domain/processCapture.fixture.js";
import { parseProcessCapture } from "../domain/processCapture.js";
import { parseProcessScenario } from "../domain/processScenario.js";
import { createProcessCaptureEvidence } from "./ProcessEvidence.js";
import { observeSettlement } from "./ProcessCaptureLifecycle.js";
it("stamps process Evidence from the capture manifest host", () => {
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: process.cwd(),
events: [],
});
const base = emptyUnverifiedProcessCapture();
const capture = parseProcessCapture({
...base,
manifest: {
...base.manifest,
platform: "linux",
architecture: "arm64",
},
});
expect(
createProcessCaptureEvidence(scenario, capture).environment,
).toMatchObject({
id: "linux-arm64",
platform: "linux",
architecture: "arm64",
});
});
it("uses the selected host platform for process settlement behavior", async () => {
const events: string[] = [];
const result = await observeSettlement(
"run",
[],
0,
(event) => events.push(event),
"win32",
);
expect(result).toEqual({ state: "unverifiable", elapsed_ms: 0 });
expect(events).toEqual(["lifecycle"]);
});
+3 -3
View File
@@ -38,9 +38,9 @@ export const createProcessCaptureEvidence = (
confidence: "observed",
authority: "controlled-replay",
environment: {
id: `${process.platform}-${process.arch}`,
platform: process.platform,
architecture: process.arch,
id: `${capture.manifest.platform}-${capture.manifest.architecture}`,
platform: capture.manifest.platform,
architecture: capture.manifest.architecture,
isolation: "process",
},
limitations: capture.limitations,
+8 -1
View File
@@ -237,6 +237,7 @@ const finishProcessRun = async (options: {
const completeCapture = async (options: {
readonly scenario: ProcessScenario;
readonly hostPlatform: NodeJS.Platform;
readonly runtime: StartedCaptureRuntime;
readonly runId: string;
readonly temporaryRoot: string;
@@ -258,6 +259,7 @@ const completeCapture = async (options: {
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
scenario.settle_ms,
options.recordEvent,
options.hostPlatform,
);
const samplingPartial = (await runtime.stopSampler()).partial;
await settleProcessCaptureJournal(options.eventJournal);
@@ -285,6 +287,7 @@ const completeCapture = async (options: {
scenario,
runtime.startedAt,
new Date(),
{ platform: options.hostPlatform, architecture: process.arch },
);
const truncated =
options.initiallyTruncated ||
@@ -317,6 +320,7 @@ const runProcessScenario = async (
scenario: ProcessScenario,
signal?: AbortSignal,
hostEnvironment: Readonly<Record<string, string | undefined>> = process.env,
hostPlatform: NodeJS.Platform = process.platform,
): Promise<ProcessCapture> => {
const { temporaryRoot, runId, before } = await prepareProcessCapture(
scenario,
@@ -363,6 +367,7 @@ const runProcessScenario = async (
});
capture = await completeCapture({
scenario,
hostPlatform,
runtime,
runId,
temporaryRoot,
@@ -414,7 +419,9 @@ export const captureProcessScenario = async (
scenario,
environment,
);
return ok(await runProcessScenario(resolvedScenario, signal, environment));
return ok(
await runProcessScenario(resolvedScenario, signal, environment, platform),
);
} catch (cause: unknown) {
return err(
cause instanceof ProcessCaptureError
+23 -2
View File
@@ -131,6 +131,26 @@ export interface SetupResult {
readonly code?: SetupFailureCode;
}
/** Whether the requested setup actions still need approval or human repair. */
export const isSetupFailure = (result: SetupResult): boolean => {
const status = result.status;
switch (status) {
case "planned":
case "cancelled":
case "ready":
return false;
case "needs_confirmation":
case "needs_human":
return true;
default: {
const exhaustive: never = status;
throw new TypeError(
`Unhandled setup result status: ${String(exhaustive)}`,
);
}
}
};
/** Relevant health and identity evidence for the selected setup scope. */
export interface SetupDoctorSummary {
readonly healthy: boolean;
@@ -683,8 +703,9 @@ const hostRemediation = async (
export const systemSetupHost = (
doctorHost: DoctorHost = systemDoctorHost(),
): SetupHost => {
const platform = doctorHost.platform;
return {
platform: process.platform,
platform,
nodeVersion: process.versions.node,
macosVersion: () => doctorHost.macosVersion(),
linuxDistribution: readLinuxDistribution,
@@ -700,7 +721,7 @@ export const systemSetupHost = (
},
installHopper: async (replaceExisting) => {
const result =
process.platform === "linux"
platform === "linux"
? await installLinuxHopper()
: await installMacHopper({ replaceExisting });
if (result.status === "installed") return result;
+17
View File
@@ -54,6 +54,23 @@ export interface UninstallResult {
readonly items: readonly UninstallItem[];
}
/** Whether any requested REA-owned uninstall action failed. */
export const isUninstallFailure = (result: UninstallResult): boolean => {
const status = result.status;
switch (status) {
case "complete":
return false;
case "failed":
return true;
default: {
const exhaustive: never = status;
throw new TypeError(
`Unhandled uninstall result status: ${String(exhaustive)}`,
);
}
}
};
/** Filesystem boundary used by the contained uninstall workflow. */
export interface UninstallHost {
clients(): Promise<readonly SetupClient[]>;
+18
View File
@@ -63,6 +63,24 @@ export type UpdateResult =
readonly remediation: string;
};
/** Whether the update command left the requested release unapplied. */
export const isUpdateFailure = (result: UpdateResult): boolean => {
const status = result.status;
switch (status) {
case "current":
case "updated":
return false;
case "failed":
return true;
default: {
const exhaustive: never = status;
throw new TypeError(
`Unhandled update result status: ${String(exhaustive)}`,
);
}
}
};
/** Resolve the exact npm command for the identified prefix and release. */
export const updateInstallCommand = (
installation: NpmInstallation,
+10
View File
@@ -8,6 +8,8 @@ import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { composeBinarySession } from "./BinarySessionComposition.js";
import { nativeHostCapabilities } from "../native/NativeHostCapabilities.js";
import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js";
import { ManagedStaticProvider } from "../dotnet/ManagedStaticProvider.js";
import { SessionProviderRouter } from "./SessionProviderRouter.js";
/**
* Compose the target-switching runtime shared directly by CLI and MCP adapters.
@@ -63,3 +65,11 @@ export const createBinarySession = (
],
);
};
/** Compose an execution-free managed session without native provider selection. */
export const createManagedBinarySession = (): BinarySession =>
composeBinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), [
new ManagedStaticProvider(),
]),
);
+10 -2
View File
@@ -86,11 +86,13 @@ export class MachOSliceArtifactReader implements ArtifactReader {
new ArtifactReaderFailure("cancelled", "Mach-O slice read cancelled"),
);
const [offsetText, sizeText] = entry.adapterKey.split(":");
const offset = Number(offsetText);
const size = Number(sizeText);
const offset = parseSliceKeyInteger(offsetText);
const size = parseSliceKeyInteger(sizeText);
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
offset === null ||
size === null ||
offset < 0 ||
size <= 0
)
@@ -154,3 +156,9 @@ export class MachOSliceArtifactReader implements ArtifactReader {
return Promise.resolve();
}
}
const parseSliceKeyInteger = (value: string | undefined): number | null => {
if (value === undefined || !/^\d+$/u.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
};
+28 -8
View File
@@ -28,10 +28,15 @@ export const captureFrames = (
): {
readonly items: WebPageInspection["frames"];
} => {
const root = recordValue(requiredRecord(result).frameTree);
if (root === undefined) return { items: [] };
const root = recordValue(recordValue(result)?.frameTree);
if (root === undefined) {
completeness?.exclude("frames", "invalid_protocol_value");
return { items: [] };
}
const items: WebPageInspection["frames"] = [];
for (const tree of walkFrameTrees(root)) {
for (const tree of walkFrameTrees(root, () =>
completeness?.exclude("frames", "invalid_protocol_value"),
)) {
const frame = recordValue(tree.frame);
const frameId = stringValue(frame?.id);
const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins);
@@ -71,10 +76,15 @@ export const captureResources = (
): {
readonly items: readonly CapturedResource[];
} => {
const root = recordValue(requiredRecord(result).frameTree);
if (root === undefined) return { items: [] };
const root = recordValue(recordValue(result)?.frameTree);
if (root === undefined) {
completeness?.exclude("resources", "invalid_protocol_value");
return { items: [] };
}
const items: CapturedResource[] = [];
for (const tree of walkFrameTrees(root)) {
for (const tree of walkFrameTrees(root, () =>
completeness?.exclude("resources", "invalid_protocol_value"),
)) {
for (const resource of recordsValue(tree.resources)) {
const url = allowedSanitizedUrl(resource.url, allowedOrigins);
if (url === undefined || url.origin === null) {
@@ -99,14 +109,24 @@ export const captureResources = (
return { items };
};
const walkFrameTrees = function* (
export const walkFrameTrees = function* (
root: UnknownRecord,
onMalformedChild?: () => void,
): Generator<UnknownRecord> {
const pending = [root];
while (pending.length > 0) {
const tree = pending.pop();
if (tree === undefined) return;
const children = recordsValue(tree.childFrames);
const rawChildren = tree.childFrames;
if (rawChildren !== undefined && !Array.isArray(rawChildren))
onMalformedChild?.();
const children = Array.isArray(rawChildren)
? rawChildren.flatMap((child) => {
const parsed = recordValue(child);
if (parsed === undefined) onMalformedChild?.();
return parsed === undefined ? [] : [parsed];
})
: [];
for (let index = children.length - 1; index >= 0; index -= 1) {
const child = children[index];
if (child !== undefined) pending.push(child);
+9 -1
View File
@@ -19,6 +19,7 @@ import {
firstCallFrame,
initiatorLocation,
integerOrNull,
isMainFrameNavigation,
} from "./CdpCaptureEventHelpers.js";
import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js";
import type { CapturedScript } from "./CdpCaptureEventTypes.js";
@@ -365,7 +366,14 @@ export const handleFrameNavigated = (
params: UnknownRecord,
): void => {
const frame = recordValue(params.frame);
if (stringValue(frame?.id) !== state.mainFrameId) return;
if (state.mainFrameId === undefined) return;
if (
!isMainFrameNavigation(
{ method: "Page.frameNavigated", params },
state.mainFrameId,
)
)
return;
const rawUrl = stringValue(frame?.url);
const loaderId = stringValue(frame?.loaderId);
// `frameNavigated` re-fires for a document already reported (same URL and
+18
View File
@@ -6,6 +6,24 @@ import {
stringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import type { CdpEvent } from "./CdpConnection.js";
/** Identify a top-level navigation event from its untrusted CDP envelope. */
export const isMainFrameNavigation = (
event: CdpEvent,
mainFrameId: string,
): boolean => {
const params = recordValue(event.params);
if (params === undefined) return false;
const frameId =
event.method === "Page.frameNavigated"
? stringValue(recordValue(params.frame)?.id)
: event.method === "Page.frameRequestedNavigation" ||
event.method === "Page.navigatedWithinDocument"
? stringValue(params.frameId)
: undefined;
return frameId === mainFrameId;
};
export const firstCallFrame = (
value: UnknownRecord | undefined,
+6 -12
View File
@@ -15,8 +15,8 @@ import {
recordsValue,
requiredRecord,
stringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import { walkFrameTrees } from "./CdpCaptureDocuments.js";
import {
ingestElectronScriptEvent,
type ElectronScriptDraft,
@@ -233,15 +233,11 @@ const captureFrames = async (
const frameTree = recordValue(requiredRecord(result).frameTree);
if (frameTree === undefined)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
const pending: Array<{
readonly tree: UnknownRecord;
readonly parent: string | null;
}> = [{ tree: frameTree, parent: null }];
const frames: ElectronPageInspection["frames"] = [];
while (pending.length > 0) {
const current = pending.shift();
if (current === undefined) break;
const frame = recordValue(current.tree.frame);
for (const tree of walkFrameTrees(frameTree, () =>
completeness.exclude("frames", "invalid_protocol_value"),
)) {
const frame = recordValue(tree.frame);
const frameId = stringValue(frame?.id);
const path = await authorizedElectronFile(stringValue(frame?.url) ?? "");
if (frameId === undefined || path === undefined) {
@@ -250,11 +246,9 @@ const captureFrames = async (
}
frames.push({
frame_id: frameId,
parent_frame_id: current.parent,
parent_frame_id: stringValue(frame?.parentId) ?? null,
file_path: path,
});
for (const child of recordsValue(current.tree.childFrames))
pending.push({ tree: child, parent: frameId });
}
return frames;
};
+10 -9
View File
@@ -20,6 +20,7 @@ import {
type UnknownRecord,
} from "./CdpCaptureValues.js";
import { mainFrameUrl } from "./CdpCaptureDocuments.js";
import { isMainFrameNavigation } from "./CdpCaptureEventHelpers.js";
interface ObservationContext {
readonly connection: CdpConnection;
@@ -158,13 +159,13 @@ class TimelineCapture {
if (params === undefined) return;
switch (event.method) {
case "Page.frameRequestedNavigation":
this.#navigationRequested(params);
this.#navigationRequested(event, params);
break;
case "Page.frameNavigated":
this.#navigationCommitted(params);
this.#navigationCommitted(event, params);
break;
case "Page.navigatedWithinDocument":
this.#sameDocument(params);
this.#sameDocument(event, params);
break;
case "Network.requestWillBeSent":
this.#redirect(params);
@@ -212,8 +213,8 @@ class TimelineCapture {
this.finalUrl = destination.scope === "approved" ? destination.url : null;
}
#navigationRequested(params: UnknownRecord): void {
if (stringValue(params.frameId) !== this.mainFrameId) return;
#navigationRequested(event: CdpEvent, params: UnknownRecord): void {
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
const reason = safeDetail(params.reason);
this.#pendingReload = reason === "reload";
this.#add({
@@ -225,9 +226,9 @@ class TimelineCapture {
});
}
#navigationCommitted(params: UnknownRecord): void {
#navigationCommitted(event: CdpEvent, params: UnknownRecord): void {
const frame = recordValue(params.frame);
if (stringValue(frame?.id) !== this.mainFrameId) return;
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
const rawUrl = stringValue(frame?.url);
const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.#add({
@@ -246,8 +247,8 @@ class TimelineCapture {
}
}
#sameDocument(params: UnknownRecord): void {
if (stringValue(params.frameId) !== this.mainFrameId) return;
#sameDocument(event: CdpEvent, params: UnknownRecord): void {
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
const rawUrl = stringValue(params.url);
const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.#add({
+2 -6
View File
@@ -5,6 +5,7 @@ import type {
} from "../domain/webScreenshot.js";
import { createWebScreenshotArtifact } from "../domain/webScreenshot.js";
import { BrowserObservationError } from "../domain/errors.js";
import { isMainFrameNavigation } from "./CdpCaptureEventHelpers.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
import {
@@ -126,16 +127,11 @@ const observeScreenshotNavigation = (
origins: ReadonlySet<string>,
state: { changed: boolean; leftScope: boolean },
): void => {
if (!isMainFrameNavigation(event, mainFrameId)) return;
const params = recordValue(event.params);
if (params === undefined) return;
const frame =
event.method === "Page.frameNavigated" ? recordValue(params.frame) : params;
if (
(event.method !== "Page.frameNavigated" &&
event.method !== "Page.navigatedWithinDocument") ||
(stringValue(frame?.id) ?? stringValue(frame?.frameId)) !== mainFrameId
)
return;
state.changed = true;
if (allowedSanitizedUrl(frame?.url, origins) === undefined)
state.leftScope = true;
+135 -20
View File
@@ -19,7 +19,11 @@ import {
stringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import { captureFrames, mainFrameUrl } from "./CdpCaptureDocuments.js";
import {
captureFrames,
mainFrameUrl,
walkFrameTrees,
} from "./CdpCaptureDocuments.js";
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
interface DiscoveryContext {
@@ -63,18 +67,16 @@ export const discoverWebMcp = async (
undefined,
completeness,
).items;
const frameUrls = new Map(
frames.map((frame) => [
frame.frame_id,
{
url: frame.url,
origin: frame.origin,
parentFrameId: frame.parent_frame_id,
},
]),
);
const frameUrls = initialFrameScope(frameTree, frames, completeness);
const tools = new Map<string, WebMcpDiscovery["tools"]["items"][number]>();
const frameScope = { origins, frames: frameUrls, tools, completeness };
const frameScope = {
origins,
frames: frameUrls,
tools,
completeness,
transientFrames: new Set<string>(),
resolvedTransientFrames: new Set<string>(),
};
const removeListener = context.connection.onEvent((event) => {
if (event.sessionId !== context.sessionId) return;
if (ingestFrameScopeEvent(event, frameScope)) return;
@@ -84,6 +86,8 @@ export const discoverWebMcp = async (
frames: frameUrls,
tools,
completeness,
transientFrames: frameScope.transientFrames,
resolvedTransientFrames: frameScope.resolvedTransientFrames,
});
});
await context.progress?.report({
@@ -110,6 +114,8 @@ export const discoverWebMcp = async (
context.signal,
);
await assertStableAuthorizedFrame(context, initialUrl, origins);
if (frameScope.transientFrames.size > 0)
completeness.attachLimited("webmcp_tools");
return buildWebMcpResult({
context,
frameTree,
@@ -135,14 +141,46 @@ interface FrameScopeState {
readonly frames: Map<string, FrameScopeFrame>;
readonly tools: Map<string, WebMcpDiscovery["tools"]["items"][number]>;
readonly completeness: CdpCaptureCompleteness;
readonly transientFrames: Set<string>;
readonly resolvedTransientFrames: Set<string>;
}
interface FrameScopeFrame {
readonly url: string;
readonly origin: string | null;
readonly parentFrameId: string | null;
readonly loaderId?: string | undefined;
}
const initialFrameScope = (
frameTree: unknown,
frames: ReturnType<typeof captureFrames>["items"],
completeness: CdpCaptureCompleteness,
): Map<string, FrameScopeFrame> => {
const scoped = new Map<string, FrameScopeFrame>(
frames.map((frame) => [
frame.frame_id,
{
url: frame.url,
origin: frame.origin,
parentFrameId: frame.parent_frame_id,
},
]),
);
const root = recordValue(recordValue(frameTree)?.frameTree);
if (root === undefined) return scoped;
for (const tree of walkFrameTrees(root, () =>
completeness.exclude("webmcp_tools", "invalid_protocol_value"),
)) {
const frame = recordValue(tree.frame);
const frameId = stringValue(frame?.id);
const prior = frameId === undefined ? undefined : scoped.get(frameId);
if (frameId !== undefined && prior !== undefined)
scoped.set(frameId, { ...prior, loaderId: stringValue(frame?.loaderId) });
}
return scoped;
};
const ingestFrameScopeEvent = (
event: CdpEvent,
state: FrameScopeState,
@@ -153,9 +191,9 @@ const ingestFrameScopeEvent = (
case "Page.frameDetached":
return handleFrameDetached(params, state);
case "Page.frameNavigated":
return handleFrameUpdate(recordValue(params.frame), state);
return handleFrameUpdate(recordValue(params.frame), state, true);
case "Page.navigatedWithinDocument":
return handleFrameUpdate(params, state);
return handleFrameUpdate(params, state, false);
default:
return false;
}
@@ -171,36 +209,97 @@ const handleFrameDetached = (
return true;
}
removeFrame(frameId, state.frames, state.tools);
state.transientFrames.delete(frameId);
state.resolvedTransientFrames.delete(frameId);
return true;
};
const handleFrameUpdate = (
frame: UnknownRecord | undefined,
state: FrameScopeState,
documentCommitted: boolean,
): boolean => {
if (frame === undefined) {
state.completeness.exclude("webmcp_tools", "invalid_protocol_value");
return true;
}
const frameId = stringValue(frame?.id) ?? stringValue(frame?.frameId);
if (frameId === undefined) {
state.completeness.exclude("webmcp_tools", "invalid_protocol_value");
return true;
}
const url = allowedSanitizedUrl(frame?.url, state.origins);
if (url === undefined || url.origin === null) {
removeFrame(frameId, state.frames, state.tools);
state.completeness.exclude("webmcp_tools", "out_of_target_scope");
const rawUrl = stringValue(frame?.url);
// Chromium can briefly commit an inherited, empty document while replacing
// a subframe. That intermediate URL says nothing about the frame's eventual
// scope or its registered tools; wait for the next committed URL (or the
// authoritative frame-tree re-probe at the end of discovery).
if (isTransientFrameUrl(rawUrl)) {
if (documentCommitted) {
removeFrameTools(frameId, state.tools);
state.transientFrames.add(frameId);
state.resolvedTransientFrames.delete(frameId);
}
return true;
}
updateFrameScope(frame, frameId, state, documentCommitted);
return true;
};
const updateFrameScope = (
frame: UnknownRecord,
frameId: string,
state: FrameScopeState,
documentCommitted: boolean,
): void => {
const url = allowedSanitizedUrl(frame?.url, state.origins);
if (url === undefined || url.origin === null) {
removeFrameTools(frameId, state.tools);
state.frames.delete(frameId);
state.completeness.exclude("webmcp_tools", "out_of_target_scope");
return;
}
const previous = state.frames.get(frameId);
const loaderId = stringValue(frame?.loaderId);
if (hasNewDocument(documentCommitted, previous, url.url, loaderId))
removeFrameTools(frameId, state.tools);
if (documentCommitted && state.transientFrames.has(frameId))
state.resolvedTransientFrames.add(frameId);
state.frames.set(frameId, {
url: url.url,
origin: url.origin,
...(loaderId === undefined ? {} : { loaderId }),
parentFrameId:
(stringValue(frame?.parentId) ??
state.frames.get(frameId)?.parentFrameId ??
"") ||
null,
});
return true;
};
const hasNewDocument = (
documentCommitted: boolean,
previous: FrameScopeFrame | undefined,
url: string,
loaderId: string | undefined,
): boolean =>
documentCommitted &&
previous !== undefined &&
(previous.url !== url ||
(loaderId !== undefined &&
previous.loaderId !== undefined &&
previous.loaderId !== loaderId));
const removeFrameTools = (
frameId: string,
tools: Map<string, WebMcpDiscovery["tools"]["items"][number]>,
): void => {
for (const [key, tool] of tools)
if (tool.frame_id === frameId) tools.delete(key);
};
const isTransientFrameUrl = (url: string | undefined): boolean =>
url === "" || url === "about:blank" || url === "about:srcdoc";
const removeFrame = (
frameId: string,
frames: Map<string, FrameScopeFrame>,
@@ -247,10 +346,20 @@ interface WebMcpIngestOptions {
>;
readonly tools: Map<string, WebMcpDiscovery["tools"]["items"][number]>;
readonly completeness: CdpCaptureCompleteness;
readonly transientFrames: Set<string>;
readonly resolvedTransientFrames: Set<string>;
}
const ingestWebMcpEvent = (options: WebMcpIngestOptions): void => {
const { event, input, frames, tools, completeness } = options;
const {
event,
input,
frames,
tools,
completeness,
transientFrames,
resolvedTransientFrames,
} = options;
const params = recordValue(event.params);
if (params === undefined) return;
if (event.method === "WebMCP.toolsRemoved") {
@@ -265,6 +374,12 @@ const ingestWebMcpEvent = (options: WebMcpIngestOptions): void => {
}
if (event.method !== "WebMCP.toolsAdded") return;
for (const declared of recordsValue(params.tools)) {
const frameId = stringValue(declared.frameId);
if (frameId !== undefined && transientFrames.has(frameId)) {
if (!resolvedTransientFrames.has(frameId)) continue;
transientFrames.delete(frameId);
resolvedTransientFrames.delete(frameId);
}
const normalized = normalizeTool(declared, input, frames, completeness);
if (normalized === undefined) continue;
tools.set(normalized.tool_key, normalized);
@@ -1,3 +1,5 @@
// Fake-backed composition coverage; real browser verification lives in
// `npm run verify:browser`.
import { describe, expect, it } from "vitest";
import type { BrowserScenarioSessionPort } from "./BrowserScenarioSessionPort.js";
@@ -1,3 +1,5 @@
// Fixture-backed action coverage; real Electron verification lives in
// `npm run verify:electron`.
import { expect, it } from "vitest";
import {
@@ -0,0 +1,30 @@
import { expect, it } from "vitest";
import { closePlaywrightScenarioBrowser } from "./PlaywrightScenarioBrowser.js";
it("returns cancellation instead of success when an attached browser hangs on close", async () => {
const controller = new AbortController();
let closeStarted = false;
const closing = closePlaywrightScenarioBrowser(
{
browser: {
close: () => {
closeStarted = true;
return new Promise<void>(() => undefined);
},
},
context: { close: async () => undefined },
profilePath: undefined,
},
controller.signal,
);
controller.abort();
expect(closeStarted).toBe(true);
await expect(closing).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "cleanup_failed",
cleanupIncomplete: true,
userCategory: "cancelled",
});
});
+28 -10
View File
@@ -11,6 +11,7 @@ import {
import type { BrowserScenario } from "../domain/browserScenario.js";
import { BrowserObservationError } from "../domain/errors.js";
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
const OPERATION = "capture_browser_scenario" as const;
@@ -21,6 +22,12 @@ export interface OpenedScenarioBrowser {
readonly profilePath: string | undefined;
}
interface CloseableScenarioBrowser {
readonly context: Pick<BrowserContext, "close">;
readonly browser: Pick<Browser, "close">;
readonly profilePath: string | undefined;
}
const allowedBrowserEnvironment = (
environment: Readonly<Record<string, string | undefined>>,
): Record<string, string | undefined> => {
@@ -156,16 +163,27 @@ export const openPlaywrightScenarioBrowser = async (
}
};
export const closePlaywrightScenarioBrowser = async ({
context,
browser,
profilePath,
}: OpenedScenarioBrowser): Promise<void> => {
export const closePlaywrightScenarioBrowser = async (
{ context, browser, profilePath }: CloseableScenarioBrowser,
signal?: AbortSignal,
): Promise<void> => {
const cleanup = (async () => {
try {
// In connect mode Playwright's Browser.close disconnects its CDP client;
// it does not close the externally owned browser process.
if (profilePath === undefined) await browser.close();
else await context.close();
} finally {
if (profilePath !== undefined)
await rm(profilePath, { recursive: true, force: true, maxRetries: 3 });
}
})();
void cleanup.catch(() => undefined);
// The shared execution boundary rejects cancellation/timeouts instead of
// returning a successful capture whose browser or profile may still exist.
try {
if (profilePath === undefined) await browser.close();
else await context.close();
} finally {
if (profilePath !== undefined)
await rm(profilePath, { recursive: true, force: true, maxRetries: 3 });
await withPlaywrightExecutionBoundary(() => cleanup, 1_000, signal);
} catch (cause: unknown) {
throw new BrowserObservationError(OPERATION, "cleanup_failed", { cause });
}
};
+25 -13
View File
@@ -104,15 +104,25 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
readonly version: string;
readonly initialUrl: string;
private closed = false;
private readonly secrets: BrowserScenarioSecrets;
private readonly eventCapture: PlaywrightScenarioEvents;
private readonly signal: AbortSignal | undefined;
private constructor(
private readonly opened: OpenedScenarioBrowser,
mode: BrowserScenario["browser"]["mode"],
private readonly secrets: BrowserScenarioSecrets,
private readonly eventCapture: PlaywrightScenarioEvents,
options: {
readonly mode: BrowserScenario["browser"]["mode"];
readonly secrets: BrowserScenarioSecrets;
readonly eventCapture: PlaywrightScenarioEvents;
readonly signal?: AbortSignal;
},
) {
this.mode = mode;
this.processOwnership = mode === "launch" ? "provider-owned" : "external";
this.mode = options.mode;
this.processOwnership =
options.mode === "launch" ? "provider-owned" : "external";
this.secrets = options.secrets;
this.eventCapture = options.eventCapture;
this.signal = options.signal;
this.version = opened.browser.version();
this.initialUrl = opened.page.url();
}
@@ -139,7 +149,9 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
);
} catch (cause: unknown) {
void opening
.then((lateOpened) => closePlaywrightScenarioBrowser(lateOpened))
.then((lateOpened) =>
closePlaywrightScenarioBrowser(lateOpened, options.signal),
)
.catch(() => undefined);
throw cause;
}
@@ -154,12 +166,12 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
enabled: new Set(scenario.capture.events),
secrets,
});
const session = new PlaywrightScenarioSession(
opened,
scenario.browser.mode,
const session = new PlaywrightScenarioSession(opened, {
mode: scenario.browser.mode,
secrets,
events,
);
eventCapture: events,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
await withPlaywrightExecutionBoundary(
() =>
opened.page.goto(secrets.url(scenario.start_url), {
@@ -171,7 +183,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
);
return session;
} catch (cause: unknown) {
await closePlaywrightScenarioBrowser(opened);
await closePlaywrightScenarioBrowser(opened, options.signal);
throw cause;
}
}
@@ -241,7 +253,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
? ("terminated-owned-process" as const)
: ("disconnected-external" as const);
this.closed = true;
await closePlaywrightScenarioBrowser(this.opened);
await closePlaywrightScenarioBrowser(this.opened, this.signal);
return this.mode === "launch"
? ("terminated-owned-process" as const)
: ("disconnected-external" as const);
@@ -0,0 +1,37 @@
import { expect, it } from "vitest";
import {
closeInspectorConnection,
inspectorCleanupError,
} from "./V8InspectorProvider.js";
it("returns promptly on cancellation while a V8 socket close is hung", async () => {
const controller = new AbortController();
let closeStarted = false;
const closing = closeInspectorConnection(
{
close: () => {
closeStarted = true;
return new Promise<void>(() => undefined);
},
},
controller.signal,
);
controller.abort();
expect(closeStarted).toBe(true);
await expect(closing).resolves.toBeUndefined();
});
it("maps a rejected close to incomplete cleanup and preserves the primary failure", () => {
const primary = new Error("observation failed");
const cleanup = new Error("socket close failed");
const error = inspectorCleanupError(primary, cleanup, true);
expect(error.reason).toBe("cleanup_failed");
expect(error.cleanupIncomplete).toBe(true);
expect(error.cleanupResources).toEqual(["browser_transport"]);
expect(error.cause).toBeInstanceOf(AggregateError);
if (!(error.cause instanceof AggregateError)) return;
expect(error.cause.errors).toEqual([primary, cleanup]);
});
+70 -3
View File
@@ -125,6 +125,13 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
options: ExecutionOptions = {},
): Promise<Result<JavaScriptRuntimeObservation, AnalysisError>> {
let connection: CdpConnection | undefined;
let primaryFailure: unknown;
let failed = false;
let cleanupFailure: unknown;
let cleanupFailed = false;
let outcome:
| Result<JavaScriptRuntimeObservation, AnalysisError>
| undefined;
try {
const discovery = await discoverV8Inspector(
input.inspector_endpoint,
@@ -160,15 +167,75 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
target,
state,
});
return ok(javascriptRuntimeObservationSchema.parse(result));
outcome = ok(javascriptRuntimeObservationSchema.parse(result));
} catch (cause: unknown) {
return err(providerError(cause, "observe_javascript_runtime"));
failed = true;
primaryFailure = cause;
outcome = err(providerError(cause, "observe_javascript_runtime"));
} finally {
await connection?.close();
if (connection !== undefined)
try {
await closeInspectorConnection(connection, options.signal);
} catch (cause: unknown) {
cleanupFailed = true;
cleanupFailure = cause;
}
}
if (cleanupFailed) {
return err(inspectorCleanupError(primaryFailure, cleanupFailure, failed));
}
return (
outcome ??
err(
new BrowserObservationError(
"observe_javascript_runtime",
"protocol_error",
),
)
);
}
}
export const closeInspectorConnection = async (
connection: Pick<CdpConnection, "close">,
signal?: AbortSignal,
): Promise<void> => {
const closing = connection.close();
void closing.catch(() => undefined);
// CdpConnection.close has its own one second transport bound. Do not make a
// cancelled caller wait for that fallback.
if (signal === undefined) return await closing;
let onAbort: (() => void) | undefined;
const cancelled = new Promise<void>((resolve) => {
onAbort = () => resolve();
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
});
try {
await Promise.race([closing, cancelled]);
} finally {
if (onAbort !== undefined) signal.removeEventListener("abort", onAbort);
}
};
export const inspectorCleanupError = (
primaryFailure: unknown,
cleanupFailure: unknown,
hasPrimaryFailure: boolean,
): BrowserObservationError => {
const cause = hasPrimaryFailure
? new AggregateError(
[primaryFailure, cleanupFailure],
"Inspector observation and cleanup both failed",
)
: cleanupFailure;
return new BrowserObservationError(
"observe_javascript_runtime",
"cleanup_failed",
{ cause },
);
};
const projectTarget = (target: AuthorizedV8InspectorTarget) => ({
target_id: target.id,
protocol_type: target.type,
+1 -1
View File
@@ -91,7 +91,7 @@ const registerCompareManagedMembers = (
): void => {
cli.command(CLI_COMMANDS.compareManagedMembers, {
description:
"Compare two managed PE/CLI member inventories without name-based matching",
"Compare managed PE/CLI member inventories by exact declared type, name, and signature; names alone never form a match",
args: compareManagedMemberArgs,
run: ({ args }) =>
logCliCommand(logger, "compare-managed-members", async () => {
+24 -11
View File
@@ -7,11 +7,12 @@ import {
} from "../application/Doctor.js";
import {
runSetup,
isSetupFailure,
systemSetupHost,
type SetupOptions,
} from "../application/Setup.js";
import { runUninstall } from "../application/Uninstall.js";
import { runUpdate } from "../application/Update.js";
import { isUninstallFailure, runUninstall } from "../application/Uninstall.js";
import { isUpdateFailure, runUpdate } from "../application/Update.js";
import { systemUpdateHost } from "../application/UpdateRuntime.js";
import {
confirmInteractiveSetup,
@@ -82,8 +83,11 @@ const registerSetupCommand = (cli: CliInstance, logger: Logger): void => {
}),
alias: { yes: "y" },
run: ({ options, formatExplicit }) =>
logCliCommand(logger, "setup", () =>
runSetupCommand({ options, formatExplicit }),
logCliCommand(
logger,
CLI_COMMANDS.setup,
() => runSetupCommand({ options, formatExplicit }),
isSetupFailure,
),
});
};
@@ -131,18 +135,27 @@ const registerMaintenanceCommands = (
}),
alias: { purgeData: "purge-data" },
run: ({ options }) =>
logCliCommand(logger, "uninstall", () => runUninstall(options.purgeData)),
logCliCommand(
logger,
"uninstall",
() => runUninstall(options.purgeData),
isUninstallFailure,
),
});
cli.command(CLI_COMMANDS.update, {
description:
"Update this REA installation and verify the installed release",
run: ({ formatExplicit }) =>
logCliCommand(logger, "update", () =>
runUpdate(
PRODUCT_IDENTITY.packageVersion,
systemUpdateHost(),
formatExplicit ? "structured" : "human",
),
logCliCommand(
logger,
CLI_COMMANDS.update,
() =>
runUpdate(
PRODUCT_IDENTITY.packageVersion,
systemUpdateHost(),
formatExplicit ? "structured" : "human",
),
isUpdateFailure,
),
});
};
+76
View File
@@ -73,3 +73,79 @@ export const CLI_COMMANDS = Object.freeze({
/** Ordered primary CLI inventory; aliases are intentionally excluded. */
export const CLI_COMMAND_NAMES = Object.freeze(Object.values(CLI_COMMANDS));
/** Command aliases registered on top of the primary CLI command inventory. */
export const CLI_COMMAND_ALIASES = Object.freeze(["compare-bundles"]);
/** Canonical MCP operations reached by CLI commands with different names. */
export const CLI_COMMAND_TOOL_ALIASES = Object.freeze({
analyze: ["binary_overview"],
inspect: ["binary_overview"],
decompile: ["procedure_pseudo_code"],
function: ["analyze_function"],
instructions: ["read_function_instructions"],
trace: ["trace_feature"],
search: ["search_strings", "search_procedures"],
"capture-process": ["capture_process_scenario"],
"compare-bundles": ["compare_bundles"],
});
/** MCP operations without a dedicated CLI command or direct command alias. */
export const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([
"address_name",
"comment",
"current_address",
"current_procedure",
"current_document",
"goto_address",
"inline_comment",
"list_bookmarks",
"list_documents",
"list_names",
"list_procedures",
"list_segments",
"list_strings",
"next_address",
"prev_address",
"procedure_address",
"procedure_assembly",
"procedure_callees",
"procedure_callers",
"procedure_info",
"read_bytes",
"address_to_file_offset",
"procedure_references",
"resolve_containing_procedure",
"set_address_name",
"set_addresses_names",
"set_bookmark",
"set_comment",
"set_current_document",
"set_inline_comment",
"unset_bookmark",
"get_objc_classes",
"get_objc_protocols",
"batch_decompile",
"get_call_graph",
"analyze_swift_types",
"find_xrefs_to_name",
"trace_call_path",
"open_binary",
"close_binary",
"binary_session",
"export_evidence_bundle",
"import_evidence_bundle",
"compare_artifacts",
"compare_functions",
"find_changed_behavior",
"build_call_path",
"correlate_static_and_runtime",
"verify_reconstruction",
"list_unknowns",
"record_unknown",
"update_unknown",
"verify_unknown_resolution",
"get_evidence_bundle",
"get_navigation_context",
"inspect_address_context",
]);
+165 -6
View File
@@ -1,8 +1,35 @@
import { describe, expect, it } from "vitest";
import { ConfigurationError, projectAnalysisError } from "./domain/errors.js";
import {
ArtifactOperationError,
ConfigurationError,
projectAnalysisError,
} from "./domain/errors.js";
import { isCliOperationFailure } from "./cliLogging.js";
import { isCliOperationFailure, logCliCommand } from "./cliLogging.js";
import { silentLogger } from "./logger.js";
import {
isSetupFailure,
runSetup,
type SetupResult,
} from "./application/Setup.js";
import {
FakeSetupHost,
options as setupOptions,
} from "./application/Setup.fixture.js";
import { err } from "./domain/result.js";
import {
isUpdateFailure,
runUpdate,
type UpdateHost,
} from "./application/Update.js";
import {
isUninstallFailure,
runUninstall,
type UninstallHost,
} from "./application/Uninstall.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
import { isProcessCliFailure } from "./application/ProcessCli.js";
describe("CLI operation status classification", () => {
it.each([
@@ -10,11 +37,13 @@ describe("CLI operation status classification", () => {
"projected error",
projectAnalysisError(new ConfigurationError("invalid setting")),
],
["typed error", { error: "Analysis failed", category: "timeout" }],
[
"MCP-shaped projected error",
{
error: projectAnalysisError(new ConfigurationError("invalid setting")),
},
],
["unhealthy diagnostic", { healthy: false, checks: [] }],
["failed status", { status: "failed" }],
["confirmation requirement", { status: "needs_confirmation" }],
["human remediation", { status: "needs_human" }],
])("classifies %s as failure", (_label, value) => {
expect(isCliOperationFailure(value)).toBe(true);
});
@@ -31,6 +60,8 @@ describe("CLI operation status classification", () => {
},
],
["requested dry run", { status: "planned" }],
["partial analysis", { status: "partial" }],
["unsupported facet with evidence", { status: "unsupported" }],
["cancelled setup", { status: "cancelled" }],
["ready setup", { status: "ready" }],
["complete uninstall", { status: "complete" }],
@@ -41,4 +72,132 @@ describe("CLI operation status classification", () => {
])("keeps %s successful", (_label, value) => {
expect(isCliOperationFailure(value)).toBe(false);
});
it("uses the process command's typed error output", () => {
expect(
isProcessCliFailure({
error: "Process command failed",
category: "invalid_input",
message: "The capture input is invalid.",
}),
).toBe(true);
expect(isProcessCliFailure({ error: "data about an error" })).toBe(false);
});
});
describe("CLI command outcome classification", () => {
it("returns nonzero for the UpdateResult failure variant", async () => {
const installation = {
prefix: "/fixture",
packageRoot: "/fixture/lib/node_modules/rea-agents",
};
const updateHost: UpdateHost = {
installation: () => Promise.resolve(installation),
latestVersion: () => Promise.resolve(err("registry unavailable")),
installVersion: () => Promise.resolve(err("not called")),
installedVersion: () => Promise.resolve(err("not called")),
planMaintenance: () =>
Promise.resolve({ status: "current", plannedActions: [] }),
};
const result = await runUpdate("3.2.1", updateHost);
expect(result.status).toBe("failed");
const previousExitCode = process.exitCode;
const output = await logCliCommand(
silentLogger,
CLI_COMMANDS.update,
async () => result,
isUpdateFailure,
);
expect(output).toBe(result);
expect(process.exitCode).toBe(1);
process.exitCode = previousExitCode;
});
it.each(["needs_confirmation", "needs_human"] as const)(
"returns nonzero for a real setup result with status %s",
async (expectedStatus) => {
const host = new FakeSetupHost();
const setup: SetupResult =
expectedStatus === "needs_human"
? await runSetup(
{ ...setupOptions(false), structured: false },
host,
() => Promise.resolve(true),
)
: await runSetup(
{ ...setupOptions(false), clientIds: ["cursor"] },
Object.assign(host, {
clients: [{ name: "cursor", configPath: "/cursor.json" }],
}),
);
expect(setup.status).toBe(expectedStatus);
const previousExitCode = process.exitCode;
const output = await logCliCommand(
silentLogger,
CLI_COMMANDS.setup,
async () => setup,
isSetupFailure,
);
expect(output).toBe(setup);
expect(process.exitCode).toBe(1);
process.exitCode = previousExitCode;
},
);
it("returns nonzero when the real uninstall outcome contains a failed action", async () => {
const host: UninstallHost = {
clients: () => Promise.resolve([]),
removeClient: () =>
Promise.resolve({ name: "client", status: "failed", detail: "write" }),
removeSkill: () =>
Promise.resolve({ name: "skill", status: "failed", detail: "write" }),
purgeData: () => Promise.resolve([]),
};
const result = await runUninstall(false, host);
expect(result.status).toBe("failed");
const previousExitCode = process.exitCode;
await logCliCommand(
silentLogger,
CLI_COMMANDS.uninstall,
async () => result,
isUninstallFailure,
);
expect(process.exitCode).toBe(1);
process.exitCode = previousExitCode;
});
});
describe("CLI thrown analysis failures", () => {
it("returns the shared projection with actionable failure coordinates", async () => {
const previousExitCode = process.exitCode;
try {
const projected = await logCliCommand(
silentLogger,
"fixture",
async () => {
throw new ArtifactOperationError(
"analyze_javascript_application",
"integrity",
{
logicalPath: "src/main.js",
declaredSha256: "a".repeat(64),
calculatedSha256: "b".repeat(64),
unpacked: true,
},
);
},
);
expect(projected).toMatchObject({
code: "artifact_integrity_mismatch",
category: "integrity_mismatch",
details: {
logical_path: "src/main.js",
declared_sha256: "a".repeat(64),
calculated_sha256: "b".repeat(64),
},
});
} finally {
process.exitCode = previousExitCode;
}
});
});
+24 -9
View File
@@ -1,4 +1,12 @@
import { analysisErrorProjectionSchema } from "./contracts/errorSchemas.js";
import {
analysisCliErrorEnvelopeSchema,
analysisErrorProjectionSchema,
} from "./contracts/errorSchemas.js";
import {
AnalysisError,
projectAnalysisError,
type AnalysisErrorProjection,
} from "./domain/errors.js";
import type { Logger } from "./logger.js";
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
@@ -7,14 +15,15 @@ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
/** Identify caller-visible CLI outcomes that mean the requested operation failed. */
export const isCliOperationFailure = (value: unknown): boolean => {
if (!isRecord(value)) return false;
if (typeof value.error === "string") return true;
if (
isRecord(value.error) &&
analysisErrorProjectionSchema.safeParse(value.error).success
)
return true;
if (analysisCliErrorEnvelopeSchema.safeParse(value).success) return true;
if (analysisErrorProjectionSchema.safeParse(value).success) return true;
if (value.healthy === false) return true;
return (
value.status === "failed" ||
value.status === "needs_confirmation" ||
value.status === "needs_human"
);
return false;
};
/** Log one CLI command with duration and a stable success or failure status. */
@@ -22,11 +31,12 @@ export const logCliCommand = async <Value>(
logger: Logger,
command: string,
execute: () => Promise<Value>,
): Promise<Value> => {
isCommandFailure?: (value: Value) => boolean,
): Promise<Value | AnalysisErrorProjection> => {
const startedAt = performance.now();
try {
const value = await execute();
const failed = isCliOperationFailure(value);
const failed = isCommandFailure?.(value) ?? isCliOperationFailure(value);
logger[failed ? "error" : "info"](
{
command,
@@ -38,6 +48,7 @@ export const logCliCommand = async <Value>(
if (failed) process.exitCode = 1;
return value;
} catch (cause: unknown) {
process.exitCode = 1;
logger.error(
{
command,
@@ -46,6 +57,10 @@ export const logCliCommand = async <Value>(
},
"CLI command failed",
);
if (cause instanceof AnalysisError) {
process.exitCode = 1;
return projectAnalysisError(cause);
}
throw cause;
}
};
+2 -3
View File
@@ -19,8 +19,7 @@ type JsonRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const validationError = (value: unknown): JsonRecord | undefined => {
if (!isRecord(value)) return undefined;
const validationError = (value: JsonRecord): JsonRecord | undefined => {
if (value.code === "VALIDATION_ERROR") return value;
if (value.ok === false && isRecord(value.error))
return value.error.code === "VALIDATION_ERROR" ? value.error : undefined;
@@ -97,6 +96,7 @@ export const sanitizeCliOutput = (output: string): string => {
if (trimmed.startsWith("{")) {
try {
const parsed: unknown = JSON.parse(trimmed);
if (!isRecord(parsed)) return output;
const error = validationError(parsed);
if (error !== undefined) {
const safeError = {
@@ -104,7 +104,6 @@ export const sanitizeCliOutput = (output: string): string => {
message: SAFE_VALIDATION_MESSAGE,
};
if (error === parsed) return `${JSON.stringify(safeError)}\n`;
if (!isRecord(parsed)) return output;
return `${JSON.stringify({ ...parsed, error: safeError })}\n`;
}
} catch {
+12 -4
View File
@@ -3,6 +3,7 @@ import { Cli, z } from "incur";
import {
captureProcessScenarioFile,
compareProcessEvidenceFiles,
isProcessCliFailure,
} from "./application/ProcessCli.js";
import { logCliCommand } from "./cliLogging.js";
import type { Logger } from "./logger.js";
@@ -18,8 +19,11 @@ export const registerProcessCommands = (
description: "Capture one caller-selected process scenario",
args: z.object({ scenario: z.string().describe("Scenario JSON path") }),
run: ({ args }) =>
logCliCommand(logger, "capture-process", () =>
captureProcessScenarioFile(args.scenario, environment),
logCliCommand(
logger,
"capture-process",
() => captureProcessScenarioFile(args.scenario, environment),
isProcessCliFailure,
),
});
cli.command(CLI_COMMANDS.compareProcessCaptures, {
@@ -33,8 +37,12 @@ export const registerProcessCommands = (
.describe("Optional partial-order or finite-trace specification path"),
}),
run: ({ args }) =>
logCliCommand(logger, "compare-process-captures", () =>
compareProcessEvidenceFiles(args.left, args.right, args.traceSpec),
logCliCommand(
logger,
"compare-process-captures",
() =>
compareProcessEvidenceFiles(args.left, args.right, args.traceSpec),
isProcessCliFailure,
),
});
};
+44
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { createCli } from "../cli.js";
import { createCliInventory } from "../../scripts/lib/product-catalog.mjs";
import {
ENHANCED_TOOL_CONTRACTS,
@@ -16,6 +18,13 @@ import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
import { TOOL_EFFECTS } from "./toolEffects.js";
import { TOOL_CONTRACTS } from "./toolContracts.js";
import {
CLI_COMMAND_ALIASES,
CLI_COMMAND_NAMES,
CLI_COMMAND_TOOL_ALIASES,
MCP_TOOLS_WITHOUT_DEDICATED_CLI,
} from "../cliCommandNames.js";
const contractJsonSchema = (schema: z.ZodType) =>
z.toJSONSchema(schema, { target: "draft-07", unrepresentable: "any" });
@@ -102,4 +111,39 @@ describe("tool contract surface", () => {
destructiveHint: false,
});
});
it("accounts for every MCP tool on the CLI or marks it intentionally MCP-only", () => {
const registeredCli = createCliInventory(createCli());
const registeredCommandNames = new Set([
...registeredCli.primary,
...registeredCli.aliases.map(({ name }) => name),
]);
const cliToolNames = new Set(
registeredCli.primary.map((command) => command.replaceAll("-", "_")),
);
for (const [command, names] of Object.entries(CLI_COMMAND_TOOL_ALIASES)) {
expect(registeredCommandNames.has(command)).toBe(true);
for (const name of names) cliToolNames.add(name);
}
for (const command of CLI_COMMAND_NAMES)
expect(registeredCommandNames.has(command)).toBe(true);
for (const command of CLI_COMMAND_ALIASES)
expect(registeredCommandNames.has(command)).toBe(true);
const intentionalMcpOnly = new Set<string>(MCP_TOOLS_WITHOUT_DEDICATED_CLI);
expect(
[...intentionalMcpOnly].filter((name) => cliToolNames.has(name)).sort(),
).toEqual([]);
const unaccounted = TOOL_CONTRACTS.map(({ name }) => name).filter(
(name) => !cliToolNames.has(name) && !intentionalMcpOnly.has(name),
);
expect(unaccounted).toEqual([]);
expect(new Set(MCP_TOOLS_WITHOUT_DEDICATED_CLI).size).toBe(
MCP_TOOLS_WITHOUT_DEDICATED_CLI.length,
);
expect(
MCP_TOOLS_WITHOUT_DEDICATED_CLI.every((name) =>
TOOL_CONTRACTS.some((contract) => contract.name === name),
),
).toBe(true);
});
});
+18
View File
@@ -60,6 +60,24 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
generic("execution_failure"),
]);
/** Legacy CLI envelope: a human label plus the canonical projected error fields. */
export const analysisCliErrorEnvelopeSchema = z
.object({
error: z.string().min(1),
})
.passthrough()
.superRefine((value, context) => {
const { error: _label, ...projection } = value;
const parsed = analysisErrorProjectionSchema.safeParse(projection);
if (!parsed.success) {
context.addIssue({
code: "custom",
message:
"CLI error envelope must contain a canonical analysis error projection",
});
}
});
/** JSON Schema document used by generated API documentation and clients. */
export const analysisErrorJsonSchema = z.toJSONSchema(
analysisErrorProjectionSchema,
+10 -4
View File
@@ -6,21 +6,23 @@ import { toolContractMetadata } from "./toolEffects.js";
import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
/** Exact caller boundary for execution-free PE/CLI triage and identity. */
export const managedArtifactInputSchema = z.object({
const managedTargetInputSchema = z.object({
path: z
.string()
.min(1)
.optional()
.describe(
"Managed PE/CLI path to open before inspection; omit only when a managed target is already active",
"Explicit managed PE/CLI path; omit to reuse the previously selected managed target",
),
});
export const managedArtifactInputSchema = managedTargetInputSchema;
/** Exact caller boundary for execution-free metadata/signature/IL inspection. */
export const managedMemberInputSchema = z.object({});
export const managedMemberInputSchema = managedTargetInputSchema;
/** Exact caller boundary for execution-free managed/native boundary inspection. */
export const managedNativeBoundaryInputSchema = z.object({});
export const managedNativeBoundaryInputSchema = managedTargetInputSchema;
const outputSchema = requireOutputSchema(
managedOutputSchemas,
@@ -86,3 +88,7 @@ export const MANAGED_TOOL_CONTRACTS = [
/** Names of execution-free managed static operations. */
export type ManagedToolName = (typeof MANAGED_TOOL_CONTRACTS)[number]["name"];
/** Parse whether an analysis operation belongs to the managed static tools. */
export const isManagedToolName = (name: string): name is ManagedToolName =>
MANAGED_TOOL_CONTRACTS.some((contract) => contract.name === name);
@@ -5,6 +5,7 @@ import {
AnalysisInputError,
ArtifactOperationError,
BinaryTargetError,
BrowserObservationError,
HopperProcessError,
HopperRemoteError,
HopperStartError,
@@ -15,6 +16,35 @@ import {
} from "./errors.js";
describe("analysis error projection: provider failures", () => {
it("projects the primary browser failure alongside incomplete cleanup", () => {
const projected = projectAnalysisError(
new BrowserObservationError(
"observe_javascript_runtime",
"cleanup_failed",
{
cause: new AggregateError([
new BrowserObservationError(
"observe_javascript_runtime",
"target_changed",
),
new Error("socket close failed"),
]),
},
),
);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: {
operation: "observe_javascript_runtime",
reason: "cleanup_failed",
cleanup: "incomplete",
resources: ["browser_transport"],
primary_reason: "target_changed",
},
});
});
it("reports a timed-out active Hopper request and actionable retry guidance", () => {
const projected = projectAnalysisError(
new HopperTimeoutError(30_000, "trace_feature", 42, "busy"),
+31 -2
View File
@@ -249,8 +249,22 @@ const providerErrorDetails = (
? {}
: { diagnostics: error.diagnostics }),
};
if (error instanceof BrowserObservationError)
return { operation: error.operation, reason: error.reason };
if (error instanceof BrowserObservationError) {
const primaryReason = primaryBrowserFailureReason(error);
return {
operation: error.operation,
reason: error.reason,
...(error.cleanupIncomplete
? {
cleanup: "incomplete",
resources: [...error.cleanupResources],
...(primaryReason === undefined
? {}
: { primary_reason: primaryReason }),
}
: {}),
};
}
if (error instanceof HopperRemoteError)
return {
stage: "analysis",
@@ -275,6 +289,21 @@ const providerErrorDetails = (
return undefined;
};
const primaryBrowserFailureReason = (
error: BrowserObservationError,
): string | undefined => {
if (!(error.cause instanceof AggregateError)) return undefined;
const primary = error.cause.errors[0];
if (primary instanceof BrowserObservationError) return primary.reason;
if (primary instanceof AnalysisCancelledError) return "cancelled";
if (primary instanceof AnalysisTimeoutError) return "timeout";
if (typeof primary === "object" && primary !== null && "_tag" in primary)
return typeof primary._tag === "string"
? primary._tag
: "execution_failure";
return "execution_failure";
};
const lifecycleErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
+12 -2
View File
@@ -90,7 +90,17 @@ describe("compiled Apple asset catalog projection", () => {
);
expect(() =>
parseAppleAssetCatalogRecords({ Name: "not an array" }),
).toThrow(/JSON array/u);
expect(() => parseAppleAssetCatalogRecords([null])).toThrow(/record 0/u);
).toThrow(
expect.objectContaining({
_tag: "AnalysisOutputError",
reason: "assetutil output must be a JSON array",
}),
);
expect(() => parseAppleAssetCatalogRecords([null])).toThrow(
expect.objectContaining({
_tag: "AnalysisOutputError",
reason: "assetutil record 0 is not a JSON object",
}),
);
});
});
+10 -2
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { jsonValueSchema } from "./jsonValue.js";
import { AnalysisOutputError } from "./errors.js";
/** Caller-controlled page bounds for compiled Apple asset metadata. */
export const appleAssetCatalogInputSchema = z.strictObject({
@@ -64,11 +65,18 @@ export const parseAppleAssetCatalogRecords = (
Record<string, ReturnType<typeof jsonValueSchema.parse>>
>[] => {
if (!Array.isArray(value))
throw new Error("assetutil output must be a JSON array");
throw new AnalysisOutputError(
"inspect_asset_catalog",
"assetutil output must be a JSON array",
);
return value.map((record, index) => {
const parsed = z.record(z.string(), jsonValueSchema).safeParse(record);
if (!parsed.success)
throw new Error(`assetutil record ${index} is not a JSON object`);
throw new AnalysisOutputError(
"inspect_asset_catalog",
`assetutil record ${index} is not a JSON object`,
{ cause: parsed.error },
);
return parsed.data;
});
};
+1 -10
View File
@@ -1,4 +1,3 @@
import canonicalize from "canonicalize";
import { z } from "zod";
import {
@@ -12,6 +11,7 @@ import {
type InventorySet,
} from "./artifactInventoryEvidence.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
import { canonicalJson } from "./comparisonSemantics.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const comparisonStatusSchema = z.enum([
@@ -325,15 +325,6 @@ const metadataChanged = (
JSON.stringify(input.leftOccurrence?.limitations) !==
JSON.stringify(input.rightOccurrence?.limitations);
const canonicalJson = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError(
"Artifact comparison could not canonicalize graph data",
);
return encoded;
};
const nodeFor = (
occurrence: ArtifactOccurrence | undefined,
nodes: ReadonlyMap<string, ArtifactNode>,
+20 -11
View File
@@ -54,7 +54,7 @@ describe("bundle comparison", () => {
expect(result.left_bundle_sha256).toBe(result.right_bundle_sha256);
});
it("classifies explicit pairs and returns every membership change inline", () => {
it("classifies explicit pairs and leaves one-sided membership unresolved", () => {
const oldRecord = evidence("old");
const newRecord = evidence("new");
const removed = evidence("removed");
@@ -71,19 +71,24 @@ describe("bundle comparison", () => {
const repeated = compareBundles(left, right, pairs);
expect(first).toEqual(repeated);
expect(first).toMatchObject({
status: "changed",
status: "unknown",
summary: {
records_added: 1,
records_removed: 1,
records_added: 0,
records_removed: 0,
records_changed: 1,
unresolved: 2,
},
changes: expect.arrayContaining([
expect.objectContaining({ classification: "changed" }),
expect.objectContaining({
classification: "unknown",
conclusion_kind: "unresolved_branch",
}),
]),
});
expect(first.changes).toHaveLength(3);
expect(first.limitations).toContain(
"One-sided membership proves only bundle inclusion or omission, not behavioral absence.",
"EvidenceBundle has no inventory completeness marker; one-sided membership remains unknown.",
);
});
@@ -176,12 +181,12 @@ describe("bundle comparison history", () => {
[initial, advanced],
);
expect(compareBundles(initialBundle, advancedBundle)).toMatchObject({
status: "changed",
summary: { unknowns_advanced: 1, unresolved: 0 },
status: "unknown",
summary: { unknowns_advanced: 1, unresolved: 1 },
changes: [
expect.objectContaining({
entity: "evidence",
classification: "added",
classification: "unknown",
}),
expect.objectContaining({
entity: "residual_unknown",
@@ -193,14 +198,18 @@ describe("bundle comparison history", () => {
const missing = compareBundles(initialBundle, absent);
expect(missing).toMatchObject({
status: "unknown",
summary: { unknowns_removed: 1, unresolved: 1 },
summary: {
unknowns_added: 0,
unknowns_removed: 0,
unresolved: 2,
},
});
expect(missing.changes).toContainEqual(
expect.objectContaining({
classification: "removed",
classification: "unknown",
conclusion_kind: "unresolved_branch",
limitations: [
"A missing unknown history does not establish resolution.",
"EvidenceBundle has no inventory completeness marker; one-sided history membership remains unknown.",
],
}),
);
+13 -20
View File
@@ -115,7 +115,7 @@ export const compareBundles = (
changes,
limitations: changes.some(({ limitations }) => limitations.length > 0)
? [
"One-sided membership proves only bundle inclusion or omission, not behavioral absence.",
"EvidenceBundle has no inventory completeness marker; one-sided membership remains unknown.",
]
: [],
});
@@ -171,30 +171,30 @@ const compareRecords = (
const added = [...rightIds].filter(
(id) => !leftIds.has(id) && !pairedRight.has(id),
);
for (const [classification, ids, side] of [
["removed", removed, "left"],
["added", added, "right"],
for (const [ids, side] of [
[removed, "left"],
[added, "right"],
] as const)
for (const id of ids)
changes.push({
entity: "evidence",
key: id,
classification,
conclusion_kind: "observed_change",
classification: "unknown",
conclusion_kind: "unresolved_branch",
left_evidence_ids: side === "left" ? [id] : [],
right_evidence_ids: side === "right" ? [id] : [],
left_digest: side === "left" ? id.slice(3) : null,
right_digest: side === "right" ? id.slice(3) : null,
limitations: [
"One-sided membership does not establish behavioral absence.",
"EvidenceBundle has no inventory completeness marker; one-sided membership may reflect incomplete capture.",
],
});
return {
changes,
summary: {
records_unchanged: common.length + pairs.length - changed,
records_added: added.length,
records_removed: removed.length,
records_added: 0,
records_removed: 0,
records_changed: changed,
},
};
@@ -216,16 +216,7 @@ const compareUnknowns = (left: EvidenceBundle, right: EvidenceBundle) => {
const leftHistory = leftHistories.get(id);
const rightHistory = rightHistories.get(id);
if (leftHistory === undefined || rightHistory === undefined) {
const added = leftHistory === undefined;
summary[added ? "unknowns_added" : "unknowns_removed"] += 1;
changes.push(
unknownChange(
id,
added ? "added" : "removed",
leftHistory,
rightHistory,
),
);
changes.push(unknownChange(id, "unknown", leftHistory, rightHistory));
continue;
}
const leftDigests = leftHistory.map(
@@ -275,7 +266,9 @@ const unknownChange = (
right_digest: right?.at(-1)?.revision_digest ?? null,
limitations:
left === undefined || right === undefined
? ["A missing unknown history does not establish resolution."]
? [
"EvidenceBundle has no inventory completeness marker; one-sided history membership remains unknown.",
]
: [],
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { absenceClaimable, canonicalDigest } from "./comparisonSemantics.js";
describe("shared comparison semantics", () => {
it("permits absence claims only for complete, non-truncated inventories", () => {
expect(
absenceClaimable({
status: "complete",
truncated: false,
omitted_count: 0,
}),
).toBe(true);
expect(
absenceClaimable({
status: "complete",
truncated: true,
omitted_count: 2,
}),
).toBe(false);
expect(
absenceClaimable({
status: "complete",
truncated: false,
omitted_count: 1,
}),
).toBe(false);
expect(absenceClaimable({ status: "partial" })).toBe(false);
});
it("produces one stable SHA-256 for equivalent canonical JSON values", () => {
expect(canonicalDigest({ b: 2, a: 1 })).toBe(
canonicalDigest({ a: 1, b: 2 }),
);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
/** Whether an inventory can support a claim that an item is absent. */
export const absenceClaimable = (coverage: {
readonly status: string;
readonly truncated?: boolean;
readonly omitted_count?: number | null;
}): boolean =>
coverage.status === "complete" &&
coverage.truncated !== true &&
(coverage.omitted_count === undefined || coverage.omitted_count === 0);
/** SHA-256 of canonical JSON for stable, provider-neutral comparison IDs. */
export const canonicalDigest = (
value: unknown,
context = "Comparison",
): string => {
return createHash("sha256")
.update(canonicalJson(value, context))
.digest("hex");
};
/** Canonical JSON used for deterministic tie breaking and stable digests. */
export const canonicalJson = (
value: unknown,
context = "Comparison",
): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError(`${context} could not canonicalize data`);
return encoded;
};
+10
View File
@@ -248,6 +248,9 @@ export class ProviderAdapterError extends AnalysisError {
/** A bounded passive browser observation failed at its CDP boundary. */
export class BrowserObservationError extends AnalysisError {
readonly _tag = "BrowserObservationError";
override readonly cleanupIncomplete: boolean;
override readonly cleanupResources: readonly string[];
override readonly userCategory: "cancelled" | undefined;
constructor(
readonly operation: BrowserObservationOperation,
@@ -255,6 +258,13 @@ export class BrowserObservationError extends AnalysisError {
options?: ErrorOptions,
) {
super(`Browser observation ${operation} failed: ${reason}`, options);
this.cleanupIncomplete = reason === "cleanup_failed";
this.cleanupResources =
reason === "cleanup_failed" ? ["browser_transport"] : [];
this.userCategory =
reason === "cancelled" || options?.cause instanceof AnalysisCancelledError
? "cancelled"
: undefined;
}
}
+6 -9
View File
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import {
canonicalDigest,
canonicalJson as encodeCanonicalJson,
} from "./comparisonSemantics.js";
import type {
DimensionName,
FunctionComparisonResult,
@@ -94,11 +94,8 @@ export const summarize = (dimensions: readonly FunctionDimension[]) => ({
});
export const canonicalJson = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Function comparison could not canonicalize data");
return encoded;
return encodeCanonicalJson(value, "Function comparison");
};
const digest = (value: unknown): string =>
createHash("sha256").update(canonicalJson(value)).digest("hex");
canonicalDigest(value, "Function comparison");
+20 -21
View File
@@ -1,7 +1,8 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import {
absenceClaimable,
canonicalDigest,
canonicalJson,
} from "./comparisonSemantics.js";
import type { Evidence } from "./evidence.js";
import {
compareCodePoints,
@@ -165,7 +166,7 @@ const leftOnlyItem = (
dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION],
});
const absenceObserved = context.rightGraph.coverage.status === "complete";
const absenceObserved = absenceClaimable(context.rightGraph.coverage);
return itemWithId({
...common,
status: absenceObserved ? "removed" : "unknown",
@@ -205,7 +206,7 @@ const rightOnlyItem = (
dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION],
});
const absenceObserved = context.leftGraph.coverage.status === "complete";
const absenceObserved = absenceClaimable(context.leftGraph.coverage);
return itemWithId({
...common,
status: absenceObserved ? "added" : "unknown",
@@ -289,9 +290,14 @@ const changedDimensions = (
leftContent !== rightContent
)
dimensions.push("content");
if (canonical(locations(pair.left)) !== canonical(locations(pair.right)))
if (
canonicalJson(locations(pair.left)) !== canonicalJson(locations(pair.right))
)
dimensions.push("location");
if (canonical(properties(pair.left)) !== canonical(properties(pair.right)))
if (
canonicalJson(properties(pair.left)) !==
canonicalJson(properties(pair.right))
)
dimensions.push("properties");
if (
relationshipSignature(pair.left, "left", context) !==
@@ -320,14 +326,14 @@ const locations = (node: ApplicationNode) =>
evidence.location.available ? evidence.location.value : evidence.location,
)
.sort((left, right) =>
compareCodePoints(canonical(left), canonical(right)),
compareCodePoints(canonicalJson(left), canonicalJson(right)),
);
const properties = (node: ApplicationNode) =>
node.observations
.map(({ label, properties: values }) => ({ label, properties: values }))
.sort((left, right) =>
compareCodePoints(canonical(left), canonical(right)),
compareCodePoints(canonicalJson(left), canonicalJson(right)),
);
const relationshipSignature = (
@@ -361,7 +367,7 @@ const relationshipSignature = (
];
return [];
});
return canonical(signatures.sort(compareCodePoints));
return canonicalJson(signatures.sort(compareCodePoints));
};
interface EdgeSignatureContext {
@@ -450,15 +456,8 @@ const stringProperty = (node: ApplicationNode, key: string): string | null => {
};
const graphsComplete = (context: ItemContext): boolean =>
context.leftGraph.coverage.status === "complete" &&
context.rightGraph.coverage.status === "complete";
const canonical = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Application version item could not canonicalize data");
return encoded;
};
absenceClaimable(context.leftGraph.coverage) &&
absenceClaimable(context.rightGraph.coverage);
const digestCanonical = (value: unknown): string =>
createHash("sha256").update(canonical(value)).digest("hex");
canonicalDigest(value, "Application version item");
+9
View File
@@ -7,3 +7,12 @@ export const propertyName = (node: t.Node): string => {
return String(node.value);
return "";
};
/** Read a property name only when its syntax commits to one exact key. */
export const semanticStaticPropertyName = (
property: t.Node,
computed: boolean,
): string =>
computed && !t.isStringLiteral(property) && !t.isNumericLiteral(property)
? ""
: propertyName(property);
+56 -36
View File
@@ -20,6 +20,9 @@ import { range } from "./javascriptStaticAnalysisHelpers.js";
import {
dataEffectLiteralString,
dataEffectMemberCallee,
assignedSemanticResultBindings,
scalarAssignedResultBinding,
semanticProjectedBindingFacts,
} from "./javascriptSemanticDataEffectHelpers.js";
const EVENT_REGISTER_METHODS = [
@@ -61,14 +64,19 @@ interface TimerCandidate {
readonly kind: JavaScriptSemanticTimerOperation["kind"];
readonly ownerCallableId: string | null;
readonly handleBindingId: string | null;
readonly projectedResultBindings: readonly {
readonly bindingId: string;
readonly projectionPath: readonly (string | number | null)[];
readonly resolution: "complete" | "partial";
}[];
}
interface TimerCollectionInput {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly parent: t.Node | null;
readonly ownerCallableId: string | null;
readonly state: JavaScriptSemanticAnalysisState;
readonly output: TimerCandidate[];
readonly ancestors: readonly t.Node[];
}
/** Recover explicit EventEmitter and Node timer candidates from inert syntax. */
@@ -81,19 +89,28 @@ export const collectJavaScriptSemanticAsyncEffects = (
const timers: TimerCandidate[] = [];
const callableIds = new Set(callables.map(({ callableId }) => callableId));
const callableStack: string[] = [];
const ancestors: t.Node[] = [];
traverseJavaScriptAst(program, {
enter: (node, parent) => {
enter: (node) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && callableIds.has(callableId))
callableStack.push(callableId);
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return;
const ownerCallableId = callableStack.at(-1) ?? null;
collectEvent(node, ownerCallableId, state, events);
collectTimer({ node, parent, ownerCallableId, state, output: timers });
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const ownerCallableId = callableStack.at(-1) ?? null;
collectEvent(node, ownerCallableId, state, events);
collectTimer({
node,
ownerCallableId,
state,
output: timers,
ancestors,
});
}
ancestors.push(node);
},
exit: (node) => {
const callableId = semanticCallableIdForNode(node);
ancestors.pop();
if (callableId !== null && callableStack.at(-1) === callableId)
callableStack.pop();
},
@@ -187,12 +204,17 @@ const emitterIdentity = (
};
const collectTimer = (input: TimerCollectionInput): void => {
const { node, parent, ownerCallableId, state, output } = input;
const { node, ownerCallableId, state, output, ancestors } = input;
const method = timerMethod(node.callee, state);
if (method === null) return;
const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method)
? "schedule"
: "cancel";
const projectedResultBindings = assignedSemanticResultBindings(
node,
ancestors,
state,
);
output.push({
node,
method,
@@ -200,8 +222,16 @@ const collectTimer = (input: TimerCollectionInput): void => {
ownerCallableId,
handleBindingId:
kind === "schedule"
? assignedBindingId(parent, node, state)
? scalarAssignedResultBinding(projectedResultBindings)
: bindingId(node.arguments[0], state),
projectedResultBindings:
kind === "schedule"
? semanticProjectedBindingFacts(
projectedResultBindings.filter(
({ projectionPath }) => projectionPath.length > 0,
),
)
: [],
});
};
@@ -210,11 +240,18 @@ const resolveTimers = (
): JavaScriptSemanticTimerOperation[] => {
const schedulesByBinding = new Map<string, TimerCandidate[]>();
for (const candidate of candidates) {
if (candidate.kind !== "schedule" || candidate.handleBindingId === null)
continue;
const existing = schedulesByBinding.get(candidate.handleBindingId) ?? [];
existing.push(candidate);
schedulesByBinding.set(candidate.handleBindingId, existing);
if (candidate.kind !== "schedule") continue;
const ids = [
...(candidate.handleBindingId === null
? []
: [candidate.handleBindingId]),
...candidate.projectedResultBindings.map(({ bindingId }) => bindingId),
];
for (const id of ids) {
const existing = schedulesByBinding.get(id) ?? [];
existing.push(candidate);
schedulesByBinding.set(id, existing);
}
}
return candidates.map((candidate) => {
const linked =
@@ -228,6 +265,7 @@ const resolveTimers = (
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
handleBindingId: candidate.handleBindingId,
projectedResultBindings: candidate.projectedResultBindings,
linkedTimerId:
linked.length === 1 && linked[0] !== undefined
? timerId(linked[0])
@@ -236,7 +274,10 @@ const resolveTimers = (
resolution:
candidate.kind === "schedule"
? "complete"
: linked.length === 1
: linked.length === 1 &&
!linked[0]?.projectedResultBindings.some(
({ bindingId }) => bindingId === candidate.handleBindingId,
)
? "complete"
: linked.length > 1
? "partial"
@@ -303,27 +344,6 @@ const timerNamespaceBinding = (
const timerModule = (specifier: string): boolean =>
specifier === "timers" || specifier === "node:timers";
const assignedBindingId = (
parent: t.Node | null,
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): string | null => {
const identifier =
t.isVariableDeclarator(parent) &&
parent.init === node &&
t.isIdentifier(parent.id)
? parent.id
: t.isAssignmentExpression(parent) &&
parent.right === node &&
t.isIdentifier(parent.left)
? parent.left
: null;
return identifier === null
? null
: (resolveSemanticBindingState(state, identifier, identifier.name)
?.bindingId ?? null);
};
const bindingId = (
node:
| t.Expression
+29 -31
View File
@@ -27,6 +27,7 @@ import {
} from "./javascriptSemanticCallResolution.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import { assignedSemanticResultBindings } from "./javascriptSemanticDataEffectHelpers.js";
/** Local call, flow, capture, and unsupported-frontier facts. */
export interface JavaScriptSemanticCallAnalysis {
@@ -56,6 +57,7 @@ interface CallCollectionContext {
string,
readonly JavaScriptSemanticBindingState[]
>;
readonly ancestors: t.Node[];
}
/** Recover direct-call and lexical-capture candidates from inert syntax. */
@@ -81,6 +83,7 @@ export const collectJavaScriptSemanticCalls = (
output,
bindingResolutionCache: new Map(),
parameterBindingCache: new Map(),
ancestors: [],
};
const callableStack: JavaScriptSemanticCallable[] = [];
traverseJavaScriptAst(program, {
@@ -99,8 +102,10 @@ export const collectJavaScriptSemanticCalls = (
t.isNewExpression(node)
)
collectCallSite(node, parent, owner, context);
context.ancestors.push(node);
},
exit: (node) => {
context.ancestors.pop();
const callableId = semanticCallableIdForNode(node);
if (
callableId !== null &&
@@ -151,7 +156,7 @@ const collectCallSite = (
arguments: argumentsValue,
};
output.callSites.push(site);
collectCallResultFlow(site, node, parent, context);
collectCallResultFlow(site, node, context);
if (site.resolution !== "exact")
addFrontier(
{
@@ -171,39 +176,32 @@ const collectCallSite = (
const collectCallResultFlow = (
site: JavaScriptSemanticCallSite,
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
parent: t.Node | null,
context: CallCollectionContext,
): void => {
const identifier =
t.isVariableDeclarator(parent) &&
parent.init === node &&
t.isIdentifier(parent.id)
? parent.id
: t.isAssignmentExpression(parent) &&
parent.right === node &&
t.isIdentifier(parent.left)
? parent.left
: null;
if (identifier === null) return;
const binding = resolveSemanticBindingState(
for (const assigned of assignedSemanticResultBindings(
node,
context.ancestors,
context.state,
identifier,
identifier.name,
);
const identifierRange = range(identifier);
const definition = binding?.definitions.find(
({ location }) =>
location.start.line === identifierRange.start.line &&
location.start.column === identifierRange.start.column &&
location.end.line === identifierRange.end.line &&
location.end.column === identifierRange.end.column,
);
if (binding === undefined || definition === undefined) return;
context.output.callResultFlows.push({
callSiteId: site.callSiteId,
bindingId: binding.bindingId,
definitionLocation: definition.location,
});
).filter(({ projectionPath }) => projectionPath.length === 0)) {
const binding = [...context.state.bindingsById.values()].find(
({ bindingId }) => bindingId === assigned.bindingId,
);
if (binding === undefined) continue;
const identifierRange = range(assigned.identifier);
const definition = binding.definitions.find(
({ location }) =>
location.start.line === identifierRange.start.line &&
location.start.column === identifierRange.start.column &&
location.end.line === identifierRange.end.line &&
location.end.column === identifierRange.end.column,
);
if (definition === undefined) continue;
context.output.callResultFlows.push({
callSiteId: site.callSiteId,
bindingId: binding.bindingId,
definitionLocation: definition.location,
});
}
};
const retainedArguments = (
+56 -43
View File
@@ -15,14 +15,13 @@ import {
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import {
compareCodePoints,
propertyName,
range,
} from "./javascriptStaticAnalysisHelpers.js";
import { compareCodePoints, range } from "./javascriptStaticAnalysisHelpers.js";
import {
dataEffectLiteralString,
dataEffectMemberCallee,
assignedSemanticResultBindings,
scalarAssignedResultBinding,
semanticProjectedBindingFacts,
} from "./javascriptSemanticDataEffectHelpers.js";
const CHILD_PROCESS_METHODS = ["spawn", "exec", "execFile", "fork"] as const;
@@ -38,6 +37,11 @@ interface SpawnCandidate {
readonly method: JavaScriptSemanticChildProcessSpawn["method"];
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
readonly projectedResultBindings: readonly {
readonly bindingId: string;
readonly projectionPath: readonly (string | number | null)[];
readonly resolution: "complete" | "partial";
}[];
}
interface ChildInteractionContext {
@@ -77,23 +81,41 @@ const collectSpawns = (
callables.map(({ callableId }) => callableId),
);
const callableStack: string[] = [];
const ancestors: t.Node[] = [];
traverseJavaScriptAst(program, {
enter: (node, parent) => {
enter: (node) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && admittedCallables.has(callableId))
callableStack.push(callableId);
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return;
const method = childProcessMethod(node.callee, state);
if (method === null) return;
output.push({
node,
method,
ownerCallableId: callableStack.at(-1) ?? null,
resultBindingId: assignedBindingId(parent, node, state),
});
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const method = childProcessMethod(node.callee, state);
if (method !== null) {
const projectedResultBindings = assignedSemanticResultBindings(
node,
ancestors,
state,
);
output.push({
node,
method,
ownerCallableId: callableStack.at(-1) ?? null,
resultBindingId: scalarAssignedResultBinding(
projectedResultBindings,
),
projectedResultBindings: semanticProjectedBindingFacts(
projectedResultBindings.filter(
({ projectionPath }) => projectionPath.length > 0,
),
),
});
}
}
ancestors.push(node);
},
exit: (node) => {
ancestors.pop();
popCallable(node, callableStack);
},
exit: (node) => popCallable(node, callableStack),
});
return output;
};
@@ -109,6 +131,7 @@ const immutableSpawn = (
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
resultBindingId: candidate.resultBindingId,
projectedResultBindings: candidate.projectedResultBindings,
command,
argvCount: spawnArgvCount(candidate),
environmentSupplied: objectHasProperty(options, "env"),
@@ -130,10 +153,15 @@ const collectInteractions = (
const spawnsByBinding = new Map<string, SpawnCandidate[]>();
for (const spawn of spawns) {
spawnByNode.set(spawn.node, spawn);
if (spawn.resultBindingId === null) continue;
const existing = spawnsByBinding.get(spawn.resultBindingId) ?? [];
existing.push(spawn);
spawnsByBinding.set(spawn.resultBindingId, existing);
const ids = [
...(spawn.resultBindingId === null ? [] : [spawn.resultBindingId]),
...spawn.projectedResultBindings.map(({ bindingId }) => bindingId),
];
for (const id of ids) {
const existing = spawnsByBinding.get(id) ?? [];
existing.push(spawn);
spawnsByBinding.set(id, existing);
}
}
const context: ChildInteractionContext = {
state,
@@ -198,7 +226,13 @@ const childInteraction = (
: null,
listenerLocation:
kind === "listener" && t.isNode(listener) ? range(listener) : null,
resolution: linked.length === 1 ? "complete" : "partial",
resolution:
linked.length === 1 &&
!linked[0]?.projectedResultBindings.some(
({ bindingId }) => bindingId === binding?.bindingId,
)
? "complete"
: "partial",
};
};
@@ -334,27 +368,6 @@ const objectBinding = (
? resolveSemanticBindingState(state, node, node.name)
: undefined;
const assignedBindingId = (
parent: t.Node | null,
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): string | null => {
const identifier =
t.isVariableDeclarator(parent) &&
parent.init === node &&
t.isIdentifier(parent.id)
? parent.id
: t.isAssignmentExpression(parent) &&
parent.right === node &&
t.isIdentifier(parent.left)
? parent.left
: null;
return identifier === null
? null
: (resolveSemanticBindingState(state, identifier, identifier.name)
?.bindingId ?? null);
};
const popCallable = (node: t.Node, stack: string[]): void => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && stack.at(-1) === callableId) stack.pop();
+122 -35
View File
@@ -1,9 +1,6 @@
import * as t from "@babel/types";
import {
semanticCallableIdForNode,
semanticStaticPropertyName,
} from "./javascriptSemanticProjection.js";
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
@@ -11,6 +8,124 @@ import {
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
export interface SemanticProjectedBinding {
readonly bindingId: string;
readonly identifier: t.Identifier;
readonly projectionPath: readonly (string | number | null)[];
readonly resolution: "complete" | "partial";
}
/** Resolve bindings written by the nearest variable or assignment owner. */
export const assignedSemanticResultBindings = (
node: t.Node,
ancestors: readonly t.Node[],
state: JavaScriptSemanticAnalysisState,
): SemanticProjectedBinding[] => {
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
const owner = ancestors[index];
if (owner === undefined) continue;
let pattern: t.Node | undefined;
let source: t.Node | null = null;
if (t.isVariableDeclarator(owner)) {
pattern = owner.id;
source = owner.init ?? null;
} else if (t.isAssignmentExpression(owner)) {
pattern = owner.left;
source = owner.right;
}
if (
pattern !== undefined &&
source !== null &&
assignedSourceIs(node, source)
)
return projectedPatternBindings(pattern, state);
if (semanticCallableIdForNode(owner) !== null) return [];
}
return [];
};
const assignedSourceIs = (node: t.Node, source: t.Node): boolean => {
let value = source;
while (true) {
if (t.isAwaitExpression(value)) value = value.argument;
else if (
t.isParenthesizedExpression(value) ||
t.isTSAsExpression(value) ||
t.isTSTypeAssertion(value) ||
t.isTypeCastExpression(value) ||
t.isTSSatisfiesExpression(value)
)
value = value.expression;
else break;
}
return value === node;
};
const projectedPatternBindings = (
pattern: t.Node,
state: JavaScriptSemanticAnalysisState,
): SemanticProjectedBinding[] => {
const output: SemanticProjectedBinding[] = [];
const visit = (
current: t.Node,
path: readonly (string | number | null)[],
partial: boolean,
): void => {
if (t.isIdentifier(current)) {
const binding = resolveSemanticBindingState(state, current, current.name);
if (binding !== undefined)
output.push({
bindingId: binding.bindingId,
identifier: current,
projectionPath: path,
resolution: partial ? "partial" : "complete",
});
return;
}
if (t.isAssignmentPattern(current)) return visit(current.left, path, true);
if (t.isRestElement(current))
return visit(current.argument, [...path, null], true);
if (t.isObjectPattern(current)) {
for (const property of current.properties) {
if (t.isRestElement(property))
visit(property.argument, [...path, null], true);
else
visit(
property.value,
[
...path,
semanticStaticPropertyName(property.key, property.computed) ||
null,
],
partial ||
semanticStaticPropertyName(property.key, property.computed) ===
"",
);
}
} else if (t.isArrayPattern(current)) {
current.elements.forEach((element, index) => {
if (element !== null) visit(element, [...path, index], partial);
});
}
};
visit(pattern, [], false);
return output;
};
export const semanticProjectedBindingFacts = (
bindings: readonly SemanticProjectedBinding[],
): Omit<SemanticProjectedBinding, "identifier">[] =>
bindings.map(({ identifier: _identifier, ...fact }) => fact);
/** Return a scalar owner only for an unprojected identifier result. */
export const scalarAssignedResultBinding = (
bindings: readonly SemanticProjectedBinding[],
): string | null =>
bindings.length === 1 && bindings[0]?.projectionPath.length === 0
? (bindings[0]?.bindingId ?? null)
: null;
/** Shared mutable stacks reset before each deterministic data-effect pass. */
export interface DataEffectTraversalContext {
@@ -80,37 +195,9 @@ export const outerDataEffectBinding = (
node: t.Node,
context: DataEffectTraversalContext,
): string | null => {
for (let index = context.ancestors.length - 1; index >= 0; index -= 1) {
const ancestor = context.ancestors[index];
if (ancestor === undefined) continue;
if (
t.isVariableDeclarator(ancestor) &&
t.isNode(ancestor.init) &&
containsSemanticNode(ancestor.init, node) &&
t.isIdentifier(ancestor.id)
)
return (
resolveSemanticBindingState(
context.state,
ancestor.id,
ancestor.id.name,
)?.bindingId ?? null
);
if (
t.isAssignmentExpression(ancestor) &&
containsSemanticNode(ancestor.right, node) &&
t.isIdentifier(ancestor.left)
)
return (
resolveSemanticBindingState(
context.state,
ancestor.left,
ancestor.left.name,
)?.bindingId ?? null
);
if (semanticCallableIdForNode(ancestor) !== null) return null;
}
return null;
return scalarAssignedResultBinding(
assignedSemanticResultBindings(node, context.ancestors, context.state),
);
};
/** Traverse with exact callable and ancestor stacks. */
+37 -11
View File
@@ -22,14 +22,12 @@ import {
dataEffectPrimitive as primitiveValue,
isSemanticProcessObject as isProcessObject,
outerDataEffectBinding as outerAssignedBinding,
assignedSemanticResultBindings,
semanticProjectedBindingFacts,
traverseDataEffects as traverseWithContext,
type DataEffectTraversalContext as TraversalContext,
} from "./javascriptSemanticDataEffectHelpers.js";
import {
compareCodePoints,
propertyName,
range,
} from "./javascriptStaticAnalysisHelpers.js";
import { compareCodePoints, range } from "./javascriptStaticAnalysisHelpers.js";
interface DataEffectAnalysis {
readonly configurationOperations: readonly JavaScriptSemanticConfigurationOperation[];
@@ -207,6 +205,13 @@ const collectRequests = (
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
projectedResultBindings: semanticProjectedBindingFacts(
assignedSemanticResultBindings(
node,
context.ancestors,
context.state,
).filter(({ projectionPath }) => projectionPath.length > 0),
),
linkedRequestIds: [],
endpoint,
fields: requestFields(node, method, context.state),
@@ -224,11 +229,19 @@ const collectResponseConsumers = (
): JavaScriptSemanticRequestOperation[] => {
const requestsByBinding = new Map<string, RequestCandidate[]>();
for (const request of requests) {
const bindingId = request.operation.resultBindingId;
if (bindingId === null) continue;
const existing = requestsByBinding.get(bindingId) ?? [];
existing.push(request);
requestsByBinding.set(bindingId, existing);
const bindingIds = [
...(request.operation.resultBindingId === null
? []
: [request.operation.resultBindingId]),
...request.operation.projectedResultBindings.map(
({ bindingId }) => bindingId,
),
];
for (const bindingId of bindingIds) {
const existing = requestsByBinding.get(bindingId) ?? [];
existing.push(request);
requestsByBinding.set(bindingId, existing);
}
}
const output: JavaScriptSemanticRequestOperation[] = [];
traverseWithContext(program, context, (node) => {
@@ -255,12 +268,25 @@ const collectResponseConsumers = (
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
projectedResultBindings: semanticProjectedBindingFacts(
assignedSemanticResultBindings(
node,
context.ancestors,
context.state,
).filter(({ projectionPath }) => projectionPath.length > 0),
),
linkedRequestIds: linked
.map(({ operation }) => operation.requestId)
.sort(compareCodePoints),
endpoint: null,
fields: [],
resolution: linked.length === 1 ? "complete" : "partial",
resolution:
linked.length === 1 &&
!linked[0]?.operation.projectedResultBindings.some(
({ bindingId }) => bindingId === binding?.bindingId,
)
? "complete"
: "partial",
});
});
return output;
+10
View File
@@ -257,6 +257,7 @@ export interface JavaScriptSemanticTimerOperation {
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly handleBindingId: string | null;
readonly projectedResultBindings: readonly JavaScriptSemanticProjectedResultBinding[];
readonly linkedTimerId: string | null;
readonly delayMilliseconds: number | null;
readonly resolution: "complete" | "partial" | "unresolved";
@@ -269,6 +270,7 @@ export interface JavaScriptSemanticChildProcessSpawn {
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
readonly projectedResultBindings: readonly JavaScriptSemanticProjectedResultBinding[];
readonly command: string | null;
readonly argvCount: number | null;
readonly environmentSupplied: boolean;
@@ -319,6 +321,7 @@ export interface JavaScriptSemanticRequestOperation {
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
readonly projectedResultBindings: readonly JavaScriptSemanticProjectedResultBinding[];
readonly linkedRequestIds: readonly string[];
readonly endpoint: string | null;
readonly fields: readonly {
@@ -328,6 +331,13 @@ export interface JavaScriptSemanticRequestOperation {
readonly resolution: "complete" | "partial" | "unresolved";
}
/** A destructured result binding with its known projection path. */
export interface JavaScriptSemanticProjectedResultBinding {
readonly bindingId: string;
readonly projectionPath: readonly (string | number | null)[];
readonly resolution: "complete" | "partial";
}
/** One parse, coercion, or validation boundary candidate. */
export interface JavaScriptSemanticBoundaryOperation {
readonly boundaryId: string;
+3 -13
View File
@@ -25,6 +25,7 @@ import {
range,
stringValue,
} from "./javascriptStaticAnalysisHelpers.js";
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
interface CollectCallableInput {
readonly node: t.Node;
@@ -201,14 +202,7 @@ const semanticModuleOrigin = (
};
};
/** Read a property only when its syntax commits an exact name. */
export const semanticStaticPropertyName = (
property: t.Node,
computed: boolean,
): string =>
computed && !t.isStringLiteral(property) && !t.isNumericLiteral(property)
? ""
: propertyName(property);
export { semanticStaticPropertyName } from "./javascriptAstValues.js";
const callableKind = (
node: t.Node,
@@ -642,8 +636,4 @@ const commonJsExportName = (
const memberKey = (
node: t.MemberExpression | t.OptionalMemberExpression,
): string | null =>
node.computed &&
!t.isStringLiteral(node.property) &&
!t.isNumericLiteral(node.property)
? null
: propertyName(node.property);
semanticStaticPropertyName(node.property, node.computed) || null;
+19 -30
View File
@@ -19,7 +19,12 @@ import {
range,
sourceRangesEqual,
} from "./javascriptStaticAnalysisHelpers.js";
import { dataEffectMemberCallee } from "./javascriptSemanticDataEffectHelpers.js";
import {
assignedSemanticResultBindings,
containsSemanticNode,
dataEffectMemberCallee,
scalarAssignedResultBinding,
} from "./javascriptSemanticDataEffectHelpers.js";
type PromiseMethod = JavaScriptSemanticPromiseOperation["method"];
type PromiseKind = JavaScriptSemanticPromiseOperation["kind"];
@@ -244,21 +249,15 @@ const assignedPromiseBinding = (
ancestor: t.Node,
state: JavaScriptSemanticAnalysisState,
): string | null | undefined => {
if (
t.isVariableDeclarator(ancestor) &&
ancestor.init !== null &&
t.isIdentifier(ancestor.id)
)
return (
resolveSemanticBindingState(state, ancestor.id, ancestor.id.name)
?.bindingId ?? null
);
if (t.isAssignmentExpression(ancestor) && t.isIdentifier(ancestor.left))
return (
resolveSemanticBindingState(state, ancestor.left, ancestor.left.name)
?.bindingId ?? null
);
return undefined;
if (!t.isVariableDeclarator(ancestor) && !t.isAssignmentExpression(ancestor))
return undefined;
const source = t.isVariableDeclarator(ancestor)
? ancestor.init
: ancestor.right;
if (source == null) return undefined;
return scalarAssignedResultBinding(
assignedSemanticResultBindings(source, [ancestor], state),
);
};
const returnedPromiseOwnership = (
@@ -280,30 +279,20 @@ const outerConsumesCandidate = (
(t.isCallExpression(outer.node) || t.isOptionalCallExpression(outer.node))
) {
const member = dataEffectMemberCallee(outer.node);
return member !== null && containsNode(member.object, candidate.node);
return (
member !== null && containsSemanticNode(member.object, candidate.node)
);
}
if (
outer.kind === "aggregate" &&
(t.isCallExpression(outer.node) || t.isOptionalCallExpression(outer.node))
) {
const input = outer.node.arguments[0];
return t.isNode(input) && containsNode(input, candidate.node);
return t.isNode(input) && containsSemanticNode(input, candidate.node);
}
return false;
};
const containsNode = (outer: t.Node, inner: t.Node): boolean =>
outer.start !== null &&
outer.start !== undefined &&
outer.end !== null &&
outer.end !== undefined &&
inner.start !== null &&
inner.start !== undefined &&
inner.end !== null &&
inner.end !== undefined &&
outer.start <= inner.start &&
outer.end >= inner.end;
const emptyOwnership = (
ownership: JavaScriptSemanticPromiseOperation["ownership"],
): PromiseOwnership => ({
+3 -4
View File
@@ -17,6 +17,7 @@ import {
propertyName,
stringValue,
} from "./javascriptStaticAnalysisHelpers.js";
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
import {
semanticAmbiguousProvenance,
semanticLocalProvenance,
@@ -454,10 +455,8 @@ const mergeValues = (
const memberKey = (
node: t.MemberExpression | t.OptionalMemberExpression,
): string | number | undefined => {
if (!node.computed) return propertyName(node.property) || undefined;
if (t.isStringLiteral(node.property) || t.isNumericLiteral(node.property))
return node.property.value;
return undefined;
const name = semanticStaticPropertyName(node.property, node.computed);
return name === "" ? undefined : name;
};
const nestedContext = (
@@ -3,6 +3,15 @@ import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("JavaScript static analysis findings", () => {
it("does not infer a webpack push from a dynamic computed key", () => {
const analysis = analyzeJavaScriptStaticSource(`
const push = "push";
const arr = {};
arr[push]([[1], { 1: function () {} }]);
`);
expect(analysis.bundler_registrations).toEqual([]);
});
it("retains every static reference and Electron property in large literals", () => {
const names = Array.from({ length: 10_001 }, (_, index) => `key${index}`);
const source = [
+3 -3
View File
@@ -12,7 +12,7 @@ import {
calleeName,
endpointArgument as endpointArgumentHelper,
prefixedArgument,
propertyName,
semanticStaticPropertyName,
range,
rangeForOffsets,
sourceSlice,
@@ -176,7 +176,7 @@ export const inspectRouteProperty = (
node: t.ObjectProperty,
context: FindingContext,
): void => {
const key = propertyName(node.key);
const key = semanticStaticPropertyName(node.key, node.computed);
if ((key === "path" || key === "route") && t.isStringLiteral(node.value))
addEndpoint(context, {
node,
@@ -190,7 +190,7 @@ export const inspectRoleProperty = (
node: t.ObjectProperty,
context: FindingContext,
): void => {
if (propertyName(node.key) !== "preload") return;
if (semanticStaticPropertyName(node.key, node.computed) !== "preload") return;
const path = staticPath(node.value);
if (path === undefined) return;
addLocatedFinding(context, {
+14 -7
View File
@@ -10,9 +10,12 @@ import type {
JavaScriptStaticPathContext,
JavaScriptStaticStorage,
} from "./javascriptStaticAnalysisTypes.js";
import { propertyName } from "./javascriptAstValues.js";
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
export { propertyName } from "./javascriptAstValues.js";
export {
propertyName,
semanticStaticPropertyName,
} from "./javascriptAstValues.js";
/** Explicit result for source text that Babel cannot parse. */
export const failedJavaScriptStaticAnalysis = (): JavaScriptStaticAnalysis => ({
@@ -59,7 +62,11 @@ export const chunkRuntime = (call: t.CallExpression): string | undefined => {
!t.isOptionalMemberExpression(call.callee)
)
return undefined;
if (propertyName(call.callee.property) !== "push") return undefined;
if (
semanticStaticPropertyName(call.callee.property, call.callee.computed) !==
"push"
)
return undefined;
return findChunkRuntime(call.callee.object);
};
@@ -67,7 +74,7 @@ const findChunkRuntime = (node: t.Node): string | undefined => {
if (t.isIdentifier(node) && /(?:webpack|rspack)Chunk/iu.test(node.name))
return node.name;
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
const property = propertyName(node.property);
const property = semanticStaticPropertyName(node.property, node.computed);
if (/(?:webpack|rspack)Chunk/iu.test(property)) return property;
return t.isNode(node.object) ? findChunkRuntime(node.object) : undefined;
}
@@ -107,7 +114,7 @@ export const modulePropertyName = (
!t.isStringLiteral(property.key) &&
!t.isNumericLiteral(property.key)
? `[computed@${String(property.start ?? -1)}]`
: propertyName(property.key) ||
: semanticStaticPropertyName(property.key, property.computed) ||
`[unknown@${String(property.start ?? -1)}]`
: `[unknown@${String(property.start ?? -1)}]`;
@@ -241,7 +248,7 @@ const isFileIdentity = (node: t.Node | undefined): boolean => {
t.isMetaProperty(node.object) &&
node.object.meta.name === "import" &&
node.object.property.name === "meta" &&
propertyName(node.property) === "url"
semanticStaticPropertyName(node.property, node.computed) === "url"
)
return true;
if (!t.isCallExpression(node)) return false;
@@ -312,7 +319,7 @@ const memberPropertyName = (
!t.isStringLiteral(node.property) &&
!t.isNumericLiteral(node.property)
? `[computed@${String(node.property.start ?? -1)}]`
: propertyName(node.property);
: semanticStaticPropertyName(node.property, node.computed);
/** Return a string literal value without evaluating an expression. */
export const stringValue = (
@@ -0,0 +1,124 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { compareManagedMembers } from "./managedMemberComparison.js";
import { inspectManagedMembersBytes } from "../dotnet/ManagedMemberInspector.js";
import {
buildManagedPeFixture,
managedPeFixtureTarget,
} from "../dotnet/ManagedPe.fixture.js";
const inspect = (bytes: Buffer, path: string) => {
const target = managedPeFixtureTarget(bytes, path);
const result = inspectManagedMembersBytes(bytes, target);
return { result, evidenceId: `ev_${hash(Buffer.from(path))}` };
};
const hash = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
describe("managed member partial body comparison", () => {
it("keeps unavailable body facets unknown while retaining an observed signature change", () => {
const left = inspect(buildManagedPeFixture(), "/tmp/left-body-partial.dll");
const right = inspect(
buildManagedPeFixture(),
"/tmp/right-body-partial.dll",
);
const leftMethod = left.result.methods[0];
const rightMethod = right.result.methods[0];
expect(leftMethod).toBeDefined();
expect(rightMethod).toBeDefined();
if (leftMethod === undefined || rightMethod === undefined) return;
const leftResult = {
...left.result,
methods: [
{
...leftMethod,
body: {
...leftMethod.body,
status: "partial" as const,
normalized_il_sha256: null,
},
},
],
};
const rightResult = {
...right.result,
methods: [
{
...rightMethod,
body: {
...rightMethod.body,
status: "partial" as const,
normalized_il_sha256: null,
opcode_counts: { nop: 1 },
},
},
],
};
const partial = compareManagedMembers(
{ evidenceId: left.evidenceId, result: leftResult },
{ evidenceId: right.evidenceId, result: rightResult },
);
expect(partial.methods[0]).toMatchObject({
status: "unknown",
dimensions: ["body-coverage"],
});
const rightSignatureChanged = {
...right.result,
methods: [
{
...rightMethod,
signature: {
...rightMethod.signature,
raw_sha256: "f".repeat(64),
},
},
],
};
const signatureChanged = compareManagedMembers(
{ evidenceId: left.evidenceId, result: left.result },
{
evidenceId: right.evidenceId,
result: rightSignatureChanged,
},
);
expect(signatureChanged.methods[0]).toMatchObject({
status: "changed",
dimensions: ["signature"],
});
const bothAbsent = compareManagedMembers(
{
evidenceId: left.evidenceId,
result: {
...left.result,
methods: [
{
...leftMethod,
body: { ...leftMethod.body, status: "absent" as const },
},
],
},
},
{
evidenceId: right.evidenceId,
result: {
...right.result,
methods: [
{
...rightMethod,
body: { ...rightMethod.body, status: "absent" as const },
},
],
},
},
);
expect(bothAbsent.methods[0]).toMatchObject({
status: "unchanged",
dimensions: [],
});
});
});
+5 -5
View File
@@ -29,7 +29,7 @@ describe("managed member comparison", () => {
{ evidenceId: right.evidenceId, result: right.result },
);
expect(result.algorithm.name_matching).toBe("not-used");
expect(result.algorithm.name_matching).toBe("exact-signature-fallback");
expect(result.left.mvid).toBe("00112233-4455-6677-8899-aabbccddeeff");
expect(result.right.mvid).toBe("33221100-5544-7766-8899-aabbccddeefe");
expect(result.matching.exact_il_signature).toBe(1);
@@ -47,7 +47,7 @@ describe("managed member comparison", () => {
}),
]);
expect(result.limitations).toContain(
"Names are reported as observations but are not used as a matching basis.",
"Methods pair by exact CIL/signature, then declared type, name, and signature, then structural shape; names alone are never a matching basis.",
);
expect(
managedMemberComparisonResultSchema.safeParse({
@@ -57,7 +57,7 @@ describe("managed member comparison", () => {
).toBe(true);
});
it("uses structural method shape when build-local token operands drift", () => {
it("compares changed CIL after pairing by exact signature", () => {
const leftBytes = buildManagedPeFixture();
const rightBytes = buildManagedPeFixture({
ilBody: Buffer.from([
@@ -73,10 +73,10 @@ describe("managed member comparison", () => {
);
expect(result.matching.exact_il_signature).toBe(0);
expect(result.matching.structural_method_shape).toBe(1);
expect(result.matching.exact_signature).toBe(1);
expect(result.methods[0]).toMatchObject({
status: "changed",
match: { status: "matched", basis: "structural-method-shape" },
match: { status: "matched", basis: "exact-signature" },
dimensions: ["cil"],
});
});
+7 -4
View File
@@ -42,6 +42,7 @@ export const compareManagedMembersInputSchema = z
const concreteMatchBasisSchema = z.enum([
"exact-il-signature",
"exact-signature",
"structural-method-shape",
"field-signature",
]);
@@ -85,7 +86,7 @@ const comparisonItemSchema = <
z.union([
z.strictObject({
...base,
status: z.enum(["unchanged", "changed"]),
status: z.enum(["unchanged", "changed", "unknown"]),
left: identity,
right: identity,
match: matchedComparisonSchema,
@@ -131,6 +132,7 @@ const methodComparisonItemSchema = comparisonItemSchema(
"field-shape",
"exception-shape",
"availability",
"body-coverage",
]),
),
},
@@ -151,7 +153,7 @@ export const managedMemberComparisonResultSchema = z.strictObject({
algorithm: z.strictObject({
name: z.literal("rea-managed-member-comparison"),
token_identity: z.literal("build-local"),
name_matching: z.literal("not-used"),
name_matching: z.literal("exact-signature-fallback"),
}),
left: z.strictObject({
evidence_id: evidenceIdSchema,
@@ -180,6 +182,7 @@ export const managedMemberComparisonResultSchema = z.strictObject({
}),
matching: z.strictObject({
exact_il_signature: z.number().int().min(0),
exact_signature: z.number().int().min(0),
structural_method_shape: z.number().int().min(0),
field_signature: z.number().int().min(0),
ambiguous: z.number().int().min(0),
@@ -209,7 +212,7 @@ export interface ManagedMemberComparisonSide {
readonly result: ManagedMemberInspection;
}
/** Compare two parsed managed member observations without name-based matching. */
/** Compare managed members with constrained exact and structural identities. */
export const compareManagedMembers = (
left: ManagedMemberComparisonSide,
right: ManagedMemberComparisonSide,
@@ -247,7 +250,7 @@ export const compareManagedMembers = (
algorithm: {
name: "rea-managed-member-comparison" as const,
token_identity: "build-local" as const,
name_matching: "not-used" as const,
name_matching: "exact-signature-fallback" as const,
},
left: sideManifest(left),
right: sideManifest(right),
@@ -68,6 +68,9 @@ export const buildComparisonMatching = (
exact_il_signature: methodItems.filter(
({ match }) => match.basis === "exact-il-signature",
).length,
exact_signature: methodItems.filter(
({ match }) => match.basis === "exact-signature",
).length,
structural_method_shape: methodItems.filter(
({ match }) => match.basis === "structural-method-shape",
).length,
@@ -88,7 +91,7 @@ export const comparisonLimitations = (
): string[] => {
const limitations: string[] = [
"Metadata tokens are build-local coordinates; matched pairs are remaps, not persistent identities.",
"Names are reported as observations but are not used as a matching basis.",
"Methods pair by exact CIL/signature, then declared type, name, and signature, then structural shape; names alone are never a matching basis.",
];
if (left.coverage.state !== "complete" || right.coverage.state !== "complete")
limitations.push("At least one managed member observation is partial.");
+38 -7
View File
@@ -28,13 +28,30 @@ interface ComparisonItemContext {
readonly rightComplete: boolean;
}
const changedMethodDimensions = (
const methodDimensions = (
left: Method,
right: Method,
): MethodItem["dimensions"] => {
): {
readonly dimensions: MethodItem["dimensions"];
readonly bodyUnknown: boolean;
} => {
const dimensions: MethodItem["dimensions"] = [];
if (left.signature.raw_sha256 !== right.signature.raw_sha256)
dimensions.push("signature");
const leftBodyComplete =
left.body.status === "present" && left.body.truncated_instructions === 0;
const rightBodyComplete =
right.body.status === "present" && right.body.truncated_instructions === 0;
const bothAbsent =
left.body.status === "absent" && right.body.status === "absent";
if (
(left.body.status === "absent" && rightBodyComplete) ||
(right.body.status === "absent" && leftBodyComplete)
)
dimensions.push("availability");
if (bothAbsent) return { dimensions, bodyUnknown: false };
if (!leftBodyComplete || !rightBodyComplete)
return { dimensions, bodyUnknown: true };
if (left.body.normalized_il_sha256 !== right.body.normalized_il_sha256)
dimensions.push("cil");
if (
@@ -51,8 +68,7 @@ const changedMethodDimensions = (
canonicalize(right.body.exception_regions)
)
dimensions.push("exception-shape");
if (left.body.status !== right.body.status) dimensions.push("availability");
return dimensions;
return { dimensions, bodyUnknown: false };
};
const callShape = (anchors: Method["body"]["anchors"]): string =>
@@ -174,14 +190,25 @@ export const buildMethodItems = (
): MethodItem[] => {
const items: MethodItem[] = [];
for (const pair of matches.pairs) {
const dimensions = changedMethodDimensions(pair.left.item, pair.right.item);
const { dimensions: observedDimensions, bodyUnknown } = methodDimensions(
pair.left.item,
pair.right.item,
);
const dimensions = bodyUnknown
? [...observedDimensions, "body-coverage" as const]
: observedDimensions;
const observedChange = observedDimensions.length > 0;
items.push({
item_id: `mmc_method_${sha256({
left: pair.left.item.token,
right: pair.right.item.token,
basis: pair.basis,
})}`,
status: dimensions.length === 0 ? "unchanged" : "changed",
status: observedChange
? "changed"
: bodyUnknown
? "unknown"
: "unchanged",
left: methodIdentity(pair.left.item),
right: methodIdentity(pair.right.item),
match: {
@@ -193,7 +220,11 @@ export const buildMethodItems = (
},
dimensions,
evidence_links: [context.leftEvidenceId, context.rightEvidenceId],
limitations: [],
limitations: bodyUnknown
? [
"Method body facets are unknown because at least one side has unavailable or partial body data.",
]
: [],
});
}
for (const ambiguous of matches.ambiguous) {
+27 -6
View File
@@ -29,6 +29,7 @@ type ConcreteMatchBasis = Exclude<MatchBasis, "none">;
interface Keyed<Item> {
readonly item: Item;
readonly exactKey: string | null;
readonly signatureKey: string | null;
readonly structuralKey: string | null;
}
@@ -57,6 +58,15 @@ const keyMethod = (item: Method): Keyed<Method> => ({
item.body.normalized_il_sha256,
])
: null,
signatureKey:
item.signature.parse_status === "decoded"
? stableKey([
"method-signature",
item.declaring_type,
item.name,
item.signature.raw_sha256,
])
: null,
structuralKey:
item.body.status === "present"
? stableKey([
@@ -94,6 +104,7 @@ const keyField = (item: Field): Keyed<Field> => ({
item.signature.parse_status === "decoded"
? stableKey(["field-exact", item.signature.raw_sha256])
: null,
signatureKey: null,
structuralKey: stableKey([
"field-structural",
item.signature.kind,
@@ -112,7 +123,16 @@ const matchMethods = (
left,
right,
exactBasis: "exact-il-signature",
fallbackBases: ["structural-method-shape"],
fallbackBases: [
{
basis: "exact-signature",
key: ({ signatureKey }) => signatureKey,
},
{
basis: "structural-method-shape",
key: ({ structuralKey }) => structuralKey,
},
],
});
const matchFields = (
@@ -133,7 +153,10 @@ interface MatchByKeysInput<Item> {
readonly left: readonly Keyed<Item>[];
readonly right: readonly Keyed<Item>[];
readonly exactBasis: ConcreteMatchBasis;
readonly fallbackBases: readonly ConcreteMatchBasis[];
readonly fallbackBases: readonly {
readonly basis: ConcreteMatchBasis;
readonly key: (item: Keyed<Item>) => string | null;
}[];
}
const matchByKeys = <Item>({
@@ -156,10 +179,7 @@ const matchByKeys = <Item>({
readonly key: (item: Keyed<Item>) => string | null;
}[] = [
{ basis: exactBasis, key: ({ exactKey }) => exactKey },
...fallbackBases.map((basis) => ({
basis,
key: ({ structuralKey }: Keyed<Item>) => structuralKey,
})),
...fallbackBases,
];
for (const round of rounds) {
const leftGroups = groupBy(
@@ -185,6 +205,7 @@ const matchByKeys = <Item>({
basis: round.basis,
confidence:
round.basis === "exact-il-signature" ||
round.basis === "exact-signature" ||
round.basis === "field-signature"
? "exact"
: "high",
-1
View File
@@ -73,7 +73,6 @@ const dependencySchema = z.object({
const loadCommandSchema = z.object({
index: z.number().int().min(0),
kind: z.string(),
file_offset: z.number().int().min(0).nullable(),
fields: z.record(z.string(), z.union([z.string(), z.number(), z.null()])),
});
const buildMetadataSchema = z.object({
+4 -11
View File
@@ -1,6 +1,4 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { absenceClaimable, canonicalDigest } from "./comparisonSemantics.js";
import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js";
import type { HistoricalSourceGraph } from "./referenceSourceGraph.js";
@@ -102,8 +100,7 @@ const projectComparison = (
index,
absenceComplete:
input.reference.inventory_state === "complete" &&
input.application.graph.coverage.status === "complete" &&
!input.application.graph.coverage.truncated,
absenceClaimable(input.application.graph.coverage),
factors: {
input,
},
@@ -367,12 +364,8 @@ const compareCandidates = (
right.score - left.score ||
compareText(left.current_node_id, right.current_node_id);
const digestCanonical = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Source-to-bundle comparison could not canonicalize");
return createHash("sha256").update(encoded).digest("hex");
};
const digestCanonical = (value: unknown): string =>
canonicalDigest(value, "Source-to-bundle comparison");
const uniqueSorted = (values: readonly string[]): string[] =>
[...new Set(values)].sort(compareText);
+2 -9
View File
@@ -1,7 +1,4 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { canonicalDigest } from "./comparisonSemantics.js";
import type { WebPageInspection } from "./browserObservation.js";
import {
webCaptureDiffSchema,
@@ -333,11 +330,7 @@ const metadataProjection = (inspection: WebPageInspection) => ({
const singleton = (key: string, value: string): ReadonlyMap<string, string> =>
new Map([[key, value]]);
const digest = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined) throw new TypeError("Expected canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};
const digest = (value: unknown): string => canonicalDigest(value);
const accessibilityComparable = (
inspection: WebPageInspection,

Some files were not shown because too many files have changed in this diff Show More