mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
feat(javascript): analyze selected application sources
Analyze exact selected sources in first-request order before unrelated static scopes, preserving full artifact identity and byte verification. Retain selection in CLI/MCP Evidence and summaries, with explicit unknowns and partial coverage for unselected sources. Keep default full analysis. Fixes #1848 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Kaoru0822 <[email protected]>
This commit is contained in:
co-authored by
factory-droid[bot]
Kaoru0822
parent
0d8b728d24
commit
85c1d2587a
@@ -9,7 +9,19 @@ provenance; inputs and recovered code are never executed. Recovery requires a
|
||||
caller-supplied Wakaru on Linux x64; see [the recovery guide](https://github.com/morluto/rea/blob/main/docs/javascript-recovery.md).
|
||||
|
||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||
extracted tree. Exact `#alias` specifiers resolve through the importer's
|
||||
extracted tree. When the question concerns known source paths and the connected
|
||||
schema advertises `source_paths`, select exact inventoried paths such as
|
||||
`["package.json", "main.js", "preload.js"]`. Include supporting manifests or
|
||||
source maps explicitly; dependencies are not implicitly analyzed. REA retains
|
||||
the complete artifact inventory and identity but reads source text and analyzes
|
||||
only selected files. The result and summary record `source_selection`; files
|
||||
outside scope and their relationships remain unknown, so partial application
|
||||
coverage does not mean the selected source failed. Use repeated `--source-path`
|
||||
on either static application CLI route. Omit the selection when the question
|
||||
requires complete application analysis. `detail: "summary"` reduces delivery
|
||||
and does not by itself reduce analysis work.
|
||||
|
||||
Exact `#alias` specifiers resolve through the importer's
|
||||
enclosing package `imports` map to package-local files; nested `node_modules`
|
||||
packages use their own map. Wildcard, recursive, and external-package import
|
||||
targets stay unresolved with that limitation. When size is unknown or large and the connected schema advertises
|
||||
|
||||
@@ -66,6 +66,45 @@ code-point order. Dependency manifests remain package observations but do not
|
||||
declare Electron main or renderer entries. If only dependency manifests are
|
||||
present, the inventory artifact remains the graph root.
|
||||
|
||||
## Select sources before analysis
|
||||
|
||||
For a question about known entry sources, supply exact artifact-relative file
|
||||
paths in `source_paths`, or repeat `--source-path` on either CLI route:
|
||||
|
||||
```bash
|
||||
rea analyze /absolute/path/to/app.asar \
|
||||
--source-path package.json --source-path main.js --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"input_path": "/absolute/path/to/app.asar",
|
||||
"source_paths": ["package.json", "main.js"],
|
||||
"detail": "summary"
|
||||
}
|
||||
```
|
||||
|
||||
REA inventories and verifies the complete artifact, including unselected bytes,
|
||||
but allocates source text and performs static analysis only for selected files.
|
||||
Include supporting manifests, HTML or source maps explicitly when needed;
|
||||
imports do not expand the selection. Paths must match relevant inventoried
|
||||
files exactly, including nested ASAR prefixes. Unknown paths, aliases and globs
|
||||
return an `invalid_request` correction. Selected files are analyzed in request
|
||||
order before unselected scopes are projected. Repeated paths preserve the
|
||||
caller's request and first-occurrence priority, analyzing the file once.
|
||||
Omit the selection for the existing complete
|
||||
application workflow.
|
||||
|
||||
The result and summary retain `source_selection.requested_paths` and
|
||||
`source_selection.unselected_files`. Every relevant file keeps its original
|
||||
inventory identity; unselected files report `text_status:
|
||||
"outside-analysis-scope"`. Their relationships remain unknown, and application
|
||||
coverage is partial while any file remains outside scope. Selection does not
|
||||
claim whole-application completeness or change integrity policy. Summary detail
|
||||
only reduces delivery; source selection reduces text acquisition and analysis.
|
||||
Explicit native provider or snapshot routing rejects `--source-path` rather
|
||||
than ignoring it.
|
||||
|
||||
## ASAR integrity
|
||||
|
||||
ASAR inventory checks Electron integrity metadata for embedded archive entries
|
||||
|
||||
@@ -91,4 +91,7 @@ const parameters = (
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
format: input.format,
|
||||
integrity_policy: input.integrity_policy,
|
||||
...(input.source_paths === undefined
|
||||
? {}
|
||||
: { source_paths: input.source_paths }),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { writeJavaScriptSourceSelectionFixture } from "../../../tests/fixtures/javascriptSourceSelection.js";
|
||||
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../../tests/fixtures/artifactInventory.js";
|
||||
@@ -10,6 +13,110 @@ import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirect
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js";
|
||||
|
||||
describe("Selected JavaScript source failure diagnostics", () => {
|
||||
it("analyzes selected files in first-request order and reports only selected progress", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-selected-priority-");
|
||||
await writeJavaScriptSourceSelectionFixture(root);
|
||||
await writeFile(join(root, "z-later.mjs"), "export const later = 1;\n");
|
||||
const progress: string[] = [];
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{
|
||||
input_path: root,
|
||||
source_paths: ["z-later.mjs", "main.mjs", "z-later.mjs"],
|
||||
},
|
||||
{
|
||||
progress: {
|
||||
report: async (event) => {
|
||||
if (event.phase === "parse_javascript_source")
|
||||
progress.push(event.message);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(progress).toEqual([
|
||||
"Parsing and projecting z-later.mjs (1/2)",
|
||||
"Parsing and projecting main.mjs (2/2)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("verifies unselected bytes against the inventoried identity without decoding them", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-unselected-integrity-");
|
||||
await writeJavaScriptSourceSelectionFixture(root);
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, source_paths: ["main.mjs"] },
|
||||
{
|
||||
progress: {
|
||||
report: async (event) => {
|
||||
if (event.phase === "read_javascript_artifacts")
|
||||
await writeFile(
|
||||
join(root, "vendor.js"),
|
||||
"changed after inventory\n",
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error(
|
||||
"Unselected content changes must still fail integrity verification",
|
||||
);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "artifact_operation_failed",
|
||||
details: { reason: "integrity" },
|
||||
});
|
||||
});
|
||||
|
||||
it("retains selected facts and unselected scope after cancellation between selected sources", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-selected-cancellation-");
|
||||
await writeJavaScriptSourceSelectionFixture(root);
|
||||
await writeFile(join(root, "z-later.mjs"), "export const later = 1;\n");
|
||||
const controller = new AbortController();
|
||||
const source_paths = ["main.mjs", "z-later.mjs"];
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, source_paths },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
report: async (event) => {
|
||||
if (
|
||||
event.phase === "parse_javascript_source" &&
|
||||
event.message.includes("z-later.mjs")
|
||||
)
|
||||
controller.abort();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok) throw new Error("Expected selected-source cancellation");
|
||||
expect(projectAnalysisError(result.error).code).toBe("cancelled");
|
||||
const evidence = parseEvidence(result.error.partialObservation);
|
||||
const partial = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(partial.source_selection).toEqual({
|
||||
requested_paths: source_paths,
|
||||
unselected_files: 5,
|
||||
});
|
||||
expect(
|
||||
partial.semantic_graph.nodes.some(
|
||||
({ kind, label }) => kind === "function" && label === "selected",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(partial.statistics.parsed_javascript_files).toBe(1);
|
||||
expect(partial.graph.coverage.status).toBe("partial");
|
||||
expect(
|
||||
partial.graph.nodes
|
||||
.flatMap(({ observations }) =>
|
||||
observations.map(({ properties }) => properties),
|
||||
)
|
||||
.filter(
|
||||
(properties) => properties.text_status === "outside-analysis-scope",
|
||||
),
|
||||
).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JavaScript application failure diagnostics", () => {
|
||||
it("identifies the rejected result field in caller-visible diagnostics", async () => {
|
||||
const inputPath = await createTestTempDirectory("rea-js-schema-failure-");
|
||||
|
||||
@@ -66,6 +66,9 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
input_path: input.input_path,
|
||||
format: input.format,
|
||||
integrity_policy: input.integrity_policy,
|
||||
...(input.source_paths === undefined
|
||||
? {}
|
||||
: { source_paths: input.source_paths }),
|
||||
...(input.max_heap_mb === undefined
|
||||
? {}
|
||||
: { max_heap_mb: input.max_heap_mb }),
|
||||
|
||||
@@ -137,12 +137,29 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
|
||||
}> => {
|
||||
const state = emptyArtifactAnalysis<JavaScriptModuleSemanticIr>();
|
||||
let interruption: AnalysisError | null = null;
|
||||
for (const [index, file] of fileSet.files.entries()) {
|
||||
const selectedFileCount =
|
||||
fileSet.files.length - (fileSet.source_selection?.unselected_files ?? 0);
|
||||
let selectedFileIndex = 0;
|
||||
for (const file of fileSet.files) {
|
||||
try {
|
||||
await setImmediate();
|
||||
await beforeFile(file, index, fileSet.files.length);
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError("analyze_javascript_application");
|
||||
if (
|
||||
!file.text.included &&
|
||||
file.text.reason === "outside-analysis-scope"
|
||||
) {
|
||||
state.files.push({ file, javascript: null, semantic: null });
|
||||
if (file.kind === "javascript")
|
||||
projection.recordUnavailableFile(
|
||||
file,
|
||||
"Source was not analyzed because it is outside the caller-selected source_paths.",
|
||||
null,
|
||||
"incomplete-module",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await beforeFile(file, selectedFileIndex++, selectedFileCount);
|
||||
if (file.kind !== "javascript" || !file.text.included) {
|
||||
const steps = analyzeArtifactFileSteps(file, {
|
||||
state,
|
||||
@@ -267,6 +284,20 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
|
||||
};
|
||||
for (const file of fileSet.files) {
|
||||
if (analyzedPaths.has(file.path)) continue;
|
||||
if (
|
||||
!file.text.included &&
|
||||
file.text.reason === "outside-analysis-scope"
|
||||
) {
|
||||
state.files.push({ file, javascript: null, semantic: null });
|
||||
if (file.kind === "javascript")
|
||||
projection.recordUnavailableFile(
|
||||
file,
|
||||
"Source was not analyzed because it is outside the caller-selected source_paths.",
|
||||
null,
|
||||
"incomplete-module",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
state.files.push({
|
||||
file,
|
||||
javascript: null,
|
||||
@@ -336,6 +367,10 @@ function* analyzeArtifactFileSteps<
|
||||
context: ArtifactAnalysisContext<SemanticIr>,
|
||||
): Generator<void, void> {
|
||||
const { state } = context;
|
||||
if (!file.text.included && file.text.reason === "outside-analysis-scope") {
|
||||
state.files.push({ file, javascript: null, semantic: null });
|
||||
return;
|
||||
}
|
||||
addStructuredObservations(file, state);
|
||||
if (file.kind === "html" && file.text.included)
|
||||
state.htmlScripts.push(...parseHtmlScripts(file.path, file.text.value));
|
||||
|
||||
@@ -168,6 +168,7 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
|
||||
javascript !== null && javascript.parse_status === "partial",
|
||||
);
|
||||
const unknownGap =
|
||||
(context.fileSet.source_selection?.unselected_files ?? 0) > 0 ||
|
||||
context.analysis.files.some(
|
||||
({ analysis_failure, application_projection_failure }) =>
|
||||
analysis_failure !== undefined ||
|
||||
@@ -244,6 +245,11 @@ const graphLimitations = (
|
||||
);
|
||||
return [
|
||||
...context.analysis.limitations,
|
||||
...(context.fileSet.source_selection === undefined
|
||||
? []
|
||||
: [
|
||||
`Caller-selected source analysis leaves ${String(context.fileSet.source_selection.unselected_files)} inventoried files outside analysis scope. Inventory identity covers the complete artifact; relationships through unselected sources remain unknown.`,
|
||||
]),
|
||||
...context.snapshot.integrity_contradictions.map(
|
||||
({ logical_path: path }) =>
|
||||
`Artifact integrity metadata contradicts observed bytes at ${path}; the observed bytes are untrusted.`,
|
||||
|
||||
@@ -199,7 +199,15 @@ export const addJavaScriptArtifactFiles = (
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (analyzed.analysis_failure !== undefined)
|
||||
if (!file.text.included && file.text.reason === "outside-analysis-scope")
|
||||
addUnavailableStaticParseScope(context, {
|
||||
file,
|
||||
asset: target,
|
||||
operation: "select-javascript-sources",
|
||||
limitation:
|
||||
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
|
||||
});
|
||||
else if (analyzed.analysis_failure !== undefined)
|
||||
addUnavailableStaticParseScope(context, {
|
||||
file,
|
||||
asset: target,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { createJavaScriptArtifactReader as createReader } from "../../artifacts/javascript/JavaScriptArtifactReader.js";
|
||||
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSourceSelection } from "../../domain/javascript/javascriptSourceSelection.js";
|
||||
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { parseOwnedJavaScriptApplicationAnalysisSteps } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
@@ -45,6 +46,7 @@ export interface JavaScriptArtifactReconstructionResult {
|
||||
readonly inventory_manifest_id: string;
|
||||
readonly inventory_graph_sha256: string;
|
||||
readonly integrity_contradictions: ArtifactInventorySnapshot["integrity_contradictions"];
|
||||
readonly source_selection?: JavaScriptSourceSelection;
|
||||
readonly graph: JavaScriptApplicationGraph;
|
||||
readonly semantic_graph: JavaScriptSemanticGraph;
|
||||
readonly electron_summary: ElectronBoundarySummary;
|
||||
@@ -119,6 +121,7 @@ export const reconstructJavaScriptArtifact = async (
|
||||
snapshot,
|
||||
scope,
|
||||
signal,
|
||||
input.source_paths,
|
||||
);
|
||||
// Release acquisition before analysis consumes these owned file facts.
|
||||
const earlyCleanup = await scope.release(readerOwner);
|
||||
@@ -134,7 +137,7 @@ export const reconstructJavaScriptArtifact = async (
|
||||
);
|
||||
await reportPhase(
|
||||
"parse_javascript_sources",
|
||||
`Parsing and projecting ${String(files.files.length)} application source files`,
|
||||
`Parsing and projecting ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application source files`,
|
||||
);
|
||||
const semanticProjection = createJavaScriptSemanticGraphProjection();
|
||||
const worker = createJavaScriptSourceAnalysis(
|
||||
@@ -237,6 +240,9 @@ export const reconstructJavaScriptArtifact = async (
|
||||
inventory_manifest_id: snapshot.manifest.manifest_id,
|
||||
inventory_graph_sha256: snapshot.manifest.graph_sha256,
|
||||
integrity_contradictions: snapshot.integrity_contradictions,
|
||||
...(files.source_selection === undefined
|
||||
? {}
|
||||
: { source_selection: files.source_selection }),
|
||||
graph,
|
||||
semantic_graph: semanticGraph,
|
||||
electron_summary: summarizeElectronBoundaries(analysis),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { artifactIntegrityPolicySchema } from "../../domain/artifactIntegrityPolicy.js";
|
||||
import { javaScriptAnalysisResourceControlsSchema } from "../../domain/javascript/javascriptAnalysisResourceControls.js";
|
||||
import { javaScriptSourcePathsSchema } from "../../domain/javascript/javascriptSourceSelection.js";
|
||||
|
||||
/** Local ASAR/directory reconstruction request. */
|
||||
export const javascriptArtifactReconstructionInputSchema = z.strictObject({
|
||||
input_path: z.string().min(1),
|
||||
format: z.enum(["auto", "asar", "directory"]).default("auto"),
|
||||
integrity_policy: artifactIntegrityPolicySchema,
|
||||
source_paths: javaScriptSourcePathsSchema.optional(),
|
||||
...javaScriptAnalysisResourceControlsSchema.shape,
|
||||
});
|
||||
|
||||
|
||||
@@ -130,6 +130,16 @@ export const buildJavaScriptSemanticGraph = ({
|
||||
for (const analyzed of analysis.files) {
|
||||
if (analyzed.semantic !== null)
|
||||
projection.projectFile(analyzed.file, analyzed.semantic.ir);
|
||||
else if (
|
||||
!analyzed.file.text.included &&
|
||||
analyzed.file.text.reason === "outside-analysis-scope"
|
||||
)
|
||||
projection.recordUnavailableFile(
|
||||
analyzed.file,
|
||||
"Source was not analyzed because it is outside the caller-selected source_paths.",
|
||||
null,
|
||||
"incomplete-module",
|
||||
);
|
||||
}
|
||||
return projection.finish(rootArtifactSha256, applicationGraph);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { streamChunkToBuffer } from "../StreamBytes.js";
|
||||
import { hashReadable } from "../ArtifactHash.js";
|
||||
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
import type {
|
||||
JavaScriptArtifactFileKind,
|
||||
@@ -47,6 +48,7 @@ interface ReadContext {
|
||||
readonly files: JavaScriptArtifactFile[];
|
||||
readonly containers: JavaScriptArtifactContainer[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly selectedPaths: ReadonlyMap<string, number> | undefined;
|
||||
textBytes: number;
|
||||
invalidUtf8: number;
|
||||
}
|
||||
@@ -57,13 +59,49 @@ interface ReadTextInput {
|
||||
readonly expected: ExpectedFile;
|
||||
}
|
||||
|
||||
/** Read all relevant textual entries through an already-inventoried reader. */
|
||||
/** Verify inventoried entries; acquire text only inside the caller's scope. */
|
||||
export const readJavaScriptArtifactFiles = async (
|
||||
reader: ArtifactReader,
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
sourcePaths?: readonly string[],
|
||||
): Promise<JavaScriptArtifactFileSet> => {
|
||||
const inventory = expectedInventory(snapshot);
|
||||
const expected = inventory.files;
|
||||
const selectedPaths =
|
||||
sourcePaths === undefined
|
||||
? undefined
|
||||
: new Map([...new Set(sourcePaths)].map((path, index) => [path, index]));
|
||||
const issues = (sourcePaths ?? []).flatMap((path, index) =>
|
||||
expected.has(path) || inventory.unavailableFiles.has(path)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
path: ["source_paths", index],
|
||||
reason: "invalid_value" as const,
|
||||
message:
|
||||
"Select an exact relevant file path from the artifact inventory; no globs or path aliases.",
|
||||
},
|
||||
],
|
||||
);
|
||||
if (issues.length > 0)
|
||||
throw new AnalysisInputError(
|
||||
"analyze_javascript_application",
|
||||
undefined,
|
||||
issues,
|
||||
);
|
||||
const unavailablePath = sourcePaths?.find((path) =>
|
||||
inventory.unavailableFiles.has(path),
|
||||
);
|
||||
if (unavailablePath !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Selected inventoried source bytes are unavailable: ${unavailablePath}`,
|
||||
{
|
||||
partialObservation: { kind: "artifact-inventory", inventory: snapshot },
|
||||
},
|
||||
);
|
||||
if (reader instanceof AsarArtifactReader) {
|
||||
const root = snapshot.nodes.find(
|
||||
({ artifact_id }) => artifact_id === snapshot.manifest.root_artifact_id,
|
||||
@@ -79,8 +117,6 @@ export const readJavaScriptArtifactFiles = async (
|
||||
signal,
|
||||
);
|
||||
}
|
||||
const inventory = expectedInventory(snapshot);
|
||||
const expected = inventory.files;
|
||||
const context: ReadContext = {
|
||||
resources,
|
||||
expected,
|
||||
@@ -89,13 +125,17 @@ export const readJavaScriptArtifactFiles = async (
|
||||
files: [],
|
||||
containers: [],
|
||||
signal,
|
||||
selectedPaths,
|
||||
textBytes: 0,
|
||||
invalidUtf8: 0,
|
||||
};
|
||||
await visitReader(reader, "", snapshot.manifest.root_sha256, context);
|
||||
const files = context.files.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.path, right.path),
|
||||
);
|
||||
const files = context.files.sort((left, right) => {
|
||||
const leftPriority = selectedPaths?.get(left.path) ?? Infinity;
|
||||
const rightPriority = selectedPaths?.get(right.path) ?? Infinity;
|
||||
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
|
||||
return compareUnicodeCodePoints(left.path, right.path);
|
||||
});
|
||||
assertExpectedFilesWereVisited(expected, files);
|
||||
return {
|
||||
files,
|
||||
@@ -104,6 +144,17 @@ export const readJavaScriptArtifactFiles = async (
|
||||
),
|
||||
text_bytes_read: context.textBytes,
|
||||
invalid_utf8_files: context.invalidUtf8,
|
||||
...(sourcePaths === undefined
|
||||
? {}
|
||||
: {
|
||||
source_selection: {
|
||||
requested_paths: [...sourcePaths],
|
||||
unselected_files: files.filter(
|
||||
({ text }) =>
|
||||
!text.included && text.reason === "outside-analysis-scope",
|
||||
).length,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -251,6 +302,19 @@ const readText = async (
|
||||
context: ReadContext,
|
||||
input: ReadTextInput,
|
||||
): Promise<JavaScriptArtifactFile["text"]> => {
|
||||
if (
|
||||
context.selectedPaths !== undefined &&
|
||||
!context.selectedPaths.has(input.expected.path)
|
||||
) {
|
||||
// Keep the inventory/acquisition identity check without allocating or
|
||||
// decoding an unrelated source's text.
|
||||
await verifyEntryBytes(
|
||||
await input.reader.open(input.entry, context.signal),
|
||||
input.expected,
|
||||
context.signal,
|
||||
);
|
||||
return { included: false, reason: "outside-analysis-scope" };
|
||||
}
|
||||
if (input.expected.kind === "native-addon") {
|
||||
await verifyEntryBytes(
|
||||
await input.reader.open(input.entry, context.signal),
|
||||
@@ -311,16 +375,23 @@ const expectedInventory = (
|
||||
): {
|
||||
readonly files: ReadonlyMap<string, ExpectedFile>;
|
||||
readonly containers: ReadonlyMap<string, ExpectedContainer>;
|
||||
readonly unavailableFiles: ReadonlySet<string>;
|
||||
} => {
|
||||
const nodes = new Map(snapshot.nodes.map((node) => [node.artifact_id, node]));
|
||||
const files = new Map<string, ExpectedFile>();
|
||||
const containers = new Map<string, ExpectedContainer>();
|
||||
const unavailableFiles = new Set<string>();
|
||||
for (const occurrence of snapshot.occurrences) {
|
||||
if (occurrence.logical_path === ".") continue;
|
||||
const isNestedAsar =
|
||||
occurrence.entry_kind === "file" &&
|
||||
occurrence.logical_path.toLowerCase().endsWith(".asar");
|
||||
if (occurrence.artifact_id === null) {
|
||||
if (
|
||||
occurrence.entry_kind === "file" &&
|
||||
relevantKind(occurrence.logical_path) !== undefined
|
||||
)
|
||||
unavailableFiles.add(occurrence.logical_path);
|
||||
if (isNestedAsar)
|
||||
containers.set(occurrence.logical_path, {
|
||||
hash_status: occurrence.hash_status,
|
||||
@@ -358,7 +429,7 @@ const expectedInventory = (
|
||||
kind,
|
||||
});
|
||||
}
|
||||
return { files, containers };
|
||||
return { files, containers, unavailableFiles };
|
||||
};
|
||||
|
||||
const readAll = async (
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { CliInstance } from "./types.js";
|
||||
import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import { javascriptApplicationOptions } from "../cliObservationOptions.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
/** Register provider-neutral binary overview and procedure CLI commands. */
|
||||
export const registerCoreBinaryCommands = (
|
||||
@@ -51,6 +52,7 @@ const registerOverviewCommands = (
|
||||
}),
|
||||
options: overviewOptions.extend({
|
||||
integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy,
|
||||
sourcePath: javascriptApplicationOptions.shape.sourcePath,
|
||||
}),
|
||||
run: async ({ args, options }) => {
|
||||
// Route JavaScript targets exactly like analyze-javascript-application,
|
||||
@@ -63,19 +65,35 @@ const registerOverviewCommands = (
|
||||
{
|
||||
input_path: resolve(args.path),
|
||||
integrity_policy: options.integrityPolicy,
|
||||
...(options.sourcePath === undefined
|
||||
? {}
|
||||
: { source_paths: options.sourcePath }),
|
||||
},
|
||||
signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
return logCliCommand(logger, "analyze", () =>
|
||||
runDirectAnalysis(
|
||||
return logCliCommand(logger, "analyze", () => {
|
||||
if (options.sourcePath !== undefined)
|
||||
throw new AnalysisInputError(
|
||||
"analyze_javascript_application",
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
path: ["source_paths"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Source selection requires the static JavaScript ASAR/directory analysis lane.",
|
||||
},
|
||||
],
|
||||
);
|
||||
return runDirectAnalysis(
|
||||
args.path,
|
||||
"binary_overview",
|
||||
{},
|
||||
directAnalysisOptionsFromCli(logger, options),
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspect, {
|
||||
|
||||
@@ -200,6 +200,9 @@ const registerJavaScriptApplicationCommand = (
|
||||
input_path: args.path,
|
||||
format: options.artifactFormat,
|
||||
integrity_policy: options.integrityPolicy,
|
||||
...(options.sourcePath === undefined
|
||||
? {}
|
||||
: { source_paths: options.sourcePath }),
|
||||
...(options.maxHeapMb === undefined
|
||||
? {}
|
||||
: { max_heap_mb: options.maxHeapMb }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "incur";
|
||||
import { javaScriptAnalysisResourceControlsSchema } from "./domain/javascript/javascriptAnalysisResourceControls.js";
|
||||
import { javaScriptSourcePathsSchema } from "./domain/javascript/javascriptSourceSelection.js";
|
||||
|
||||
/** Accept an agent-selected browser observation duration without an artificial ceiling. */
|
||||
export const observationDuration = (fallback: number, minimum = 0) =>
|
||||
@@ -60,6 +61,11 @@ export const electronPageInspectionOptions = z.object({
|
||||
});
|
||||
|
||||
export const javascriptApplicationOptions = z.object({
|
||||
sourcePath: javaScriptSourcePathsSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Exact inventoried file path relative to the application root; repeat to select several. No implicit dependencies.",
|
||||
),
|
||||
maxHeapMb: javaScriptAnalysisResourceControlsSchema.shape.max_heap_mb,
|
||||
analysisTimeoutMs:
|
||||
javaScriptAnalysisResourceControlsSchema.shape.analysis_timeout_ms,
|
||||
|
||||
@@ -88,7 +88,7 @@ export const ELECTRON_TOOL_CONTRACTS = [
|
||||
name: "analyze_javascript_application",
|
||||
...toolContractMetadata("analyze_javascript_application"),
|
||||
description:
|
||||
"Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns recovered graph nodes, edges, semantic relations, integrity contradictions, limitations, and coverage. Select detail summary to retain the complete analysis Evidence in this session and receive only its summary view, then inspect selected modules or pages with inspect_analysis_view. Integrity mismatches fail by default; record-and-continue retains observed bytes as untrusted and marks graph coverage partial.",
|
||||
"Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns recovered graph nodes, edges, semantic relations, integrity contradictions, limitations, and coverage. Select source_paths to acquire and analyze only exact inventoried file paths; the complete artifact inventory and identity remain intact, and unselected sources remain explicit unknowns. Dependencies are not implicitly analyzed. Select detail summary to retain the complete analysis Evidence in this session and receive only its summary view, then inspect selected modules or pages with inspect_analysis_view. Summary detail reduces delivery, whereas source_paths bounds source analysis. Integrity mismatches fail by default; record-and-continue retains observed bytes as untrusted and marks graph coverage partial.",
|
||||
kind: "electron-provider",
|
||||
inputSchema: analyzeJavaScriptApplicationRequestSchema,
|
||||
outputSchema: applicationOutputSchema,
|
||||
@@ -101,6 +101,14 @@ export const ELECTRON_TOOL_CONTRACTS = [
|
||||
integrity_policy: "fail",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Analyze selected entry sources before unrelated vendor files",
|
||||
input: {
|
||||
input_path: "/Applications/Example.app/Contents/Resources/app.asar",
|
||||
source_paths: ["package.json", "main.js", "preload.js"],
|
||||
detail: "summary",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Retain the complete analysis and return its summary view",
|
||||
input: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { javaScriptSourceSelectionSchema } from "../javascript/javascriptSourceSelection.js";
|
||||
|
||||
import {
|
||||
AnalysisInputError,
|
||||
@@ -170,6 +171,7 @@ const javascriptSummarySchema = z.strictObject({
|
||||
input_path: z.string().min(1),
|
||||
format: z.enum(["asar", "directory"]),
|
||||
root_artifact_sha256: digestSchema,
|
||||
source_selection: javaScriptSourceSelectionSchema.exactOptional(),
|
||||
statistics: jsonObjectSchema,
|
||||
electron: jsonObjectSchema,
|
||||
coverage: jsonObjectSchema,
|
||||
|
||||
@@ -255,6 +255,11 @@ export const projectJavaScriptApplicationView = (
|
||||
input_path: analysis.input_path,
|
||||
format: analysis.format,
|
||||
root_artifact_sha256: analysis.root_artifact_sha256,
|
||||
...(analysis.source_selection === undefined
|
||||
? {}
|
||||
: {
|
||||
source_selection: analysis.source_selection,
|
||||
}),
|
||||
statistics: jsonObjectSchema.parse(analysis.statistics),
|
||||
electron: jsonObjectSchema.parse(analysis.summary),
|
||||
coverage: jsonObjectSchema.parse({
|
||||
|
||||
@@ -16,6 +16,10 @@ import { integrityContradictionSchema } from "../artifactGraph.js";
|
||||
import { artifactIntegrityPolicySchema } from "../artifactIntegrityPolicy.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { javaScriptAnalysisResourceControlsSchema } from "./javascriptAnalysisResourceControls.js";
|
||||
import {
|
||||
javaScriptSourcePathsSchema,
|
||||
javaScriptSourceSelectionSchema,
|
||||
} from "./javascriptSourceSelection.js";
|
||||
|
||||
const countSchema = z.number().int().min(0);
|
||||
|
||||
@@ -24,6 +28,7 @@ export const analyzeJavaScriptApplicationInputSchema = z.strictObject({
|
||||
input_path: localPathStringSchema,
|
||||
format: z.enum(["auto", "asar", "directory"]).default("auto"),
|
||||
integrity_policy: artifactIntegrityPolicySchema,
|
||||
source_paths: javaScriptSourcePathsSchema.optional(),
|
||||
...javaScriptAnalysisResourceControlsSchema.shape,
|
||||
});
|
||||
|
||||
@@ -71,6 +76,7 @@ const applicationAnalysisResultShape = z.strictObject({
|
||||
inventory_manifest_id: prefixedDigestSchema("agm"),
|
||||
inventory_graph_sha256: digestSchema,
|
||||
integrity_contradictions: z.array(integrityContradictionSchema),
|
||||
source_selection: javaScriptSourceSelectionSchema.exactOptional(),
|
||||
graph: javascriptApplicationGraphSchema,
|
||||
summary: electronBoundarySummarySchema,
|
||||
statistics: reconstructionStatisticsSchema,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { JavaScriptSourceSelection } from "./javascriptSourceSelection.js";
|
||||
|
||||
/** Relevant file categories projected from the complete artifact inventory. */
|
||||
export type JavaScriptArtifactFileKind =
|
||||
| "package-json"
|
||||
@@ -20,7 +22,10 @@ export interface JavaScriptArtifactFile {
|
||||
| { readonly included: true; readonly value: string }
|
||||
| {
|
||||
readonly included: false;
|
||||
readonly reason: "not-applicable" | "invalid-utf8";
|
||||
readonly reason:
|
||||
| "not-applicable"
|
||||
| "invalid-utf8"
|
||||
| "outside-analysis-scope";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,4 +43,5 @@ export interface JavaScriptArtifactFileSet {
|
||||
readonly containers: readonly JavaScriptArtifactContainer[];
|
||||
readonly text_bytes_read: number;
|
||||
readonly invalid_utf8_files: number;
|
||||
readonly source_selection?: JavaScriptSourceSelection;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Exact inventoried paths selected for text acquisition and static analysis. */
|
||||
export const javaScriptSourcePathsSchema = z
|
||||
.array(z.string().min(1))
|
||||
.min(1)
|
||||
.describe(
|
||||
"Exact relevant file paths from the artifact inventory, relative to the application root, analyzed in first-request order before unselected scopes; no globs or implicit dependencies. Omit to analyze all sources.",
|
||||
);
|
||||
|
||||
/** Caller scope retained independently of analysis success or completeness. */
|
||||
export const javaScriptSourceSelectionSchema = z.strictObject({
|
||||
requested_paths: javaScriptSourcePathsSchema,
|
||||
unselected_files: z.number().int().min(0),
|
||||
});
|
||||
|
||||
/** Explicit selected-source boundary for an otherwise complete inventory. */
|
||||
export type JavaScriptSourceSelection = z.infer<
|
||||
typeof javaScriptSourceSelectionSchema
|
||||
>;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { writeJavaScriptSourceSelectionFixture } from "../../fixtures/javascriptSourceSelection.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.for([
|
||||
{ format: "directory", command: "analyze" },
|
||||
{ format: "asar", command: "analyze-javascript-application" },
|
||||
])(
|
||||
"analyzes selected $format sources through $command with complete artifact identity",
|
||||
{ timeout: 60_000 },
|
||||
async ({ format, command }, { cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-js-source-selection-");
|
||||
const files = await writeJavaScriptSourceSelectionFixture(directory);
|
||||
let input = directory;
|
||||
if (format === "asar") {
|
||||
input = join(
|
||||
await createTestTempDirectory("rea-js-selected-asar-"),
|
||||
"app.asar",
|
||||
);
|
||||
await createPackageWithOptions(directory, input, {});
|
||||
}
|
||||
const requested = ["main.mjs", "package.json", "main.mjs"];
|
||||
const response = await cli.run({
|
||||
arguments: [
|
||||
command,
|
||||
input,
|
||||
...requested.flatMap((path) => ["--source-path", path]),
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
});
|
||||
expect(response.exitCode).toBe(0);
|
||||
const evidence = parseEvidence(response.json);
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(result.source_selection).toEqual({
|
||||
requested_paths: requested,
|
||||
unselected_files: 4,
|
||||
});
|
||||
expect(evidence.parameters.source_paths).toEqual(requested);
|
||||
expect(result.statistics).toMatchObject({
|
||||
relevant_files: 6,
|
||||
parsed_javascript_files: 1,
|
||||
invalid_utf8_files: 0,
|
||||
parse_failures: 0,
|
||||
text_bytes_read:
|
||||
files.get("main.mjs")!.length + files.get("package.json")!.length,
|
||||
});
|
||||
expect(result.graph.coverage.status).toBe("partial");
|
||||
expect(result.semantic_graph.coverage).toMatchObject({
|
||||
status: "partial",
|
||||
truncated: false,
|
||||
});
|
||||
expect(
|
||||
result.semantic_graph.nodes.some(
|
||||
({ kind, label }) => kind === "function" && label === "selected",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.semantic_graph.unknowns.some(
|
||||
({ reason, detail }) =>
|
||||
reason === "incomplete-module" &&
|
||||
detail.includes("caller-selected source_paths"),
|
||||
),
|
||||
).toBe(true);
|
||||
for (const [path, bytes] of files) {
|
||||
const properties = result.graph.nodes
|
||||
.flatMap(({ observations }) =>
|
||||
observations.map(({ properties }) => properties),
|
||||
)
|
||||
.find(
|
||||
(properties) =>
|
||||
properties.path === path && properties.text_status !== undefined,
|
||||
);
|
||||
expect(properties?.text_status).toBe(
|
||||
requested.includes(path) ? "included" : "outside-analysis-scope",
|
||||
);
|
||||
expect(properties?.parse_status).toBe(
|
||||
path === "main.mjs" ? "complete" : null,
|
||||
);
|
||||
expect(await readFile(join(directory, path))).toEqual(bytes);
|
||||
}
|
||||
const fullResponse = await cli.run({
|
||||
arguments: [command, input, "--format", "json"],
|
||||
});
|
||||
expect(fullResponse.exitCode).toBe(0);
|
||||
const full = javascriptApplicationAnalysisResultSchema.parse(
|
||||
parseEvidence(fullResponse.json).normalized_result,
|
||||
);
|
||||
expect(full.source_selection).toBeUndefined();
|
||||
expect(full.statistics.invalid_utf8_files).toBe(1);
|
||||
expect(full.statistics.parse_failures).toBeGreaterThan(0);
|
||||
expect([
|
||||
result.root_artifact_sha256,
|
||||
result.inventory_manifest_id,
|
||||
result.inventory_graph_sha256,
|
||||
]).toEqual([
|
||||
full.root_artifact_sha256,
|
||||
full.inventory_manifest_id,
|
||||
full.inventory_graph_sha256,
|
||||
]);
|
||||
if (format === "asar")
|
||||
expect(result.root_artifact_sha256).toBe(
|
||||
createHash("sha256")
|
||||
.update(await readFile(input))
|
||||
.digest("hex"),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"rejects source selection when an explicit provider chooses the native lane",
|
||||
async ({ cli }) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-js-source-native-refusal-",
|
||||
);
|
||||
await writeJavaScriptSourceSelectionFixture(directory);
|
||||
const response = await cli.run({
|
||||
arguments: [
|
||||
"analyze",
|
||||
directory,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--source-path",
|
||||
"main.mjs",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
});
|
||||
expect(response.exitCode).toBe(1);
|
||||
expect(response.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [{ path: ["source_paths"], reason: "invalid_value" }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { writeJavaScriptSourceSelectionFixture } from "../../fixtures/javascriptSourceSelection.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const createSelectionClient = () => {
|
||||
const client = new Client({
|
||||
name: "javascript-source-selection",
|
||||
version: "1",
|
||||
});
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [resolve("scripts/rea.mjs"), "mcp"],
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
NODE_OPTIONS: "--max-old-space-size=512 --max-semi-space-size=8",
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
return { client, transport };
|
||||
};
|
||||
|
||||
it("advertises exact source selection and retains its scope through summary, Evidence retrieval and invalid-input recovery", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-selected-mcp-");
|
||||
await writeJavaScriptSourceSelectionFixture(root);
|
||||
const { client, transport } = createSelectionClient();
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
const definition = tools.tools.find(
|
||||
({ name }) => name === "analyze_javascript_application",
|
||||
);
|
||||
const schema = definition?.inputSchema;
|
||||
if (schema === undefined)
|
||||
throw new Error("Missing advertised application contract");
|
||||
expect(schema.properties?.source_paths).toBeDefined();
|
||||
const validate = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile(schema);
|
||||
expect(validate({ input_path: root, source_paths: ["main.mjs"] })).toBe(
|
||||
true,
|
||||
);
|
||||
for (const source_paths of [[], [""], [1]])
|
||||
expect(validate({ input_path: root, source_paths })).toBe(false);
|
||||
const selected = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: {
|
||||
input_path: root,
|
||||
source_paths: ["main.mjs", "package.json"],
|
||||
detail: "summary",
|
||||
},
|
||||
});
|
||||
expect(selected.isError, JSON.stringify(selected.content)).not.toBe(true);
|
||||
if (definition?.outputSchema === undefined)
|
||||
throw new Error("Missing advertised application output contract");
|
||||
const validateOutput = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile(
|
||||
z.record(z.string(), z.unknown()).parse(definition.outputSchema),
|
||||
);
|
||||
expect(
|
||||
validateOutput(selected.structuredContent),
|
||||
JSON.stringify(validateOutput.errors),
|
||||
).toBe(true);
|
||||
const summary = z
|
||||
.object({
|
||||
normalized_result: z.object({
|
||||
parent_evidence_id: z.string(),
|
||||
summary: z.object({
|
||||
source_selection: z.object({
|
||||
requested_paths: z.array(z.string()),
|
||||
unselected_files: z.number(),
|
||||
}),
|
||||
coverage: z.object({
|
||||
application: z.object({ status: z.string() }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.parse(selected.structuredContent).normalized_result;
|
||||
expect(summary.summary.source_selection).toEqual({
|
||||
requested_paths: ["main.mjs", "package.json"],
|
||||
unselected_files: 4,
|
||||
});
|
||||
expect(summary.summary.coverage.application.status).toBe("partial");
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(bundle.isError).not.toBe(true);
|
||||
const retained = z
|
||||
.object({ result: z.object({ records: z.array(z.unknown()) }) })
|
||||
.parse(bundle.structuredContent)
|
||||
.result.records.map(parseEvidence)
|
||||
.find(({ evidence_id }) => evidence_id === summary.parent_evidence_id);
|
||||
expect(retained).toBeDefined();
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
retained!.normalized_result,
|
||||
);
|
||||
expect(result.statistics.parsed_javascript_files).toBe(1);
|
||||
expect(result.source_selection).toEqual(summary.summary.source_selection);
|
||||
expect(
|
||||
result.semantic_graph.nodes.some(
|
||||
({ kind, label }) => kind === "function" && label === "selected",
|
||||
),
|
||||
).toBe(true);
|
||||
for (const source_paths of [
|
||||
["missing.mjs"],
|
||||
["./main.mjs"],
|
||||
["../main.mjs"],
|
||||
["*.mjs"],
|
||||
]) {
|
||||
const invalid = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: { input_path: root, source_paths },
|
||||
});
|
||||
expect(invalid.isError).toBe(true);
|
||||
const text = invalid.content.find((item) => item.type === "text");
|
||||
if (text?.type !== "text")
|
||||
throw new Error("Missing source-selection correction");
|
||||
expect(JSON.parse(text.text)).toMatchObject({
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [{ path: ["source_paths", 0], reason: "invalid_value" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.ping();
|
||||
}
|
||||
const next = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: { input_path: root, source_paths: ["main.mjs"] },
|
||||
});
|
||||
expect(next.isError).not.toBe(true);
|
||||
expect(
|
||||
javascriptApplicationAnalysisResultSchema.parse(
|
||||
parseEvidence(next.structuredContent).normalized_result,
|
||||
).statistics.parsed_javascript_files,
|
||||
).toBe(1);
|
||||
await client.ping();
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("selects nested ASAR paths and distinguishes missing inventoried bytes from invalid paths", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-selected-containers-");
|
||||
await writeJavaScriptSourceSelectionFixture(root);
|
||||
const { client, transport } = createSelectionClient();
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const nestedRoot = await createTestTempDirectory(
|
||||
"rea-js-nested-selection-",
|
||||
);
|
||||
await createPackageWithOptions(root, join(nestedRoot, "nested.asar"), {});
|
||||
const nested = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: {
|
||||
input_path: nestedRoot,
|
||||
source_paths: ["nested.asar/main.mjs"],
|
||||
},
|
||||
});
|
||||
expect(nested.isError).not.toBe(true);
|
||||
const nestedResult = javascriptApplicationAnalysisResultSchema.parse(
|
||||
parseEvidence(nested.structuredContent).normalized_result,
|
||||
);
|
||||
expect(nestedResult.source_selection).toEqual({
|
||||
requested_paths: ["nested.asar/main.mjs"],
|
||||
unselected_files: 5,
|
||||
});
|
||||
expect(nestedResult.statistics).toMatchObject({
|
||||
nested_asar_containers: 1,
|
||||
parsed_javascript_files: 1,
|
||||
parse_failures: 0,
|
||||
});
|
||||
expect(
|
||||
nestedResult.semantic_graph.nodes.some(
|
||||
({ identity, kind, label }) =>
|
||||
identity.module_path === "nested.asar/main.mjs" &&
|
||||
kind === "function" &&
|
||||
label === "selected",
|
||||
),
|
||||
).toBe(true);
|
||||
const unpacked = join(
|
||||
await createTestTempDirectory("rea-js-unavailable-selection-"),
|
||||
"app.asar",
|
||||
);
|
||||
await createPackageWithOptions(root, unpacked, { unpack: "main.mjs" });
|
||||
await rm(join(unpacked + ".unpacked", "main.mjs"));
|
||||
const unavailable = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: { input_path: unpacked, source_paths: ["main.mjs"] },
|
||||
});
|
||||
expect(unavailable.isError).toBe(true);
|
||||
const unavailableText = unavailable.content.find(
|
||||
(item) => item.type === "text",
|
||||
);
|
||||
if (unavailableText?.type !== "text")
|
||||
throw new Error("Missing selected-source availability reason");
|
||||
expect(JSON.parse(unavailableText.text)).toMatchObject({
|
||||
error: {
|
||||
code: "artifact_operation_failed",
|
||||
details: { reason: "unavailable" },
|
||||
},
|
||||
});
|
||||
await client.ping();
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
}, 60_000);
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Selected entry sources with unrelated malformed text and source-map inputs. */
|
||||
export const writeJavaScriptSourceSelectionFixture = async (root: string) => {
|
||||
const sources = new Map<string, Buffer>([
|
||||
[
|
||||
"main.mjs",
|
||||
Buffer.from(
|
||||
'import "./vendor.js"; export function selected(value) { return value + 1; } throw new Error("must never execute");\n',
|
||||
),
|
||||
],
|
||||
[
|
||||
"package.json",
|
||||
Buffer.from('{"name":"rea-selected-source-fixture","main":"main.mjs"}\n'),
|
||||
],
|
||||
["vendor.js", Buffer.from("export const unrelated = ;\n")],
|
||||
["invalid.mjs", Buffer.from([0xff, 0xfe, 0x80])],
|
||||
["broken.json", Buffer.from("{")],
|
||||
["main.mjs.map", Buffer.from("{")],
|
||||
]);
|
||||
for (const [path, bytes] of sources) await writeFile(join(root, path), bytes);
|
||||
return sources;
|
||||
};
|
||||
Reference in New Issue
Block a user