Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02fe120523 | ||
|
|
d28336c6e5 |
@@ -13,8 +13,6 @@ Prioritize:
|
||||
|
||||
Installers must not install or upgrade Homebrew, Node.js, npm, or other unrelated software. `rea setup` must print its planned changes and require approval before writing files or installing Hopper.
|
||||
|
||||
REA is a local-only tool; do not sanitize actionable local diagnostics such as artifact paths, digests, mismatch locations, or analysis metadata, while continuing to redact genuine secrets such as credentials and authorization headers.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
REA is a layered ESM TypeScript application. Dependencies flow inward: `domain` ← `contracts` ← `hopper` ← `application` ← `server` ← adapters.
|
||||
|
||||
@@ -420,27 +420,6 @@ rea mcp
|
||||
|
||||
REA accepts a Mac `.app` folder directly. If an agent cannot find an app by name, tell it where the app is installed.
|
||||
|
||||
### CLI exit status
|
||||
|
||||
| Status | Meaning |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `0` | The requested operation completed. Truthful unknowns, warnings, and bounded or truncated evidence remain successful results. |
|
||||
| `1` | Arguments, policy, permission, provider analysis, integrity checking, cancellation, timeout, setup, diagnostics, update, uninstall, output encoding, or output writing prevented completion. Structured output identifies the failure category when REA could encode it. |
|
||||
| `128 + N` | The process ended from signal `N`, where the shell or runtime preserves the conventional signal-derived status. |
|
||||
|
||||
`setup` returns `1` for `planned`, `needs_confirmation`, or `needs_human`
|
||||
because configuration is not ready; rerun it after approval or remediation.
|
||||
`doctor` returns `1` when any check is unhealthy. Output format, full envelopes,
|
||||
filters, and token controls never change the operation status.
|
||||
|
||||
When REA feeds a shell pipeline, enable `pipefail` so a downstream formatter
|
||||
cannot hide its failure:
|
||||
|
||||
```bash
|
||||
set -o pipefail
|
||||
rea inventory-artifact ./app.asar --json | jq . > inventory.json
|
||||
```
|
||||
|
||||
## Current Hopper provider
|
||||
|
||||
REA starts Hopper when needed; Hopper does not need to be running first. Hopper's launcher internally activates the application, so opening a target may bring Hopper to the foreground. REA asks macOS to start Hopper hidden and in the background when possible, but cannot guarantee that it will remain behind the current application.
|
||||
|
||||
+14
-56
@@ -114,12 +114,7 @@ try {
|
||||
const cli = join(prefix, "bin", "rea");
|
||||
const help = await run(cli, ["--help"], environment);
|
||||
const llms = await run(cli, ["--llms"], environment);
|
||||
const doctorExecution = await runWithStatus(
|
||||
cli,
|
||||
["doctor", "--json"],
|
||||
environment,
|
||||
);
|
||||
const doctor = json(doctorExecution.stdout);
|
||||
const doctor = json(await run(cli, ["doctor", "--json"], environment));
|
||||
const supportedSetupHost =
|
||||
doctor.checks?.find(({ name }) => name === "host")?.ok === true;
|
||||
const expectedDoctorHealth = doctor.checks?.every(({ ok }) => ok) === true;
|
||||
@@ -141,7 +136,6 @@ try {
|
||||
!llms.includes("providers") ||
|
||||
!llms.includes("inventory-artifact") ||
|
||||
doctor.healthy !== expectedDoctorHealth ||
|
||||
doctorExecution.status !== (expectedDoctorHealth ? 0 : 1) ||
|
||||
doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true
|
||||
)
|
||||
throw new Error(
|
||||
@@ -334,16 +328,10 @@ try {
|
||||
await writeFile(skillPath, "stale managed skill\n");
|
||||
await writeFile(siblingSkillPath, "unrelated skill\n");
|
||||
}
|
||||
const plannedExecution = await runWithStatus(
|
||||
cli,
|
||||
["setup", "--json"],
|
||||
environment,
|
||||
);
|
||||
const planned = json(plannedExecution.stdout);
|
||||
const planned = json(await run(cli, ["setup", "--json"], environment));
|
||||
if (supportedSetupHost) {
|
||||
if (
|
||||
planned.status !== "needs_confirmation" ||
|
||||
plannedExecution.status !== 1 ||
|
||||
planned.appliedActions.length !== 0 ||
|
||||
!planned.plannedActions.some(({ kind }) => kind === "configure_client") ||
|
||||
!planned.plannedActions.some(({ kind }) => kind === "install_skill") ||
|
||||
@@ -353,25 +341,19 @@ try {
|
||||
)
|
||||
throw new Error("packaged setup plan mutated files before approval");
|
||||
}
|
||||
const firstExecution = await runWithStatus(
|
||||
cli,
|
||||
["setup", "--yes", "--json"],
|
||||
environment,
|
||||
const first = json(await run(cli, ["setup", "--yes", "--json"], environment));
|
||||
const second = json(
|
||||
await run(
|
||||
cli,
|
||||
["setup", "--yes", "--install-hopper", "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
const first = json(firstExecution.stdout);
|
||||
const secondExecution = await runWithStatus(
|
||||
cli,
|
||||
["setup", "--yes", "--install-hopper", "--json"],
|
||||
environment,
|
||||
);
|
||||
const second = json(secondExecution.stdout);
|
||||
if (supportedSetupHost) {
|
||||
const status = process.platform === "linux" ? "needs_human" : "ready";
|
||||
if (
|
||||
first.status !== status ||
|
||||
second.status !== status ||
|
||||
firstExecution.status !== (status === "ready" ? 0 : 1) ||
|
||||
secondExecution.status !== (status === "ready" ? 0 : 1) ||
|
||||
second.appliedActions.length !== 0
|
||||
)
|
||||
throw new Error("packaged setup was not ready and idempotent");
|
||||
@@ -405,29 +387,19 @@ try {
|
||||
throw new Error("packaged stale-skill upgrade was not isolated");
|
||||
|
||||
await writeFile(cursorConfig, "malformed");
|
||||
const failedExecution = await runWithStatus(
|
||||
cli,
|
||||
["setup", "--yes", "--json"],
|
||||
environment,
|
||||
const failed = json(
|
||||
await run(cli, ["setup", "--yes", "--json"], environment),
|
||||
);
|
||||
const failed = json(failedExecution.stdout);
|
||||
if (
|
||||
failed.status !== "needs_human" ||
|
||||
failedExecution.status !== 1 ||
|
||||
(await readFile(cursorConfig, "utf8")) !== "malformed"
|
||||
)
|
||||
throw new Error("packaged setup failure recovery did not preserve input");
|
||||
await writeFile(cursorConfig, "{}\n");
|
||||
const recoveredExecution = await runWithStatus(
|
||||
cli,
|
||||
["setup", "--yes", "--json"],
|
||||
environment,
|
||||
const recovered = json(
|
||||
await run(cli, ["setup", "--yes", "--json"], environment),
|
||||
);
|
||||
const recovered = json(recoveredExecution.stdout);
|
||||
if (
|
||||
recovered.status !== status ||
|
||||
recoveredExecution.status !== (status === "ready" ? 0 : 1)
|
||||
)
|
||||
if (recovered.status !== status)
|
||||
throw new Error("packaged setup did not recover");
|
||||
} else if (
|
||||
first.status !== "needs_human" ||
|
||||
@@ -531,20 +503,6 @@ try {
|
||||
async function run(command, args, env) {
|
||||
return (await exec(command, args, { env })).stdout;
|
||||
}
|
||||
async function runWithStatus(command, args, env) {
|
||||
try {
|
||||
return { stdout: await run(command, args, env), status: 0 };
|
||||
} catch (cause) {
|
||||
if (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
cause.code === 1 &&
|
||||
typeof cause.stdout === "string"
|
||||
)
|
||||
return { stdout: cause.stdout, status: 1 };
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
function json(text) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,19 @@ export interface ProcessCliErrorOutput {
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Distinguish a safe process-command failure from successful Evidence. */
|
||||
export const isProcessCliErrorOutput = (
|
||||
value: unknown,
|
||||
): value is ProcessCliErrorOutput =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"error" in value &&
|
||||
value.error === "Process command failed" &&
|
||||
"category" in value &&
|
||||
typeof value.category === "string" &&
|
||||
"message" in value &&
|
||||
typeof value.message === "string";
|
||||
|
||||
/** Capture one JSON scenario through the same policy and evidence contract as MCP. */
|
||||
export const captureProcessScenarioFile = async (path: string) => {
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Cli, z } from "incur";
|
||||
import { runCrossVersionInvestigation } from "./application/CrossVersionInvestigation.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import { projectAnalysisError, type AnalysisError } from "./domain/errors.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const investigationOptionsSchema = z.object({
|
||||
@@ -75,7 +74,8 @@ const runInvestigation = async (
|
||||
message: "Persistent workspace writes require explicit --yes approval.",
|
||||
};
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
if (!config.ok)
|
||||
return { error: config.error._tag, message: config.error.message };
|
||||
const investigated = await runCrossVersionInvestigation(
|
||||
{
|
||||
approved: true,
|
||||
@@ -102,10 +102,8 @@ const runInvestigation = async (
|
||||
);
|
||||
return investigated.ok
|
||||
? investigated.value.evidence
|
||||
: cliError(investigated.error);
|
||||
: {
|
||||
error: investigated.error._tag,
|
||||
message: investigated.error.message,
|
||||
};
|
||||
};
|
||||
|
||||
const cliError = (error: AnalysisError) => ({
|
||||
error: "Analysis failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
|
||||
+3
-21
@@ -1,21 +1,5 @@
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Identify caller-visible CLI outcomes that mean the requested operation failed. */
|
||||
export const isCliOperationFailure = (value: unknown): boolean => {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.error === "string") return true;
|
||||
if (value.healthy === false) return true;
|
||||
return (
|
||||
value.status === "failed" ||
|
||||
value.status === "needs_confirmation" ||
|
||||
value.status === "needs_human" ||
|
||||
value.status === "planned"
|
||||
);
|
||||
};
|
||||
|
||||
/** Log one CLI command with duration and a stable success or failure status. */
|
||||
export const logCliCommand = async <Value>(
|
||||
logger: Logger,
|
||||
@@ -25,16 +9,14 @@ export const logCliCommand = async <Value>(
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const value = await execute();
|
||||
const failed = isCliOperationFailure(value);
|
||||
logger[failed ? "error" : "info"](
|
||||
logger.info(
|
||||
{
|
||||
command,
|
||||
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
status: failed ? "error" : "ok",
|
||||
status: "ok",
|
||||
},
|
||||
failed ? "CLI command failed" : "CLI command completed",
|
||||
"CLI command completed",
|
||||
);
|
||||
if (failed) process.exitCode = 1;
|
||||
return value;
|
||||
} catch (cause: unknown) {
|
||||
logger.error(
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { Cli, z } from "incur";
|
||||
import { Cli, Errors, z } from "incur";
|
||||
|
||||
import {
|
||||
captureProcessScenarioFile,
|
||||
compareProcessEvidenceFiles,
|
||||
isProcessCliErrorOutput,
|
||||
} from "./application/ProcessCli.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const requireProcessCommandSuccess = async <Value>(
|
||||
execute: () => Promise<Value>,
|
||||
): Promise<Value> => {
|
||||
const value = await execute();
|
||||
if (isProcessCliErrorOutput(value))
|
||||
throw new Errors.IncurError({
|
||||
code: "PROCESS_COMMAND_FAILED",
|
||||
message: value.message,
|
||||
retryable: false,
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Register Process Capture v4 one-shot commands through shared application services. */
|
||||
export const registerProcessCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
@@ -17,7 +31,9 @@ export const registerProcessCommands = (
|
||||
args: z.object({ scenario: z.string().describe("Scenario JSON path") }),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "capture-process", () =>
|
||||
captureProcessScenarioFile(args.scenario),
|
||||
requireProcessCommandSuccess(() =>
|
||||
captureProcessScenarioFile(args.scenario),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command("compare-process-captures", {
|
||||
@@ -28,7 +44,9 @@ export const registerProcessCommands = (
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "compare-process-captures", () =>
|
||||
compareProcessEvidenceFiles(args.left, args.right),
|
||||
requireProcessCommandSuccess(() =>
|
||||
compareProcessEvidenceFiles(args.left, args.right),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -191,15 +191,6 @@ const analysisErrorProjectionSchema = z
|
||||
"execution_failure",
|
||||
]),
|
||||
message: z.string(),
|
||||
details: z
|
||||
.object({
|
||||
logical_path: z.string(),
|
||||
declared_sha256: z.string().nullable(),
|
||||
calculated_sha256: z.string().nullable(),
|
||||
unpacked: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
code: z
|
||||
.enum([
|
||||
"private_display_unavailable",
|
||||
|
||||
+2
-13
@@ -3,7 +3,8 @@ import {
|
||||
hopperStartupFailure,
|
||||
type HopperStartupFailureCode,
|
||||
} from "./hopperStartupFailure.js";
|
||||
export { hopperStartupFailure, type HopperStartupFailureCode };
|
||||
export { hopperStartupFailure } from "./hopperStartupFailure.js";
|
||||
export type { HopperStartupFailureCode } from "./hopperStartupFailure.js";
|
||||
|
||||
/** Stable tags exposed by safe analysis-error projections. */
|
||||
const ANALYSIS_ERROR_TAGS = [
|
||||
@@ -348,7 +349,6 @@ export interface AnalysisErrorProjection
|
||||
| "execution_failure";
|
||||
readonly message: string;
|
||||
readonly code?: HopperStartupFailureCode;
|
||||
readonly details?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Project expected failures into exhaustive, secret-safe caller fields. */
|
||||
@@ -358,21 +358,10 @@ export const projectAnalysisError = (
|
||||
assertKnownTag(error._tag);
|
||||
const code =
|
||||
error instanceof HopperProcessError ? error.failureCode : undefined;
|
||||
const artifact =
|
||||
error instanceof ArtifactOperationError && error.artifactDetails;
|
||||
const details = artifact
|
||||
? {
|
||||
logical_path: artifact.logicalPath,
|
||||
declared_sha256: artifact.declaredSha256,
|
||||
calculated_sha256: artifact.calculatedSha256,
|
||||
unpacked: artifact.unpacked,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
category: errorCategory(error),
|
||||
message: userMessage(error),
|
||||
...(code === undefined ? {} : { code }),
|
||||
...(details === undefined ? {} : { details }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ const logLevelSchema = z
|
||||
export const parseLogLevel = (value: unknown): LogLevel =>
|
||||
logLevelSchema.parse(value);
|
||||
|
||||
/** Create a JSON logger on stderr so protocol and CLI result stdout stay valid. */
|
||||
/** Create a JSON logger whose destination cannot corrupt MCP stdout frames. */
|
||||
export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger =>
|
||||
pino(
|
||||
{
|
||||
@@ -32,7 +32,7 @@ export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger =>
|
||||
censor: "[Redacted]",
|
||||
},
|
||||
},
|
||||
pino.destination({ dest: 2, sync: false }),
|
||||
pino.destination({ dest: mode === "mcp" ? 2 : 1, sync: false }),
|
||||
);
|
||||
|
||||
/** Logger used by embedders that have not opted into process output. */
|
||||
|
||||
+44
-68
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -17,75 +17,51 @@ import { createServer } from "../src/server/createServer.js";
|
||||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
|
||||
describe("artifact graph MCP integration", () => {
|
||||
it.each([
|
||||
{ unpacked: false, label: "embedded" },
|
||||
{ unpacked: true, label: "unpacked" },
|
||||
])(
|
||||
"returns actionable local details for an $label ASAR integrity error",
|
||||
async ({ unpacked }) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-asar-integrity-mcp-"));
|
||||
const source = join(root, "source");
|
||||
const original = "console.log('ok');\n";
|
||||
const changed = "console.log('no');\n";
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "main.js"), original);
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackageWithOptions(
|
||||
source,
|
||||
archive,
|
||||
unpacked ? { unpack: "*.js" } : {},
|
||||
);
|
||||
if (unpacked) {
|
||||
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
|
||||
} else {
|
||||
const bytes = await readFile(archive);
|
||||
const contentOffset = bytes.indexOf(original);
|
||||
expect(contentOffset).toBeGreaterThanOrEqual(0);
|
||||
bytes.write(changed, contentOffset, "utf8");
|
||||
await writeFile(archive, bytes);
|
||||
}
|
||||
it("returns a safe ASAR integrity error without paths or hashes", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-asar-integrity-mcp-"));
|
||||
const source = join(root, "source");
|
||||
const original = "console.log('ok');\n";
|
||||
const changed = "changed();\n";
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "main.js"), original);
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackageWithOptions(source, archive, { unpack: "*.js" });
|
||||
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
|
||||
|
||||
const session = new BinarySession(new ArtifactProvider());
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "asar-integrity-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
const session = new BinarySession(new ArtifactProvider());
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "asar-integrity-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toEqual({
|
||||
error: {
|
||||
category: "integrity_mismatch",
|
||||
message:
|
||||
"Artifact is invalid or has changed. Get a fresh copy and try again.",
|
||||
details: {
|
||||
logical_path: "main.js",
|
||||
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
unpacked,
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
const result = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toEqual({
|
||||
error: {
|
||||
category: "integrity_mismatch",
|
||||
message:
|
||||
"Artifact is invalid or has changed. Get a fresh copy and try again.",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects altered payloads that reuse session Evidence IDs", async () => {
|
||||
const session = new BinarySession(() => ({
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { isCliOperationFailure, logCliCommand } from "../src/cliLogging.js";
|
||||
import { silentLogger } from "../src/logger.js";
|
||||
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
describe("CLI operation status", () => {
|
||||
it.each([
|
||||
["typed error", { error: "Analysis failed", category: "timeout" }],
|
||||
["unhealthy diagnostic", { healthy: false, checks: [] }],
|
||||
["failed status", { status: "failed" }],
|
||||
["confirmation requirement", { status: "needs_confirmation" }],
|
||||
["human remediation", { status: "needs_human" }],
|
||||
["unapplied plan", { status: "planned" }],
|
||||
])("classifies %s as failure", (_label, value) => {
|
||||
expect(isCliOperationFailure(value)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ready setup", { status: "ready" }],
|
||||
["complete uninstall", { status: "complete" }],
|
||||
["current version", { status: "current" }],
|
||||
["completed upgrade", { status: "upgraded" }],
|
||||
["healthy diagnostics", { healthy: true, checks: [] }],
|
||||
["bounded evidence", { evidence: [{ truncated: true }] }],
|
||||
])("keeps %s successful", (_label, value) => {
|
||||
expect(isCliOperationFailure(value)).toBe(false);
|
||||
});
|
||||
|
||||
it("sets a nonzero process status without replacing structured output", async () => {
|
||||
const output = {
|
||||
error: "Analysis failed",
|
||||
category: "integrity_mismatch",
|
||||
message: "Artifact integrity check failed.",
|
||||
details: { logical_path: "main.js" },
|
||||
};
|
||||
|
||||
await expect(
|
||||
logCliCommand(silentLogger, "inventory-artifact", () =>
|
||||
Promise.resolve(output),
|
||||
),
|
||||
).resolves.toBe(output);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
+1
-81
@@ -1,10 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sanitizeCliOutput } from "../src/cliOutput.js";
|
||||
@@ -90,81 +86,5 @@ describe("CLI output boundary", () => {
|
||||
stdout:
|
||||
'ok: false\nerror:\n code: VALIDATION_ERROR\n message: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
it("preserves artifact diagnostics in ordinary and full JSON output", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-cli-diagnostics-"));
|
||||
const source = join(root, "source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "main.js"), "console.log('ok');\n");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackageWithOptions(source, archive, { unpack: "*.js" });
|
||||
await writeFile(join(`${archive}.unpacked`, "main.js"), "changed();\n");
|
||||
|
||||
for (const flags of [["--json"], ["--full-output", "--json"]]) {
|
||||
const execution = execFileAsync(process.execPath, [
|
||||
"scripts/rea.mjs",
|
||||
...flags,
|
||||
"inventory-artifact",
|
||||
archive,
|
||||
]);
|
||||
const failure = await execution.catch((cause: unknown) => cause);
|
||||
expect(failure).toMatchObject({ code: 1 });
|
||||
const { stdout } = failure as { readonly stdout: string };
|
||||
const output = JSON.stringify(JSON.parse(stdout) as unknown);
|
||||
expect(output).toContain('"logical_path":"main.js"');
|
||||
expect(output).toMatch(/"declared_sha256":"[a-f0-9]{64}"/u);
|
||||
expect(output).toMatch(/"calculated_sha256":"[a-f0-9]{64}"/u);
|
||||
expect(output).toContain('"unpacked":true');
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("keeps operation failure status independent of output controls", async () => {
|
||||
const variants = [
|
||||
["--format", "toon"],
|
||||
["--format", "json"],
|
||||
["--format", "yaml"],
|
||||
["--format", "md"],
|
||||
["--format", "jsonl"],
|
||||
["--full-output", "--json"],
|
||||
["--filter-output", "category", "--json"],
|
||||
["--token-limit", "5", "--json"],
|
||||
["--token-count", "--json"],
|
||||
];
|
||||
|
||||
for (const flags of variants) {
|
||||
await expect(
|
||||
execFileAsync(process.execPath, [
|
||||
"scripts/rea.mjs",
|
||||
...flags,
|
||||
"investigate-versions",
|
||||
"/tmp/left",
|
||||
"/tmp/right",
|
||||
"/tmp/workspace.json",
|
||||
]),
|
||||
).rejects.toMatchObject({ code: 1, stdout: expect.any(String) });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("keeps failure logs out of structured stdout", async () => {
|
||||
const execution = execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
"--json",
|
||||
"investigate-versions",
|
||||
"/tmp/left",
|
||||
"/tmp/right",
|
||||
"/tmp/workspace.json",
|
||||
],
|
||||
{ env: { ...process.env, REA_LOG_LEVEL: "error" } },
|
||||
);
|
||||
const failure = await execution.catch((cause: unknown) => cause);
|
||||
expect(failure).toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining('"status":"error"'),
|
||||
});
|
||||
const { stdout } = failure as { readonly stdout: string };
|
||||
expect(JSON.parse(stdout)).toMatchObject({ error: "ApprovalRequired" });
|
||||
}, 20_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("analysis error projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("projects every typed failure without secrets", () => {
|
||||
it("projects every typed failure without internal details", () => {
|
||||
const secretCause = new Error("secret-token");
|
||||
const byTag = {
|
||||
AnalysisProtocolError: new AnalysisProtocolError("protocol failed"),
|
||||
@@ -132,6 +132,7 @@ describe("analysis error projection", () => {
|
||||
"fixture",
|
||||
"overview",
|
||||
"AnalysisCapabilityUnavailableError",
|
||||
"main.js",
|
||||
"declaredSha256",
|
||||
])
|
||||
expect(serialized).not.toContain(hidden);
|
||||
@@ -151,12 +152,6 @@ describe("analysis error projection", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
category: "integrity_mismatch",
|
||||
details: {
|
||||
logical_path: "main.js",
|
||||
declared_sha256: "a".repeat(64),
|
||||
calculated_sha256: "b".repeat(64),
|
||||
unpacked: true,
|
||||
},
|
||||
});
|
||||
expect(projected.every(({ message }) => message.length > 0)).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("process CLI errors", () => {
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stdout: expect.stringContaining("category: invalid_input"),
|
||||
stdout: expect.stringContaining("PROCESS_COMMAND_FAILED"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user