mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 10:43:30 +00:00
refactor: prune internal seams and preserve configuration ownership (#10430)
* refactor: prune internal seams and preserve configuration ownership * fix(setup): keep YAML aliases and inherited registration settings on refresh - Reuse an unchanged YAML collection only while every alias in it still resolves after the edit; otherwise rebuild it so the alias becomes its value. A sibling anchor replaced or moved in the same refresh no longer leaves an unresolved alias in Hermes or Goose output. - Keep a connection selector whose existing value already chooses stdio (for example an inherited Hermes transport: stdio) instead of superseding it; remote selectors are still replaced. Explicit stdio selectors are therefore no longer rewritten (Kimi expectation updated). - When the whole Hermes registration is inherited through a merge, override it with that merge and write only the changed fields. * fix(setup): edit registration fields whose YAML key is an alias A key written as an alias to a string scalar (for example *field with field: &field env) was treated as a source-only key, so any change to its value was refused in Hermes and Goose. Resolve such keys to their string, keep the aliased spelling while its anchor survives the edit, and write the plain key when the same edit rebuilds the anchor. * test(process): bound cleanup-boundary waits and always release gates Both cancellation tests awaited a bare started promise and released their gates only on success, so a missed boundary waited for the outer deadline and could leave cleanup parked. Use awaitAcknowledgement and release and join the cleanup in finally. * fix(setup): repair only aliases an edit leaves unresolved Replace the pre-edit alias survival guess, which expanded aliases to sibling anchors that survive unchanged, with an exact repair: record what each alias in the replaced value resolves to, write the edit, then replace only aliases left without a preceding anchor by that value. - Keep an aliased key's source position, so its anchor stays ahead of later aliases. - Treat an aliased key as its string only when no other key in the map resolves to the same string; an ambiguous one stays guarded. - Override an inherited Hermes registration only when the requested entry keeps every inherited field, so an explicit replacement can drop remote selectors. * fix(setup): repair aliases whose binding an edit changes, keeping comments An alias whose own anchor was removed could resolve to an earlier anchor with the same name and silently change value. Record the node each alias resolved to and repair any alias that no longer resolves to that node. Carry the alias's comments and spacing onto the repaired value. * test(setup): split the Hermes anchor and merge refresh cases * fix(setup): refuse removing an overridden key its merge would restore The merge-retention check skipped keys the mapping also overrides, so dropping such a key (for example a remote url during an explicit stdio replacement) let the retained merge restore it. Check removal before skipping overridden keys, so the edit is refused with the existing message instead of producing a registration that cannot be applied.
This commit is contained in:
1 parent
67ce671d66
commit
b138f52e12
316 files changed
+2874
-3510
No files matched your search
+24
-1
@@ -63,6 +63,18 @@
|
||||
"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",
|
||||
@@ -76,12 +88,19 @@
|
||||
{
|
||||
"files": ["src/**/*.ts"],
|
||||
"rules": {
|
||||
"rea/no-babel-visitor-keys": "error",
|
||||
"typescript/no-misused-promises": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.fixture.ts"],
|
||||
"files": [
|
||||
"tests/**/*.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.fixture.ts"
|
||||
],
|
||||
"rules": {
|
||||
"rea/no-babel-visitor-keys": "off",
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": [
|
||||
"error",
|
||||
@@ -94,6 +113,10 @@
|
||||
"typescript/no-misused-promises": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/domain/javascript/javascriptSemanticTraversal.ts"],
|
||||
"rules": { "rea/no-babel-visitor-keys": "off" }
|
||||
},
|
||||
{
|
||||
"files": ["src/application/**/*.test.ts"],
|
||||
"rules": {
|
||||
|
||||
@@ -174,7 +174,9 @@ Official references: [mitmproxy protocol support](https://github.com/mitmproxy/m
|
||||
|
||||
## Verifier regressions
|
||||
|
||||
After building REA, run these inside the same isolated guest:
|
||||
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:
|
||||
|
||||
```sh
|
||||
node --test scripts/lib/http3-verifier-lifecycle.test.mjs scripts/lib/http3-capture-assertions.test.mjs
|
||||
|
||||
@@ -14,7 +14,7 @@ const directDependencies = (rootPackage) => ({
|
||||
const installedPackageKey = (name) => `node_modules/${name}`;
|
||||
|
||||
/** Compare root direct dependencies with npm's hidden installed lockfile. */
|
||||
export const dependencyInstallProblems = (rootLock, installedLock) => {
|
||||
const dependencyInstallProblems = (rootLock, installedLock) => {
|
||||
const rootPackage = rootLock.packages?.[""];
|
||||
if (rootPackage === undefined)
|
||||
return ["package-lock.json does not contain a root package entry"];
|
||||
|
||||
@@ -26,8 +26,6 @@ export type CacheRequest = (
|
||||
endpoint: string,
|
||||
) => Promise<unknown>;
|
||||
|
||||
export function assertRetentionPrefix(prefix: string): void;
|
||||
|
||||
export function selectPrefixDeletions(
|
||||
caches: readonly unknown[],
|
||||
family?: RetentionFamily,
|
||||
|
||||
@@ -22,7 +22,7 @@ const PREFIX_PATTERNS = [
|
||||
];
|
||||
|
||||
/** Reject prefixes that could match npm downloads or another platform. */
|
||||
export function assertRetentionPrefix(prefix) {
|
||||
function assertRetentionPrefix(prefix) {
|
||||
if (
|
||||
typeof prefix !== "string" ||
|
||||
!PREFIX_PATTERNS.some((pattern) => pattern.test(prefix))
|
||||
|
||||
@@ -99,9 +99,3 @@ 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 }>;
|
||||
@@ -467,7 +467,7 @@ function errorCode(error) {
|
||||
return error instanceof Error ? error.message.slice(0, 160) : "unknown_error";
|
||||
}
|
||||
|
||||
export async function writeFailureReport(
|
||||
async function writeFailureReport(
|
||||
report,
|
||||
outputDirectory = OUTPUT_DIRECTORY,
|
||||
summaryPath,
|
||||
|
||||
@@ -91,5 +91,4 @@ export function writePrCiHealthReport(
|
||||
outputDirectory?: string,
|
||||
): Promise<{ jsonPath: string; markdownPath: string }>;
|
||||
|
||||
export function renderMarkdownSummary(report: PrCiHealthReport): string;
|
||||
import type { GitHubApiRequest } from "./github-api.mjs";
|
||||
@@ -656,7 +656,7 @@ export async function writePrCiHealthReport(
|
||||
return { jsonPath, markdownPath };
|
||||
}
|
||||
|
||||
export function renderMarkdownSummary(report) {
|
||||
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,6 +28,3 @@ export interface ProductionReachabilityReport {
|
||||
export function parseProductionReachabilityReport(
|
||||
text: string,
|
||||
): ProductionReachabilityReport;
|
||||
export function recordProductionReachabilityReport(
|
||||
root?: string,
|
||||
): Promise<ProductionReachabilityReport>;
|
||||
@@ -34,9 +34,7 @@ export const parseProductionReachabilityReport = (text) =>
|
||||
reportSchema.parse(JSON.parse(text));
|
||||
|
||||
/** Run production reachability without turning candidates into failures. */
|
||||
export const recordProductionReachabilityReport = async (
|
||||
root = process.cwd(),
|
||||
) => {
|
||||
const recordProductionReachabilityReport = async (root = process.cwd()) => {
|
||||
const reportDirectory = join(root, ".cache", "static-checks");
|
||||
const reportPath = join(reportDirectory, "production-reachability.json");
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { promisify } from "node:util";
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/** Commands the macOS bundle fixture needs from Command Line Tools. */
|
||||
export const MACOS_BUNDLE_FIXTURE_COMMANDS = [
|
||||
const MACOS_BUNDLE_FIXTURE_COMMANDS = [
|
||||
["/usr/bin/xcrun", ["--find", "clang"]],
|
||||
["/usr/bin/xcrun", ["--find", "codesign"]],
|
||||
["/usr/bin/ditto", ["-h"]],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function sourceMapSummary(sourceMaps) {
|
||||
function sourceMapSummary(sourceMaps) {
|
||||
return {
|
||||
status: sourceMaps.status,
|
||||
requested: sourceMaps.requested,
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
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;
|
||||
}
|
||||
@@ -56,11 +48,6 @@ 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[];
|
||||
|
||||
@@ -7,14 +7,13 @@ 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.
|
||||
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 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";
|
||||
const GENERATE_COMMAND = "node scripts/generate-cursor-plugin-skill.mjs";
|
||||
const INSTALL_LINK_MARKERS = ["install-mcp", "deeplink/mcp", "mcp-install"];
|
||||
|
||||
@@ -27,7 +26,7 @@ const failure = (code, detail) => {
|
||||
const cursorServerArgs = (version) => ["-y", `rea-agents@${version}`, "mcp"];
|
||||
|
||||
/** Relative file inventory for one skill directory, in stable order. */
|
||||
export const cursorPluginSkillPaths = async (directory, prefix = "") => {
|
||||
const cursorPluginSkillPaths = async (directory, prefix = "") => {
|
||||
const paths = [];
|
||||
let entries;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
export const WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE = Object.freeze({
|
||||
const WINDOWS_GHIDRA_BYTE_SEARCH_FIXTURE = Object.freeze({
|
||||
pattern: "a0 ?? c0",
|
||||
mask: "f0 ff f0",
|
||||
startFileOffset: 0x380,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ function assertHeaders(record, side, headers) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertEndpointHeaders(actual, expected) {
|
||||
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,28 +1,10 @@
|
||||
import { analyzeJavaScriptApplication } from "../../dist/application/javascript/JavaScriptApplicationService.js";
|
||||
import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js";
|
||||
import { withVerifierArtifactScope } from "./verifier-artifact-scope.mjs";
|
||||
|
||||
/** Analyze one JavaScript application and close its caller-owned resources. */
|
||||
export const analyzeJavaScriptApplicationWithOwnedScope = async (input) => {
|
||||
const scope = new ArtifactResourceScope();
|
||||
let outcome;
|
||||
try {
|
||||
export const analyzeJavaScriptApplicationWithOwnedScope = (input) =>
|
||||
withVerifierArtifactScope(async (scope) => {
|
||||
const result = await analyzeJavaScriptApplication(input, scope);
|
||||
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;
|
||||
};
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
});
|
||||
@@ -78,7 +78,7 @@ export const expandMcpSessionStatus = (status) => ({
|
||||
});
|
||||
|
||||
/** Expand provider capability groups while retaining candidate identity/state. */
|
||||
export const expandProviderCandidates = (candidates) =>
|
||||
const expandProviderCandidates = (candidates) =>
|
||||
candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
capabilities: expandProviderCapabilities(
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
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,8 +1,5 @@
|
||||
export { runOwnedVerifierTerminal } from "./verifier-terminal-owner.mjs";
|
||||
export type {
|
||||
VerifierTerminalOptions,
|
||||
VerifierTerminalDependencies,
|
||||
VerifierTerminalResult,
|
||||
VerifierTerminalFailure,
|
||||
VerifierTerminalCleanupFailure,
|
||||
} from "./verifier-terminal-owner.mjs";
|
||||
@@ -1,6 +1,10 @@
|
||||
/** Observe concrete Hopper runtime resources for a before/after cleanup check. */
|
||||
/** Observe possible Hopper resources, including entries with unresolved ownership. */
|
||||
export function snapshotHopperRuntime(
|
||||
parent: string,
|
||||
targetLeaseDirectory: string,
|
||||
ownedProcessIds?: ReadonlySet<number>,
|
||||
): Promise<Set<string>>;
|
||||
expectedRunId?: string,
|
||||
): Promise<{
|
||||
readonly paths: Set<string>;
|
||||
readonly confirmedSessions: Set<string>;
|
||||
readonly unresolvedSessions: Set<string>;
|
||||
}>;
|
||||
@@ -1,35 +1,38 @@
|
||||
import { lstat, readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Observe session paths and target leases without treating their shared parent as a session. */
|
||||
/** Observe possible sessions and target leases without discarding uncertainty. */
|
||||
export async function snapshotHopperRuntime(
|
||||
parent,
|
||||
targetLeaseDirectory,
|
||||
ownedProcessIds,
|
||||
expectedRunId,
|
||||
) {
|
||||
const resources = new Set();
|
||||
const paths = new Set();
|
||||
const confirmedSessions = new Set();
|
||||
const unresolvedSessions = 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;
|
||||
if (
|
||||
ownedProcessIds !== undefined &&
|
||||
(!entry.isDirectory() ||
|
||||
!(await belongsToOwnedProcess(path, ownedProcessIds)))
|
||||
)
|
||||
continue;
|
||||
resources.add(path);
|
||||
const confirmed =
|
||||
typeof expectedRunId === "string" &&
|
||||
expectedRunId.length > 0 &&
|
||||
entry.isDirectory() &&
|
||||
(await hasRunIdentity(path, expectedRunId));
|
||||
(confirmed ? confirmedSessions : unresolvedSessions).add(path);
|
||||
paths.add(path);
|
||||
}
|
||||
const leaseRoot = await lstat(targetLeaseDirectory).catch(missingIsAbsent);
|
||||
if (leaseRoot === undefined) return resources;
|
||||
if (!leaseRoot.isDirectory() || leaseRoot.isSymbolicLink()) {
|
||||
resources.add(targetLeaseDirectory);
|
||||
return resources;
|
||||
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));
|
||||
}
|
||||
}
|
||||
const leases = await readdir(targetLeaseDirectory).catch(missingIsAbsent);
|
||||
for (const name of leases ?? [])
|
||||
resources.add(join(targetLeaseDirectory, name));
|
||||
return resources;
|
||||
return { paths, confirmedSessions, unresolvedSessions };
|
||||
}
|
||||
|
||||
const missingIsAbsent = (cause) => {
|
||||
@@ -37,16 +40,21 @@ const missingIsAbsent = (cause) => {
|
||||
throw cause;
|
||||
};
|
||||
|
||||
const belongsToOwnedProcess = async (path, ownedProcessIds) => {
|
||||
const hasRunIdentity = async (path, expectedRunId) => {
|
||||
const encoded = await readFile(join(path, "ownership.json"), "utf8").catch(
|
||||
missingIsAbsent,
|
||||
);
|
||||
if (encoded === undefined) return false;
|
||||
const ownership = JSON.parse(encoded);
|
||||
let ownership;
|
||||
try {
|
||||
ownership = JSON.parse(encoded);
|
||||
} catch (cause) {
|
||||
if (cause instanceof SyntaxError) return false;
|
||||
throw cause;
|
||||
}
|
||||
return (
|
||||
ownership !== null &&
|
||||
typeof ownership === "object" &&
|
||||
Number.isSafeInteger(ownership.parent_pid) &&
|
||||
ownedProcessIds.has(ownership.parent_pid)
|
||||
ownership.run_id === expectedRunId
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface FixtureProcedure {
|
||||
interface FixtureProcedure {
|
||||
readonly address: string;
|
||||
readonly name: string;
|
||||
}
|
||||
@@ -26,13 +26,6 @@ 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;
|
||||
|
||||
@@ -310,7 +310,7 @@ export async function resolveFixtureProcedure(
|
||||
}
|
||||
|
||||
/** Request the assembly evidence required by the fixture oracle. */
|
||||
export const analyzeFixtureProcedure = async (
|
||||
const analyzeFixtureProcedure = async (
|
||||
client,
|
||||
options,
|
||||
procedure,
|
||||
|
||||
@@ -6,6 +6,3 @@ export function unsupportedRuntimeMessage(
|
||||
range: string,
|
||||
version: string,
|
||||
): string | undefined;
|
||||
|
||||
/** Human-readable release families for an `engines.node` range. */
|
||||
export function supportedReleaseProse(range: string): string;
|
||||
@@ -21,7 +21,7 @@ export const unsupportedRuntimeMessage = (range, version) => {
|
||||
};
|
||||
|
||||
/** Human-readable release families for an `engines.node` range. */
|
||||
export const supportedReleaseProse = (range) =>
|
||||
const supportedReleaseProse = (range) =>
|
||||
`Node.js ${range
|
||||
.split("||")
|
||||
.map((clause) => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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>;
|
||||
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export interface VerifierTerminalOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface VerifierTerminalDependencies {
|
||||
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. */
|
||||
export class VerifierTerminalFailure extends Error {
|
||||
class VerifierTerminalFailure extends Error {
|
||||
readonly _tag = "VerifierTerminalFailure";
|
||||
|
||||
constructor(
|
||||
@@ -74,7 +74,7 @@ export class VerifierTerminalFailure extends Error {
|
||||
}
|
||||
|
||||
/** The adopted owner remains the exact authority for retrying this release. */
|
||||
export class VerifierTerminalCleanupFailure extends Error {
|
||||
class VerifierTerminalCleanupFailure extends Error {
|
||||
readonly _tag = "VerifierTerminalCleanupFailure";
|
||||
readonly reason = "cleanup_incomplete";
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ 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" },
|
||||
@@ -15,5 +17,7 @@ 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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RuleTester } from "oxlint/plugins-dev";
|
||||
|
||||
declare const rule: Parameters<RuleTester["run"]>[1];
|
||||
export default rule;
|
||||
@@ -0,0 +1,73 @@
|
||||
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" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RuleTester } from "oxlint/plugins-dev";
|
||||
|
||||
declare const rule: Parameters<RuleTester["run"]>[1];
|
||||
export default rule;
|
||||
@@ -0,0 +1,34 @@
|
||||
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,13 +1,3 @@
|
||||
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;
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function verifyPackageEnvironment({
|
||||
};
|
||||
}
|
||||
|
||||
export const packageHopperEnvironment = (root, platform = process.platform) => {
|
||||
const packageHopperEnvironment = (root, platform = process.platform) => {
|
||||
const fakeLauncher = join(root, "tests/fixtures/fakeLauncher.mjs");
|
||||
if (platform !== "linux")
|
||||
return {
|
||||
|
||||
@@ -414,7 +414,7 @@ export function matchesSymbol(value, expected) {
|
||||
return tail === expected || tail === `_${expected}`;
|
||||
}
|
||||
|
||||
export function symbolTail(value) {
|
||||
function symbolTail(value) {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return value.split("::").at(-1);
|
||||
}
|
||||
|
||||
@@ -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 { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
|
||||
import { withVerifierArtifactScope } from "./lib/verifier-artifact-scope.mjs";
|
||||
import { readGhidraLanguageCatalog } from "../dist/ghidra/GhidraLanguageCatalog.js";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { functionDossierSchema } from "../dist/domain/native/functionDossier.js";
|
||||
@@ -82,16 +82,13 @@ 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) {
|
||||
const scope = new ArtifactResourceScope();
|
||||
try {
|
||||
return withVerifierArtifactScope(async (scope) => {
|
||||
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. */
|
||||
|
||||
@@ -11,6 +11,10 @@ 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");
|
||||
@@ -48,11 +52,9 @@ const digest = async (path) =>
|
||||
createHash("sha256")
|
||||
.update(await readFile(path))
|
||||
.digest("hex");
|
||||
const sessionsBefore = await snapshotHopperRuntime(
|
||||
"/tmp",
|
||||
HOPPER_TARGET_LEASE_DIRECTORY,
|
||||
);
|
||||
const ownedProcessIds = new Set();
|
||||
const sessionsBefore = (
|
||||
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
|
||||
).paths;
|
||||
const client = new Client({ name: "hopper-callers-verifier", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
@@ -93,7 +95,7 @@ const matchingRef = (ref, site, from, to) =>
|
||||
ref.source_procedure?.address === from &&
|
||||
ref.target_procedure?.address === to &&
|
||||
ref.call?.classification === "direct";
|
||||
try {
|
||||
await withVerifierCleanup(async () => {
|
||||
await run(clang, [
|
||||
"-isysroot",
|
||||
sdk,
|
||||
@@ -129,7 +131,6 @@ try {
|
||||
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) => {
|
||||
@@ -287,23 +288,36 @@ try {
|
||||
sha256: beforeLarge,
|
||||
}),
|
||||
);
|
||||
} 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)
|
||||
}, [
|
||||
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);
|
||||
}
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
]);
|
||||
@@ -1,10 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { copyFile, mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { copyFile, mkdtemp, realpath } 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";
|
||||
@@ -29,6 +30,7 @@ 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({
|
||||
@@ -40,6 +42,7 @@ const profileResourceScope = {
|
||||
};
|
||||
let client;
|
||||
let primaryFailure;
|
||||
let operationFailed = false;
|
||||
try {
|
||||
await copyFile(fixtures.primary.path, target);
|
||||
const parsed = await parseBinaryTarget(target);
|
||||
@@ -148,31 +151,19 @@ 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 {
|
||||
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(
|
||||
if (client !== undefined) cleanupOwner.setClient(client);
|
||||
await cleanupOwner.close();
|
||||
} catch (cleanupFailure) {
|
||||
throw operationFailed
|
||||
? new AggregateError(
|
||||
[primaryFailure, cleanupFailure],
|
||||
"Hopper verification and cleanup failed",
|
||||
);
|
||||
)
|
||||
: cleanupFailure;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ 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 {
|
||||
@@ -70,14 +74,12 @@ 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,
|
||||
);
|
||||
const before = (
|
||||
await snapshotHopperRuntime("/tmp", HOPPER_TARGET_LEASE_DIRECTORY)
|
||||
).paths;
|
||||
const client = new Client({ name: "hopper-fat-verifier", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
@@ -94,7 +96,7 @@ const ajv = new Ajv({ strict: false });
|
||||
addFormats(ajv);
|
||||
const observations = [];
|
||||
const sourceBinding = [];
|
||||
try {
|
||||
await withVerifierCleanup(async () => {
|
||||
await run(toolPaths.clang, [
|
||||
"-isysroot",
|
||||
sdk,
|
||||
@@ -183,9 +185,17 @@ try {
|
||||
"overlap",
|
||||
/slices overlap/u,
|
||||
(bytes) => {
|
||||
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);
|
||||
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,
|
||||
);
|
||||
},
|
||||
],
|
||||
]) {
|
||||
@@ -211,11 +221,14 @@ try {
|
||||
}
|
||||
const path = join(directory, "c-fat64-ambiguous");
|
||||
await writeFile(path, ambiguous);
|
||||
invalidFat64.push({ path, reason: /selection.*ambiguous/u });
|
||||
invalidFat64.push({
|
||||
path,
|
||||
reason:
|
||||
/FAT binary declares several (?:arm64|x86_64) slices; no slice was selected/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(
|
||||
@@ -359,14 +372,27 @@ try {
|
||||
if (verifyFat64) {
|
||||
await call("open_binary", { path: fat64 });
|
||||
await call("list_procedures");
|
||||
const resources = [
|
||||
...(await snapshotHopperRuntime(
|
||||
"/tmp",
|
||||
HOPPER_TARGET_LEASE_DIRECTORY,
|
||||
owned,
|
||||
)),
|
||||
].filter((path) => !before.has(path));
|
||||
for (const root of resources) {
|
||||
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 image = join(root, "image.macho");
|
||||
if (
|
||||
await access(image).then(
|
||||
@@ -403,27 +429,43 @@ try {
|
||||
},
|
||||
);
|
||||
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);
|
||||
}
|
||||
} 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",
|
||||
);
|
||||
}, [
|
||||
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 });
|
||||
},
|
||||
]);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
|
||||
@@ -62,10 +62,9 @@ const parseServerArgs = (encoded) => {
|
||||
};
|
||||
|
||||
const runtimeParent = process.platform === "darwin" ? "/tmp" : tmpdir();
|
||||
const sessionsBefore = await snapshotHopperRuntime(
|
||||
runtimeParent,
|
||||
HOPPER_TARGET_LEASE_DIRECTORY,
|
||||
);
|
||||
const sessionsBefore = (
|
||||
await snapshotHopperRuntime(runtimeParent, HOPPER_TARGET_LEASE_DIRECTORY)
|
||||
).paths;
|
||||
const ownedProcessIds = new Set();
|
||||
const textValue = mcpTextValue;
|
||||
|
||||
@@ -557,11 +556,8 @@ try {
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const sessionsAfter = [
|
||||
...(await snapshotHopperRuntime(
|
||||
runtimeParent,
|
||||
HOPPER_TARGET_LEASE_DIRECTORY,
|
||||
ownedProcessIds,
|
||||
)),
|
||||
...(await snapshotHopperRuntime(runtimeParent, HOPPER_TARGET_LEASE_DIRECTORY))
|
||||
.paths,
|
||||
]
|
||||
.filter((path) => !sessionsBefore.has(path))
|
||||
.sort();
|
||||
@@ -573,7 +569,7 @@ if (sessionsAfter.length > 0) {
|
||||
retainedRuntimePaths: sessionsAfter,
|
||||
});
|
||||
throw new Error(
|
||||
`The MCP runtime retained Hopper resources: ${sessionsAfter.join(", ")}`,
|
||||
`Hopper resources remain or have unresolved ownership: ${sessionsAfter.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (summary === undefined)
|
||||
|
||||
@@ -40,7 +40,7 @@ try {
|
||||
const expected = [];
|
||||
for (const path of files.stdout
|
||||
.split("\0")
|
||||
.filter((path) => path.endsWith(".test.ts"))) {
|
||||
.filter((path) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(path))) {
|
||||
try {
|
||||
if ((await stat(join(root, path))).isFile()) expected.push(path);
|
||||
} catch (cause) {
|
||||
@@ -65,4 +65,6 @@ try {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
if (discovered !== undefined)
|
||||
console.log(`Discovered all ${discovered} test files exactly once.`);
|
||||
console.log(
|
||||
`Discovered all ${discovered} test and spec files in src and tests exactly once.`,
|
||||
);
|
||||
@@ -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 { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
|
||||
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
|
||||
import { inspectAppleDispatchMetadata } from "../../../dist/native/AppleDispatchMetadata.js";
|
||||
import {
|
||||
artifactCliEvidence,
|
||||
@@ -57,8 +57,7 @@ const fixupCoverage = (metadata) =>
|
||||
const inspect = async (path) => {
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
return withVerifierArtifactScope(async (resources) => {
|
||||
return {
|
||||
target: target.value,
|
||||
metadata: (
|
||||
@@ -67,10 +66,7 @@ const inspect = async (path) => {
|
||||
)
|
||||
).result,
|
||||
};
|
||||
} finally {
|
||||
// Retry any descriptor whose close failed, surfacing unresolved cleanup.
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const checkObjc = (metadata, mode) => {
|
||||
|
||||
@@ -5,17 +5,12 @@ 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.
|
||||
// compositeKey (JSON tuple) is the single owner for composite keys.
|
||||
// The text check below only flags one spelling; it is not a complete audit.
|
||||
// - 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));
|
||||
|
||||
@@ -27,6 +22,7 @@ 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);
|
||||
@@ -48,8 +44,6 @@ 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(
|
||||
@@ -68,21 +62,7 @@ check(
|
||||
/providers\/ProviderInspectionScope\.js/,
|
||||
);
|
||||
|
||||
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("NUL-joined list key (use compositeKey)", files, /\.join\(["']\\0["']\)/);
|
||||
|
||||
check(
|
||||
"direct transparent-wrapper unwrap outside javascriptAstValues (use unwrapJavaScriptExpression)",
|
||||
@@ -91,13 +71,6 @@ 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.
|
||||
@@ -163,7 +136,6 @@ 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",
|
||||
@@ -179,7 +151,6 @@ 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,7 +5,6 @@ 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,
|
||||
@@ -14,6 +13,7 @@ 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,33 +106,23 @@ process.stdout.write(
|
||||
);
|
||||
|
||||
async function readEvidenceFile(path) {
|
||||
const scope = new ArtifactResourceScope();
|
||||
let outcome;
|
||||
try {
|
||||
outcome = {
|
||||
ok: true,
|
||||
value: await readStableArtifact(
|
||||
await realpath(path),
|
||||
64 * 1024 * 1024,
|
||||
undefined,
|
||||
undefined,
|
||||
scope,
|
||||
value: await withVerifierArtifactScope(async (scope) =>
|
||||
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 =
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
||||
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
readManagedSnapshot,
|
||||
verifyManagedSourceIdentity,
|
||||
} from "../../../dist/dotnet/ManagedSnapshot.js";
|
||||
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
|
||||
import { withVerifierArtifactScope } from "../../lib/verifier-artifact-scope.mjs";
|
||||
|
||||
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
|
||||
import { inspectManagedArtifactBytes } from "../../../dist/dotnet/ManagedArtifactInspector.js";
|
||||
@@ -24,13 +23,10 @@ import {
|
||||
const READ_FILE_MAX_BYTES = 0x7fff_ffff;
|
||||
|
||||
export async function verifyManagedAppManifest(rawManifest, manifestPath) {
|
||||
const resources = new ArtifactResourceScope();
|
||||
return withVerifierCleanup(
|
||||
() =>
|
||||
resources.run(() =>
|
||||
verifyManifestSnapshot(rawManifest, manifestPath, resources),
|
||||
),
|
||||
[() => resources.close()],
|
||||
return withVerifierArtifactScope((resources) =>
|
||||
resources.run(() =>
|
||||
verifyManifestSnapshot(rawManifest, manifestPath, resources),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
/** 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;
|
||||
|
||||
@@ -55,6 +48,8 @@ export function discoverNumericLimitUses(
|
||||
readonly unresolved: readonly {
|
||||
readonly path: string;
|
||||
readonly offset: number;
|
||||
readonly line: number;
|
||||
readonly column: number;
|
||||
readonly reason: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ 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);
|
||||
@@ -39,7 +40,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.
|
||||
*/
|
||||
export const REQUIRED_RUNTIME_LIMITS = [
|
||||
const REQUIRED_RUNTIME_LIMITS = [
|
||||
"REA_NTFS_MFT_MAX_FILE_BYTES",
|
||||
"REA_REGF_HIVE_MAX_FILE_BYTES",
|
||||
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
|
||||
@@ -170,8 +171,16 @@ const stringBindings = (sourceFile, symbols, unresolved) => {
|
||||
item.offset === offset &&
|
||||
item.reason === reason,
|
||||
)
|
||||
)
|
||||
unresolved.push({ path, offset, reason });
|
||||
) {
|
||||
const location = sourceFile.getLineAndCharacterOfPosition(offset);
|
||||
unresolved.push({
|
||||
path,
|
||||
offset,
|
||||
line: location.line + 1,
|
||||
column: location.character + 1,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
};
|
||||
const initializer = (identifier) => {
|
||||
if (!candidates.has(identifier.text)) return undefined;
|
||||
@@ -949,7 +958,7 @@ if (direct) {
|
||||
|
||||
for (const issue of discovered.unresolved)
|
||||
console.log(
|
||||
`${issue.path}:${issue.offset}: numeric limit candidate unresolved (${issue.reason})`,
|
||||
`${issue.path}:${issue.line}:${issue.column}: numeric limit candidate unresolved (${issue.reason})`,
|
||||
);
|
||||
|
||||
for (const [name, file] of [...discovered].sort(([left], [right]) =>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { JadxToolPort } from "./JadxAnalysis.js";
|
||||
import type { AndroidNativeDeclarations } from "../domain/android/androidNativeDeclarations.js";
|
||||
import { jadxNativeDeclarationsSchema } from "./JadxProtocol.js";
|
||||
import {
|
||||
androidNativeDeclarationsSchema,
|
||||
type AndroidNativeDeclarations,
|
||||
} from "../domain/android/androidNativeDeclarations.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
/** Validate the native-only batch at its producing boundary, preserving raw facts. */
|
||||
@@ -9,7 +11,7 @@ export const readAndroidNativeInventory = async (
|
||||
snapshot: string,
|
||||
): Promise<AndroidNativeDeclarations> => {
|
||||
const raw = await tools.json("rea_native_declarations", { path: snapshot });
|
||||
const parsed = jadxNativeDeclarationsSchema.safeParse(raw);
|
||||
const parsed = androidNativeDeclarationsSchema.safeParse(raw);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
"inspect_android_jni_bindings",
|
||||
|
||||
@@ -10,11 +10,6 @@ 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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js";
|
||||
import { stableArtifactInputFailure } from "../artifacts/StableArtifactFailure.js";
|
||||
import {
|
||||
artifactFilePrimaryFacts,
|
||||
type ArtifactFilePrimaryFacts,
|
||||
@@ -231,11 +231,7 @@ const artifactReaderFailure = (
|
||||
},
|
||||
);
|
||||
case "path":
|
||||
return new AnalysisInputError(
|
||||
OPERATION,
|
||||
{ cause, ...stableArtifactInputRecovery(cause) },
|
||||
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
|
||||
);
|
||||
return stableArtifactInputFailure(cause, OPERATION);
|
||||
default:
|
||||
return adapterFailure(cause, path);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
type ClientRegistrationDialect,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { ClientRegistrationTarget } from "./ClientRegistrationProfile.js";
|
||||
import {
|
||||
stdioSelectingFields,
|
||||
type ClientRegistrationTarget,
|
||||
} from "./ClientRegistrationProfile.js";
|
||||
import {
|
||||
classifyClientRegistrationCommand,
|
||||
equivalentClientRegistrationCommands,
|
||||
@@ -277,12 +280,18 @@ export const clientRegistrationSourceFacts = (
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const launcherFields = new Set(fields);
|
||||
for (const field of connection.kind === "cline"
|
||||
? ["transport", "type", "transportType", "url"]
|
||||
: connection.kind === "commandcode"
|
||||
? ["type", "url"]
|
||||
: connection.fields)
|
||||
fields.add(field);
|
||||
// An existing selector that already chooses stdio stays, including one
|
||||
// inherited through a YAML merge; only remote selectors are superseded.
|
||||
if (connection.kind === "flat")
|
||||
for (const field of stdioSelectingFields(source ?? {}, connection))
|
||||
if (!launcherFields.has(field)) fields.delete(field);
|
||||
}
|
||||
return ok({ ...parsed, connectionFields: fields });
|
||||
};
|
||||
|
||||
@@ -51,3 +51,28 @@ export interface ClientRegistrationTarget {
|
||||
readonly dialect: ClientRegistrationDialect;
|
||||
readonly profile: ClientRegistrationProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum selectors whose current value already chooses stdio. Replacing the
|
||||
* launcher with a stdio launcher leaves them correct, so a refresh keeps them
|
||||
* instead of superseding them like a remote selector.
|
||||
*/
|
||||
export const stdioSelectingFields = (
|
||||
source: Readonly<Record<string, unknown>>,
|
||||
connection: Extract<ClientRegistrationConnectionProfile, { kind: "flat" }>,
|
||||
): ReadonlySet<string> =>
|
||||
new Set(
|
||||
connection.stdio.flatMap((condition) => {
|
||||
if (
|
||||
condition.kind !== "required-enum" &&
|
||||
condition.kind !== "optional-enum"
|
||||
)
|
||||
return [];
|
||||
const value = Object.hasOwn(source, condition.field)
|
||||
? source[condition.field]
|
||||
: undefined;
|
||||
return typeof value === "string" && condition.values.includes(value)
|
||||
? [condition.field]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
@@ -18,6 +18,123 @@ 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 =
|
||||
@@ -344,3 +461,45 @@ describe("pinned Goose serde_yaml scalar resolution", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("materializes an alias whose sibling anchor the refresh replaces", () => {
|
||||
const source =
|
||||
"extensions:\n rea:\n cmd: &launcher /old/rea\n envs: {REA_LAUNCHER: *launcher}\n";
|
||||
const out = editGooseConfigurationYaml(source, ["extensions", "rea"], {
|
||||
cmd: "/new/rea",
|
||||
envs: { REA_LAUNCHER: "/old/rea" },
|
||||
});
|
||||
expect(parseGooseConfigurationYaml(out)).toMatchObject({
|
||||
extensions: {
|
||||
rea: { cmd: "/new/rea", envs: { REA_LAUNCHER: "/old/rea" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("edits a field whose key is an alias to a shared string, keeping the alias", () => {
|
||||
const source =
|
||||
"field: &field envs\nextensions:\n rea:\n cmd: rea\n *field : {A: '1'}\n";
|
||||
const out = editGooseConfigurationYaml(source, ["extensions", "rea"], {
|
||||
cmd: "rea",
|
||||
envs: { A: "2" },
|
||||
});
|
||||
expect(out).toContain("*field");
|
||||
expect(parseGooseConfigurationYaml(out)).toMatchObject({
|
||||
field: "envs",
|
||||
extensions: { rea: { cmd: "rea", envs: { A: "2" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an anchored value with an aliased key ahead of its later aliases", () => {
|
||||
const source =
|
||||
"field: &field envs\nextensions:\n rea:\n cmd: old\n *field : &settings {A: '1'}\n 1: *settings\n \"1\": string\n";
|
||||
const out = editGooseConfigurationYaml(source, ["extensions", "rea"], {
|
||||
cmd: "new",
|
||||
envs: { A: "1" },
|
||||
1: "string",
|
||||
});
|
||||
expect(out.indexOf("&settings")).toBeLessThan(out.indexOf("*settings"));
|
||||
expect(parseGooseConfigurationYaml(out)).toMatchObject({
|
||||
extensions: { rea: { cmd: "new", envs: { A: "1" } } },
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,12 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
copyYamlAliasSource,
|
||||
preserveYamlAliasValues,
|
||||
resolveYamlEditPath,
|
||||
} from "./YamlConfigurationAliases.js";
|
||||
import {
|
||||
materializeUnresolvedYamlAliases,
|
||||
retainYamlConfigurationSource,
|
||||
yamlAliasValues,
|
||||
retainYamlScalarSource,
|
||||
} from "./YamlConfigurationSource.js";
|
||||
|
||||
@@ -251,7 +254,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 = path.slice(0, length);
|
||||
const parent = resolveYamlEditPath(document, path.slice(0, length));
|
||||
let previous = document.getIn(parent, true);
|
||||
if (isAlias(previous)) {
|
||||
previous = copyYamlAliasSource(previous, document);
|
||||
@@ -262,30 +265,33 @@ export const editGooseConfigurationYaml = (
|
||||
document.setIn(parent, document.createNode({}));
|
||||
} else if (!isMap(previous))
|
||||
throw new ConfigurationError(
|
||||
`Goose configuration ${parent.join(".")} must be a mapping`,
|
||||
`Goose configuration ${path.slice(0, length).join(".")} must be a mapping`,
|
||||
);
|
||||
}
|
||||
const previous = document.getIn(path, true);
|
||||
const parent = document.getIn(path.slice(0, -1), true);
|
||||
const sourcePath = resolveYamlEditPath(document, path);
|
||||
const previous = document.getIn(sourcePath, true);
|
||||
const parent = document.getIn(sourcePath.slice(0, -1), true);
|
||||
const removedKey =
|
||||
value === undefined && isMap(parent)
|
||||
? parent.items.find(
|
||||
(pair) => isScalar(pair.key) && pair.key.value === path.at(-1),
|
||||
)?.key
|
||||
? parent.items.find((pair) => pair.key === sourcePath.at(-1))?.key
|
||||
: undefined;
|
||||
const aliasValues = yamlAliasValues(previous, document);
|
||||
const retained = preserveYamlAliasValues(document, [previous, removedKey]);
|
||||
if (value === undefined) {
|
||||
if (!document.deleteIn(path)) return text;
|
||||
} else
|
||||
if (!document.deleteIn(sourcePath)) return text;
|
||||
} else {
|
||||
document.setIn(
|
||||
path,
|
||||
sourcePath,
|
||||
isMap(previous) || isSeq(previous)
|
||||
? retainYamlConfigurationSource(
|
||||
previous,
|
||||
document.createNode(value),
|
||||
retained,
|
||||
document,
|
||||
)
|
||||
: value,
|
||||
);
|
||||
materializeUnresolvedYamlAliases(document, aliasValues);
|
||||
}
|
||||
return document.toString({ lineWidth: 0 });
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import {
|
||||
effectiveClientServer,
|
||||
parseClientConfiguration,
|
||||
serializeClientConfiguration,
|
||||
withClientServers,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { refreshClientRegistration } from "./ClientRegistration.js";
|
||||
import { supportedClients } from "./SupportedClients.js";
|
||||
|
||||
import {
|
||||
editHermesConfigurationYaml,
|
||||
@@ -12,6 +15,69 @@ 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";
|
||||
@@ -601,3 +667,193 @@ it("keeps argument annotations with unique values and retains removed annotation
|
||||
])
|
||||
expect(updated.split(comment)).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("Hermes registration refresh around anchors and merges", () => {
|
||||
it("keeps an alias's value when an earlier same-name anchor would capture it", () => {
|
||||
const source =
|
||||
"defaults: &launcher /other/rea\nmcp_servers:\n rea:\n command: &launcher /old/rea\n env: {REA_LAUNCHER: *launcher}\n";
|
||||
const out = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "/new/rea",
|
||||
env: { REA_LAUNCHER: "/old/rea" },
|
||||
});
|
||||
expect(parseHermesConfigurationYaml(out)).toMatchObject({
|
||||
defaults: "/other/rea",
|
||||
mcp_servers: {
|
||||
rea: { command: "/new/rea", env: { REA_LAUNCHER: "/old/rea" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the comment on an alias the refresh materializes", () => {
|
||||
const source =
|
||||
"mcp_servers:\n rea:\n command: &launcher /old/rea\n env:\n REA_LAUNCHER: *launcher # explanation\n";
|
||||
const out = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "/new/rea",
|
||||
env: { REA_LAUNCHER: "/old/rea" },
|
||||
});
|
||||
expect(out).toContain("# explanation");
|
||||
expect(parseHermesConfigurationYaml(out)).toMatchObject({
|
||||
mcp_servers: { rea: { env: { REA_LAUNCHER: "/old/rea" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an alias to a sibling anchor that survives the refresh", () => {
|
||||
const source =
|
||||
"mcp_servers:\n rea:\n command: old\n setting: &setting keep\n metadata: {nested: *setting}\n";
|
||||
const out = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "new",
|
||||
setting: "keep",
|
||||
metadata: { nested: "keep" },
|
||||
});
|
||||
expect(out).toContain("nested: *setting");
|
||||
expect(
|
||||
parseHermesConfigurationYaml(
|
||||
out.replace("&setting keep", "&setting changed"),
|
||||
),
|
||||
).toMatchObject({
|
||||
mcp_servers: { rea: { metadata: { nested: "changed" } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hermes registration refresh through inherited merges", () => {
|
||||
it("refuses to drop locally overridden settings that its merge would restore", () => {
|
||||
const source =
|
||||
"remote: &remote\n url: https://example.invalid\n transport: sse\nmcp_servers:\n rea:\n <<: *remote\n url: https://local.invalid\n transport: sse\n";
|
||||
expect(() =>
|
||||
editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "rea",
|
||||
args: ["mcp"],
|
||||
}),
|
||||
).toThrow(/Cannot remove inherited YAML setting/);
|
||||
});
|
||||
|
||||
it("replaces an inherited remote registration without its remote selectors", () => {
|
||||
const source =
|
||||
"shared: &shared\n rea: {url: https://example.invalid, transport: sse}\nmcp_servers:\n <<: *shared\n";
|
||||
const out = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "rea",
|
||||
args: ["mcp"],
|
||||
});
|
||||
const parsed = parseHermesConfigurationYaml(out) as {
|
||||
mcp_servers: { rea: Record<string, unknown> };
|
||||
};
|
||||
expect(parsed.mcp_servers.rea).toEqual({ command: "rea", args: ["mcp"] });
|
||||
});
|
||||
|
||||
it("refuses to edit a field whose aliased and plain keys name the same setting", () => {
|
||||
const source =
|
||||
"field: &field env\nmcp_servers:\n rea:\n command: rea\n *field : {A: first}\n env: {A: second}\n";
|
||||
expect(() =>
|
||||
editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "rea",
|
||||
env: { A: "third" },
|
||||
}),
|
||||
).toThrow(/env/);
|
||||
});
|
||||
|
||||
it("edits a field whose key is an alias to a shared string, keeping the alias", () => {
|
||||
const source =
|
||||
"field: &field env\nmcp_servers:\n rea:\n command: rea\n *field : {A: '1'}\n";
|
||||
const out = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "rea",
|
||||
env: { A: "2" },
|
||||
});
|
||||
expect(out).toContain("*field");
|
||||
expect(parseHermesConfigurationYaml(out)).toMatchObject({
|
||||
field: "env",
|
||||
mcp_servers: { rea: { command: "rea", env: { A: "2" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a plain key when an aliased key's anchor is rebuilt by the same edit", () => {
|
||||
const source =
|
||||
"mcp_servers:\n rea:\n command: &field env\n *field : {A: '1'}\n";
|
||||
const out = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "rea",
|
||||
env: { A: "2" },
|
||||
});
|
||||
expect(out).not.toContain("*field");
|
||||
expect(parseHermesConfigurationYaml(out)).toMatchObject({
|
||||
mcp_servers: { rea: { command: "rea", env: { A: "2" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes an alias whose sibling anchor the refresh replaces", () => {
|
||||
const source =
|
||||
"mcp_servers:\n rea:\n command: &launcher /old/rea\n env: {REA_LAUNCHER: *launcher}\n";
|
||||
const out = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "/new/rea",
|
||||
env: { REA_LAUNCHER: "/old/rea" },
|
||||
});
|
||||
expect(parseHermesConfigurationYaml(out)).toMatchObject({
|
||||
mcp_servers: {
|
||||
rea: { command: "/new/rea", env: { REA_LAUNCHER: "/old/rea" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes an alias to an anchor moved to an outside consumer", () => {
|
||||
const source =
|
||||
"mcp_servers:\n rea:\n command: rea\n setting: &setting keep\n metadata: {nested: *setting}\nobserver: *setting\n";
|
||||
const out = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "rea2",
|
||||
setting: "changed",
|
||||
metadata: { nested: "keep" },
|
||||
});
|
||||
expect(parseHermesConfigurationYaml(out)).toMatchObject({
|
||||
observer: "keep",
|
||||
mcp_servers: {
|
||||
rea: { setting: "changed", metadata: { nested: "keep" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an inherited stdio transport through a launcher refresh", () => {
|
||||
const source =
|
||||
"defaults: &defaults\n transport: stdio\n command: rea\n args: [mcp]\nmcp_servers:\n rea:\n <<: *defaults\n";
|
||||
const client = supportedClients("/home/rea-test").find(
|
||||
({ name }) => name === "hermes",
|
||||
)!;
|
||||
const parsed = parseClientConfiguration(source, "hermes");
|
||||
const registration = refreshClientRegistration(
|
||||
client,
|
||||
"hermes",
|
||||
["rea", "mcp"],
|
||||
{ A: "1" },
|
||||
effectiveClientServer(parsed, "rea"),
|
||||
{
|
||||
analysisProvider: "auto",
|
||||
ghidraStartupTimeoutMs: 1,
|
||||
hopperStartupTimeoutMs: 1,
|
||||
},
|
||||
);
|
||||
if (!registration.ok) throw registration.error;
|
||||
const written = serializeClientConfiguration(
|
||||
withClientServers(parsed, { ...parsed.servers, rea: registration.value }),
|
||||
"hermes",
|
||||
source,
|
||||
);
|
||||
if (!written.ok) throw written.error;
|
||||
expect(written.value).toContain("<<: *defaults");
|
||||
expect(parseHermesConfigurationYaml(written.value)).toMatchObject({
|
||||
mcp_servers: {
|
||||
rea: { transport: "stdio", command: "rea", env: { A: "1" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides an inherited registration with only its changed fields", () => {
|
||||
const source =
|
||||
"shared: &shared\n rea: {command: rea, metadata: {marker: old}}\nmcp_servers:\n <<: *shared\n";
|
||||
const out = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], {
|
||||
command: "rea",
|
||||
metadata: { marker: "old" },
|
||||
env: { A: "1" },
|
||||
});
|
||||
const changed = out.replace("marker: old", "marker: new");
|
||||
expect(parseHermesConfigurationYaml(changed)).toMatchObject({
|
||||
mcp_servers: { rea: { metadata: { marker: "new" }, env: { A: "1" } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,19 +3,26 @@ 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 {
|
||||
materializeUnresolvedYamlAliases,
|
||||
retainYamlConfigurationSource,
|
||||
yamlAliasValues,
|
||||
retainYamlScalarSource,
|
||||
yamlNumericValuesEqual,
|
||||
} from "./YamlConfigurationSource.js";
|
||||
@@ -268,6 +275,108 @@ const parseHermesDocument = (text: string) => {
|
||||
return document;
|
||||
};
|
||||
|
||||
/** The value a path resolves to, merges included, or `absent`. */
|
||||
const projectedYamlValue = (
|
||||
document: Document,
|
||||
path: readonly string[],
|
||||
): { readonly value: unknown } | "absent" => {
|
||||
let current: unknown = document.toJS();
|
||||
for (const segment of path) {
|
||||
if (
|
||||
typeof current !== "object" ||
|
||||
current === null ||
|
||||
!Object.hasOwn(current, segment)
|
||||
)
|
||||
return "absent";
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return { value: current };
|
||||
};
|
||||
|
||||
const projectedYamlPathExists = (
|
||||
document: Document,
|
||||
path: readonly string[],
|
||||
): boolean => projectedYamlValue(document, path) !== "absent";
|
||||
|
||||
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);
|
||||
@@ -299,7 +408,7 @@ export const editHermesConfigurationYaml = (
|
||||
document.contents = map;
|
||||
}
|
||||
for (let length = 1; length < path.length; length++) {
|
||||
const parent = path.slice(0, length);
|
||||
const parent = resolveYamlEditPath(document, path.slice(0, length));
|
||||
const previous = document.getIn(parent, true);
|
||||
if (isAlias(previous)) {
|
||||
const copied = copyYamlAliasSource(previous, document);
|
||||
@@ -308,38 +417,74 @@ 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 previous = document.getIn(path, true);
|
||||
// A registration inherited whole through a merge keeps that inheritance:
|
||||
// override it with the merge, then write only the fields that differ.
|
||||
const inheritedSourcePath = resolveYamlEditPath(document, path);
|
||||
// An explicit replacement that drops inherited fields, such as remote
|
||||
// connection selectors, writes a local registration instead.
|
||||
const inheritedRegistration = document.hasIn(inheritedSourcePath)
|
||||
? "absent"
|
||||
: projectedYamlValue(document, path);
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
inheritedRegistration !== "absent" &&
|
||||
typeof inheritedRegistration.value === "object" &&
|
||||
inheritedRegistration.value !== null &&
|
||||
Object.keys(inheritedRegistration.value).every((key) =>
|
||||
Object.hasOwn(value, key),
|
||||
)
|
||||
)
|
||||
inheritedYamlMapOverride(document, inheritedSourcePath, path.at(-1)!);
|
||||
const sourcePath = resolveYamlEditPath(document, path);
|
||||
const previous = document.getIn(sourcePath, true);
|
||||
// Deletion removes the pair's key too; replacement retains that key node.
|
||||
const parent =
|
||||
value === undefined ? document.getIn(path.slice(0, -1), true) : undefined;
|
||||
value === undefined
|
||||
? document.getIn(sourcePath.slice(0, -1), true)
|
||||
: undefined;
|
||||
if (
|
||||
value === undefined &&
|
||||
!document.hasIn(sourcePath) &&
|
||||
projectedYamlPathExists(document, path)
|
||||
)
|
||||
throw inheritedRegistrationRemovalError();
|
||||
const removedKey = isMap(parent)
|
||||
? parent.items.find(
|
||||
(pair) => isScalar(pair.key) && pair.key.value === path.at(-1),
|
||||
)?.key
|
||||
? parent.items.find((pair) => pair.key === sourcePath.at(-1))?.key
|
||||
: undefined;
|
||||
const aliasValues = yamlAliasValues(previous, document);
|
||||
const retainedSources = preserveYamlAliasValues(document, [
|
||||
previous,
|
||||
removedKey,
|
||||
]);
|
||||
if (value === undefined) {
|
||||
if (!document.deleteIn(path)) return text;
|
||||
if (!document.deleteIn(sourcePath)) return text;
|
||||
if (projectedYamlPathExists(document, path))
|
||||
throw inheritedRegistrationRemovalError();
|
||||
} else {
|
||||
// Let YAML's scalar update preserve numeric formats for a direct edit.
|
||||
// Replacing containers needs explicit retention of unchanged child nodes.
|
||||
document.setIn(
|
||||
path,
|
||||
sourcePath,
|
||||
isMap(previous) || isSeq(previous)
|
||||
? retainYamlConfigurationSource(
|
||||
previous,
|
||||
document.createNode(value),
|
||||
retainedSources,
|
||||
document,
|
||||
)
|
||||
: value,
|
||||
);
|
||||
materializeUnresolvedYamlAliases(document, aliasValues);
|
||||
}
|
||||
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
|
||||
return document.toString({
|
||||
|
||||
@@ -1089,11 +1089,13 @@ const removeClient = async (
|
||||
// clientRegistrationRemoval admitted only names whose repeats REA owns.
|
||||
{ removeShadowedMembersAt: editedPaths },
|
||||
);
|
||||
} catch {
|
||||
} catch (cause: unknown) {
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be updated, so no change was made. Repair the configuration, then rerun uninstall.",
|
||||
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.",
|
||||
);
|
||||
}
|
||||
if (!serialized.ok)
|
||||
|
||||
@@ -9,6 +9,36 @@ 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>;
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import {
|
||||
isAlias,
|
||||
isMap,
|
||||
isNode,
|
||||
isScalar,
|
||||
isSeq,
|
||||
Pair,
|
||||
visit,
|
||||
YAMLMap,
|
||||
type Document,
|
||||
type Node,
|
||||
type Alias,
|
||||
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 = (
|
||||
@@ -26,12 +35,99 @@ export const yamlNumericValuesEqual = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Keep source annotations and unchanged scalars when updating a value. */
|
||||
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;
|
||||
};
|
||||
|
||||
/** What each alias inside `root` resolves to before an edit. */
|
||||
export interface YamlAliasBinding {
|
||||
readonly target: Node;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
/** The node and value each alias inside `root` resolves to before an edit. */
|
||||
export const yamlAliasValues = (
|
||||
root: unknown,
|
||||
document: Document,
|
||||
): ReadonlyMap<Alias, YamlAliasBinding> => {
|
||||
const bindings = new Map<Alias, YamlAliasBinding>();
|
||||
if (!isNode(root)) return bindings;
|
||||
visit(root, {
|
||||
Alias: (_key, alias) => {
|
||||
const target = alias.resolve(document);
|
||||
if (target !== undefined)
|
||||
bindings.set(alias, { target, value: target.toJS(document) });
|
||||
},
|
||||
});
|
||||
return bindings;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace each alias that no longer resolves to its original node, whether
|
||||
* its anchor is gone or an earlier same-name anchor now captures it, with the
|
||||
* value it resolved to before the edit. Its comments and spacing are kept.
|
||||
*/
|
||||
export const materializeUnresolvedYamlAliases = (
|
||||
document: Document,
|
||||
bindings: ReadonlyMap<Alias, YamlAliasBinding>,
|
||||
): void => {
|
||||
if (bindings.size === 0) return;
|
||||
visit(document, {
|
||||
Alias: (_key, alias) => {
|
||||
const binding = bindings.get(alias);
|
||||
if (binding === undefined || alias.resolve(document) === binding.target)
|
||||
return undefined;
|
||||
const replacement = document.createNode(binding.value);
|
||||
if (alias.comment !== undefined) replacement.comment = alias.comment;
|
||||
if (alias.commentBefore !== undefined)
|
||||
replacement.commentBefore = alias.commentBefore;
|
||||
if (alias.spaceBefore !== undefined)
|
||||
replacement.spaceBefore = alias.spaceBefore;
|
||||
return replacement;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** Keep source nodes and annotations 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) &&
|
||||
@@ -60,53 +156,273 @@ export const retainYamlConfigurationSource = (
|
||||
next.spaceBefore = previous.spaceBefore;
|
||||
}
|
||||
if (isMap(previous) && isMap(next)) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
retainYamlMapSource(previous, next, retainedSources, document);
|
||||
} else if (isSeq(previous) && isSeq(next)) {
|
||||
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,
|
||||
);
|
||||
retainYamlSequenceSource(previous, next, retainedSources, document);
|
||||
}
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Each pair's key as the string YAML resolves it to. A key written as an alias
|
||||
* of a string scalar counts as that string only when no other pair in the map
|
||||
* resolves to the same string; an ambiguous one stays a source-only key.
|
||||
*/
|
||||
const resolvedStringKeys = (
|
||||
map: YAMLMap,
|
||||
document: Document,
|
||||
): Map<(typeof map.items)[number], string> => {
|
||||
const resolved = new Map<(typeof map.items)[number], string>();
|
||||
const aliased = new Map<(typeof map.items)[number], string>();
|
||||
for (const pair of map.items) {
|
||||
if (isScalar(pair.key) && typeof pair.key.value === "string")
|
||||
resolved.set(pair, pair.key.value);
|
||||
else if (isAlias(pair.key)) {
|
||||
const target = pair.key.resolve(document);
|
||||
if (isScalar(target) && typeof target.value === "string")
|
||||
aliased.set(pair, target.value);
|
||||
}
|
||||
}
|
||||
const counts = new Map<string, number>();
|
||||
for (const key of [...resolved.values(), ...aliased.values()])
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
for (const [pair, key] of aliased)
|
||||
if (counts.get(key) === 1) resolved.set(pair, key);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
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 previousKeys = resolvedStringKeys(previous, document);
|
||||
// The source pair each rewritten pair replaces, including aliased keys.
|
||||
const matched = new Map<
|
||||
(typeof next.items)[number],
|
||||
(typeof previous.items)[number]
|
||||
>();
|
||||
const entries = new Map(
|
||||
[...previousKeys].map(([pair, key]) => [key, 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;
|
||||
matched.set(pair, prior);
|
||||
// An aliased key keeps its spelling; a later repair materializes it if
|
||||
// the edit leaves it without an anchor.
|
||||
pair.key = isAlias(prior.key)
|
||||
? prior.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)
|
||||
? pair
|
||||
: (matched.get(pair) ??
|
||||
(typeof key === "string" ? entries.get(key) : undefined));
|
||||
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)) {
|
||||
// Removing a key the merge supplies would resurrect it, even when the
|
||||
// mapping also overrides it locally.
|
||||
if (!Object.hasOwn(desired, key))
|
||||
throw new ConfigurationError(
|
||||
`Cannot remove inherited YAML setting ${JSON.stringify(key)} while retaining its merge.`,
|
||||
);
|
||||
if (Object.hasOwn(directValues, key)) continue;
|
||||
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>();
|
||||
const stringKeys = resolvedStringKeys(previous, document);
|
||||
for (const pair of previous.items) {
|
||||
if (isYamlMergeKey(pair.key)) continue;
|
||||
let key: string;
|
||||
const stringKey = stringKeys.get(pair);
|
||||
if (stringKey !== undefined) key = stringKey;
|
||||
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/analysisView.js";
|
||||
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisViewRequest.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/analysisView.js";
|
||||
} from "../../domain/analysisView/analysisViewRequest.js";
|
||||
import { prepareAnalysisViewProjection } from "../../domain/analysisView/analysisView.js";
|
||||
import type { AnalysisViewResult } from "../../domain/analysisView/analysisViewResult.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/analysisView.js";
|
||||
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisViewRequest.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/analysisView.js";
|
||||
import { analysisViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { jsonValueValidationIssue } from "../../domain/jsonValue.js";
|
||||
import { compareSourceToBundle } from "../../domain/javascript/sourceToBundleComparison.js";
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
analysisViewResultSchema,
|
||||
inspectAnalysisViewInputSchema,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisViewRequest.js";
|
||||
import { analysisViewResultSchema } from "../../domain/analysisView/analysisViewResult.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/analysisView.js";
|
||||
} from "../../domain/analysisView/analysisViewResult.js";
|
||||
import {
|
||||
inspectAnalysisView,
|
||||
projectRetainedAnalysis,
|
||||
|
||||
@@ -2,10 +2,8 @@ import { expect, it } from "vitest";
|
||||
|
||||
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
|
||||
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
|
||||
import {
|
||||
artifactInventorySummaryViewResultSchema,
|
||||
sealAnalysisView,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import { sealAnalysisView } from "../../domain/analysisView/analysisView.js";
|
||||
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisViewResult.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/analysisView.js";
|
||||
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
|
||||
import {
|
||||
compareArtifacts,
|
||||
type ArtifactComparisonResult,
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "../EvidenceBundleFiles.js";
|
||||
import type { ResetInvestigationReceipt } from "../../domain/investigationLifecycle.js";
|
||||
|
||||
import { retainSavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
|
||||
import {
|
||||
retainSavedAnalysisSnapshot,
|
||||
type SavedAnalysisSnapshot,
|
||||
} from "../../domain/savedAnalysisSnapshot.js";
|
||||
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
@@ -39,7 +42,6 @@ import type {
|
||||
BinarySessionPort,
|
||||
BinaryOpenResult,
|
||||
OpenSelectionReceipt,
|
||||
SavedAnalysisSnapshot,
|
||||
} from "./BinarySessionPort.js";
|
||||
import { binaryTargetSummary } from "./BinaryTargetSummary.js";
|
||||
import {
|
||||
|
||||
@@ -30,7 +30,6 @@ 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,7 +28,11 @@ 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 type { ControlledComputer } from "../../desktop/BuildComputerController.js";
|
||||
import {
|
||||
controller,
|
||||
type Answer,
|
||||
type ControlledComputer,
|
||||
} from "../../desktop/BuildComputerController.js";
|
||||
import { BuildComputerLongRuns } from "../../desktop/BuildComputerLongRuns.js";
|
||||
import {
|
||||
endedCapture,
|
||||
@@ -37,12 +41,10 @@ import {
|
||||
} from "./DesktopBuildCaptureFailure.js";
|
||||
import {
|
||||
adapterError,
|
||||
controller,
|
||||
errorText,
|
||||
refused,
|
||||
runRea,
|
||||
WORK,
|
||||
type Answer,
|
||||
} from "./DesktopBuildCommands.js";
|
||||
import {
|
||||
exportBuild,
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
} from "../../domain/desktop/desktopCapture.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js";
|
||||
import type { ControlledComputer } from "../../desktop/BuildComputerController.js";
|
||||
import type {
|
||||
Answer,
|
||||
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,11 +22,6 @@ 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/analysisView.js";
|
||||
} from "../../domain/analysisView/analysisViewResult.js";
|
||||
import { scenarioCaptureRecords } from "../../domain/analysisView/scenarioCaptureRecords.js";
|
||||
import {
|
||||
readRecordedResponseBody,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnalysisViewResult } from "../../domain/analysisView/analysisView.js";
|
||||
import type { AnalysisViewResult } from "../../domain/analysisView/analysisViewResult.js";
|
||||
import {
|
||||
changesState,
|
||||
type StateDocument,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
projectAnalysisView,
|
||||
analysisViewResultSchema,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
import { projectAnalysisView } from "../../domain/analysisView/analysisView.js";
|
||||
import { analysisViewResultSchema } from "../../domain/analysisView/analysisViewResult.js";
|
||||
import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatching.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { matchNativeFunctions } from "./NativeFunctionMatchingService.js";
|
||||
|
||||
@@ -3,14 +3,43 @@ 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 { bindArtifactFileCleanup } from "./ArtifactFileOperation.js";
|
||||
import {
|
||||
artifactFileCancellationCause,
|
||||
bindArtifactFileCleanup,
|
||||
type ArtifactFilePrimaryFacts,
|
||||
} 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,
|
||||
@@ -57,6 +86,18 @@ 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,
|
||||
|
||||
@@ -5,8 +5,6 @@ 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 { stableArtifactInputRecovery } from "../../../artifacts/StableArtifactFailure.js";
|
||||
import { stableArtifactInputFailure } from "../../../artifacts/StableArtifactFailure.js";
|
||||
|
||||
/** Classify the archive owner's facts without interpreting opaque cancellation reasons. */
|
||||
export const traceFailure = (cause: unknown, path: string): AnalysisError => {
|
||||
@@ -38,11 +38,7 @@ export const traceFailure = (cause: unknown, path: string): AnalysisError => {
|
||||
},
|
||||
);
|
||||
if (cause.reason === "format" || cause.reason === "path")
|
||||
return new AnalysisInputError(
|
||||
operation,
|
||||
{ cause, ...stableArtifactInputRecovery(cause) },
|
||||
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
|
||||
);
|
||||
return stableArtifactInputFailure(cause, operation);
|
||||
return new ArtifactOperationError(
|
||||
operation,
|
||||
cause.reason,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
analysisEvidenceSources,
|
||||
undeliveredSourceClosure,
|
||||
} from "../application/AnalysisEvidenceSources.js";
|
||||
import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js";
|
||||
import type { AnalysisViewRequest } from "../domain/analysisView/analysisViewRequest.js";
|
||||
import { z } from "incur";
|
||||
import {
|
||||
isImmutableEvidence,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js";
|
||||
import type { AnalysisViewRequest } from "../domain/analysisView/analysisViewRequest.js";
|
||||
import { publishCliAnalysisEvidence } from "./analysisEvidenceDelivery.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../domain/investigationProviders.js";
|
||||
import { createProgressReporter } from "../application/ProgressReporter.js";
|
||||
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
@@ -37,8 +36,6 @@ 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());
|
||||
|
||||
@@ -12,18 +12,17 @@ import {
|
||||
} from "./capabilityDeclarations.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { createGpuCodeObjectsService } from "./gpu.js";
|
||||
import { createPacketCaptureService, tsharkHostSupported } from "./captures.js";
|
||||
import {
|
||||
createSignalRecordingService,
|
||||
sigrokHostSupported,
|
||||
} from "./signals.js";
|
||||
import { createPacketCaptureService } from "./captures.js";
|
||||
import { tsharkHostSupported } from "../captures/TsharkCaptureProvider.js";
|
||||
import { createSignalRecordingService } from "./signals.js";
|
||||
import { sigrokHostSupported } from "../signals/SigrokRecordingProvider.js";
|
||||
import { wasmArtifactModule } from "./wasm.js";
|
||||
import { apkSignatureModule } from "./apkSignature.js";
|
||||
import { evmInterfaceModule } from "./evm.js";
|
||||
import {
|
||||
evmInterfaceModule,
|
||||
evmHostSupported,
|
||||
evmLimiterAvailability,
|
||||
} from "./evm.js";
|
||||
} from "../evm/EvmoleInterfaceProvider.js";
|
||||
import { createSqliteDatabaseService } from "./sqlite.js";
|
||||
import { createDesktopApplicationService } from "./desktop.js";
|
||||
import { DesktopBuildComputers } from "../application/desktop/DesktopBuildComputers.js";
|
||||
@@ -40,10 +39,8 @@ import { createWebNetworkCaptureService } from "./webNetworkCaptures.js";
|
||||
import { createWebSourceLocationService } from "./webSourceLocations.js";
|
||||
import { createWebModuleTraceService } from "./webModules.js";
|
||||
import { createWebRuntimeService } from "./webRuntime.js";
|
||||
import {
|
||||
createJavaScriptRecoveryProvider,
|
||||
wakaruHostSupported,
|
||||
} from "./javascriptRecovery.js";
|
||||
import { createJavaScriptRecoveryProvider } from "./javascriptRecovery.js";
|
||||
import { wakaruHostSupported } from "../javascript/recovery/WakaruCommand.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { createReverseEngineeringService } from "./reverseEngineering.js";
|
||||
import type {
|
||||
@@ -51,10 +48,8 @@ import type {
|
||||
CommandProviderLaunchFailures,
|
||||
} from "../application/reverse/ReverseEngineeringService.js";
|
||||
import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js";
|
||||
import {
|
||||
createFirmwareAnalysisProvider,
|
||||
firmwareHostSupported,
|
||||
} from "./firmware.js";
|
||||
import { createFirmwareAnalysisProvider } from "./firmware.js";
|
||||
import { firmwareHostSupported } from "../firmware/FirmwareCommand.js";
|
||||
import { createAndroidAnalysisProvider } from "./android.js";
|
||||
import { createPcapAnalysisProvider } from "./pcap.js";
|
||||
import { createFlutterBuildAnalysisProvider } from "./flutter.js";
|
||||
@@ -79,7 +74,6 @@ import {
|
||||
createWindowsShortcutService,
|
||||
createRecordedCrashService,
|
||||
createBinaryLayoutService,
|
||||
pwntoolsHostSupported,
|
||||
createDevicetreeService,
|
||||
createElfNotesService,
|
||||
createNativeInitializersService,
|
||||
@@ -88,6 +82,7 @@ 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";
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { PacketCaptureService } from "../application/captures/PacketCaptureService.js";
|
||||
import {
|
||||
TsharkCaptureProvider,
|
||||
tsharkHostSupported,
|
||||
} from "../captures/TsharkCaptureProvider.js";
|
||||
|
||||
export { tsharkHostSupported };
|
||||
import { TsharkCaptureProvider } from "../captures/TsharkCaptureProvider.js";
|
||||
|
||||
export const createPacketCaptureService = (
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
|
||||
import {
|
||||
EvmoleInterfaceProvider,
|
||||
evmHostSupported,
|
||||
evmLimiterAvailability,
|
||||
} from "../evm/EvmoleInterfaceProvider.js";
|
||||
import { EvmoleInterfaceProvider } from "../evm/EvmoleInterfaceProvider.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { INSPECT_EVM_INTERFACE_CONTRACT } from "../contracts/evm/evmToolContracts.js";
|
||||
|
||||
@@ -13,8 +9,6 @@ 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.
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js";
|
||||
import {
|
||||
firmwareHostSupported,
|
||||
type FirmwareLauncher,
|
||||
} from "../firmware/FirmwareCommand.js";
|
||||
import 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,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { JavaScriptRecoveryPort } from "../application/javascript/JavaScriptRecoveryPort.js";
|
||||
import { WakaruProvider } from "../javascript/recovery/WakaruProvider.js";
|
||||
import {
|
||||
wakaruHostSupported,
|
||||
type WakaruLauncher,
|
||||
} from "../javascript/recovery/WakaruCommand.js";
|
||||
|
||||
export { wakaruHostSupported };
|
||||
import type { WakaruLauncher } from "../javascript/recovery/WakaruCommand.js";
|
||||
|
||||
/** Construct optional recovery without acquiring tools, processes or workspaces. */
|
||||
export const createJavaScriptRecoveryProvider = (
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { SignalRecordingService } from "../application/signals/SignalRecordingService.js";
|
||||
import {
|
||||
SigrokRecordingProvider,
|
||||
sigrokHostSupported,
|
||||
} from "../signals/SigrokRecordingProvider.js";
|
||||
|
||||
export { sigrokHostSupported };
|
||||
import { SigrokRecordingProvider } from "../signals/SigrokRecordingProvider.js";
|
||||
|
||||
export const createSignalRecordingService = (
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
|
||||
@@ -12,11 +12,13 @@ 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,
|
||||
parseBridgeMaxRequestBytes,
|
||||
} from "./bridgeMaxRequestBytes.js";
|
||||
} from "../domain/analysisResourceLimits.js";
|
||||
|
||||
const configured = (maximumBytes: number) =>
|
||||
({ maximumBytes, source: "setting" }) as const;
|
||||
|
||||
@@ -13,12 +13,6 @@ 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
|
||||
|
||||
@@ -2,11 +2,11 @@ import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
|
||||
import { projectAnalysisView } from "../domain/analysisView/analysisView.js";
|
||||
import {
|
||||
binaryLayoutSummaryViewResultSchema,
|
||||
javascriptApplicationSummaryViewResultSchema,
|
||||
projectAnalysisView,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
} from "../domain/analysisView/analysisViewResult.js";
|
||||
import { analysisViewRequestSchema } from "../domain/analysisView/analysisViewRequest.js";
|
||||
import {
|
||||
analysisViewCaptureEvidence,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { API_SURFACE_EXAMPLE } from "./analysisViewApiSurfaceExample.js";
|
||||
import { ENVIRONMENT_MANIFEST_EXAMPLE } from "./analysisViewEnvironmentManifestExample.js";
|
||||
import {
|
||||
analysisViewResultSchema,
|
||||
inspectAnalysisViewInputSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
import { inspectAnalysisViewInputSchema } from "../domain/analysisView/analysisViewRequest.js";
|
||||
import { analysisViewResultSchema } from "../domain/analysisView/analysisViewResult.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
androidMethodSummaryViewResultSchema,
|
||||
androidJniSummaryViewResultSchema,
|
||||
androidJniPageViewResultSchema,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
} from "../../domain/analysisView/analysisViewResult.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
apiContractSummaryViewResultSchema,
|
||||
apiContractPageViewResultSchema,
|
||||
graphqlContractPageViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
} from "../domain/analysisView/analysisViewResult.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { inspectApkSignatureInputSchema } from "../../domain/apkSignature/apkSig
|
||||
import {
|
||||
apkSignaturePageViewResultSchema,
|
||||
apkSignatureSummaryViewResultSchema,
|
||||
} from "../../domain/analysisView/analysisView.js";
|
||||
} from "../../domain/analysisView/analysisViewResult.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sourceToBundlePageViewResultSchema,
|
||||
endpointComparisonSummaryViewResultSchema,
|
||||
endpointComparisonPageViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
} from "../domain/analysisView/analysisViewResult.js";
|
||||
import { staticObservedEndpointComparisonSchema } from "../domain/javascript/staticObservedEndpointSchemas.js";
|
||||
import { STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE } from "./javascript/staticObservedEndpointExample.js";
|
||||
import { applicationVersionComparisonResultSchema } from "../domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
|
||||
|
||||
Loaded 100 of 316 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user