fix(javascript): report application analysis progress on the CLI (#1008)

* fix(javascript): report application analysis progress on the CLI

* test(cli): preserve progress in filtered output checks
This commit is contained in:
Ysopwnable 2026-10-08 03:19:37 +08:00 committed by GitHub
parent 51956c1a49
commit 7b18c58140
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 138 additions and 9 deletions

View File

@ -31,6 +31,11 @@ command and the generic route return their complete results inline. Native
targets, single JavaScript files, `.app` bundles, and explicit deep-provider or
snapshot requests retain the native deep-analysis route.
Both CLI routes emit `rea_progress` JSON lines on stderr as inventory, source
parsing, graph construction, validation, and Evidence hashing begin. Stdout
remains the selected result document. Completion is reported after Evidence
creation; a failed analysis retains its typed diagnostic result.
Configure an MCP client with the ordinary REA setup command:
```bash

View File

@ -57,13 +57,27 @@ export const analyzeJavaScriptApplicationValidated = async (
format: input.format,
},
options.signal,
options.progress,
);
const { electron_summary: summary, ...application } = reconstructed;
await options.progress?.report({
phase: "validate_javascript_application_result",
completed: 0,
total: 1,
message: "Validating the application analysis result",
});
const result = javascriptApplicationAnalysisResultSchema.parse({
...application,
summary,
limitations: reconstructed.graph.limitations,
});
await options.progress?.report({
phase: "create_javascript_application_evidence",
completed: 0,
total: 1,
message: "Creating and hashing application analysis Evidence",
});
const evidence = createJavaScriptApplicationEvidence(input, result);
await options.progress?.report({
phase: "analyze_javascript_application",
completed: 1,
@ -71,7 +85,7 @@ export const analyzeJavaScriptApplicationValidated = async (
message: "Application graph and Electron boundaries reconstructed",
terminal: true,
});
return ok(createJavaScriptApplicationEvidence(input, result));
return ok(evidence);
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure)
return err(

View File

@ -15,6 +15,7 @@ import {
import { scanCanonicalArtifactInventory } from "../ArtifactInventory.js";
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import type { ProgressReporter } from "../ProgressReporter.js";
/** Application-layer result retaining local diagnostics outside the canonical graph. */
export interface JavaScriptArtifactReconstructionResult {
@ -45,7 +46,11 @@ export interface JavaScriptArtifactReconstructionResult {
export const reconstructJavaScriptArtifact = async (
rawInput: unknown,
signal?: AbortSignal,
progress?: ProgressReporter,
): Promise<JavaScriptArtifactReconstructionResult> => {
const reportPhase = async (phase: string, message: string): Promise<void> => {
await progress?.report({ phase, completed: 0, total: 1, message });
};
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
abortIfNeeded(signal);
const path = await realpath(input.input_path);
@ -58,10 +63,26 @@ export const reconstructJavaScriptArtifact = async (
);
const reader = createReader(path, format);
try {
await reportPhase(
"read_javascript_artifacts",
"Reading inventoried JavaScript application sources",
);
const files = await readJavaScriptArtifactFiles(reader, snapshot, signal);
await reportPhase(
"parse_javascript_sources",
`Parsing ${String(files.files.length)} application source files`,
);
const analysis = analyzeJavaScriptArtifactFiles(files);
abortIfNeeded(signal);
await reportPhase(
"build_javascript_application_graph",
"Constructing application and Electron boundary relationships",
);
const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis);
await reportPhase(
"build_javascript_semantic_graph",
"Constructing static semantic relationships",
);
const semanticGraph = buildJavaScriptSemanticGraph({
rootArtifactSha256: snapshot.manifest.root_sha256,
applicationGraph: graph,

View File

@ -1,4 +1,5 @@
import { analyzeJavaScriptApplication } from "../application/javascript/JavaScriptApplicationService.js";
import { createProgressReporter } from "../application/ProgressReporter.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { JsonValue } from "../domain/jsonValue.js";
@ -6,7 +7,13 @@ import type { JsonValue } from "../domain/jsonValue.js";
export const runCliJavaScriptApplicationAnalysis = async (
input: unknown,
): Promise<JsonValue> => {
const result = await analyzeJavaScriptApplication(input);
const progress = createProgressReporter(
async (update) => {
process.stderr.write(`${JSON.stringify({ rea_progress: update })}\n`);
},
{ minimumIntervalMs: 0 },
);
const result = await analyzeJavaScriptApplication(input, { progress });
return result.ok ? result.value : cliError(result.error);
};

View File

@ -245,7 +245,7 @@ describe("compiled CLI output boundary", () => {
expect(result).toMatchObject({
exitCode: 0,
stdout: "{}\n",
stderr: "",
stderr: expect.stringContaining('"rea_progress":'),
json: {},
});
const nested = await cli.run({
@ -260,7 +260,7 @@ describe("compiled CLI output boundary", () => {
});
expect(nested).toMatchObject({
exitCode: 0,
stderr: "",
stderr: expect.stringContaining('"rea_progress":'),
json: { normalized_result: {} },
});
const selected = await cli.run({
@ -275,7 +275,7 @@ describe("compiled CLI output boundary", () => {
});
expect(selected).toMatchObject({
exitCode: 0,
stderr: "",
stderr: expect.stringContaining('"rea_progress":'),
json: {
normalized_result: {
summary: expect.objectContaining({ browser_windows: 0 }),

View File

@ -0,0 +1,72 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const progressLine = z.object({
rea_progress: z.object({
phase: z.string(),
completed: z.number(),
total: z.number().nullable(),
message: z.string(),
sequence: z.number(),
terminal: z.boolean().optional(),
}),
});
for (const command of ["analyze-javascript-application", "analyze"]) {
cliTest(
`${command} reports real analysis phases on stderr and preserves stdout Evidence`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-js-cli-progress-");
await writeFile(join(root, "main.js"), "fetch('');\n");
const direct = await analyzeJavaScriptApplication({ input_path: root });
if (!direct.ok) throw direct.error;
const output = await cli.run({ arguments: [command, root, "--json"] });
expect(output.exitCode).toBe(0);
expect(parseEvidence(output.json).evidence_id).toBe(
direct.value.evidence_id,
);
const updates = output.stderr
.trim()
.split("\n")
.map((line) => progressLine.parse(JSON.parse(line)).rea_progress);
expect(updates[0]?.completed).toBe(0);
expect(
updates.some(({ phase }) => phase === "parse_javascript_sources"),
).toBe(true);
expect(updates.at(-1)).toMatchObject({ completed: 1, terminal: true });
expect(
updates.slice(0, -1).every(({ terminal }) => terminal !== true),
).toBe(true);
},
);
}
cliTest(
"keeps a failed analysis distinct from a completed result",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-js-cli-progress-failure-");
const output = await cli.run({
arguments: [
"analyze-javascript-application",
join(root, "absent"),
"--json",
],
});
expect(output.json).toMatchObject({ code: "artifact_operation_failed" });
const updates = output.stderr
.trim()
.split("\n")
.map((line) => progressLine.parse(JSON.parse(line)).rea_progress);
expect(updates[0]?.completed).toBe(0);
expect(updates.every(({ terminal }) => terminal !== true)).toBe(true);
},
);

View File

@ -10,6 +10,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import type { ElectronActiveObservationPort } from "../../../src/application/javascript/ElectronActiveObservationPort.js";
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
import { CdpElectronProvider } from "../../../src/browser/CdpElectronProvider.js";
import { createServer } from "../../../src/server/createServer.js";
import { observed } from "../../fixtures/analysisExecution.js";
@ -270,12 +271,18 @@ it("exposes the target-free static JavaScript application workflow", async () =>
await server.connect(serverTransport);
await client.connect(clientTransport);
const analyzed = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root },
});
const progress: number[] = [];
const analyzed = await client.callTool(
{
name: "analyze_javascript_application",
arguments: { input_path: root },
},
{ onprogress: (update) => progress.push(update.progress) },
);
expect(analyzed.isError).not.toBe(true);
expect(progress[0]).toBe(0);
expect(progress.at(-1)).toBe(1);
const projected = z
.object({
evidence_id: z.string(),
@ -314,6 +321,9 @@ it("exposes the target-free static JavaScript application workflow", async () =>
expect(projected.result.graph.nodes.length).toBeGreaterThan(0);
expect(projected.result.semantic_graph.nodes.length).toBeGreaterThan(0);
expect(projected.evidence.operation).toBe("analyze_javascript_application");
const direct = await analyzeJavaScriptApplication({ input_path: root });
if (!direct.ok) throw direct.error;
expect(projected.evidence_id).toBe(direct.value.evidence_id);
});
const evidenceFor = (value: unknown) => {