Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 c06444ad67 fix(javascript): omit self-referential bundler edges with disclosure
A webpack/Rspack chunk file that lazily requests one of its own chunk
ids (`__webpack_require__.e(<own id>)`, including merged multi-id
chunks) produced a bundler-async-chunk edge from the chunk to itself. A
bundled module that requires its own key (`r(50)` inside module 50) did
the same through the static-reference composer. The application graph
forbids self-referential edges, so one such edge failed result
validation and lost the entire analysis ("Result schema rejected ...
(custom)").

Apply the treatment #1130 gave self-imports to both composers: skip the
edge, count it, and disclose the omission in the graph limitations. The
shared helper keeps the existing self-import wording.

Fixes #1146

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:27:09 +00:00
morluto 2609352eef chore(release): preserve concurrent main updates 2026-10-09 09:17:44 +08:00
morluto 90f532feb1 chore(release): sync published 6.1.0 checkpoint 2026-10-09 09:16:40 +08:00
YsopwnableandGitHub e9ebfb94ef fix(android): validate engine archive prerequisites (#1145)
* fix(android): validate engine archive prerequisites

* fix(android): bound engine metadata reads before allocation
2026-10-09 09:15:33 +08:00
Andrew BarnesandGitHub 13935077ba fix(browser): report Windows HAR heap exhaustion (#1147)
Recognize exit code 134 without a signal when the HAR decoder reports
V8 heap exhaustion. Preserve the observed termination in the memory
failure and cover Windows, POSIX, and unrelated decoder failures.
2026-10-09 09:13:00 +08:00
YsopwnableandGitHub 441813c070 perf(mcp): share repeated output schema definitions 2026-10-09 08:46:59 +08:00
morluto c12bad7f13 fix(release): publish reviewed release PR merges 2026-10-09 08:44:47 +08:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>morluto
70e5c24718 chore(main): release rea-agents 6.1.0 (#1106)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-09 08:15:17 +08:00
23 changed files with 848 additions and 43 deletions
+1
View File
@@ -159,6 +159,7 @@
### Performance Improvements
* **mcp:** advertise each tool JSON Schema once per target ([#1059](https://github.com/morluto/rea/issues/1059)) ([5ccb924](https://github.com/morluto/rea/commit/5ccb9241be15bb6a19b99fbbfb27ff3356ba157b))
* **build:** avoid duplicate documentation generation ([c701376](https://github.com/morluto/rea/commit/c701376777212ec9acfd10c97f45b1eaf0c885c6))
* **javascript:** index configuration and module lookups ([67252b2](https://github.com/morluto/rea/commit/67252b23b9e35e9526fed7842dab47a2c7c9fe5f))
* **javascript:** reuse validated immutable graphs ([#1112](https://github.com/morluto/rea/issues/1112)) ([ec6db0f](https://github.com/morluto/rea/commit/ec6db0f0630c4b0b35f0db84c0030784c1aa58a6))
+8
View File
@@ -23,6 +23,14 @@ export REA_JADX_MCP_JAR=/absolute/path/jadx-headless-mcp-0.7.1-all.jar
export JAVA_HOME=/absolute/path/existing-jdk
```
Readiness checks the selected JAR's ZIP directory for the classes consumed by
REA's metadata bridge, as well as Java's compiler and version modules. It does
not load an APK or execute engine code. Invalid archives and missing bridge
classes report JAR-specific recovery; classpath inventory alone does not prove
that every class or engine operation will load successfully. Discovery limits
each ZIP metadata read to 8 MiB before allocation; a larger directory reports
the exhausted read budget and the selected JAR path.
The current metadata bridge is verified on macOS arm64 with OpenJDK 21 and the
public Appium ApiDemos fixture. The POSIX adapter also supports Linux; the new
bridge has not yet undergone real verification on Linux/JDK 25.
+4
View File
@@ -31,6 +31,10 @@ check the complete catalog after SDK conversion and its generated counterpart.
This is REA's local compatibility profile; individual model APIs can impose
additional limits.
Output schemas share repeated definitions through local references while
preserving their complete fields and validation rules. Canonical Zod validation
and the input compatibility profile continue to apply.
For passive `compare_web_captures` inputs, pass each complete
`inspect_web_page` result in `before.inspection` or `after.inspection`, with an
optional complete `discover_webmcp_tools` result in the matching `webmcp` field.
+26 -3
View File
@@ -1,5 +1,12 @@
import assert from "node:assert/strict";
import { access, mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
import {
access,
mkdtemp,
mkdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, dirname, isAbsolute, join } from "node:path";
import { Client } from "@modelcontextprotocol/client";
@@ -67,6 +74,8 @@ export async function verifyAndroidFailureRecovery({
}
const withoutJavaHome = { ...environment };
delete withoutJavaHome.JAVA_HOME;
const invalidJar = join(root, "invalid.jar");
await writeFile(invalidJar, "not a Java archive");
const environments = [
{
label: "JRE without compiler",
@@ -78,6 +87,12 @@ export async function verifyAndroidFailureRecovery({
env: { ...withoutJavaHome, PATH: empty },
observation: "ENOENT",
},
{
label: "invalid engine archive with a working JDK",
env: { ...environment, REA_JADX_MCP_JAR: invalidJar },
observation: "Cannot inspect REA_JADX_MCP_JAR",
jarFailure: true,
},
];
if (process.platform === "darwin") {
// /usr/bin/java either resolves an installed runtime or reports the actual stub failure.
@@ -114,8 +129,16 @@ export async function verifyAndroidFailureRecovery({
JSON.stringify(failure).includes(scenario.observation),
JSON.stringify(failure),
);
assert.match(failure.message, /full JDK/u);
assert.match(failure.remediation.action, /java --list-modules/u);
assert.match(
failure.message,
scenario.jarFailure ? /REA_JADX_MCP_JAR/u : /full JDK/u,
);
assert.match(
failure.remediation.action,
scenario.jarFailure ? /jadx-headless-mcp/u : /java --list-modules/u,
);
if (scenario.jarFailure)
assert.doesNotMatch(failure.message, /Select a full JDK/u);
assert.doesNotMatch(JSON.stringify(failure), /rea doctor/u);
await verifyAndroidReadiness({
entrypoint,
+48
View File
@@ -5,6 +5,7 @@ import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
import {
execFileOutput,
execFileOutputFailure,
@@ -13,6 +14,9 @@ import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
// Bound ZIP metadata allocation during discovery. The audited 0.7.1 JAR's
// central directory occupies 2,294,350 bytes; class bodies are not read here.
const JAR_METADATA_READ_MAX_BYTES = 8 * 1024 * 1024;
/** Caller-supplied tools, resolved only when an Android operation is selected. */
export interface JadxConfiguration {
@@ -116,9 +120,53 @@ const readJadxConfiguration = async (
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
const jvmArguments = resolveJvmArguments(environment);
await inspectJavaRuntime(java, environment, signal);
await requireEngineArchive(jar, signal);
return { jar, java, jvmArguments };
};
const requireEngineArchive = async (
jar: string,
signal?: AbortSignal,
): Promise<void> => {
// These classes are consumed directly by ReaJadxBridge.java. This checks the
// classpath inventory without loading engine code or starting a JADX session.
const missing = new Set([
"com/atxx/jhmcp/JadxSession.class",
"com/atxx/jhmcp/SessionHolder.class",
"jadx/api/JadxDecompiler.class",
"com/google/gson/Gson.class",
"io/modelcontextprotocol/kotlin/sdk/server/Server.class",
]);
const reader = new ZipArtifactReader(jar, "zip", JAR_METADATA_READ_MAX_BYTES);
try {
for await (const entry of reader.entries(signal)) {
if (entry.kind === "file" && !entry.encrypted) missing.delete(entry.path);
if (missing.size === 0) return;
}
} catch (cause) {
if (signal?.aborted === true) throw cause;
const reason = cause instanceof Error ? cause.message : String(cause);
throw configurationFailure(
"version_unresolved",
`Cannot inspect REA_JADX_MCP_JAR ${jar} as a Java archive: ${reason}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`,
{
jar_path: jar,
phase: "jar-inspection",
archive_error: reason,
maximum_metadata_read_bytes: JAR_METADATA_READ_MAX_BYTES,
},
cause,
);
} finally {
await reader.close();
}
throw configurationFailure(
"unsupported_version",
`REA_JADX_MCP_JAR ${jar} does not contain the required metadata bridge classes: ${[...missing].join(", ")}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`,
{ jar_path: jar, phase: "jar-inspection", missing_classes: [...missing] },
);
};
const requireSupportedHost = (): void => {
if (process.platform === "linux" || process.platform === "darwin") return;
throw configurationFailure(
@@ -12,7 +12,11 @@ import {
addJavaScriptHtmlRoles,
addJavaScriptSourceMapOriginals,
} from "./JavaScriptArtifactGraphDocuments.js";
import { addJavaScriptStaticFindings } from "./JavaScriptArtifactGraphFindings.js";
import {
addJavaScriptStaticFindings,
type JavaScriptStaticFindingOmissions,
} from "./JavaScriptArtifactGraphFindings.js";
import type { JavaScriptBundlerOmissions } from "./JavaScriptArtifactGraphBundlers.js";
import {
addJavaScriptModuleRelationships,
type JavaScriptModuleRelationshipOmissions,
@@ -88,9 +92,9 @@ const buildJavaScriptArtifactGraphInput = (
addJavaScriptArtifactFiles(context);
const packageRoots = addJavaScriptPackageNodes(context);
addJavaScriptSourceModules(context);
addJavaScriptBundlerNodes(context);
const bundlerOmissions = addJavaScriptBundlerNodes(context);
const relationshipOmissions = addJavaScriptModuleRelationships(context);
addJavaScriptStaticFindings(context);
const findingOmissions = addJavaScriptStaticFindings(context);
addElectronBoundaries(context);
addJavaScriptHtmlRoles(context);
addJavaScriptSourceMapOriginals(context);
@@ -104,11 +108,11 @@ const buildJavaScriptArtifactGraphInput = (
nodes: accumulator.nodes(),
edges: accumulator.edges(),
coverage,
limitations: graphLimitations(
context,
coverage.status,
relationshipOmissions,
),
limitations: graphLimitations(context, coverage.status, {
...relationshipOmissions,
...bundlerOmissions,
...findingOmissions,
}),
};
};
@@ -173,10 +177,47 @@ const applicationGraphResourceLimit = (
: ("items" as const),
});
type SelfReferenceOmissions = JavaScriptModuleRelationshipOmissions &
JavaScriptBundlerOmissions &
JavaScriptStaticFindingOmissions;
/** Disclose each relationship omitted because it would reference itself. */
const selfReferenceLimitations = (
omissions: SelfReferenceOmissions,
): string[] =>
(
[
[
omissions.selfImports,
"import specifier",
"import specifiers",
"the importing module",
],
[
omissions.selfAsyncChunks,
"bundler async-chunk request",
"bundler async-chunk requests",
"the requesting chunk",
],
[
omissions.selfReferences,
"static reference",
"static references",
"the referencing module",
],
] as const
).flatMap(([count, singular, plural, owner]) =>
count === 0
? []
: [
`${String(count)} ${count === 1 ? singular : plural} resolved back to ${owner} itself and ${count === 1 ? "was" : "were"} omitted; application graph edges cannot be self-referential.`,
],
);
const graphLimitations = (
context: JavaScriptArtifactGraphContext,
coverage: "complete" | "partial" | "unknown" | "unavailable",
relationshipOmissions: JavaScriptModuleRelationshipOmissions,
omissions: SelfReferenceOmissions,
): string[] => {
const ipc = collectElectronIpcRecords(context.analysis);
const pairings = classifyElectronIpcPairings(ipc);
@@ -195,11 +236,7 @@ const graphLimitations = (
);
return [
...context.analysis.limitations,
...(relationshipOmissions.selfImports > 0
? [
`${String(relationshipOmissions.selfImports)} import ${relationshipOmissions.selfImports === 1 ? "specifier" : "specifiers"} resolved back to the importing module itself and ${relationshipOmissions.selfImports === 1 ? "was" : "were"} omitted; application graph edges cannot be self-referential.`,
]
: []),
...selfReferenceLimitations(omissions),
"CommonJS and ESM binding relationships were recovered from inert syntax and resolved only within the inventoried artifact container.",
"Webpack/Rspack factories were recovered from AST literals; REA did not invoke push handlers or bundle bootstrap code.",
"Static imports, entrypoints, workers, endpoints, and storage relationships do not prove runtime execution.",
@@ -15,10 +15,17 @@ import {
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
/** Bundler compositions the application graph cannot represent, for disclosure. */
export interface JavaScriptBundlerOmissions {
/** Async chunk requests that resolved back to the requesting chunk itself. */
readonly selfAsyncChunks: number;
}
/** Project Webpack/Rspack chunk and factory literals recovered from AST. */
export const addJavaScriptBundlerNodes = (
context: JavaScriptArtifactGraphContext,
): void => {
): JavaScriptBundlerOmissions => {
const omissions = { selfAsyncChunks: 0 };
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
const asset = context.assetNodes.get(file.path);
@@ -49,8 +56,10 @@ export const addJavaScriptBundlerNodes = (
records.push({ file, registration, chunk, coverage });
addBundlerModuleNodes(context, { ...input, chunk });
}
for (const record of records) addBundlerRuntimeEdges(context, record);
for (const record of records)
addBundlerRuntimeEdges(context, record, omissions);
}
return omissions;
};
interface BundlerProjectionInput {
@@ -177,6 +186,7 @@ interface BundlerGraphRecord {
const addBundlerRuntimeEdges = (
context: JavaScriptArtifactGraphContext,
record: BundlerGraphRecord,
omissions: { selfAsyncChunks: number },
): void => {
const { file, registration, chunk, coverage } = record;
for (const moduleKey of registration.entry_module_keys) {
@@ -209,6 +219,13 @@ const addBundlerRuntimeEdges = (
for (const chunkKey of registration.async_chunk_keys) {
const lookup = chunkLookupKey(file.path, registration.runtime, chunkKey);
const resolved = context.chunkNodes.get(lookup);
// A chunk file that requests one of its own registered chunk ids carries
// no chunk-graph relationship, and the application graph forbids
// self-referential edges, so one such request would fail the analysis.
if (resolved?.node_id === chunk.node_id) {
omissions.selfAsyncChunks += 1;
continue;
}
const target =
resolved ?? unresolvedBundlerChunkNode(context, record, chunkKey);
context.accumulator.addEdge({
@@ -39,17 +39,24 @@ interface ResolvedReference {
readonly node: ApplicationNode;
}
/** Static finding compositions the application graph cannot represent. */
export interface JavaScriptStaticFindingOmissions {
/** References, such as a bundled module requiring its own key, that resolve to their source. */
readonly selfReferences: number;
}
/** Project imports, workers, roles, endpoints, storage, and source-map links. */
export const addJavaScriptStaticFindings = (
context: JavaScriptArtifactGraphContext,
): void => {
): JavaScriptStaticFindingOmissions => {
const omissions = { selfReferences: 0 };
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
const asset = context.assetNodes.get(file.path);
if (javascript === null || asset === undefined) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.references)
addReference(context, { file, asset, value, coverage });
addReference(context, { file, asset, value, coverage }, omissions);
for (const value of javascript.endpoints)
addEndpoint(context, { file, asset, value, coverage });
for (const value of javascript.storage)
@@ -59,11 +66,13 @@ export const addJavaScriptStaticFindings = (
for (const value of javascript.source_map_urls)
addSourceMapEdge(context, { file, asset, value, coverage });
}
return omissions;
};
const addReference = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticReference>,
omissions: { selfReferences: number },
): void => {
const source =
sourceNodeFor(context, input.file.path, input.value.module_key) ??
@@ -119,6 +128,13 @@ const addReference = (
});
return;
}
// A reference that resolves to its own source, such as a bundled module
// requiring its own key, cannot become an edge: the application graph
// forbids self-referential edges and one would fail the whole analysis.
if (resolved?.node.node_id === source.node_id) {
omissions.selfReferences += 1;
return;
}
const target =
resolved?.node ??
unresolvedReferenceNode(context, input.file, input.value, input.coverage);
+21 -4
View File
@@ -14,7 +14,10 @@ import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
class NodeFileReader extends Reader<string> {
#handle: FileHandle | undefined;
constructor(private readonly path: string) {
constructor(
private readonly path: string,
private readonly maximumReadBytes?: number,
) {
super(path);
}
@@ -29,7 +32,16 @@ class NodeFileReader extends Reader<string> {
): Promise<Uint8Array> {
const handle = this.#handle;
if (handle === undefined) throw new Error("ZIP reader is not initialized");
const bytes = Buffer.alloc(Math.min(length, this.size - index));
const readBytes = Math.min(length, this.size - index);
if (
this.maximumReadBytes !== undefined &&
readBytes > this.maximumReadBytes
)
throw new ArtifactReaderFailure(
"limit",
`ZIP metadata read requires ${readBytes} bytes, exceeding the ${this.maximumReadBytes}-byte read budget`,
);
const bytes = Buffer.alloc(readBytes);
const read = await handle.read(bytes, 0, bytes.length, index);
return bytes.subarray(0, read.bytesRead);
}
@@ -47,9 +59,14 @@ export class ZipArtifactReader implements ArtifactReader {
readonly #reader: ZipReader<string>;
readonly #entries = new Map<string, Entry>();
constructor(path: string, format: ZipPackageFormat) {
/** Optionally bound each metadata read before allocating its backing buffer. */
constructor(
path: string,
format: ZipPackageFormat,
maximumMetadataReadBytes?: number,
) {
this.format = format;
this.#source = new NodeFileReader(path);
this.#source = new NodeFileReader(path, maximumMetadataReadBytes);
this.#reader = new ZipReader(this.#source, {
checkSignature: true,
checkOverlappingEntry: true,
@@ -7,6 +7,83 @@ import { historicalCaptureFailure } from "./CaptureFailures.js";
import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js";
import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
it.each([
{ label: "POSIX SIGABRT", exitCode: null, signal: "SIGABRT" },
{ label: "Windows exit 134", exitCode: 134, signal: null },
] as const)(
"reports HAR heap exhaustion for $label",
({ exitCode, signal }) => {
const input = inspectWebNetworkCaptureInputSchema.parse({
capture_path: "/selected/input.har",
format: "har",
});
const stderr =
"FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\r\n";
const cause = new OwnedCommandFailure("process", "Decoder failed", {
stdout: { text: "", bytes: 0 },
stderr: { text: stderr, bytes: Buffer.byteLength(stderr) },
exitCode,
signal,
});
const projected = analysisErrorProjectionSchema.parse(
projectAnalysisError(historicalCaptureFailure(input, cause, "decoder")),
);
expect(projected).toMatchObject({
code: "resource_constraint",
retryable: false,
message: expect.stringContaining("fixed V8 heap"),
remediation: {
action: expect.stringContaining(
"smaller capture exported by its producer",
),
},
details: {
resource: "memory",
reported_limits: {
observed_exit_code: exitCode,
observed_signal: signal,
},
captured_output: { stdout: "", stderr },
},
});
expect(JSON.stringify(projected)).not.toContain("rea doctor");
},
);
it.each([
{ exitCode: 134, signal: null, stderr: "Fatal decoder crash" },
{
exitCode: 2,
signal: null,
stderr: "FATAL ERROR: JavaScript heap out of memory",
},
{
exitCode: null,
signal: "SIGTERM",
stderr: "FATAL ERROR: JavaScript heap out of memory",
},
] as const)(
"preserves unrelated decoder failures %#",
({ exitCode, signal, stderr }) => {
const input = inspectWebNetworkCaptureInputSchema.parse({
capture_path: "/selected/input.har",
format: "har",
});
const cause = new OwnedCommandFailure("process", "Decoder failed", {
stdout: { text: "", bytes: 0 },
stderr: { text: stderr, bytes: Buffer.byteLength(stderr) },
exitCode,
signal,
});
expect(
projectAnalysisError(historicalCaptureFailure(input, cause, "decoder")),
).toMatchObject({
code: "execution_failure",
details: { diagnostics: { exit_code: exitCode, signal, stderr } },
});
},
);
it.each(["before open", "during read"])(
"preserves observed capture changes %s as retryable acquisition integrity failures",
(stage) => {
+6 -2
View File
@@ -61,7 +61,8 @@ export const historicalCaptureFailure = (
phase === "decoder" &&
input.format === "har" &&
cause.reason === "process" &&
cause.snapshot?.signal === "SIGABRT" &&
(cause.snapshot?.signal === "SIGABRT" ||
(cause.snapshot?.signal === null && cause.snapshot.exitCode === 134)) &&
/FATAL ERROR:[^\r\n]*heap out of memory/u.test(cause.snapshot.stderr.text)
)
return captureMemoryFailure(
@@ -182,7 +183,10 @@ export const captureMemoryFailure = (
}
: {}),
...(cause instanceof OwnedCommandFailure
? { observed_signal: cause.snapshot?.signal ?? null }
? {
observed_exit_code: cause.snapshot?.exitCode ?? null,
observed_signal: cause.snapshot?.signal ?? null,
}
: {}),
},
{
+177
View File
@@ -0,0 +1,177 @@
import { Ajv } from "ajv";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
import { z } from "zod";
import { inlineLocalJsonSchemaReferences } from "../../tests/fixtures/localJsonSchemaReferences.js";
import { toolContract } from "./toolContracts.js";
import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js";
it.each(["draft-2020-12", "draft-07"] as const)(
"preserves refined numeric bounds and strict repeated objects through %s references",
(target) => {
const integer = z.number().int().nonnegative();
const range = z
.object({
offset: integer.max(65535),
length: integer.min(1),
exclusive: integer.positive(),
})
.strict();
const canonical = z.object({ before: range, after: range }).strict();
const advertised = toolOutputSchemaWithMetadata({
...toolContract("open_binary"),
outputSchema: canonical,
})["~standard"].jsonSchema.output({ target });
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false })
: new Ajv({ strict: false });
const validate = ajv.compile(advertised);
const valid = {
before: { offset: 0, length: 1, exclusive: 1 },
after: { offset: 65535, length: 9007199254740991, exclusive: 1 },
};
const samples: readonly unknown[] = [
valid,
...[
{ offset: -1 },
{ offset: 65536 },
{ offset: 0.5 },
{ length: 0 },
{ length: 9007199254740992 },
{ exclusive: 0 },
{ exclusive: -1 },
{ length: null },
{ unexpected: true },
].map((change) => ({ ...valid, before: { ...valid.before, ...change } })),
{ ...valid, before: { offset: 0, exclusive: 1 } },
{ ...valid, after: null },
{ ...valid, unexpected: true },
];
expect(canonical.safeParse(valid).success).toBe(true);
for (const sample of samples)
expect(validate(sample)).toBe(canonical.safeParse(sample).success);
expect(inlineLocalJsonSchemaReferences(advertised)).toEqual(
inlineLocalJsonSchemaReferences(
canonical["~standard"].jsonSchema.output({ target }),
),
);
},
);
it("keeps schema data containing reference-shaped objects untouched", () => {
const literal = { $ref: "https://example.invalid/value" };
const expanded = inlineLocalJsonSchemaReferences({
$defs: {
"a/b": {
type: "object",
properties: { $defs: { const: literal } },
examples: [literal],
},
},
$ref: "#/$defs/a~1b",
description: "Keep the caller-facing annotation",
});
expect(expanded).toEqual({
type: "object",
properties: { $defs: { const: literal } },
examples: [literal],
description: "Keep the caller-facing annotation",
});
});
it.each(["draft-2020-12", "draft-07"] as const)(
"preserves layered native-selector string patterns through %s references",
(target) => {
const selector = z
.string()
.min(1)
.regex(/^[^\0]*$/u)
.regex(/^[^\s[\]]+$/u);
const canonical = z.object({ before: selector, after: selector }).strict();
const advertised = toolOutputSchemaWithMetadata({
...toolContract("open_binary"),
outputSchema: canonical,
})["~standard"].jsonSchema.output({ target });
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false })
: new Ajv({ strict: false });
const validate = ajv.compile(advertised);
for (const value of [
"setFoo:",
"dataTaskWithRequest:completionHandler:",
"",
"set\0Foo:",
"bad selector:",
"bad[selector]:",
]) {
const sample = { before: value, after: "setFoo:" };
expect(validate(sample)).toBe(canonical.safeParse(sample).success);
}
expect(inlineLocalJsonSchemaReferences(advertised)).toEqual(
inlineLocalJsonSchemaReferences(
canonical["~standard"].jsonSchema.output({ target }),
),
);
},
);
it("rejects unresolved and cyclic references rather than concealing schema drift", () => {
expect(() =>
inlineLocalJsonSchemaReferences({ $ref: "#/$defs/missing" }),
).toThrow("Missing schema reference");
expect(() =>
inlineLocalJsonSchemaReferences({
$defs: { loop: { $ref: "#/$defs/loop" } },
$ref: "#/$defs/loop",
}),
).toThrow("Recursive schema reference");
});
it("intersects referenced numeric bounds and rejects conflicting validation keywords", () => {
expect(
inlineLocalJsonSchemaReferences({
$defs: { bounded: { type: "number", minimum: 1, maximum: 10 } },
$ref: "#/$defs/bounded",
minimum: 0,
maximum: 9,
}),
).toEqual({ type: "number", minimum: 1, maximum: 9 });
expect(() =>
inlineLocalJsonSchemaReferences({
$defs: { strict: { type: "object" } },
$ref: "#/$defs/strict",
type: "string",
}),
).toThrow("Conflicting schema reference sibling: type");
});
it("preserves strict-object evaluation scope across references and singleton intersections", () => {
const strict = {
type: "object",
properties: { value: { type: "string" } },
additionalProperties: false,
};
const schemas = [
{
$defs: { strict },
$ref: "#/$defs/strict",
additionalProperties: false,
},
{ allOf: [strict], additionalProperties: false },
];
for (const schema of schemas) {
const expanded = z
.record(z.string(), z.unknown())
.parse(inlineLocalJsonSchemaReferences(schema));
const ajv = new Ajv2020({ strict: false });
const original = ajv.compile(schema);
const normalized = ajv.compile(expanded);
expect(original({})).toBe(true);
expect(original({ value: "kept" })).toBe(false);
for (const sample of [{}, { value: "kept" }, { unexpected: true }, null])
expect(normalized(sample)).toBe(original(sample));
}
});
+50 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { inlineLocalJsonSchemaReferences } from "../../tests/fixtures/localJsonSchemaReferences.js";
import { TOOL_CONTRACTS, toolContract } from "./toolContracts.js";
import {
toolInputSchemaWithMetadata,
@@ -19,8 +20,13 @@ describe("advertised tool JSON Schema", () => {
expect(advertisedInput.examples, contract.name).toEqual(
contract.examples.map(({ input: example }) => example),
);
expect(advertisedOutput, contract.name).toEqual(
contract.outputSchema["~standard"].jsonSchema.output({ target }),
expect(
inlineLocalJsonSchemaReferences(advertisedOutput),
contract.name,
).toEqual(
inlineLocalJsonSchemaReferences(
contract.outputSchema["~standard"].jsonSchema.output({ target }),
),
);
}
});
@@ -34,4 +40,46 @@ describe("advertised tool JSON Schema", () => {
expect(latest.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
expect(draft07.$schema).toBe("http://json-schema.org/draft-07/schema#");
});
it("preserves complete output meaning across JSON Schema dialects", () => {
for (const dialect of ["draft-2020-12", "draft-07"] as const)
for (const contract of TOOL_CONTRACTS) {
const advertised = toolOutputSchemaWithMetadata(contract)[
"~standard"
].jsonSchema.output({ target: dialect });
const canonical = contract.outputSchema["~standard"].jsonSchema.output({
target: dialect,
});
expect(
inlineLocalJsonSchemaReferences(advertised),
contract.name,
).toEqual(inlineLocalJsonSchemaReferences(canonical));
}
});
it("retains memoization and explicit caller projection options", () => {
const contract = toolContract("analyze_javascript_application");
const output =
toolOutputSchemaWithMetadata(contract)["~standard"].jsonSchema;
const shared = output.output({ target });
expect(shared.$defs).toBeDefined();
expect(output.output({ target })).toBe(shared);
const options = { target, libraryOptions: { reused: "inline" } };
const inline = output.output(options);
expect(inline).toEqual(
contract.outputSchema["~standard"].jsonSchema.output(options),
);
expect(inline.$defs).toBeUndefined();
expect(output.output({ target })).toBe(shared);
const draft07 = output.output({ target: "draft-07" });
expect(draft07.$schema).toBe("http://json-schema.org/draft-07/schema#");
expect(inlineLocalJsonSchemaReferences(draft07)).toEqual(
inlineLocalJsonSchemaReferences(
contract.outputSchema["~standard"].jsonSchema.output({
target: "draft-07",
}),
),
);
expect(output.output({ target: "draft-07" })).toBe(draft07);
});
});
+10 -2
View File
@@ -63,11 +63,19 @@ export const toolInputSchemaWithMetadata = <Contract extends ToolContract>(
}),
);
/** Advertise a canonical output schema without reconverting it per listing. */
/** Share repeated output definitions without changing canonical validation. */
export const toolOutputSchemaWithMetadata = <Contract extends ToolContract>(
contract: Contract,
): Contract["outputSchema"] =>
withAdvertisedJsonSchema(contract.outputSchema, "output");
withAdvertisedJsonSchema(
contract.outputSchema,
"output",
(project) => (options) =>
project({
...options,
libraryOptions: { reused: "ref", ...options.libraryOptions },
}),
);
const withAdvertisedJsonSchema = <Schema extends z.ZodType>(
canonical: Schema,
@@ -70,7 +70,8 @@ it("reports real HAR heap exhaustion through CLI and MCP and preserves server us
resource: "memory",
reported_limits: {
old_generation_heap_mib: 192,
observed_signal: "SIGABRT",
observed_exit_code: process.platform === "win32" ? 134 : null,
observed_signal: process.platform === "win32" ? null : "SIGABRT",
},
captured_output: {
stderr: expect.stringContaining("heap out of memory"),
@@ -9,14 +9,40 @@ import {
} from "../../../src/android/JadxConfiguration.js";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
const it = test.skipIf(process.platform === "win32");
it("rejects malformed or unrelated engine archives with JAR-specific recovery", async () => {
const root = await createTestTempDirectory("rea-jadx-invalid-jar-");
const javaHome = await createFakeJdk(root);
const jar = join(root, "engine.jar");
for (const malformed of [true, false]) {
if (malformed) await writeFile(jar, "not a Java archive");
else await writeOrderedZip(jar, ["unrelated/Library.class"]);
const environment = { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome };
const availability = await inspectJadxAvailability(environment);
expect(availability).toMatchObject({
status: "unavailable",
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" },
});
await expect(
resolveJadxConfiguration(environment, "inspect_android_package"),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
});
expect(availability.reason).not.toContain("Select a full JDK");
}
});
it("rejects an explicit JAVA_HOME without an executable java binary", async () => {
const root = await createTestTempDirectory("rea-jadx-config-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = join(root, "missing-jdk");
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await expect(
resolveJadxConfiguration(
@@ -33,7 +59,7 @@ it("accepts a full JDK and canonicalizes the selected JAR", async () => {
const root = await createTestTempDirectory("rea-jadx-config-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = await createFakeJdk(root);
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await expect(
resolveJadxConfiguration(
@@ -51,7 +77,7 @@ it("configures heap and visible processors independently without forcing either"
const root = await createTestTempDirectory("rea-jadx-config-");
const jar = join(root, "engine.jar");
const javaHome = await createFakeJdk(root);
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
const configuration = await resolveJadxConfiguration(
{
REA_JADX_MCP_JAR: jar,
@@ -98,7 +124,7 @@ it("configures heap and visible processors independently without forcing either"
it("distinguishes a runnable JRE from a missing Java executable", async () => {
const root = await createTestTempDirectory("rea-jadx-runtime-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
const jreHome = await createFakeJdk(root, false);
await expect(
resolveJadxConfiguration(
@@ -142,7 +168,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
const bin = join(javaHome, "bin");
const java = join(bin, "java");
await mkdir(bin, { recursive: true });
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await writeFile(
java,
`#!/bin/sh\nexec ${process.execPath} -e 'process.stdout.write("x".repeat(2 * 1024 * 1024))'\n`,
@@ -186,7 +212,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
it("distinguishes an unreported Java version from an unsupported old version", async () => {
const root = await createTestTempDirectory("rea-jadx-version-listing-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
const unresolvedHome = await createFakeJdk(root);
await writeFile(
@@ -233,7 +259,7 @@ it("cancels an in-flight Java readiness probe", async () => {
const java = join(bin, "java");
const marker = join(root, "java-probe-started");
await mkdir(bin, { recursive: true });
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await writeFile(
java,
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,
@@ -269,7 +295,7 @@ it("reports a killed Java probe as unresolved instead of missing runtime", async
const java = join(bin, "java");
const marker = join(root, "java-probe-started");
await mkdir(bin, { recursive: true });
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await writeFile(
java,
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,
@@ -0,0 +1,81 @@
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
const chunk = (ids: readonly number[], modules: string): string =>
`"use strict";(self.webpackChunkdemo=self.webpackChunkdemo||[]).push([[${ids.join(",")}],{${modules}}]);\n`;
const analyze = async (
cli: TestCli,
files: Readonly<Record<string, string>>,
) => {
const root = await createTestTempDirectory("rea-self-bundler-");
await writeFixtureFiles(root, files);
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode, JSON.stringify(output.json)).toBe(0);
const result = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(
result.graph.edges.filter(
(edge) => edge.source_node_id === edge.target_node_id,
),
).toEqual([]);
expect(result.limitations).toEqual(
expect.arrayContaining(result.graph.limitations),
);
return result.graph;
};
cliTest(
"omits a chunk's request for its own id while keeping sibling chunk requests",
async ({ cli }) => {
const graph = await analyze(cli, {
"1.js": chunk(
[1],
"10(e,t,r){r.e(1).then(r.bind(r,11));r.e(2)},11(e,t,r){r.r(t)}",
),
"2.js": chunk([2], "20(e,t,r){r.r(t)}"),
"3.js": chunk(
[3, 4],
"30(e,t,r){r.e(4).then(r.bind(r,31))},31(e,t,r){r.r(t)}",
),
});
expect(graph.limitations).toContainEqual(
expect.stringMatching(
/^2 bundler async-chunk requests resolved back to the requesting chunk itself and were omitted;/u,
),
);
expect(
graph.edges
.filter(({ properties }) => properties.kind === "bundler-async-chunk")
.map(({ properties }) => properties.chunk_key),
).toEqual(["2"]);
},
);
cliTest(
"omits a bundled module's require of its own key while keeping sibling requires",
async ({ cli }) => {
const graph = await analyze(cli, {
"5.js": chunk([5], "50(e,t,r){r(50);r(51)},51(e,t,r){r.r(t)}"),
});
expect(graph.limitations).toContainEqual(
expect.stringMatching(
/^1 static reference resolved back to the referencing module itself and was omitted;/u,
),
);
expect(
graph.edges.some(
({ relation, properties }) =>
relation === "imports" && properties.specifier === "51",
),
).toBe(true);
},
);
@@ -9,8 +9,32 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
it("bounds a requested metadata read without rejecting the underlying valid ZIP", async () => {
const root = await createTestTempDirectory("rea-zip-metadata-budget-");
const path = join(root, "large-directory.zip");
const name = `${"a".repeat(8192)}.class`;
await writeOrderedZip(path, [name]);
const bounded = new ZipArtifactReader(path, "zip", 4096);
const control = new ZipArtifactReader(path, "zip");
const collect = async (reader: ZipArtifactReader): Promise<string[]> => {
const names: string[] = [];
for await (const entry of reader.entries()) names.push(entry.path);
return names;
};
try {
await expect(collect(bounded)).rejects.toMatchObject({
reason: "limit",
message: expect.stringContaining("4096-byte read budget"),
});
await expect(collect(control)).resolves.toEqual([name]);
} finally {
await Promise.all([bounded.close(), control.close()]);
}
});
it("rejects overlapping ZIP member data ranges through artifact inventory", async () => {
const root = await createTestTempDirectory("rea-zip-overlap-");
const path = join(root, "overlap.zip");
@@ -17,6 +17,7 @@ import {
import { nativeUiResultSchema } from "../../../src/domain/native/nativeUiObservation.js";
import { processCaptureSchema } from "../../../src/domain/process/processCapture.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
import { inlineLocalJsonSchemaReferences } from "../../fixtures/localJsonSchemaReferences.js";
const record = (value: unknown): Record<string, unknown> => {
if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -66,7 +67,7 @@ it("advertises semantic output invariants and keeps producers aligned with Zod",
: { $defs: outputSchema.$defs }),
});
return {
result,
result: record(inlineLocalJsonSchemaReferences(standaloneResultSchema)),
validate: new Ajv2020({
strict: false,
validateFormats: false,
@@ -13,6 +13,7 @@ import {
createJadxProtocolFixture as setup,
verifyJadxFixtureCleanup as verifyCleanup,
} from "../../fixtures/android/jadx.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
const it = test.skipIf(process.platform === "win32");
@@ -104,7 +105,7 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async (
const changed = await inspect();
expect(changed.subject?.digest.sha256).not.toBe(first.subject?.digest.sha256);
await verifyCleanup(launches.slice(0, 1));
await writeFile(jar, "new engine bytes");
await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]);
await inspect();
environment.JAVA_TOOL_OPTIONS = "-Xmx8g";
environment._JAVA_OPTIONS = "-XX:+UseG1GC";
+2 -1
View File
@@ -12,6 +12,7 @@ import {
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import { writeOrderedZip } from "../artifactEntryOrder.js";
import { createTestTempDirectory } from "../temporaryDirectory.js";
import { writeJadxJarInventory } from "./jadxJar.js";
const fixture = fileURLToPath(new URL("./jadx-mcp.mjs", import.meta.url));
@@ -30,7 +31,7 @@ export const createJadxProtocolFixture = async (mode = "normal") => {
);
await chmod(java, 0o755);
await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]);
await writeFile(jar, "synthetic engine bytes; not a Java archive");
await writeJadxJarInventory(jar);
const launches: {
runId: string;
cwd: string | undefined;
+15
View File
@@ -0,0 +1,15 @@
import { writeOrderedZip } from "../artifactEntryOrder.js";
/** ZIP classpath inventory for synthetic protocol tests; not executable bytecode. */
export const writeJadxJarInventory = (
path: string,
extraEntries: readonly string[] = [],
): Promise<void> =>
writeOrderedZip(path, [
"com/atxx/jhmcp/JadxSession.class",
"com/atxx/jhmcp/SessionHolder.class",
"jadx/api/JadxDecompiler.class",
"com/google/gson/Gson.class",
"io/modelcontextprotocol/kotlin/sdk/server/Server.class",
...extraEntries,
]);
+170
View File
@@ -0,0 +1,170 @@
const schemaMaps = new Set([
"properties",
"patternProperties",
"dependentSchemas",
]);
const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
const schemaChildren = new Set([
"items",
"additionalItems",
"contains",
"additionalProperties",
"unevaluatedProperties",
"unevaluatedItems",
"propertyNames",
"not",
"if",
"then",
"else",
]);
const lowerBounds = new Set(["minimum", "exclusiveMinimum"]);
const upperBounds = new Set(["maximum", "exclusiveMaximum"]);
const scopedKeywords = new Set([
"properties",
"patternProperties",
"additionalProperties",
"items",
"additionalItems",
"prefixItems",
"contains",
"unevaluatedProperties",
"unevaluatedItems",
]);
const record = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/** Inline finite local references for representation-independent schema assertions. */
export const inlineLocalJsonSchemaReferences = (
root: Readonly<Record<string, unknown>>,
): unknown => {
const resolve = (reference: string): Record<string, unknown> => {
if (!reference.startsWith("#/"))
throw new Error(`Expected a local schema reference: ${reference}`);
let current: unknown = root;
for (const encoded of reference.slice(2).split("/")) {
const key = encoded.replaceAll("~1", "/").replaceAll("~0", "~");
if (!record(current) || !Object.hasOwn(current, key))
throw new Error(`Missing schema reference: ${reference}`);
current = current[key];
}
if (!record(current))
throw new Error(`Expected a referenced schema object: ${reference}`);
return current;
};
const visit = (value: unknown, active: ReadonlySet<string>): unknown => {
if (!record(value)) return value;
if (typeof value.$ref === "string") {
const { $ref: reference, ...siblings } = value;
if (active.has(reference))
throw new Error(`Recursive schema reference: ${reference}`);
const referenced = resolve(reference);
// Keep declarations in their original evaluation scope.
if (Object.keys(siblings).some((key) => scopedKeywords.has(key)))
return {
allOf: [
visit(referenced, new Set([...active, reference])),
visit(siblings, active),
],
};
const merged = { ...referenced, ...siblings };
for (const [key, entry] of Object.entries(siblings)) {
const previous = referenced[key];
if (
previous === undefined ||
JSON.stringify(previous) === JSON.stringify(entry)
)
continue;
if (typeof previous === "number" && typeof entry === "number") {
if (lowerBounds.has(key)) {
merged[key] = Math.max(previous, entry);
continue;
}
if (upperBounds.has(key)) {
merged[key] = Math.min(previous, entry);
continue;
}
}
throw new Error(`Conflicting schema reference sibling: ${key}`);
}
return visit(merged, new Set([...active, reference]));
}
if (Array.isArray(value.allOf) && value.allOf.length === 1) {
const child = visit(value.allOf[0], active);
const { allOf: _allOf, ...siblings } = value;
if (
record(child) &&
!Object.keys(siblings).some((key) => scopedKeywords.has(key)) &&
Object.entries(siblings).every(
([key, entry]) =>
child[key] === undefined ||
JSON.stringify(child[key]) === JSON.stringify(entry) ||
(typeof child[key] === "number" &&
typeof entry === "number" &&
(lowerBounds.has(key) || upperBounds.has(key))),
)
) {
const merged = { ...child, ...siblings };
for (const [key, entry] of Object.entries(siblings)) {
const previous = child[key];
if (typeof previous !== "number" || typeof entry !== "number")
continue;
if (lowerBounds.has(key)) merged[key] = Math.max(previous, entry);
else if (upperBounds.has(key))
merged[key] = Math.min(previous, entry);
}
return visit(merged, active);
}
}
const normalized = { ...value };
if (
typeof normalized.pattern === "string" &&
Array.isArray(normalized.allOf) &&
normalized.allOf.some(
(child: unknown) =>
record(child) && child.pattern === normalized.pattern,
)
)
delete normalized.pattern;
if (
typeof normalized.minimum === "number" &&
typeof normalized.exclusiveMinimum === "number" &&
normalized.exclusiveMinimum >= normalized.minimum
)
delete normalized.minimum;
if (
typeof normalized.maximum === "number" &&
typeof normalized.exclusiveMaximum === "number" &&
normalized.exclusiveMaximum <= normalized.maximum
)
delete normalized.maximum;
return Object.fromEntries(
Object.entries(normalized)
.filter(([key]) => key !== "$defs" && key !== "definitions")
.map(([key, child]) => {
if (schemaMaps.has(key) && record(child))
return [
key,
Object.fromEntries(
Object.entries(child).map(([name, schema]) => [
name,
visit(schema, active),
]),
),
];
if (schemaArrays.has(key) && Array.isArray(child))
return [key, child.map((schema) => visit(schema, active))];
if (schemaChildren.has(key))
return [
key,
Array.isArray(child)
? child.map((schema) => visit(schema, active))
: visit(child, active),
];
return [key, child];
}),
);
};
return visit(root, new Set());
};