Compare commits

..
Author SHA1 Message Date
morluto 5e169583f6 Merge remote-tracking branch 'origin/main' into perf/tests-cli-session-default
# Conflicts:
#	tests/boundary/cli/reconstructionObligationLedgerCli.test.ts
2026-11-13 18:00:07 -05:00
morluto 497fd2acd9 test(cli): give each shared-session command an empty stdin
The shared CLI process reads session frames from its own stdin. A
command that reads stdin without supplied input, such as
`compare-wasm-interfaces - --json`, read that pipe and hung, where a
fresh process receives EOF. The worker now presents an ended, empty
stdin to each command and restores its own afterwards.

The parity test compared the routed path with itself after cli.run
began choosing the session. It now compares a fresh process with the
session directly, including the `mcp add` refusal and a command
reading absent stdin.
2026-11-13 15:24:05 -05:00
morluto 73b207ac64 style(tests): format the managed member summary call site 2026-11-13 15:08:43 -05:00
morluto 5b7f5d2298 perf(tests): run plain CLI commands on the file's shared process
`cli.run` in `cliTest` files started a fresh compiled CLI for every
command, about 0.45s and 260 MB each before any work. The file-scoped
session already existed, but callers had to opt in through
`runFileCli` or `sharedCliRun`, so 1,300 call sites still spawned.

Make the fixture choose: `cli.run` uses the shared process when it
reports the command exactly as a fresh process would, and starts one
otherwise (stdin, explicit deadlines, MCP and MCP doctor routing,
NODE_OPTIONS, or enabled logging). `freshProcess: true` keeps
explicit process-level checks. The session worker now presents the
launcher's entrypoint, so registration identity matches a fresh
process.

`runFileCli` and `sharedCliRun` are superseded and removed; their
call sites use `cli.run`, and paired fresh/shared checks use
`freshProcess`.
2026-11-13 14:44:08 -05:00
morluto 940d7156f6 test(cli): bundle the one-shot entry in the runtime bundle fixture 2026-11-13 14:44:06 -05:00
morluto 06d2413c41 refactor(cli): run one-shot commands through one shared owner
scripts/rea.mjs and the test CLI session worker each implemented the
one-shot command sequence: argument normalization, output admission,
configuration errors, version documents, the Amp scope refusal, and
result delivery. The worker copy had drifted: it never rendered
formatted version reports or the Amp refusal.

Move the sequence into src/cliOneShot.ts. Both callers supply a host
for their streams, exit and resource ownership. The launcher keeps
closed-pipe status, worker shutdown and process exit; the session
worker captures output per command.

The cliOutput bundle entry is superseded and removed. The dispatcher
test now replaces only the command runtime, so output admission runs
production code. A new parity test compares the file session with a
fresh process.
2026-11-13 14:13:07 -05:00
386 changed files with 4382 additions and 3228 deletions

No files matched your search

