Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c06444ad67 | ||
|
|
2609352eef | ||
|
|
90f532feb1 | ||
|
|
e9ebfb94ef | ||
|
|
13935077ba | ||
|
|
441813c070 | ||
|
|
c12bad7f13 | ||
|
|
70e5c24718 |
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
Vendored
+2
-1
@@ -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;
|
||||
|
||||
Vendored
+15
@@ -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
@@ -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());
|
||||
};
|
||||
Reference in New Issue
Block a user