diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 97d6cd41a..42d40e938 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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 diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 6339379db..b1b180383 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -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( diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 7acf03b05..0be85beb5 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -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 => { + const reportPhase = async (phase: string, message: string): Promise => { + 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, diff --git a/src/cli/javascriptApplicationAnalysis.ts b/src/cli/javascriptApplicationAnalysis.ts index b1f08da9d..62a8bc5b0 100644 --- a/src/cli/javascriptApplicationAnalysis.ts +++ b/src/cli/javascriptApplicationAnalysis.ts @@ -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 => { - 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); }; diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index c31ebf47a..783097b02 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -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 }), diff --git a/tests/boundary/cli/javascriptApplicationProgress.test.ts b/tests/boundary/cli/javascriptApplicationProgress.test.ts new file mode 100644 index 000000000..a4a0863e4 --- /dev/null +++ b/tests/boundary/cli/javascriptApplicationProgress.test.ts @@ -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); + }, +); diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts index 72d8892ed..4cc589e70 100644 --- a/tests/boundary/mcp/electronMcp.test.ts +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -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) => {