+1 -24
View File
@@ -63,18 +63,6 @@
"rea/no-shared-module-mock": "error"
},
"overrides": [
{
"files": ["src/domain/**/*.ts"],
"rules": { "rea/no-locale-compare": "error" }
},
{
"files": [
"src/domain/**/*.test.ts",
"src/domain/**/*.spec.ts",
"src/domain/**/*.fixture.ts"
],
"rules": { "rea/no-locale-compare": "off" }
},
{
"files": [
"scripts/electron-active-hook*.cjs",
@@ -88,19 +76,12 @@
{
"files": ["src/**/*.ts"],
"rules": {
"rea/no-babel-visitor-keys": "error",
"typescript/no-misused-promises": "error"
}
},
{
"files": [
"tests/**/*.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.fixture.ts"
],
"files": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.fixture.ts"],
"rules": {
"rea/no-babel-visitor-keys": "off",
"max-lines": "off",
"max-lines-per-function": [
"error",
@@ -113,10 +94,6 @@
"typescript/no-misused-promises": "warn"
}
},
{
"files": ["src/domain/javascript/javascriptSemanticTraversal.ts"],
"rules": { "rea/no-babel-visitor-keys": "off" }
},
{
"files": ["src/application/**/*.test.ts"],
"rules": {
+11 -5
View File
@@ -357,11 +357,17 @@ The process restarts when the environment changes, after 32 commands, or when
the worker reports a resident set of at least 1 GiB. A fatal worker failure
or a broken session frame retires that process and waits for it to exit before
the next command starts.
`cli.run` still starts a fresh process, including stdin and explicit deadlines.
`runFileCli` uses the file session for commands that have neither. Go binary,
IL2CPP metadata, and dylib-resolution commands use that session. File teardown
stops the shared CLI process, including startup still in flight, and does not
open another after close.
In a `cliTest` file, `cli.run` uses that shared process when it reports the
command exactly as a fresh `rea` process would. Both run the same one-shot
owner (`src/cliOneShot.ts`), and the shared process presents the launcher's
entrypoint. A command starts a fresh process instead when it supplies stdin or
an explicit deadline, enters MCP or the MCP doctor (which the launcher routes
first), sets `NODE_OPTIONS`, or enables logging, which writes outside the
per-command capture. Pass `freshProcess: true` for process-level facts such as
startup, signals, or exit, and keep one fresh check beside reused cases where
dispatch itself is under test. `createTestCli` drivers always start a fresh
process. File teardown stops the shared CLI process, including startup still in
flight, and does not open another after close.
`exportedFunctionBindingsCli.test.ts` uses the shared compiled CLI session for
its file and compares its JSON command results with the MCP session. It selects
20-second startup and command deadlines; other callers retain their existing
+1 -3
View File
@@ -174,9 +174,7 @@ Official references: [mitmproxy protocol support](https://github.com/mitmproxy/m
## Verifier regressions
The built process-boundary test suite runs the Node verifier regressions on
POSIX hosts. To run them directly after building REA, and to run the Python
laboratory regressions with its pinned dependencies, use the isolated guest:
After building REA, run these inside the same isolated guest:
```sh
node --test scripts/lib/http3-verifier-lifecycle.test.mjs scripts/lib/http3-capture-assertions.test.mjs
+1
View File
@@ -3,6 +3,7 @@
"entry": [
"src/main.ts",
"src/cli.ts",
"src/cliOneShot.ts",
"src/cliOutput.ts",
"src/cliBuiltinArguments.ts",
"src/cli/cliResultOutput.ts",
+1
View File
@@ -3,6 +3,7 @@
"entry": [
"src/main.ts!",
"src/cli.ts!",
"src/cliOneShot.ts!",
"src/cliOutput.ts!",
"src/cliBuiltinArguments.ts!",
"src/cli/cliResultOutput.ts!",
+1 -1
View File
@@ -24,7 +24,7 @@ export const bundleRuntime = async (root) => {
"cli-runtime",
{
cli: "cli.js",
cliOutput: "cliOutput.js",
cliOneShot: "cliOneShot.js",
cliBuiltinArguments: "cliBuiltinArguments.js",
cliResultOutput: "cli/cliResultOutput.js",
mcpDoctor: "mcpDoctor.js",
+1 -1
View File
@@ -14,7 +14,7 @@ const directDependencies = (rootPackage) => ({
const installedPackageKey = (name) => `node_modules/${name}`;
/** Compare root direct dependencies with npm's hidden installed lockfile. */
const dependencyInstallProblems = (rootLock, installedLock) => {
export const dependencyInstallProblems = (rootLock, installedLock) => {
const rootPackage = rootLock.packages?.[""];
if (rootPackage === undefined)
return ["package-lock.json does not contain a root package entry"];
+2
View File
@@ -26,6 +26,8 @@ export type CacheRequest = (
endpoint: string,
) => Promise<unknown>;
export function assertRetentionPrefix(prefix: string): void;
export function selectPrefixDeletions(
caches: readonly unknown[],
family?: RetentionFamily,
+1 -1
View File
@@ -22,7 +22,7 @@ const PREFIX_PATTERNS = [
];
/** Reject prefixes that could match npm downloads or another platform. */
function assertRetentionPrefix(prefix) {
export function assertRetentionPrefix(prefix) {
if (
typeof prefix !== "string" ||
!PREFIX_PATTERNS.some((pattern) => pattern.test(prefix))
+6
View File
@@ -99,3 +99,9 @@ export function createGitHubApi(options?: {
budget: { requests: number; startedAt: number; limitMs: number },
) => Promise<unknown>;
}): FailureReportApi;
export function writeFailureReport(
report: CiFailureReport,
outputDirectory?: string,
summaryPath?: string,
): Promise<{ jsonPath: string; markdownPath: string }>;
+1 -1
View File
@@ -467,7 +467,7 @@ function errorCode(error) {
return error instanceof Error ? error.message.slice(0, 160) : "unknown_error";
}
async function writeFailureReport(
export async function writeFailureReport(
report,
outputDirectory = OUTPUT_DIRECTORY,
summaryPath,
+1
View File
@@ -91,4 +91,5 @@ export function writePrCiHealthReport(
outputDirectory?: string,
): Promise<{ jsonPath: string; markdownPath: string }>;
export function renderMarkdownSummary(report: PrCiHealthReport): string;
import type { GitHubApiRequest } from "./github-api.mjs";
+1 -1
View File
@@ -656,7 +656,7 @@ export async function writePrCiHealthReport(
return { jsonPath, markdownPath };
}
function renderMarkdownSummary(report) {
export function renderMarkdownSummary(report) {
const rows = report.pullRequests.map(
(pull) =>
`| #${String(pull.number ?? "?")} | ${escapeCell(pull.head.repository ?? "unknown")} | ${escapeCell(pull.head.ref ?? "unknown")} | \`${pull.head.sha ?? "unknown"}\` | ${pull.ci.category} | ${escapeCell(pull.ci.reason)} |`,
@@ -28,3 +28,6 @@ export interface ProductionReachabilityReport {
export function parseProductionReachabilityReport(
text: string,
): ProductionReachabilityReport;
export function recordProductionReachabilityReport(
root?: string,
): Promise<ProductionReachabilityReport>;
@@ -34,7 +34,9 @@ export const parseProductionReachabilityReport = (text) =>
reportSchema.parse(JSON.parse(text));
/** Run production reachability without turning candidates into failures. */
const recordProductionReachabilityReport = async (root = process.cwd()) => {
export const recordProductionReachabilityReport = async (
root = process.cwd(),
) => {
const reportDirectory = join(root, ".cache", "static-checks");
const reportPath = join(reportDirectory, "production-reachability.json");
+1 -1
View File
@@ -6,7 +6,7 @@ import { promisify } from "node:util";
const exec = promisify(execFile);
/** Commands the macOS bundle fixture needs from Command Line Tools. */
const MACOS_BUNDLE_FIXTURE_COMMANDS = [
export const MACOS_BUNDLE_FIXTURE_COMMANDS = [
["/usr/bin/xcrun", ["--find", "clang"]],
["/usr/bin/xcrun", ["--find", "codesign"]],
["/usr/bin/ditto", ["-h"]],
+1 -1
View File
@@ -1,4 +1,4 @@
function sourceMapSummary(sourceMaps) {
export function sourceMapSummary(sourceMaps) {
return {
status: sourceMaps.status,
requested: sourceMaps.requested,
+13
View File
@@ -1,3 +1,11 @@
export const CURSOR_PLUGIN_MANIFEST: ".cursor-plugin/plugin.json";
export const CURSOR_PLUGIN_MCP: "mcp.json";
export const AUTHORED_SKILL_DIRECTORY: ".agents/skills/reverse-engineer-anything";
export const CURSOR_PLUGIN_SKILLS: ".cursor-plugin/skills";
export const CURSOR_PLUGIN_SKILL: "reverse-engineer-anything";
export const CURSOR_PLUGIN_LOGO: "assets/rea-plugin.png";
export const CURSOR_PLUGIN_REPOSITORY: "https://github.com/morluto/rea";
export interface CursorPluginBundleIssue {
readonly field: string;
}
@@ -48,6 +56,11 @@ export interface CursorPluginInspection {
readonly issues: readonly CursorPluginBundleIssue[];
}
export function cursorPluginSkillPaths(
directory: string,
prefix?: string,
): Promise<readonly string[] | null>;
export function cursorPluginBundleIssues(
input: CursorPluginBundleInput,
): readonly CursorPluginBundleIssue[];
+9 -8
View File
@@ -7,13 +7,14 @@ import { ensureGeneratedFile } from "./generated-file.mjs";
// publication (name, release version, repository, skill, and icon). The
// Marketplace checkout has no dist/, so the server is the pinned npm command
// instead of the bundled launcher.
const CURSOR_PLUGIN_MANIFEST = ".cursor-plugin/plugin.json";
const CURSOR_PLUGIN_MCP = "mcp.json";
const AUTHORED_SKILL_DIRECTORY = ".agents/skills/reverse-engineer-anything";
const CURSOR_PLUGIN_SKILLS = ".cursor-plugin/skills";
const CURSOR_PLUGIN_SKILL = "reverse-engineer-anything";
const CURSOR_PLUGIN_LOGO = "assets/rea-plugin.png";
const CURSOR_PLUGIN_REPOSITORY = "https://github.com/morluto/rea";
export const CURSOR_PLUGIN_MANIFEST = ".cursor-plugin/plugin.json";
export const CURSOR_PLUGIN_MCP = "mcp.json";
export const AUTHORED_SKILL_DIRECTORY =
".agents/skills/reverse-engineer-anything";
export const CURSOR_PLUGIN_SKILLS = ".cursor-plugin/skills";
export const CURSOR_PLUGIN_SKILL = "reverse-engineer-anything";
export const CURSOR_PLUGIN_LOGO = "assets/rea-plugin.png";
export const CURSOR_PLUGIN_REPOSITORY = "https://github.com/morluto/rea";
const GENERATE_COMMAND = "node scripts/generate-cursor-plugin-skill.mjs";
const INSTALL_LINK_MARKERS = ["install-mcp", "deeplink/mcp", "mcp-install"];
@@ -26,7 +27,7 @@ const failure = (code, detail) => {
const cursorServerArgs = (version) => ["-y", `rea-agents@${version}`, "mcp"];
/** Relative file inventory for one skill directory, in stable order. */
const cursorPluginSkillPaths = async (directory, prefix = "") => {
export const cursorPluginSkillPaths = async (directory, prefix = "") => {
const paths = [];
let entries;
try {
+1 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
const WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE = Object.freeze({
export const WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE = Object.freeze({
pattern: "a0 ?? c0",
mask: "f0 ff f0",
startFileOffset: 0x380,
-16
View File
@@ -1,16 +0,0 @@
import type { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
export class HopperDeadlineCleanupFailure extends AggregateError {
readonly _tag: "HopperDeadlineCleanupFailure";
readonly cleanupOwner: HopperDeadlineCleanup;
constructor(owner: HopperDeadlineCleanup, failures: unknown[]);
}
export class HopperDeadlineCleanup {
readonly workspace: string;
constructor(workspace: string, profile: ArtifactResourceScope);
setClient(client: {
close(): Promise<{ ok: true } | { ok: false; error: unknown }>;
}): void;
close(): Promise<void>;
}
-71
View File
@@ -1,71 +0,0 @@
import { rm } from "node:fs/promises";
export class HopperDeadlineCleanupFailure extends AggregateError {
constructor(owner, failures) {
super(
failures,
`Hopper cleanup incomplete; workspace may remain at ${owner.workspace}`,
);
this._tag = "HopperDeadlineCleanupFailure";
this.cleanupOwner = owner;
}
}
/** Own the client, profile scope, and workspace until all cleanup completes. */
export class HopperDeadlineCleanup {
#client;
#profile;
#pending;
#removed = false;
constructor(workspace, profile) {
this.workspace = workspace;
this.#profile = profile;
}
setClient(client) {
this.#client = client;
}
close() {
if (this.#pending !== undefined) return this.#pending;
const attempt = this.#close();
const pending = attempt.finally(() => {
if (this.#pending === pending) this.#pending = undefined;
});
this.#pending = pending;
return pending;
}
async #close() {
if (this.#removed) return;
const failures = [];
if (this.#client !== undefined) {
try {
const result = await this.#client.close();
if (!result.ok) throw result.error;
this.#client = undefined;
} catch (cause) {
failures.push(cause);
}
}
if (this.#profile !== undefined) {
try {
await this.#profile.close();
this.#profile = undefined;
} catch (cause) {
failures.push(cause);
}
}
if (failures.length === 0) {
try {
await rm(this.workspace, { recursive: true, force: true });
this.#removed = true;
return;
} catch (cause) {
failures.push(cause);
}
}
throw new HopperDeadlineCleanupFailure(this, failures);
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ function assertHeaders(record, side, headers) {
}
}
function assertEndpointHeaders(actual, expected) {
export function assertEndpointHeaders(actual, expected) {
// The proxy rebuilds pseudo fields from its HTTP state. Compare their values
// by name while retaining the exact order and duplicates of regular headers.
const partition = (headers) => {
@@ -1,10 +1,28 @@
import { analyzeJavaScriptApplication } from "../../dist/application/javascript/JavaScriptApplicationService.js";
import { withVerifierArtifactScope } from "./verifier-artifact-scope.mjs";
import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js";
/** Analyze one JavaScript application and close its caller-owned resources. */
export const analyzeJavaScriptApplicationWithOwnedScope = (input) =>
withVerifierArtifactScope(async (scope) => {
export const analyzeJavaScriptApplicationWithOwnedScope = async (input) => {
const scope = new ArtifactResourceScope();
let outcome;
try {
const result = await analyzeJavaScriptApplication(input, scope);
if (!result.ok) throw result.error;
return result.value;
});
outcome = result.ok
? { kind: "completed", value: result.value }
: { kind: "failed", cause: result.error };
} catch (cause) {
outcome = { kind: "failed", cause };
}
try {
await scope.close();
} catch (cleanupCause) {
if (outcome.kind === "failed")
throw new AggregateError(
[outcome.cause, cleanupCause],
"JavaScript analysis and artifact cleanup both failed",
);
throw cleanupCause;
}
if (outcome.kind === "failed") throw outcome.cause;
return outcome.value;
};
+1 -1
View File
@@ -78,7 +78,7 @@ export const expandMcpSessionStatus = (status) => ({
});
/** Expand provider capability groups while retaining candidate identity/state. */
const expandProviderCandidates = (candidates) =>
export const expandProviderCandidates = (candidates) =>
candidates.map((candidate) => ({
...candidate,
capabilities: expandProviderCapabilities(
+176
View File
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { inspectModuleBoundaries } from "./module-boundaries.mjs";
const fixture = async () => {
const root = await mkdtemp(join(tmpdir(), "rea-module-boundaries-"));
await mkdir(join(root, "src/domain"), { recursive: true });
await mkdir(join(root, "src/composition"), { recursive: true });
await writeFile(
join(root, "src/composition/worker.ts"),
"export default 1;\n",
);
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": "./dist/composition/worker.js" } }),
);
return root;
};
await test("reports a pure-layer composition import written relatively", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "../composition/worker.js";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.js" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
await test("reports a pure-layer composition import written through the package map", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "#worker";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.ts" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
for (const [name, specifier, imports] of [
[
"conditional",
"#worker",
{
"#worker": {
import: "./dist/composition/worker.js",
default: "./dist/composition/other.js",
},
},
],
["array", "#worker", { "#worker": ["./dist/composition/worker.js"] }],
["pattern", "#workers/worker", { "#workers/*": "./dist/composition/*.js" }],
]) {
await test(`refuses an unsupported valid ${name} package map rather than skipping its edge`, async () => {
const root = await fixture();
try {
await writeFile(join(root, "package.json"), JSON.stringify({ imports }));
const file = "src/domain/probe.ts";
await writeFile(
join(root, file),
`import ${JSON.stringify(specifier)};\n`,
);
// Resolve only: no worker module or authored target source is executed.
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
specifier,
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
resolved.stdout.trim(),
pathToFileURL(join(root, "dist/composition/worker.js")).href,
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(checked.stderr, /Could not inspect src\/domain\/probe.ts/u);
assert.match(checked.stderr, /Cannot resolve package import/u);
assert.match(checked.stderr, /relative source import/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
for (const target of [
"./dist/composition/worker.js",
"./dist/%63omposition/worker.js",
"./%64ist/composition/worker.js",
]) {
await test(`checks the URL-normalized source owner of ${target}`, async () => {
const root = await fixture();
try {
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": target } }),
);
await mkdir(join(root, "src/application"), { recursive: true });
const file = "src/application/probe.ts";
await writeFile(join(root, file), 'import "#worker";\n');
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
"#worker",
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
fileURLToPath(resolved.stdout.trim()),
join(root, "dist/composition/worker.js"),
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(
checked.stderr,
/application-composition: #worker resolves to src\/composition\/worker.ts/u,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
@@ -1,5 +1,8 @@
export { runOwnedVerifierTerminal } from "./verifier-terminal-owner.mjs";
export type {
VerifierTerminalOptions,
VerifierTerminalDependencies,
VerifierTerminalResult,
VerifierTerminalFailure,
VerifierTerminalCleanupFailure,
} from "./verifier-terminal-owner.mjs";
+3 -7
View File
@@ -1,10 +1,6 @@
/** Observe possible Hopper resources, including entries with unresolved ownership. */
/** Observe concrete Hopper runtime resources for a before/after cleanup check. */
export function snapshotHopperRuntime(
parent: string,
targetLeaseDirectory: string,
expectedRunId?: string,
): Promise<{
readonly paths: Set<string>;
readonly confirmedSessions: Set<string>;
readonly unresolvedSessions: Set<string>;
}>;
ownedProcessIds?: ReadonlySet<number>,
): Promise<Set<string>>;
+22 -30
View File
@@ -1,38 +1,35 @@
import { lstat, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
/** Observe possible sessions and target leases without discarding uncertainty. */
/** Observe session paths and target leases without treating their shared parent as a session. */
export async function snapshotHopperRuntime(
parent,
targetLeaseDirectory,
expectedRunId,
ownedProcessIds,
) {
const paths = new Set();
const confirmedSessions = new Set();
const unresolvedSessions = new Set();
const resources = new Set();
for (const entry of await readdir(parent, { withFileTypes: true })) {
const path = join(parent, entry.name);
if (!entry.name.startsWith("rea-") || path === targetLeaseDirectory)
continue;
const confirmed =
typeof expectedRunId === "string" &&
expectedRunId.length > 0 &&
entry.isDirectory() &&
(await hasRunIdentity(path, expectedRunId));
(confirmed ? confirmedSessions : unresolvedSessions).add(path);
paths.add(path);
if (
ownedProcessIds !== undefined &&
(!entry.isDirectory() ||
!(await belongsToOwnedProcess(path, ownedProcessIds)))
)
continue;
resources.add(path);
}
const leaseRoot = await lstat(targetLeaseDirectory).catch(missingIsAbsent);
if (leaseRoot !== undefined) {
if (!leaseRoot.isDirectory() || leaseRoot.isSymbolicLink())
paths.add(targetLeaseDirectory);
else {
const leases = await readdir(targetLeaseDirectory).catch(missingIsAbsent);
for (const name of leases ?? [])
paths.add(join(targetLeaseDirectory, name));
}
if (leaseRoot === undefined) return resources;
if (!leaseRoot.isDirectory() || leaseRoot.isSymbolicLink()) {
resources.add(targetLeaseDirectory);
return resources;
}
return { paths, confirmedSessions, unresolvedSessions };
const leases = await readdir(targetLeaseDirectory).catch(missingIsAbsent);
for (const name of leases ?? [])
resources.add(join(targetLeaseDirectory, name));
return resources;
}
const missingIsAbsent = (cause) => {
@@ -40,21 +37,16 @@ const missingIsAbsent = (cause) => {
throw cause;
};
const hasRunIdentity = async (path, expectedRunId) => {
const belongsToOwnedProcess = async (path, ownedProcessIds) => {
const encoded = await readFile(join(path, "ownership.json"), "utf8").catch(
missingIsAbsent,
);
if (encoded === undefined) return false;
let ownership;
try {
ownership = JSON.parse(encoded);
} catch (cause) {
if (cause instanceof SyntaxError) return false;
throw cause;
}
const ownership = JSON.parse(encoded);
return (
ownership !== null &&
typeof ownership === "object" &&
ownership.run_id === expectedRunId
Number.isSafeInteger(ownership.parent_pid) &&
ownedProcessIds.has(ownership.parent_pid)
);
};
+8 -1
View File
@@ -1,4 +1,4 @@
interface FixtureProcedure {
export interface FixtureProcedure {
readonly address: string;
readonly name: string;
}
@@ -26,6 +26,13 @@ export function requireCurrentDocument(
normalizedResult: (value: unknown, operation: string) => unknown,
): Promise<string>;
export function analyzeFixtureProcedure(
client: ToolClient,
options: unknown,
procedure: FixtureProcedure,
normalizedResult: (value: unknown, operation: string) => unknown,
): Promise<unknown>;
export function verifyRealHopperFixture(options: {
readonly client: ToolClient;
readonly options: unknown;
+1 -1
View File
@@ -310,7 +310,7 @@ export async function resolveFixtureProcedure(
}
/** Request the assembly evidence required by the fixture oracle. */
const analyzeFixtureProcedure = async (
export const analyzeFixtureProcedure = async (
client,
options,
procedure,
+3
View File
@@ -6,3 +6,6 @@ export function unsupportedRuntimeMessage(
range: string,
version: string,
): string | undefined;
/** Human-readable release families for an `engines.node` range. */
export function supportedReleaseProse(range: string): string;
+1 -1
View File
@@ -21,7 +21,7 @@ export const unsupportedRuntimeMessage = (range, version) => {
};
/** Human-readable release families for an `engines.node` range. */
const supportedReleaseProse = (range) =>
export const supportedReleaseProse = (range) =>
`Node.js ${range
.split("||")
.map((clause) => {
-15
View File
@@ -1,15 +0,0 @@
import type { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
export class VerifierArtifactCleanupFailure extends AggregateError {
readonly _tag: "VerifierArtifactCleanupFailure";
readonly cleanupOwner: ArtifactResourceScope;
constructor(
scope: ArtifactResourceScope,
failures: unknown[],
cause: unknown,
);
}
export function withVerifierArtifactScope<T>(
operation: (scope: ArtifactResourceScope) => Promise<T>,
): Promise<T>;
-32
View File
@@ -1,32 +0,0 @@
import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js";
/** A failed close retains the scope that can retry all of its owners. */
export class VerifierArtifactCleanupFailure extends AggregateError {
constructor(scope, failures, cause) {
super(failures, "Verifier cleanup failed", { cause });
this._tag = "VerifierArtifactCleanupFailure";
this.cleanupOwner = scope;
}
}
/** Run one verifier operation and retain its exact scope if close fails. */
export async function withVerifierArtifactScope(operation) {
const scope = new ArtifactResourceScope();
let outcome;
try {
outcome = { ok: true, value: await operation(scope) };
} catch (cause) {
outcome = { ok: false, cause };
}
try {
await scope.close();
} catch (cleanupCause) {
throw new VerifierArtifactCleanupFailure(
scope,
outcome.ok ? [cleanupCause] : [outcome.cause, cleanupCause],
outcome.ok ? cleanupCause : outcome.cause,
);
}
if (!outcome.ok) throw outcome.cause;
return outcome.value;
}
+3 -3
View File
@@ -40,7 +40,7 @@ export interface VerifierTerminalOptions {
readonly signal?: AbortSignal;
}
interface VerifierTerminalDependencies {
export interface VerifierTerminalDependencies {
readonly loadPty?: () => Promise<typeof import("@lydell/node-pty")>;
readonly cleanupProcessGroup?: typeof cleanupOwnedProcessGroup;
}
@@ -59,7 +59,7 @@ type TerminalFailureReason =
| "unsupported_platform";
/** Expected terminal failures retain the decoded output observed at failure. */
class VerifierTerminalFailure extends Error {
export class VerifierTerminalFailure extends Error {
readonly _tag = "VerifierTerminalFailure";
constructor(
@@ -74,7 +74,7 @@ class VerifierTerminalFailure extends Error {
}
/** The adopted owner remains the exact authority for retrying this release. */
class VerifierTerminalCleanupFailure extends Error {
export class VerifierTerminalCleanupFailure extends Error {
readonly _tag = "VerifierTerminalCleanupFailure";
readonly reason = "cleanup_incomplete";
-4
View File
@@ -5,8 +5,6 @@ import noEvidenceJsonPreadmission from "./rules/no-evidence-json-preadmission.mj
import noFixtureRootCleanup from "./rules/no-fixture-root-cleanup.mjs";
import noPositionalToolContract from "./rules/no-positional-tool-contract.mjs";
import noSharedModuleMock from "./rules/no-shared-module-mock.mjs";
import noLocaleCompare from "./rules/no-locale-compare.mjs";
import noBabelVisitorKeys from "./rules/no-babel-visitor-keys.mjs";
export default {
meta: { name: "rea" },
@@ -17,7 +15,5 @@ export default {
"no-fixture-root-cleanup": noFixtureRootCleanup,
"no-positional-tool-contract": noPositionalToolContract,
"no-shared-module-mock": noSharedModuleMock,
"no-locale-compare": noLocaleCompare,
"no-babel-visitor-keys": noBabelVisitorKeys,
},
};
@@ -1,4 +0,0 @@
import type { RuleTester } from "oxlint/plugins-dev";
declare const rule: Parameters<RuleTester["run"]>[1];
export default rule;
@@ -1,73 +0,0 @@
import { findBinding } from "../bindings.mjs";
const nameOf = (node) =>
node.type === "Identifier"
? node.name
: node.type === "Literal"
? node.value
: node.type === "TemplateLiteral" && node.expressions.length === 0
? node.quasis[0]?.value.cooked
: undefined;
export default {
meta: {
type: "problem",
docs: { description: "Keep Babel child expansion in its shared owner." },
schema: [],
messages: {
owner:
"Use childNodes or traverseJavaScriptAst instead of Babel VISITOR_KEYS.",
},
},
create(context) {
const source = context.sourceCode;
const isBabelNamespace = (node) => {
if (node?.type !== "Identifier") return false;
const definition = findBinding(source, node)?.defs[0];
if (
definition?.type !== "ImportBinding" ||
definition.node.type !== "ImportNamespaceSpecifier"
)
return false;
const declaration = definition.parent ?? definition.node.parent;
return declaration?.source?.value === "@babel/types";
};
return {
ImportSpecifier(node) {
if (
node.parent.source.value === "@babel/types" &&
(node.imported.name ?? node.imported.value) === "VISITOR_KEYS"
)
context.report({ node, messageId: "owner" });
},
ExportNamedDeclaration(node) {
if (node.source?.value !== "@babel/types") return;
for (const specifier of node.specifiers)
if (
specifier.type === "ExportSpecifier" &&
nameOf(specifier.local) === "VISITOR_KEYS"
)
context.report({ node: specifier, messageId: "owner" });
},
MemberExpression(node) {
if (
(!node.computed || node.property.type !== "Identifier") &&
nameOf(node.property) === "VISITOR_KEYS" &&
isBabelNamespace(node.object)
)
context.report({ node, messageId: "owner" });
},
VariableDeclarator(node) {
if (node.id.type !== "ObjectPattern" || !isBabelNamespace(node.init))
return;
for (const property of node.id.properties)
if (
property.type === "Property" &&
(!property.computed || property.key.type !== "Identifier") &&
nameOf(property.key) === "VISITOR_KEYS"
)
context.report({ node: property, messageId: "owner" });
},
};
},
};
@@ -1,4 +0,0 @@
import type { RuleTester } from "oxlint/plugins-dev";
declare const rule: Parameters<RuleTester["run"]>[1];
export default rule;
-34
View File
@@ -1,34 +0,0 @@
export default {
meta: {
type: "problem",
docs: {
description: "Keep domain ordering independent of the host locale.",
},
schema: [],
messages: {
locale:
"Use compareUnicodeCodePoints instead of localeCompare for domain ordering.",
},
},
create(context) {
return {
CallExpression(node) {
const member = node.callee;
if (member.type !== "MemberExpression") return;
if (
(!member.computed &&
member.property.type === "Identifier" &&
member.property.name === "localeCompare") ||
(member.computed &&
member.property.type === "Literal" &&
member.property.value === "localeCompare") ||
(member.computed &&
member.property.type === "TemplateLiteral" &&
member.property.expressions.length === 0 &&
member.property.quasis[0]?.value.cooked === "localeCompare")
)
context.report({ node, messageId: "locale" });
},
};
},
};
+1 -1
View File
@@ -54,7 +54,7 @@ if (await pathExists(join(root, "src"))) {
"dist/main.js",
"dist/mcp/main.js",
"dist/cli-runtime/cli.js",
"dist/cli-runtime/cliOutput.js",
"dist/cli-runtime/cliOneShot.js",
"dist/cli-runtime/cliBuiltinArguments.js",
"dist/cli-runtime/cliResultOutput.js",
"dist/cli-runtime/mcpDoctor.js",
+54 -142
View File
@@ -64,7 +64,7 @@ const runtimeFiles = [
? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"]
: [
"dist/cli-runtime/cli.js",
"dist/cli-runtime/cliOutput.js",
"dist/cli-runtime/cliOneShot.js",
"dist/cli-runtime/cliResultOutput.js",
]),
];
@@ -104,154 +104,66 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
);
}
} else {
const {
cliConfigurationError,
renderCliOutputArgumentError,
renderEmptyFilteredCliOutput,
renderVersionReport,
parseCliOutputArguments,
} = await import("../dist/cli-runtime/cliOutput.js");
const { runOneShotCli } = await import("../dist/cli-runtime/cliOneShot.js");
guardClosedStdout();
const cliArgs = isShellCompletion
? args
: builtinArguments.normalizeCliArguments(args);
// Completion words are transport data, including partial output flags.
const outputOptions = parseCliOutputArguments(
isShellCompletion ? [] : cliArgs,
);
const outputArguments = outputOptions.validation;
// `--version` with an explicit format reports the runtime as a document;
// plain `--version` stays Incur's bare version string.
const versionOutput = renderVersionReport(
outputOptions,
{
packageName: packageJson.name,
packageVersion: packageJson.version,
versions: process.versions,
platform: process.platform,
architecture: process.arch,
},
process.stdout.isTTY === true,
);
const configurationError = cliConfigurationError(process.env);
let dispatchFailure;
let closeCliResources;
// Incur can exit from the callback below before this function returns.
// That callback owns the status, including a closed-pipe exit of 141.
let exiting = false;
try {
// Doctor is the readiness report for this failure. Other commands still stop
// here so they do not run with an unusable log level.
if (
configurationError !== undefined &&
(isShellCompletion ||
builtinArguments.splitIncurBuiltinArguments(cliArgs).command[0] !==
"doctor")
) {
process.stderr.write(`${configurationError}\n`);
process.exitCode = 1;
} else if (!outputArguments.ok) {
writeStdout(renderCliOutputArgumentError(outputArguments));
process.exitCode = 1;
} else if (versionOutput !== undefined) {
writeStdout(versionOutput);
} else if (
!isShellCompletion &&
builtinArguments.isProjectScopedAmpRegistration(cliArgs)
) {
// Amp has global settings only. Share the parsed scope fact with the
// registration workflow and refuse before loading the command runtime.
//
// A caller that asked for a structured document gets the refusal in
// that document, through the same pre-execution error renderer the
// output-argument branch uses; anything else keeps the plain message.
const refusalMessage =
"Amp does not support project-scoped registration. Run without --no-global to register Amp globally, or select a client with project settings. No client settings were changed.";
const refusalFormat = outputOptions.format;
if (
refusalFormat === "json" ||
refusalFormat === "jsonl" ||
refusalFormat === "yaml"
) {
writeStdout(
renderCliOutputArgumentError({
ok: false,
format: refusalFormat,
code: "AMP_PROJECT_SCOPE_UNSUPPORTED",
message: refusalMessage,
}),
);
} else {
process.stderr.write(`${refusalMessage}\n`);
}
process.exitCode = 1;
} else {
const cliRuntime = await import("../dist/cli-runtime/cli.js");
closeCliResources = cliRuntime.closeCliResources;
const { createCli } = cliRuntime;
const { createCliResultOutput } =
await import("../dist/cli-runtime/cliResultOutput.js");
let wroteOutput = false;
const resultOutput = createCliResultOutput(outputOptions, process.stdout);
try {
await createCli(process.env, resultOutput).serve(cliArgs, {
stdout: (output) => {
if (resultOutput?.handled) {
if (resultOutput.failed && !stdoutClosed)
process.stderr.write(output);
return;
}
if (output.length > 0) wroteOutput = true;
if (resultOutput?.captureSdkOutput(output)) return;
writeStdout(output);
},
// A command failure caused by the closed pipe keeps the pipe's status.
// Worker shutdown is shared with the dispatch finally below. A failed
// shutdown is reported here because this callback is what exits.
exit: (code) => {
exiting = true;
void (async () => {
let status = typeof code === "number" ? code : 0;
try {
await closeCliResources();
} catch (cleanup) {
process.stderr.write(
`Failed to close CLI resources: ${cleanupFailureText(cleanup)}\n`,
);
if (status === 0) status = 1;
}
try {
await resultOutput?.finishSdkOutput(status);
} catch (cause) {
if (!stdoutClosed)
process.stderr.write(`${cleanupFailureText(cause)}\n`);
if (status === 0) status = 1;
}
process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : status);
})();
},
});
} catch (cause) {
if (wroteOutput) throw cause;
const recovered = await resultOutput
?.recoverFormattingFailure(cause)
.catch((writeFailure) => {
// Recovery writes outside Incur's command boundary. A reader that
// declined this document keeps the same quiet SIGPIPE outcome.
if (!stdoutClosed) throw writeFailure;
return true;
});
if (!recovered) throw cause;
process.exitCode = stdoutClosed ? SIGPIPE_EXIT_STATUS : 1;
}
if (!wroteOutput && !resultOutput?.handled)
writeStdout(renderEmptyFilteredCliOutput(outputOptions) ?? "");
// An SDK failure exits through the callback, which owns its pending write.
if (!exiting)
await resultOutput?.finishSdkOutput(
typeof process.exitCode === "number" ? process.exitCode : 0,
);
}
await runOneShotCli(args, {
environment: process.env,
runtime: {
packageName: packageJson.name,
packageVersion: packageJson.version,
versions: process.versions,
platform: process.platform,
architecture: process.arch,
},
stdoutIsTerminal: process.stdout.isTTY === true,
resultDestination: process.stdout,
closedStdoutStatus: SIGPIPE_EXIT_STATUS,
writeStdout,
writeStderr: (text) => {
process.stderr.write(text);
},
stdoutClosed: () => stdoutClosed,
exitCode: () =>
typeof process.exitCode === "number" ? process.exitCode : 0,
setExitCode: (code) => {
process.exitCode = code;
},
ownCliResources: (close) => {
closeCliResources = close;
},
exitRequested: () => exiting,
// A command failure caused by the closed pipe keeps the pipe's status.
// Worker shutdown is shared with the dispatch finally below. A failed
// shutdown is reported here because this callback is what exits.
exit: (code, finishOutput) => {
exiting = true;
void (async () => {
let status = code;
try {
await closeCliResources();
} catch (cleanup) {
process.stderr.write(
`Failed to close CLI resources: ${cleanupFailureText(cleanup)}\n`,
);
if (status === 0) status = 1;
}
try {
await finishOutput(status);
} catch (cause) {
if (!stdoutClosed)
process.stderr.write(`${cleanupFailureText(cause)}\n`);
if (status === 0) status = 1;
}
process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : status);
})();
},
});
} catch (cause) {
if (cause?.code === "EPIPE") {
stdoutClosed = true;
+10
View File
@@ -1,3 +1,13 @@
export interface PackageHopperEnvironment {
readonly HOPPER_LAUNCHER_PATH: string;
readonly HOPPER_LOADER_ARGS_JSON: string;
}
export function packageHopperEnvironment(
root: string,
platform?: string,
): PackageHopperEnvironment;
export function verifyPackageEnvironment(input: {
readonly root: string;
readonly workspace: string;
+1 -1
View File
@@ -117,7 +117,7 @@ export async function verifyPackageEnvironment({
};
}
const packageHopperEnvironment = (root, platform = process.platform) => {
export const packageHopperEnvironment = (root, platform = process.platform) => {
const fakeLauncher = join(root, "tests/fixtures/fakeLauncher.mjs");
if (platform !== "linux")
return {
+1 -1
View File
@@ -414,7 +414,7 @@ export function matchesSymbol(value, expected) {
return tail === expected || tail === `_${expected}`;
}
function symbolTail(value) {
export function symbolTail(value) {
if (typeof value !== "string") return undefined;
return value.split("::").at(-1);
}
+6 -3
View File
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { withVerifierArtifactScope } from "./lib/verifier-artifact-scope.mjs";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { readGhidraLanguageCatalog } from "../dist/ghidra/GhidraLanguageCatalog.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import { functionDossierSchema } from "../dist/domain/native/functionDossier.js";
@@ -82,13 +82,16 @@ for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
* installation catalog reader it uses. User extensions are not in that catalog.
*/
async function languageInstalled(languageId) {
return withVerifierArtifactScope(async (scope) => {
const scope = new ArtifactResourceScope();
try {
const catalog = await readGhidraLanguageCatalog(
installation.installDir,
scope,
);
return catalog.some((definition) => definition.id === languageId);
});
} finally {
await scope.close();
}
}
/** One MCP session plus CLI check. `languageOverride` is set only for the Gekko pass. */
+26 -40
View File
@@ -11,10 +11,6 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { artifactMcpCall } from "./lib/artifact-e2e.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js";
assert.equal(process.platform, "darwin", "requires macOS");
@@ -52,9 +48,11 @@ const digest = async (path) =>
createHash("sha256")
.update(await readFile(path))
.digest("hex");
const sessionsBefore = (
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
).paths;
const sessionsBefore = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
);
const ownedProcessIds = new Set();
const client = new Client({ name: "hopper-callers-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
@@ -95,7 +93,7 @@ const matchingRef = (ref, site, from, to) =>
ref.source_procedure?.address === from &&
ref.target_procedure?.address === to &&
ref.call?.classification === "direct";
await withVerifierCleanup(async () => {
try {
await run(clang, [
"-isysroot",
sdk,
@@ -131,6 +129,7 @@ await withVerifierCleanup(async () => {
await client.connect(transport);
if (transport.pid === null)
throw new Error("MCP process identity unavailable");
ownedProcessIds.add(transport.pid);
let notifications = 0;
const original = transport.onmessage;
transport.onmessage = (message) => {
@@ -288,36 +287,23 @@ await withVerifierCleanup(async () => {
sha256: beforeLarge,
}),
);
}, [
async () => {
try {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
const leaked = [
...(
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
).paths,
].filter((path) => !sessionsBefore.has(path));
assert.deepEqual(
leaked,
[],
`Hopper resources remain: ${leaked.join(", ")}`,
);
},
]);
} catch (cause) {
throw new Error(
`Hopper cleanup failed; fixture retained at ${directory}`,
{
cause,
},
);
} finally {
if (stderr) process.stderr.write(stderr);
}
} finally {
await client.close();
await transport.close();
await new Promise((resolve) => setTimeout(resolve, 500));
const leaked = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
ownedProcessIds,
)),
].filter((path) => !sessionsBefore.has(path));
if (leaked.length === 0)
await rm(directory, { recursive: true, force: true });
},
]);
if (stderr) process.stderr.write(stderr);
assert.deepEqual(
leaked,
[],
`Hopper resources remain: ${leaked.join(", ")}; fixture retained at ${directory}`,
);
}
+22 -13
View File
@@ -1,11 +1,10 @@
import assert from "node:assert/strict";
import { copyFile, mkdtemp, realpath } from "node:fs/promises";
import { copyFile, mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { firstProcedureAddress } from "./lib/real-hopper-assertions.mjs";
import { HopperDeadlineCleanup } from "./lib/hopper-deadline-cleanup.mjs";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
@@ -30,7 +29,6 @@ const root = await realpath(
);
const target = join(root, "deadline-fixture");
const profileResources = new ArtifactResourceScope();
const cleanupOwner = new HopperDeadlineCleanup(root, profileResources);
const profileResourceScope = {
retain: (owner, resource) =>
profileResources.retain({
@@ -42,7 +40,6 @@ const profileResourceScope = {
};
let client;
let primaryFailure;
let operationFailed = false;
try {
await copyFile(fixtures.primary.path, target);
const parsed = await parseBinaryTarget(target);
@@ -151,19 +148,31 @@ try {
),
);
} catch (cause) {
operationFailed = true;
primaryFailure = cause;
throw cause;
} finally {
let cleanupFailure;
if (client !== undefined) {
const closed = await client.close();
if (!closed.ok) cleanupFailure = closed.error;
}
try {
if (client !== undefined) cleanupOwner.setClient(client);
await cleanupOwner.close();
} catch (cleanupFailure) {
throw operationFailed
? new AggregateError(
await profileResources.close();
} catch (cause) {
cleanupFailure =
cleanupFailure === undefined
? cause
: new AggregateError(
[cleanupFailure, cause],
"Hopper profile and client cleanup failed",
);
}
await rm(root, { recursive: true, force: true });
if (cleanupFailure !== undefined)
throw primaryFailure === undefined
? cleanupFailure
: new AggregateError(
[primaryFailure, cleanupFailure],
"Hopper verification and cleanup failed",
)
: cleanupFailure;
}
);
}
+37 -79
View File
@@ -23,10 +23,6 @@ import {
requireMcpOperationResult,
} from "./lib/mcp-verifier-results.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js";
import { parseConfig } from "../dist/config/parseConfig.js";
import {
@@ -74,12 +70,14 @@ const intel = join(directory, "c-x86_64");
const fat = join(directory, "c-fat");
const fat64 = join(directory, "c-fat64");
const singleFat = join(directory, "c-single-fat");
const owned = new Set();
const extraFat64 = [];
const invalidFat64 = [];
let preparedImagePath;
const before = (
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
).paths;
const before = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
);
const client = new Client({ name: "hopper-fat-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
@@ -96,7 +94,7 @@ const ajv = new Ajv({ strict: false });
addFormats(ajv);
const observations = [];
const sourceBinding = [];
await withVerifierCleanup(async () => {
try {
await run(toolPaths.clang, [
"-isysroot",
sdk,
@@ -185,17 +183,9 @@ await withVerifierCleanup(async () => {
"overlap",
/slices overlap/u,
(bytes) => {
const firstOffset = bytes.readBigUInt64BE(16);
const secondOffset = bytes.readBigUInt64BE(48);
const earlier = firstOffset < secondOffset ? 8 : 40;
const laterOffset =
firstOffset < secondOffset ? secondOffset : firstOffset;
const earlierOffset =
firstOffset < secondOffset ? firstOffset : secondOffset;
bytes.writeBigUInt64BE(
laterOffset - earlierOffset + 16n,
earlier + 16,
);
const cpu = process.arch === "arm64" ? 0x100000c : 0x1000007;
const base = bytes.readUInt32BE(8) === cpu ? 8 : 40;
bytes.copy(bytes, base === 8 ? 40 : 8, base, base + 32);
},
],
]) {
@@ -221,14 +211,11 @@ await withVerifierCleanup(async () => {
}
const path = join(directory, "c-fat64-ambiguous");
await writeFile(path, ambiguous);
invalidFat64.push({
path,
reason:
/FAT binary declares several (?:arm64|x86_64) slices; no slice was selected/u,
});
invalidFat64.push({ path, reason: /selection.*ambiguous/u });
}
await client.connect(transport);
assert.ok(transport.pid !== null);
owned.add(transport.pid);
const catalog = await client.listTools();
const call = async (name, args = {}) => {
const reply = await client.callTool(
@@ -372,27 +359,14 @@ await withVerifierCleanup(async () => {
if (verifyFat64) {
await call("open_binary", { path: fat64 });
await call("list_procedures");
const session = await call("binary_session");
const runId = session.analysis_run?.run_id;
assert.ok(
typeof runId === "string" && runId.length > 0,
"Missing analysis run identity",
);
const resources = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
runId,
);
const unresolvedNew = [...resources.unresolvedSessions].filter(
(path) => !before.has(path),
);
assert.deepEqual(
unresolvedNew,
[],
"Cannot identify a prepared image while new Hopper resource ownership is unresolved",
);
for (const root of resources.confirmedSessions) {
if (before.has(root)) continue;
const resources = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
for (const root of resources) {
const image = join(root, "image.macho");
if (
await access(image).then(
@@ -429,43 +403,27 @@ await withVerifierCleanup(async () => {
},
);
assert.ok(pending.child.pid !== undefined);
owned.add(pending.child.pid);
const cli = JSON.parse((await pending).stdout);
assert.deepEqual(cli.normalized_result, expected.mapping);
}
}, [
async () => {
try {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
]);
} catch (cause) {
throw new Error(
`Hopper cleanup failed; fixture retained at ${directory}`,
{
cause,
},
);
}
try {
const retained = [
...(await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY))
.paths,
].filter((path) => !before.has(path));
assert.deepEqual(
retained,
[],
"Hopper resources remain or are unresolved",
);
} catch (cause) {
throw new Error(
`Hopper resource verification failed; fixture retained at ${directory}`,
{ cause },
);
}
await rm(directory, { recursive: true, force: true });
},
]);
} finally {
await client.close();
await transport.close();
await rm(directory, { recursive: true, force: true });
}
const retained = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
assert.deepEqual(
retained,
[],
"FAT verification retained owned Hopper sessions or leases",
);
console.log(
JSON.stringify(
{
+10 -6
View File
@@ -62,9 +62,10 @@ const parseServerArgs = (encoded) => {
};
const runtimeParent = process.platform === "darwin" ? "/tmp" : tmpdir();
const sessionsBefore = (
await snapshotHopperRuntime(runtimeParent, HOPPER_TARGET_LEASE_DIRECTORY)
).paths;
const sessionsBefore = await snapshotHopperRuntime(
runtimeParent,
HOPPER_TARGET_LEASE_DIRECTORY,
);
const ownedProcessIds = new Set();
const textValue = mcpTextValue;
@@ -556,8 +557,11 @@ try {
await new Promise((resolve) => setTimeout(resolve, 500));
const sessionsAfter = [
...(await snapshotHopperRuntime(runtimeParent, HOPPER_TARGET_LEASE_DIRECTORY))
.paths,
...(await snapshotHopperRuntime(
runtimeParent,
HOPPER_TARGET_LEASE_DIRECTORY,
ownedProcessIds,
)),
]
.filter((path) => !sessionsBefore.has(path))
.sort();
@@ -569,7 +573,7 @@ if (sessionsAfter.length > 0) {
retainedRuntimePaths: sessionsAfter,
});
throw new Error(
`Hopper resources remain or have unresolved ownership: ${sessionsAfter.join(", ")}`,
`The MCP runtime retained Hopper resources: ${sessionsAfter.join(", ")}`,
);
}
if (summary === undefined)
+2 -4
View File
@@ -40,7 +40,7 @@ try {
const expected = [];
for (const path of files.stdout
.split("\0")
.filter((path) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(path))) {
.filter((path) => path.endsWith(".test.ts"))) {
try {
if ((await stat(join(root, path))).isFile()) expected.push(path);
} catch (cause) {
@@ -65,6 +65,4 @@ try {
await rm(temporary, { recursive: true, force: true });
}
if (discovered !== undefined)
console.log(
`Discovered all ${discovered} test and spec files in src and tests exactly once.`,
);
console.log(`Discovered all ${discovered} test files exactly once.`);
+7 -3
View File
@@ -6,7 +6,7 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import { inspectAppleDispatchMetadata } from "../../../dist/native/AppleDispatchMetadata.js";
import {
artifactCliEvidence,
@@ -57,7 +57,8 @@ const fixupCoverage = (metadata) =>
const inspect = async (path) => {
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
return withVerifierArtifactScope(async (resources) => {
const resources = new ArtifactResourceScope();
try {
return {
target: target.value,
metadata: (
@@ -66,7 +67,10 @@ const inspect = async (path) => {
)
).result,
};
});
} finally {
// Retry any descriptor whose close failed, surfacing unresolved cleanup.
await resources.close();
}
};
const checkObjc = (metadata, mode) => {
+32 -3
View File
@@ -5,12 +5,17 @@ import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
// Architecture guards for the strict-identity / best-effort-inference split.
// - Evidence ordering must be locale-independent (ICU varies by host), so
// src/domain orders with compareUnicodeCodePoints, never localeCompare.
// - Composite dedup keys must not NUL-join variable-length or free-form
// MIDDLE fields: ["a\0b"] collapses with ["a", "b"] and drops a finding.
// The text check below only flags one spelling; it is not a complete audit.
// compositeKey (JSON tuple) is the single owner for composite keys.
// - Transparent wrappers unwrap in exactly one place
// (javascriptAstValues.unwrapJavaScriptExpression); direct isTS* unwrapping
// elsewhere drifts (the `satisfies` class of bug).
// - AST walks go through traverseJavaScriptAst/childNodes: bespoke
// Object.values walkers diverge on loc/comment fields and overflow the
// stack on deep generated chains.
const root = fileURLToPath(new URL("../../", import.meta.url));
@@ -22,7 +27,6 @@ const listFiles = async (dir) => {
else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".spec.ts") &&
!entry.name.endsWith(".fixture.ts")
)
output.push(path);
@@ -44,6 +48,8 @@ const files = await Promise.all(
content: stripComments(await readFile(path, "utf8")),
})),
);
const inDir = (dir) => files.filter((file) => file.path.startsWith(dir));
const failures = [];
const check = (label, selected, pattern, allow = []) => {
const offenders = selected.filter(
@@ -62,7 +68,21 @@ check(
/providers\/ProviderInspectionScope\.js/,
);
check("NUL-joined list key (use compositeKey)", files, /\.join\(["']\\0["']\)/);
check(
"src/domain uses localeCompare (use compareUnicodeCodePoints)",
inDir("src/domain/"),
/\.localeCompare\(/,
);
check(
"NUL-joined list key (use compositeKey)",
files,
/\.join\(["']\\0["']\)/,
[
// Sorted pair of constrained trace IDs; documented in plan.
"src/domain/process/processTraceSpecification.ts",
],
);
check(
"direct transparent-wrapper unwrap outside javascriptAstValues (use unwrapJavaScriptExpression)",
@@ -71,6 +91,13 @@ check(
["src/domain/javascript/javascriptAstValues.ts"],
);
check(
"bespoke AST child walker (use traverseJavaScriptAst/childNodes)",
files,
/VISITOR_KEYS/,
["src/domain/javascript/javascriptSemanticTraversal.ts"],
);
// A producer that pins both the soft and hard ceiling of every resource takes
// the vector from one owner. prlimitCeilingArguments is the command-less prefix
// of prlimitArgumentVector; both live in src/process/LinuxResourceLimits.ts.
@@ -136,6 +163,7 @@ check(
"src/application/GuidanceAccess.ts",
"src/application/javascript/ElectronBoundaryAnalysis.ts",
"src/application/javascript/ElectronBoundaryGraphContext.ts",
"src/application/javascript/JavaScriptArtifactGraphContext.ts",
"src/browser/CdpCaptureCompleteness.ts",
"src/browser/CdpCaptureEventHandlers.ts",
"src/browser/CdpPageCapture.ts",
@@ -151,6 +179,7 @@ check(
"src/domain/javascript/javascriptNativeHandoff.ts",
"src/domain/javascript/javascriptRuntimeReconciliationMatching.ts",
"src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts",
"src/domain/javascript/javascriptSemanticQuery.ts",
"src/domain/javascript/javascriptStaticAnalysisBundler.ts",
"src/domain/javascript/javascriptStaticAnalysisCalls.ts",
"src/domain/javascript/javascriptStaticAnalysisFindings.ts",
@@ -5,6 +5,7 @@ import { parseArgs } from "node:util";
import { readStableArtifact } from "../../../dist/artifacts/readStableArtifact.js";
import { ArtifactReaderFailure } from "../../../dist/artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import {
ArtifactFileCleanupFailure,
artifactFileCompletion,
@@ -13,7 +14,6 @@ import {
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
import {
completeVerifierRun,
createVerifierRun,
@@ -106,23 +106,33 @@ process.stdout.write(
);
async function readEvidenceFile(path) {
const scope = new ArtifactResourceScope();
let outcome;
try {
outcome = {
ok: true,
value: await withVerifierArtifactScope(async (scope) =>
readStableArtifact(
await realpath(path),
64 * 1024 * 1024,
undefined,
undefined,
scope,
),
value: await readStableArtifact(
await realpath(path),
64 * 1024 * 1024,
undefined,
undefined,
scope,
),
};
} catch (cause) {
outcome = { ok: false, cause };
}
try {
await scope.close();
} catch (cleanupCause) {
if (!outcome.ok)
throw new AggregateError(
[outcome.cause, cleanupCause],
"Verifier cleanup failed",
{ cause: outcome.cause },
);
throw cleanupCause;
}
if (!outcome.ok && outcome.cause instanceof ArtifactFileCleanupFailure) {
const completion = artifactFileCompletion(outcome.cause);
outcome =
+9 -5
View File
@@ -1,9 +1,10 @@
import { basename, dirname, isAbsolute, resolve } from "node:path";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import {
readManagedSnapshot,
verifyManagedSourceIdentity,
} from "../../../dist/dotnet/ManagedSnapshot.js";
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
import { inspectManagedArtifactBytes } from "../../../dist/dotnet/ManagedArtifactInspector.js";
@@ -23,10 +24,13 @@ import {
const READ_FILE_MAX_BYTES = 0x7fff_ffff;
export async function verifyManagedAppManifest(rawManifest, manifestPath) {
return withVerifierArtifactScope((resources) =>
resources.run(() =>
verifyManifestSnapshot(rawManifest, manifestPath, resources),
),
const resources = new ArtifactResourceScope();
return withVerifierCleanup(
() =>
resources.run(() =>
verifyManifestSnapshot(rawManifest, manifestPath, resources),
),
[() => resources.close()],
);
}
+7 -2
View File
@@ -1,6 +1,13 @@
/** Relative path of the registry. Its text is a declaration, not a producer. */
export const NUMERIC_LIMIT_DECLARATION: "src/domain/numericLimitRegistry.ts";
/** Limits that must become analysis settings and cannot be registry exceptions. */
export const REQUIRED_RUNTIME_LIMITS: readonly [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
];
/** True for a numeric `REA_` limit, including the documented endpoint-candidate count. */
export function isNumericLimit(name: string): boolean;
@@ -48,8 +55,6 @@ export function discoverNumericLimitUses(
readonly unresolved: readonly {
readonly path: string;
readonly offset: number;
readonly line: number;
readonly column: number;
readonly reason: string;
}[];
};
+4 -13
View File
@@ -25,7 +25,6 @@ const listFiles = async (dir) => {
} else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".spec.ts") &&
!entry.name.endsWith(".fixture.ts")
)
output.push(path);
@@ -40,7 +39,7 @@ export const NUMERIC_LIMIT_DECLARATION = "src/domain/numericLimitRegistry.ts";
* These limits are moving onto runtime analysis settings on their own PRs.
* A producer or a registry entry cannot satisfy that requirement.
*/
const REQUIRED_RUNTIME_LIMITS = [
export const REQUIRED_RUNTIME_LIMITS = [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
@@ -171,16 +170,8 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
item.offset === offset &&
item.reason === reason,
)
) {
const location = sourceFile.getLineAndCharacterOfPosition(offset);
unresolved.push({
path,
offset,
line: location.line + 1,
column: location.character + 1,
reason,
});
}
)
unresolved.push({ path, offset, reason });
};
const initializer = (identifier) => {
if (!candidates.has(identifier.text)) return undefined;
@@ -958,7 +949,7 @@ if (direct) {
for (const issue of discovered.unresolved)
console.log(
`${issue.path}:${issue.line}:${issue.column}: numeric limit candidate unresolved (${issue.reason})`,
`${issue.path}:${issue.offset}: numeric limit candidate unresolved (${issue.reason})`,
);
for (const [name, file] of [...discovered].sort(([left], [right]) =>
+3 -5
View File
@@ -1,8 +1,6 @@
import type { JadxToolPort } from "./JadxAnalysis.js";
import {
androidNativeDeclarationsSchema,
type AndroidNativeDeclarations,
} from "../domain/android/androidNativeDeclarations.js";
import type { AndroidNativeDeclarations } from "../domain/android/androidNativeDeclarations.js";
import { jadxNativeDeclarationsSchema } from "./JadxProtocol.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
/** Validate the native-only batch at its producing boundary, preserving raw facts. */
@@ -11,7 +9,7 @@ export const readAndroidNativeInventory = async (
snapshot: string,
): Promise<AndroidNativeDeclarations> => {
const raw = await tools.json("rea_native_declarations", { path: snapshot });
const parsed = androidNativeDeclarationsSchema.safeParse(raw);
const parsed = jadxNativeDeclarationsSchema.safeParse(raw);
if (!parsed.success)
throw new AnalysisOutputError(
"inspect_android_jni_bindings",
+5
View File
@@ -10,6 +10,11 @@ import {
dexDescriptorSchema,
dexTypeSchema,
} from "../domain/android/dexTypes.js";
import { androidNativeDeclarationsSchema } from "../domain/android/androidNativeDeclarations.js";
/** Native-only raw DEX facts authored before decompiler deduplication. */
export const jadxNativeDeclarationsSchema = androidNativeDeclarationsSchema;
const count = z.int().nonnegative();
/** Typed allocation failures authored before upstream erases the JVM exception. */
const jadxMemoryFailureSchema = z.strictObject({
+6 -2
View File
@@ -1,6 +1,6 @@
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { stableArtifactInputFailure } from "../artifacts/StableArtifactFailure.js";
import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js";
import {
artifactFilePrimaryFacts,
type ArtifactFilePrimaryFacts,
@@ -231,7 +231,11 @@ const artifactReaderFailure = (
},
);
case "path":
return stableArtifactInputFailure(cause, OPERATION);
return new AnalysisInputError(
OPERATION,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
);
default:
return adapterFailure(cause, path);
}
@@ -18,123 +18,6 @@ const extension = {
timeout: 300,
};
describe("Goose YAML source key retention", () => {
it("updates and removes an extension selected through aliased path keys", () => {
const source =
"table: &table extensions\nname: &name rea\n? *table\n:\n ? *name\n : {cmd: old, type: stdio}\n";
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
cmd: "rea",
type: "stdio",
});
expect(updated).toContain("*table");
expect(updated).toContain("*name");
expect(parseGooseConfigurationYaml(updated)).toMatchObject({
extensions: { rea: { cmd: "rea", type: "stdio" } },
});
const removed = editGooseConfigurationYaml(
updated,
["extensions", "rea"],
undefined,
);
expect(removed).not.toContain("*name");
expect(parseGooseConfigurationYaml(removed)).toMatchObject({
extensions: {},
});
const ambiguous =
"name: &name rea\nextensions:\n ? *name\n : {cmd: first}\n rea: {cmd: second}\n";
expect(() =>
editGooseConfigurationYaml(ambiguous, ["extensions", "rea"], {
cmd: "rea",
}),
).toThrow(/more than one key resolving to "rea"/);
});
it("retains a collection mapping key when an adjacent field changes", () => {
const source =
"extensions:\n rea:\n cmd: old\n ? [one, two]\n : retained\n";
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...parsed.extensions.rea,
cmd: "rea",
});
const document = parseDocument(updated);
expect(document.errors).toEqual([]);
const native = document.toJS({ mapAsMap: true }) as Map<string, unknown>;
const extensions = native.get("extensions") as Map<string, unknown>;
const entry = extensions.get("rea") as Map<unknown, unknown>;
expect([...entry.entries()]).toContainEqual([["one", "two"], "retained"]);
expect(entry.get("cmd")).toBe("rea");
});
it("retains an aliased mapping key when an adjacent launcher field changes", () => {
const source =
'label: &label 1\nextensions:\n rea:\n cmd: old\n ? *label\n : numeric\n "1": string\n';
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...parsed.extensions.rea,
cmd: "rea",
});
expect(updated).toContain("*label");
expect(updated).toContain("numeric");
expect(updated).toContain('"1": string');
});
it("preserves the order of an anchor and a retained non-string key alias", () => {
const source =
'extensions:\n rea:\n cmd: old\n shared: &seed value\n 1: *seed\n "1": string\n';
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...parsed.extensions.rea,
cmd: "rea",
});
expect(updated.indexOf("&seed")).toBeLessThan(updated.indexOf("*seed"));
expect(parseGooseConfigurationYaml(updated)).toMatchObject({
extensions: { rea: { shared: "value", cmd: "rea" } },
});
});
it("retains distinct keys beside an edited field and rejects an ambiguous edit", () => {
const source =
'extensions:\n rea:\n cmd: old\n 1: numeric\n "1": string\n';
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const entry = parsed.extensions.rea;
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...entry,
cmd: "rea",
});
expect(updated).toContain(' 1: numeric\n "1": string');
expect(updated).toContain("cmd: rea");
expect(() =>
editGooseConfigurationYaml(source, ["extensions", "rea"], {
...entry,
"1": "changed",
}),
).toThrow(/source identity is not a plain string key/);
});
it("keeps distinct native mapping keys in unchanged extension settings", () => {
const source =
'extensions:\n rea:\n cmd: old\n metadata:\n 1: numeric\n "1": string\n';
const parsed = parseGooseConfigurationYaml(source) as {
extensions: { rea: Record<string, unknown> };
};
const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], {
...parsed.extensions.rea,
cmd: "rea",
});
expect(updated).toContain(' 1: numeric\n "1": string');
expect(updated).toContain("cmd: rea");
});
});
describe("Goose YAML 1.2 source facts", () => {
it("keeps native strings and exact large integer facts distinct", () => {
const source =
+9 -10
View File
@@ -13,7 +13,6 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
import {
copyYamlAliasSource,
preserveYamlAliasValues,
resolveYamlEditPath,
} from "./YamlConfigurationAliases.js";
import {
retainYamlConfigurationSource,
@@ -252,7 +251,7 @@ export const editGooseConfigurationYaml = (
if (!isMap(document.contents))
throw new ConfigurationError("Goose configuration root must be a mapping");
for (let length = 1; length < path.length; length++) {
const parent = resolveYamlEditPath(document, path.slice(0, length));
const parent = path.slice(0, length);
let previous = document.getIn(parent, true);
if (isAlias(previous)) {
previous = copyYamlAliasSource(previous, document);
@@ -263,28 +262,28 @@ export const editGooseConfigurationYaml = (
document.setIn(parent, document.createNode({}));
} else if (!isMap(previous))
throw new ConfigurationError(
`Goose configuration ${path.slice(0, length).join(".")} must be a mapping`,
`Goose configuration ${parent.join(".")} must be a mapping`,
);
}
const sourcePath = resolveYamlEditPath(document, path);
const previous = document.getIn(sourcePath, true);
const parent = document.getIn(sourcePath.slice(0, -1), true);
const previous = document.getIn(path, true);
const parent = document.getIn(path.slice(0, -1), true);
const removedKey =
value === undefined && isMap(parent)
? parent.items.find((pair) => pair.key === sourcePath.at(-1))?.key
? parent.items.find(
(pair) => isScalar(pair.key) && pair.key.value === path.at(-1),
)?.key
: undefined;
const retained = preserveYamlAliasValues(document, [previous, removedKey]);
if (value === undefined) {
if (!document.deleteIn(sourcePath)) return text;
if (!document.deleteIn(path)) return text;
} else
document.setIn(
sourcePath,
path,
isMap(previous) || isSeq(previous)
? retainYamlConfigurationSource(
previous,
document.createNode(value),
retained,
document,
)
: value,
);
@@ -12,69 +12,6 @@ import {
stringifyHermesConfigurationYaml,
} from "./HermesConfigurationYaml.js";
it("uses the first inherited mapping when creating an override", () => {
const source =
"first: &first\n mcp_servers:\n one: {command: first}\nsecond: &second\n mcp_servers:\n two: {command: second}\n<<: [*first, *second]\n";
const updated = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
command: "rea",
});
const parsed = parseHermesConfigurationYaml(updated) as {
mcp_servers: Record<string, unknown>;
};
expect(parsed.mcp_servers).toEqual({
one: { command: "first" },
rea: { command: "rea" },
});
expect(
parseHermesConfigurationYaml(
updated.replace("command: first", "command: changed"),
),
).toMatchObject({
mcp_servers: { one: { command: "changed" } },
});
});
it("refuses an inherited override when an existing anchor name is shadowed", () => {
const source =
"shared: &shared\n mcp_servers: &table\n other: {command: first}\nshadow: &table {other: {command: second}}\n<<: *shared\n";
expect(() =>
editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
command: "rea",
}),
).toThrow(/anchor is shadowed/);
});
it("retains inherited YAML settings when updating a registration", () => {
const source =
"defaults: &defaults\n enabled: false\nmcp_servers:\n rea:\n <<: *defaults\n command: old\n";
const parsed = parseHermesConfigurationYaml(source) as {
mcp_servers: { rea: Record<string, unknown> };
};
const updated = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
...parsed.mcp_servers.rea,
command: "rea",
});
expect(updated).toContain("<<: *defaults");
const changedDefault = updated.replace("enabled: false", "enabled: true");
expect(parseHermesConfigurationYaml(changedDefault)).toMatchObject({
mcp_servers: { rea: { command: "rea", enabled: true } },
});
const overridden = editHermesConfigurationYaml(
source,
["mcp_servers", "rea"],
{ ...parsed.mcp_servers.rea, command: "rea", enabled: true },
);
expect(overridden).toContain("<<: *defaults");
expect(parseHermesConfigurationYaml(overridden)).toMatchObject({
mcp_servers: { rea: { command: "rea", enabled: true } },
});
expect(() =>
editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
command: "rea",
}),
).toThrow(/Cannot remove inherited YAML setting/);
});
it("preserves annotations on changed registration mappings and scalar fields", () => {
const source =
"mcp_servers:\n rea: # registration note\n command: old # command note\n env: # environment note\n MODE: old # mode note\n args: # arguments note\n - old # argument note\n";
+8 -123
View File
@@ -3,21 +3,16 @@ import {
isMap,
isScalar,
isSeq,
Pair,
parseDocument,
Document,
Schema,
YAMLMap,
type Node,
type ParsedNode,
type ScalarTag,
type Tags,
} from "yaml";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
preserveYamlAliasValues,
copyYamlAliasSource,
resolveYamlEditPath,
} from "./YamlConfigurationAliases.js";
import {
retainYamlConfigurationSource,
@@ -273,102 +268,6 @@ const parseHermesDocument = (text: string) => {
return document;
};
const projectedYamlPathExists = (
document: Document,
path: readonly string[],
): boolean => {
let current: unknown = document.toJS();
for (const segment of path) {
if (
typeof current !== "object" ||
current === null ||
!Object.hasOwn(current, segment)
)
return false;
current = (current as Record<string, unknown>)[segment];
}
return true;
};
const inheritedRegistrationRemovalError = () =>
new ConfigurationError(
"The registration is inherited from a YAML merge. Remove it at the shared source before rerunning uninstall",
);
/** Find the source node selected by YAML merge precedence. */
const inheritedYamlValue = (
document: Document,
source: unknown,
name: string,
visiting = new Set<object>(),
): unknown => {
const node = isAlias(source) ? source.resolve(document) : source;
if (!isMap(node) || visiting.has(node)) return undefined;
visiting.add(node);
try {
const matches = node.items.filter((pair) => {
const key = isAlias(pair.key) ? pair.key.resolve(document) : pair.key;
return isScalar(key) && key.value === name;
});
if (matches.length > 1)
throw new ConfigurationError(
`YAML mapping contains more than one key resolving to ${JSON.stringify(name)}`,
);
if (matches[0] !== undefined) return matches[0].value;
for (const pair of node.items) {
if (
!isScalar(pair.key) ||
typeof pair.key.value !== "symbol" ||
pair.key.source !== "<<"
)
continue;
const merged = isAlias(pair.value)
? pair.value.resolve(document)
: pair.value;
const sources = isSeq(merged) ? merged.items : [merged];
for (const candidate of sources) {
const found = inheritedYamlValue(document, candidate, name, visiting);
if (found !== undefined) return found;
}
}
return undefined;
} finally {
visiting.delete(node);
}
};
/** Create a local override that continues to inherit the shared mapping. */
const inheritedYamlMapOverride = (
document: Document,
parent: readonly (string | Node)[],
name: string,
): void => {
const owner =
parent.length === 1
? document.contents
: document.getIn(parent.slice(0, -1), true);
let inherited = inheritedYamlValue(document, owner, name);
if (isAlias(inherited)) inherited = inherited.resolve(document);
if (!isMap(inherited))
throw new ConfigurationError(
`Inherited YAML setting ${JSON.stringify(name)} is not a mapping`,
);
const mergeDocument = parseHermesDocument("<<: {}\n");
if (
!isMap(mergeDocument.contents) ||
mergeDocument.contents.items[0] === undefined
)
throw new ConfigurationError("Cannot create a YAML merge key");
const alias = document.createAlias(inherited, "rea_inherited");
const override = new YAMLMap(document.schema);
override.items.push(new Pair(mergeDocument.contents.items[0].key, alias));
document.setIn(parent, override);
if (alias.resolve(document) !== inherited)
throw new ConfigurationError(
"The inherited YAML anchor is shadowed; edit its shared source before rerunning setup",
);
};
/** Parse a Hermes `config.yaml`, preserving the distinction between no root and explicit null. */
export const parseHermesConfigurationYaml = (text: string): unknown => {
const document = parseHermesDocument(text);
@@ -400,7 +299,7 @@ export const editHermesConfigurationYaml = (
document.contents = map;
}
for (let length = 1; length < path.length; length++) {
const parent = resolveYamlEditPath(document, path.slice(0, length));
const parent = path.slice(0, length);
const previous = document.getIn(parent, true);
if (isAlias(previous)) {
const copied = copyYamlAliasSource(previous, document);
@@ -409,49 +308,35 @@ export const editHermesConfigurationYaml = (
document.setIn(parent, copied);
}
} else if (value !== undefined && !document.hasIn(parent)) {
if (projectedYamlPathExists(document, path.slice(0, length))) {
inheritedYamlMapOverride(document, parent, path[length - 1]!);
continue;
}
// Implicit JS Map parents serialize as !!omap in YAML 1.1.
document.setIn(parent, document.createNode({}));
}
}
const sourcePath = resolveYamlEditPath(document, path);
const previous = document.getIn(sourcePath, true);
const previous = document.getIn(path, true);
// Deletion removes the pair's key too; replacement retains that key node.
const parent =
value === undefined
? document.getIn(sourcePath.slice(0, -1), true)
: undefined;
if (
value === undefined &&
!document.hasIn(sourcePath) &&
projectedYamlPathExists(document, path)
)
throw inheritedRegistrationRemovalError();
value === undefined ? document.getIn(path.slice(0, -1), true) : undefined;
const removedKey = isMap(parent)
? parent.items.find((pair) => pair.key === sourcePath.at(-1))?.key
? parent.items.find(
(pair) => isScalar(pair.key) && pair.key.value === path.at(-1),
)?.key
: undefined;
const retainedSources = preserveYamlAliasValues(document, [
previous,
removedKey,
]);
if (value === undefined) {
if (!document.deleteIn(sourcePath)) return text;
if (projectedYamlPathExists(document, path))
throw inheritedRegistrationRemovalError();
if (!document.deleteIn(path)) return text;
} else {
// Let YAML's scalar update preserve numeric formats for a direct edit.
// Replacing containers needs explicit retention of unchanged child nodes.
document.setIn(
sourcePath,
path,
isMap(previous) || isSeq(previous)
? retainYamlConfigurationSource(
previous,
document.createNode(value),
retainedSources,
document,
)
: value,
);
+2 -4
View File
@@ -1089,13 +1089,11 @@ const removeClient = async (
// clientRegistrationRemoval admitted only names whose repeats REA owns.
{ removeShadowedMembersAt: editedPaths },
);
} catch (cause: unknown) {
} catch {
return item(
client.name,
"failed",
cause instanceof ConfigurationError
? `Configuration could not be updated, so no change was made. ${cause.message}.`
: "Configuration could not be updated, so no change was made. Repair the configuration, then rerun uninstall.",
"Configuration could not be updated, so no change was made. Repair the configuration, then rerun uninstall.",
);
}
if (!serialized.ok)
@@ -9,36 +9,6 @@ import {
type Document,
type Node,
} from "yaml";
import { ConfigurationError } from "../domain/configurationErrors.js";
/** Resolve literal and aliased string keys to their source nodes for editing. */
export const resolveYamlEditPath = (
document: Document,
path: readonly string[],
): (string | Node)[] => {
const resolved: (string | Node)[] = [];
let current: unknown = document.contents;
for (const name of path) {
if (isAlias(current)) current = current.resolve(document);
if (!isMap(current)) {
resolved.push(name);
current = undefined;
continue;
}
const matches = current.items.filter((pair) => {
const key = isAlias(pair.key) ? pair.key.resolve(document) : pair.key;
return isScalar(key) && key.value === name;
});
if (matches.length > 1)
throw new ConfigurationError(
`YAML mapping contains more than one key resolving to ${JSON.stringify(name)}.`,
);
const pair = matches[0];
resolved.push(pair !== undefined && isNode(pair.key) ? pair.key : name);
current = pair?.value;
}
return resolved;
};
interface AliasCopies {
readonly targets: ReadonlyMap<Alias, Node | undefined>;
+43 -270
View File
@@ -3,17 +3,10 @@ import {
isNode,
isScalar,
isSeq,
Pair,
visit,
YAMLMap,
type Document,
type Node,
type ScalarTag,
type Tags,
type YAMLSeq,
} from "yaml";
import { isDeepStrictEqual } from "node:util";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { retainYamlSequenceAnnotations } from "./YamlSequenceAnnotations.js";
export const yamlNumericValuesEqual = (
@@ -33,50 +26,12 @@ export const yamlNumericValuesEqual = (
);
};
const normalizeYamlInteger = (_key: unknown, value: unknown): unknown =>
typeof value === "bigint" && Number.isSafeInteger(Number(value))
? Number(value)
: value;
const containsRetainedSource = (
roots: readonly unknown[],
retainedSources: ReadonlySet<Node>,
): boolean => {
if (retainedSources.size === 0) return false;
let found = false;
for (const root of roots) {
if (!isNode(root)) continue;
visit(root, {
Value: (_key, node) => {
if (retainedSources.has(node)) found = true;
},
});
}
return found;
};
/** Keep source nodes and annotations when updating a value. */
/** Keep source annotations and unchanged scalars when updating a value. */
export const retainYamlConfigurationSource = (
previous: unknown,
next: Node,
retainedSources: ReadonlySet<Node>,
document: Document,
): Node => {
if (
(isMap(previous) || isSeq(previous)) &&
((isMap(previous) && isMap(next)) || (isSeq(previous) && isSeq(next)))
) {
// Object projection can collapse distinct YAML keys. Reuse an unchanged
// collection before reconstructing its children from that projection.
if (
!containsRetainedSource([previous], retainedSources) &&
isDeepStrictEqual(
previous.toJS(document, { reviver: normalizeYamlInteger }),
next.toJS(document, { reviver: normalizeYamlInteger }),
)
)
return previous;
}
if (
isScalar(previous) &&
isScalar(next) &&
@@ -105,235 +60,53 @@ export const retainYamlConfigurationSource = (
next.spaceBefore = previous.spaceBefore;
}
if (isMap(previous) && isMap(next)) {
retainYamlMapSource(previous, next, retainedSources, document);
const entries = new Map(
previous.items.flatMap((pair) =>
isScalar(pair.key) && typeof pair.key.value === "string"
? [[pair.key.value, pair] as const]
: [],
),
);
for (const pair of next.items) {
const key = isScalar(pair.key) ? pair.key.value : undefined;
const prior = typeof key === "string" ? entries.get(key) : undefined;
if (prior === undefined) continue;
pair.key = isNode(pair.key)
? retainYamlConfigurationSource(prior.key, pair.key, retainedSources)
: prior.key;
if (isNode(pair.value))
pair.value = retainYamlConfigurationSource(
prior.value,
pair.value,
retainedSources,
);
}
} else if (isSeq(previous) && isSeq(next)) {
retainYamlSequenceSource(previous, next, retainedSources, document);
const stringItems = (items: readonly unknown[]) =>
items.every((item) => isScalar(item) && typeof item.value === "string");
if (stringItems(previous.items) && stringItems(next.items))
retainYamlSequenceAnnotations(
previous,
next,
(item) => (isScalar(item) ? item.value : undefined),
(source, target) =>
retainYamlConfigurationSource(source, target, retainedSources),
(source) => !retainedSources.has(source),
);
else
next.items = next.items.map((item, index) =>
isNode(item)
? retainYamlConfigurationSource(
previous.items[index],
item,
retainedSources,
)
: item,
);
}
return next;
};
const retainYamlSequenceSource = (
previous: YAMLSeq,
next: YAMLSeq,
retainedSources: ReadonlySet<Node>,
document: Document,
): void => {
const stringItems = (items: readonly unknown[]) =>
items.every((item) => isScalar(item) && typeof item.value === "string");
if (stringItems(previous.items) && stringItems(next.items)) {
retainYamlSequenceAnnotations(
previous,
next,
(item) => (isScalar(item) ? item.value : undefined),
(source, target) =>
retainYamlConfigurationSource(
source,
target,
retainedSources,
document,
),
(source) => !retainedSources.has(source),
);
return;
}
next.items = next.items.map((item, index) =>
isNode(item)
? retainYamlConfigurationSource(
previous.items[index],
item,
retainedSources,
document,
)
: item,
);
};
const retainYamlMapSource = (
previous: YAMLMap,
next: YAMLMap,
retainedSources: ReadonlySet<Node>,
document: Document,
): void => {
const hasMerges = retainYamlMergeSources(previous, next, document);
const hasSourceOnlyKeys = retainProjectedYamlKeys(
previous,
next,
retainedSources,
document,
);
const entries = new Map(
previous.items.flatMap((pair) =>
isScalar(pair.key) && typeof pair.key.value === "string"
? [[pair.key.value, pair] as const]
: [],
),
);
for (const pair of next.items) {
if (previous.items.includes(pair)) continue;
const key = isScalar(pair.key) ? pair.key.value : undefined;
const prior = typeof key === "string" ? entries.get(key) : undefined;
if (prior === undefined) continue;
pair.key = isNode(pair.key)
? retainYamlConfigurationSource(
prior.key,
pair.key,
retainedSources,
document,
)
: prior.key;
if (isNode(pair.value))
pair.value = retainYamlConfigurationSource(
prior.value,
pair.value,
retainedSources,
document,
);
}
if (hasSourceOnlyKeys || hasMerges) {
// Object property ordering moves integer-like keys ahead of their
// source neighbors. Restore source order so aliases stay after anchors.
const positions = new Map(
previous.items.map((pair, index) => [pair, index] as const),
);
const position = (pair: (typeof next.items)[number]): number => {
const key = isScalar(pair.key) ? pair.key.value : undefined;
const original =
positions.has(pair) || typeof key !== "string"
? pair
: entries.get(key);
return original === undefined
? Infinity
: (positions.get(original) ?? Infinity);
};
next.items.sort((left, right) => position(left) - position(right));
}
};
const isYamlMergeKey = (key: unknown): boolean =>
isScalar(key) && typeof key.value === "symbol" && key.source === "<<";
/** Retain inheritance while materializing only explicit changes to it. */
const retainYamlMergeSources = (
previous: YAMLMap,
next: YAMLMap,
document: Document,
): boolean => {
const merges = previous.items.filter((pair) => isYamlMergeKey(pair.key));
if (merges.length === 0) return false;
const inherited = new YAMLMap(document.schema);
inherited.items = merges;
const direct = new YAMLMap(document.schema);
direct.items = previous.items.filter((pair) => !isYamlMergeKey(pair.key));
const project = (map: YAMLMap): Record<string, unknown> =>
map.toJS(document, { reviver: normalizeYamlInteger }) as Record<
string,
unknown
>;
const inheritedValues = project(inherited);
const directValues = project(direct);
const desired = project(next);
const inheritedUnchanged = new Set<string>();
for (const [key, value] of Object.entries(inheritedValues)) {
if (Object.hasOwn(directValues, key)) continue;
if (!Object.hasOwn(desired, key))
throw new ConfigurationError(
`Cannot remove inherited YAML setting ${JSON.stringify(key)} while retaining its merge.`,
);
if (isDeepStrictEqual(value, desired[key])) inheritedUnchanged.add(key);
}
next.items = [
...merges,
...next.items.filter((pair) => {
const key = isScalar(pair.key) ? pair.key.value : undefined;
return typeof key !== "string" || !inheritedUnchanged.has(key);
}),
];
return true;
};
const retainProjectedYamlKeys = (
previous: YAMLMap,
next: YAMLMap,
retainedSources: ReadonlySet<Node>,
document: Document,
): boolean => {
// A JS property can represent several distinct native YAML keys. Keep
// those source pairs together when the property was not edited.
const groups = new Map<string, typeof previous.items>();
const sourceOnlyKeys = new Set<string>();
for (const pair of previous.items) {
if (isYamlMergeKey(pair.key)) continue;
let key: string;
if (isScalar(pair.key) && typeof pair.key.value === "string")
key = pair.key.value;
else {
const singleton = new YAMLMap(document.schema);
singleton.items.push(new Pair(pair.key, null));
const projected: unknown = singleton.toJS(document);
if (typeof projected !== "object" || projected === null)
throw new ConfigurationError(
"YAML mapping key could not be resolved safely",
);
const [projectedKey] = Object.keys(projected);
if (projectedKey === undefined || Object.keys(projected).length !== 1)
throw new ConfigurationError(
"YAML mapping key could not be resolved safely",
);
key = projectedKey;
sourceOnlyKeys.add(key);
}
const group = groups.get(key) ?? [];
group.push(pair);
groups.set(key, group);
}
if (sourceOnlyKeys.size > 0) {
const oldValue: unknown = previous.toJS(document, {
reviver: normalizeYamlInteger,
});
const newValue: unknown = next.toJS(document, {
reviver: normalizeYamlInteger,
});
if (
typeof oldValue !== "object" ||
oldValue === null ||
typeof newValue !== "object" ||
newValue === null
)
throw new ConfigurationError("YAML mapping could not be compared safely");
const oldProperties = oldValue as Record<string, unknown>;
const newProperties = newValue as Record<string, unknown>;
const replacements = new Map<string, typeof previous.items>();
for (const key of sourceOnlyKeys) {
const group = groups.get(key);
if (group === undefined) continue;
if (
!Object.hasOwn(newProperties, key) ||
!isDeepStrictEqual(oldProperties[key], newProperties[key])
)
throw new ConfigurationError(
`Cannot safely change YAML mapping key ${JSON.stringify(key)} because its source identity is not a plain string key.`,
);
if (
containsRetainedSource(
group.flatMap((pair) => [pair.key, pair.value]),
retainedSources,
)
)
throw new ConfigurationError(
`Cannot safely preserve YAML mapping key ${JSON.stringify(key)} while its anchor is moved.`,
);
replacements.set(key, group);
}
next.items = next.items.flatMap((pair) => {
const key = isScalar(pair.key) ? pair.key.value : undefined;
const replacement =
typeof key === "string" ? replacements.get(key) : undefined;
return replacement ?? [pair];
});
}
return sourceOnlyKeys.size > 0;
};
/** Keep a source spelling only while the selected schema resolves its value unchanged. */
const sameResolvedScalarValue = (left: unknown, right: unknown): boolean =>
Object.is(left, right) ||
@@ -21,7 +21,7 @@ import {
parseEvidence,
rebindEvidenceToTargetPath,
} from "../../domain/evidence.js";
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisViewRequest.js";
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisView.js";
import { functionDossierSchema } from "../../domain/native/functionDossier.js";
import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
import {
@@ -28,11 +28,11 @@ import {
} from "../../domain/evidence.js";
import {
inspectAnalysisViewInputSchema,
prepareAnalysisViewProjection,
type AnalysisViewResult,
type AnalysisViewRequest,
type InspectAnalysisViewInput,
} from "../../domain/analysisView/analysisViewRequest.js";
import { prepareAnalysisViewProjection } from "../../domain/analysisView/analysisView.js";
import type { AnalysisViewResult } from "../../domain/analysisView/analysisViewResult.js";
} from "../../domain/analysisView/analysisView.js";
import {
immutableJsonSnapshotMember,
isImmutableJsonSnapshot,
@@ -15,7 +15,7 @@ import { inspectAnalysisView } from "./AnalysisViewService.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
import type { BrowserScenarioCapture } from "../../domain/browserScenarioCapture.js";
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisViewRequest.js";
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisView.js";
import { projectBrowserScenarioView } from "../../domain/analysisView/browserScenarioView.js";
const tree = (text: string) => ({
@@ -6,7 +6,7 @@ import {
analysisViewJavaScriptEvidence,
} from "../../../tests/fixtures/analysisView.js";
import { SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE } from "../../contracts/javascript/javascriptApplicationWorkflowExamples.js";
import { analysisViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import { analysisViewResultSchema } from "../../domain/analysisView/analysisView.js";
import type { Evidence } from "../../domain/evidence.js";
import { jsonValueValidationIssue } from "../../domain/jsonValue.js";
import { compareSourceToBundle } from "../../domain/javascript/sourceToBundleComparison.js";
@@ -1,7 +1,9 @@
import { expect, it } from "vitest";
import { createEvidence } from "../../domain/evidence.js";
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisViewRequest.js";
import { analysisViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import {
analysisViewResultSchema,
inspectAnalysisViewInputSchema,
} from "../../domain/analysisView/analysisView.js";
import {
RELOCATION_SYMBOL_UNRECORDED,
projectNativeLoadImageView,
@@ -13,11 +13,11 @@ import {
nativeCallGraphSchema,
type NativeCallGraph,
} from "../../domain/native/nativeCallGraph.js";
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisViewRequest.js";
import {
analysisViewResultSchema,
type AnalysisViewRequest,
type AnalysisViewResult,
} from "../../domain/analysisView/analysisViewResult.js";
} from "../../domain/analysisView/analysisView.js";
import {
inspectAnalysisView,
projectRetainedAnalysis,
@@ -2,8 +2,10 @@ import { expect, it } from "vitest";
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
import { sealAnalysisView } from "../../domain/analysisView/analysisView.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import {
artifactInventorySummaryViewResultSchema,
sealAnalysisView,
} from "../../domain/analysisView/analysisView.js";
import {
createEvidence,
parseEvidence,
@@ -9,7 +9,7 @@ import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewServic
import type { EvidenceFileReadOptions } from "../analysisView/evidenceFileSource.js";
import { artifactComparisonInputSchema } from "../../contracts/artifactComparisonContracts.js";
import { parseAnalysisInput } from "../AnalysisInput.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import {
compareArtifacts,
type ArtifactComparisonResult,
+2 -4
View File
@@ -7,10 +7,7 @@ import {
} from "../EvidenceBundleFiles.js";
import type { ResetInvestigationReceipt } from "../../domain/investigationLifecycle.js";
import {
retainSavedAnalysisSnapshot,
type SavedAnalysisSnapshot,
} from "../../domain/savedAnalysisSnapshot.js";
import { retainSavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import {
@@ -42,6 +39,7 @@ import type {
BinarySessionPort,
BinaryOpenResult,
OpenSelectionReceipt,
SavedAnalysisSnapshot,
} from "./BinarySessionPort.js";
import { binaryTargetSummary } from "./BinaryTargetSummary.js";
import {
@@ -30,6 +30,7 @@ import type {
} from "../AnalysisProvider.js";
import type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
export type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
/** Selection observed at the end of one serialized open attempt. */
export type OpenSelectionReceipt =
@@ -28,11 +28,7 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { err, ok, type Result } from "../../domain/result.js";
import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js";
import type { AgentEnvComputer } from "../../desktop/AgentEnvComputer.js";
import {
controller,
type Answer,
type ControlledComputer,
} from "../../desktop/BuildComputerController.js";
import type { ControlledComputer } from "../../desktop/BuildComputerController.js";
import { BuildComputerLongRuns } from "../../desktop/BuildComputerLongRuns.js";
import {
endedCapture,
@@ -41,10 +37,12 @@ import {
} from "./DesktopBuildCaptureFailure.js";
import {
adapterError,
controller,
errorText,
refused,
runRea,
WORK,
type Answer,
} from "./DesktopBuildCommands.js";
import {
exportBuild,
@@ -7,14 +7,12 @@ import {
} from "../../domain/desktop/desktopCapture.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js";
import type {
Answer,
ControlledComputer,
} from "../../desktop/BuildComputerController.js";
import type { ControlledComputer } from "../../desktop/BuildComputerController.js";
import {
adapterError,
readOutput,
reportedFailure,
type Answer,
} from "./DesktopBuildCommands.js";
/** The most lines, and characters, a message quotes from the end of the app's stderr. */
@@ -22,6 +22,11 @@ import {
type ControlledComputer,
} from "../../desktop/BuildComputerController.js";
export {
controller,
type Answer,
} from "../../desktop/BuildComputerController.js";
/** The work folder REA keeps under the computer user's home; every capture leaves it out. */
export const WORK = ".rea-build";
/** The checkpoint options the draft and the export read the seed and the end with. */
@@ -8,7 +8,7 @@ import {
import {
analysisViewResultSchema,
type AnalysisViewResult,
} from "../../domain/analysisView/analysisViewResult.js";
} from "../../domain/analysisView/analysisView.js";
import { scenarioCaptureRecords } from "../../domain/analysisView/scenarioCaptureRecords.js";
import {
readRecordedResponseBody,
@@ -1,4 +1,4 @@
import type { AnalysisViewResult } from "../../domain/analysisView/analysisViewResult.js";
import type { AnalysisViewResult } from "../../domain/analysisView/analysisView.js";
import {
changesState,
type StateDocument,
@@ -1,5 +1,7 @@
import { projectAnalysisView } from "../../domain/analysisView/analysisView.js";
import { analysisViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
import {
projectAnalysisView,
analysisViewResultSchema,
} from "../../domain/analysisView/analysisView.js";
import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatching.js";
import { expect, it } from "vitest";
import { matchNativeFunctions } from "./NativeFunctionMatchingService.js";
+1 -42
View File
@@ -3,43 +3,14 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import {
artifactFileCancellationCause,
bindArtifactFileCleanup,
type ArtifactFilePrimaryFacts,
} from "./ArtifactFileOperation.js";
import { bindArtifactFileCleanup } from "./ArtifactFileOperation.js";
import { completedStableArtifactObservation } from "./StableArtifactObservation.js";
import { isSelectedRegularFileError } from "../filesystem/RegularFile.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
/** Preserve ownership classification for stable-file identity and cancellation. */
export const projectOwnedArtifactIdentityFailure = (
facts: ArtifactFilePrimaryFacts,
operation: string,
path: string,
): AnalysisError | undefined => {
const { cause, origin } = facts;
if (
origin !== "owned-or-classified" ||
!(cause instanceof ArtifactReaderFailure)
)
return undefined;
if (cause.reason === "integrity")
return new AnalysisArtifactChangedError(operation, path, cause.message, {
cause,
});
if (cause.reason === "cancelled")
return new AnalysisCancelledError(operation, {
cause: artifactFileCancellationCause(cause),
});
return undefined;
};
/** Project filesystem errors that identify a refused or unreadable selected file. */
export const projectSelectedArtifactFileFailure = (
cause: unknown,
@@ -86,18 +57,6 @@ export const stableArtifactInputRecovery = (cause: ArtifactReaderFailure) =>
}
: {};
/** Report a rejected artifact path or format with its source-provided recovery. */
export const stableArtifactInputFailure = (
cause: ArtifactReaderFailure,
operation: string,
field = "path",
): AnalysisInputError =>
new AnalysisInputError(
operation,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: [field], reason: "invalid_format", message: cause.message }],
);
const artifactCleanupFailure = (
cause: unknown,
providerId: string,
+2
View File
@@ -5,6 +5,8 @@ import {
type ArtifactReaderConstruction,
} from "./artifactFormats.js";
export type { ArtifactReaderConstruction } from "./artifactFormats.js";
/** Formats with an artifact entry reader; availability policy belongs to the caller. */
export type ArtifactReaderFormat = {
[Id in keyof typeof ARTIFACT_FORMATS]: (typeof ARTIFACT_FORMATS)[Id] extends {
@@ -11,7 +11,7 @@ import { PLAYWRIGHT_TRACE_OPERATION as operation } from "../../../domain/playwri
import { ArtifactReaderFailure } from "../../../artifacts/ArtifactReader.js";
import { artifactFileCancellationCause } from "../../../artifacts/ArtifactFileOperation.js";
import { isSelectedRegularFileError } from "../../../filesystem/RegularFile.js";
import { stableArtifactInputFailure } from "../../../artifacts/StableArtifactFailure.js";
import { stableArtifactInputRecovery } from "../../../artifacts/StableArtifactFailure.js";
/** Classify the archive owner's facts without interpreting opaque cancellation reasons. */
export const traceFailure = (cause: unknown, path: string): AnalysisError => {
@@ -38,7 +38,11 @@ export const traceFailure = (cause: unknown, path: string): AnalysisError => {
},
);
if (cause.reason === "format" || cause.reason === "path")
return stableArtifactInputFailure(cause, operation);
return new AnalysisInputError(
operation,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
);
return new ArtifactOperationError(
operation,
cause.reason,
+1 -1
View File
@@ -3,7 +3,7 @@ import {
analysisEvidenceSources,
undeliveredSourceClosure,
} from "../application/AnalysisEvidenceSources.js";
import type { AnalysisViewRequest } from "../domain/analysisView/analysisViewRequest.js";
import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js";
import { z } from "incur";
import {
isImmutableEvidence,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AnalysisViewRequest } from "../domain/analysisView/analysisViewRequest.js";
import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js";
import { publishCliAnalysisEvidence } from "./analysisEvidenceDelivery.js";
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../domain/investigationProviders.js";
import { createProgressReporter } from "../application/ProgressReporter.js";
+169
View File
@@ -0,0 +1,169 @@
import type { Writable } from "node:stream";
import {
cliCompletionShell,
isProjectScopedAmpRegistration,
normalizeCliArguments,
splitIncurBuiltinArguments,
} from "./cliBuiltinArguments.js";
import {
cliConfigurationError,
parseCliOutputArguments,
renderCliOutputArgumentError,
renderEmptyFilteredCliOutput,
renderVersionReport,
} from "./cliOutput.js";
import type { VersionReportHost } from "./domain/versionReport.js";
/**
* Process facilities one command needs. The executable binds them to its own
* streams and exit; a driver that serves several commands from one process
* binds them to per-command capture.
*/
export interface OneShotCliHost {
readonly environment: NodeJS.ProcessEnv;
readonly runtime: VersionReportHost;
readonly stdoutIsTerminal: boolean;
/** Destination for result documents the command streams itself. */
readonly resultDestination: Writable;
/** Status reported after a recovered failure when the reader has closed stdout. */
readonly closedStdoutStatus: number;
writeStdout(text: string): void;
writeStderr(text: string): void;
stdoutClosed(): boolean;
/** Status the process would report if it ended now. */
exitCode(): number;
setExitCode(code: number): void;
/** Called once when the command runtime loads, before any command runs. */
ownCliResources(close: () => Promise<void>): void;
/**
* The command asked to end the process with `code`. The host owns that
* exit and must call `finishOutput` with the status it reports.
*/
exit(code: number, finishOutput: (status: number) => Promise<void>): void;
/** Whether `exit` has run. Its host then owns the pending result write. */
exitRequested(): boolean;
}
const AMP_PROJECT_SCOPE_REFUSAL =
"Amp does not support project-scoped registration. Run without --no-global to register Amp globally, or select a client with project settings. No client settings were changed.";
/**
* Run one CLI command: admit its output arguments and configuration, answer
* the documents that need no command runtime, then dispatch. Failures the
* command did not report propagate to the host.
*/
export const runOneShotCli = async (
arguments_: readonly string[],
host: OneShotCliHost,
): Promise<void> => {
const isShellCompletion = cliCompletionShell(host.environment) !== undefined;
const cliArguments = isShellCompletion
? [...arguments_]
: normalizeCliArguments([...arguments_]);
// Completion words are transport data, including partial output flags.
const outputOptions = parseCliOutputArguments(
isShellCompletion ? [] : cliArguments,
);
const outputArguments = outputOptions.validation;
// `--version` with an explicit format reports the runtime as a document;
// plain `--version` stays Incur's bare version string.
const versionOutput = renderVersionReport(
outputOptions,
host.runtime,
host.stdoutIsTerminal,
);
const configurationError = cliConfigurationError(host.environment);
// Doctor is the readiness report for this failure. Other commands still stop
// here so they do not run with an unusable log level.
if (
configurationError !== undefined &&
(isShellCompletion ||
splitIncurBuiltinArguments(cliArguments).command[0] !== "doctor")
) {
host.writeStderr(`${configurationError}\n`);
host.setExitCode(1);
return;
}
if (!outputArguments.ok) {
host.writeStdout(renderCliOutputArgumentError(outputArguments));
host.setExitCode(1);
return;
}
if (versionOutput !== undefined) {
host.writeStdout(versionOutput);
return;
}
if (!isShellCompletion && isProjectScopedAmpRegistration(cliArguments)) {
// Amp has global settings only. Share the parsed scope fact with the
// registration workflow and refuse before loading the command runtime.
//
// A caller that asked for a structured document gets the refusal in
// that document, through the same pre-execution error renderer the
// output-argument branch uses; anything else keeps the plain message.
const refusalFormat = outputOptions.format;
if (
refusalFormat === "json" ||
refusalFormat === "jsonl" ||
refusalFormat === "yaml"
) {
host.writeStdout(
renderCliOutputArgumentError({
format: refusalFormat,
code: "AMP_PROJECT_SCOPE_UNSUPPORTED",
message: AMP_PROJECT_SCOPE_REFUSAL,
}),
);
} else {
host.writeStderr(`${AMP_PROJECT_SCOPE_REFUSAL}\n`);
}
host.setExitCode(1);
return;
}
// Loaded only now: the documents above answer without the command catalog.
const { closeCliResources, createCli } = await import("./cli.js");
host.ownCliResources(closeCliResources);
const { createCliResultOutput } = await import("./cli/cliResultOutput.js");
let wroteOutput = false;
const resultOutput = createCliResultOutput(
outputOptions,
host.resultDestination,
);
try {
await createCli(host.environment, resultOutput).serve(cliArguments, {
stdout: (output) => {
if (resultOutput?.handled) {
if (resultOutput.failed && !host.stdoutClosed())
host.writeStderr(output);
return;
}
if (output.length > 0) wroteOutput = true;
if (resultOutput?.captureSdkOutput(output)) return;
host.writeStdout(output);
},
exit: (code) => {
host.exit(typeof code === "number" ? code : 0, async (status) =>
resultOutput?.finishSdkOutput(status),
);
},
});
} catch (cause) {
if (wroteOutput) throw cause;
const recovered = await resultOutput
?.recoverFormattingFailure(cause)
.catch((writeFailure: unknown) => {
// Recovery writes outside Incur's command boundary. A reader that
// declined this document keeps the same quiet closed-pipe outcome.
if (!host.stdoutClosed()) throw writeFailure;
return true;
});
if (!recovered) throw cause;
host.setExitCode(host.stdoutClosed() ? host.closedStdoutStatus : 1);
}
if (!wroteOutput && !resultOutput?.handled)
host.writeStdout(renderEmptyFilteredCliOutput(outputOptions) ?? "");
// A requested exit is finished by the host, which owns its pending write.
if (!host.exitRequested())
await resultOutput?.finishSdkOutput(host.exitCode());
};
+10 -1
View File
@@ -366,9 +366,18 @@ export const renderEmptyFilteredCliOutput = ({
return undefined;
};
/** A refusal reported as a structured document before any command runs. */
export interface CliPreExecutionError {
readonly format: StructuredOutputFormat;
readonly code:
| Exclude<CliOutputArgumentValidation, { readonly ok: true }>["code"]
| "AMP_PROJECT_SCOPE_UNSUPPORTED";
readonly message: string;
}
/** Render one complete structured error before command execution. */
export const renderCliOutputArgumentError = (
error: Exclude<CliOutputArgumentValidation, { readonly ok: true }>,
error: CliPreExecutionError,
): string => {
const value = {
ok: false,
+3
View File
@@ -7,6 +7,7 @@ import { RecordedCrashService } from "../application/binaryDiagnostics/RecordedC
import { PwntoolsRecordedCrashProvider } from "../native/pwntools/PwntoolsRecordedCrashProvider.js";
import { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js";
import { PwntoolsLayoutProvider } from "../native/pwntools/PwntoolsLayoutProvider.js";
import { pwntoolsHostSupported } from "../native/pwntools/PwntoolsDecoder.js";
import { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js";
import { PeResourcesProvider } from "../native/pe/PeResourcesProvider.js";
import { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js";
@@ -36,6 +37,8 @@ import { DevicetreeProvider } from "../firmware/devicetree/DevicetreeProvider.js
export const createDevicetreeService = (): DevicetreeService =>
new DevicetreeService(new DevicetreeProvider());
export { pwntoolsHostSupported };
/** Portable PE inspection with no engine, installation or target acquisition. */
export const createPeResourcesService = (): PeResourcesService =>
new PeResourcesService(new PeResourcesProvider());
+16 -11
View File
@@ -12,17 +12,18 @@ import {
} from "./capabilityDeclarations.js";
import { isAbsolute } from "node:path";
import { createGpuCodeObjectsService } from "./gpu.js";
import { createPacketCaptureService } from "./captures.js";
import { tsharkHostSupported } from "../captures/TsharkCaptureProvider.js";
import { createSignalRecordingService } from "./signals.js";
import { sigrokHostSupported } from "../signals/SigrokRecordingProvider.js";
import { createPacketCaptureService, tsharkHostSupported } from "./captures.js";
import {
createSignalRecordingService,
sigrokHostSupported,
} from "./signals.js";
import { wasmArtifactModule } from "./wasm.js";
import { apkSignatureModule } from "./apkSignature.js";
import { evmInterfaceModule } from "./evm.js";
import {
evmInterfaceModule,
evmHostSupported,
evmLimiterAvailability,
} from "../evm/EvmoleInterfaceProvider.js";
} from "./evm.js";
import { createSqliteDatabaseService } from "./sqlite.js";
import { createDesktopApplicationService } from "./desktop.js";
import { DesktopBuildComputers } from "../application/desktop/DesktopBuildComputers.js";
@@ -39,8 +40,10 @@ import { createWebNetworkCaptureService } from "./webNetworkCaptures.js";
import { createWebSourceLocationService } from "./webSourceLocations.js";
import { createWebModuleTraceService } from "./webModules.js";
import { createWebRuntimeService } from "./webRuntime.js";
import { createJavaScriptRecoveryProvider } from "./javascriptRecovery.js";
import { wakaruHostSupported } from "../javascript/recovery/WakaruCommand.js";
import {
createJavaScriptRecoveryProvider,
wakaruHostSupported,
} from "./javascriptRecovery.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { createReverseEngineeringService } from "./reverseEngineering.js";
import type {
@@ -48,8 +51,10 @@ import type {
CommandProviderLaunchFailures,
} from "../application/reverse/ReverseEngineeringService.js";
import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js";
import { createFirmwareAnalysisProvider } from "./firmware.js";
import { firmwareHostSupported } from "../firmware/FirmwareCommand.js";
import {
createFirmwareAnalysisProvider,
firmwareHostSupported,
} from "./firmware.js";
import { createAndroidAnalysisProvider } from "./android.js";
import { createPcapAnalysisProvider } from "./pcap.js";
import { createFlutterBuildAnalysisProvider } from "./flutter.js";
@@ -74,6 +79,7 @@ import {
createWindowsShortcutService,
createRecordedCrashService,
createBinaryLayoutService,
pwntoolsHostSupported,
createDevicetreeService,
createElfNotesService,
createNativeInitializersService,
@@ -82,7 +88,6 @@ import {
createKernelModuleService,
createKernelBuildConfigService,
} from "./binaryDiagnostics.js";
import { pwntoolsHostSupported } from "../native/pwntools/PwntoolsDecoder.js";
import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js";
import type { SignalRecordingService } from "../application/signals/SignalRecordingService.js";
+6 -1
View File
@@ -1,5 +1,10 @@
import { PacketCaptureService } from "../application/captures/PacketCaptureService.js";
import { TsharkCaptureProvider } from "../captures/TsharkCaptureProvider.js";
import {
TsharkCaptureProvider,
tsharkHostSupported,
} from "../captures/TsharkCaptureProvider.js";
export { tsharkHostSupported };
export const createPacketCaptureService = (
environment: Readonly<NodeJS.ProcessEnv> = process.env,
+7 -1
View File
@@ -1,5 +1,9 @@
import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
import { EvmoleInterfaceProvider } from "../evm/EvmoleInterfaceProvider.js";
import {
EvmoleInterfaceProvider,
evmHostSupported,
evmLimiterAvailability,
} from "../evm/EvmoleInterfaceProvider.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { INSPECT_EVM_INTERFACE_CONTRACT } from "../contracts/evm/evmToolContracts.js";
@@ -9,6 +13,8 @@ export const createEvmInterfaceService = (
): EvmInterfaceService =>
new EvmInterfaceService(new EvmoleInterfaceProvider(environment));
export { evmHostSupported, evmLimiterAvailability };
/**
* Static binding of the existing operation, command and fresh service factory.
* The host owns injected service identity and its retryable cleanup lifetime.
+6 -1
View File
@@ -1,7 +1,12 @@
import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js";
import type { FirmwareLauncher } from "../firmware/FirmwareCommand.js";
import {
firmwareHostSupported,
type FirmwareLauncher,
} from "../firmware/FirmwareCommand.js";
import { FirmwareProvider } from "../firmware/FirmwareProvider.js";
export { firmwareHostSupported };
/** Construct one firmware provider without launching or acquiring an engine. */
export const createFirmwareAnalysisProvider = (
environment: Readonly<Record<string, string | undefined>> = process.env,
+6 -1
View File
@@ -1,6 +1,11 @@
import type { JavaScriptRecoveryPort } from "../application/javascript/JavaScriptRecoveryPort.js";
import { WakaruProvider } from "../javascript/recovery/WakaruProvider.js";
import type { WakaruLauncher } from "../javascript/recovery/WakaruCommand.js";
import {
wakaruHostSupported,
type WakaruLauncher,
} from "../javascript/recovery/WakaruCommand.js";
export { wakaruHostSupported };
/** Construct optional recovery without acquiring tools, processes or workspaces. */
export const createJavaScriptRecoveryProvider = (
+6 -1
View File
@@ -1,5 +1,10 @@
import { SignalRecordingService } from "../application/signals/SignalRecordingService.js";
import { SigrokRecordingProvider } from "../signals/SigrokRecordingProvider.js";
import {
SigrokRecordingProvider,
sigrokHostSupported,
} from "../signals/SigrokRecordingProvider.js";
export { sigrokHostSupported };
export const createSignalRecordingService = (
environment: Readonly<NodeJS.ProcessEnv> = process.env,
+2 -4
View File
@@ -12,13 +12,11 @@ import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSna
import {
BRIDGE_MAX_REQUEST_BYTES_SETTING,
DEFAULT_BRIDGE_MAX_REQUEST,
parseBridgeMaxRequestBytes,
} from "./bridgeMaxRequestBytes.js";
import {
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
} from "../domain/analysisResourceLimits.js";
parseBridgeMaxRequestBytes,
} from "./bridgeMaxRequestBytes.js";
const configured = (maximumBytes: number) =>
({ maximumBytes, source: "setting" }) as const;
+6
View File
@@ -13,6 +13,12 @@ import {
export const BRIDGE_MAX_REQUEST_BYTES_SETTING = "REA_BRIDGE_MAX_REQUEST_BYTES";
export {
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
};
/**
* Smallest ceiling that still admits the largest bounded bridge request.
* Hopper symbol names stop at 1024 UTF-16 units. A name of U+0001 repeated
Loaded 100 of 386 files, more files were not shown because too many files have changed in this diff. Show more