fix(javascript): classify application input selection failures by cause

analyze_javascript_application reported every input-selection problem as
an artifact fault:

- a missing input path: "Artifact could not be read or written", with no
  path or reason;
- an unreadable selected directory: the same generic io failure;
- a regular file that is neither a directory nor an .asar, and a
  --artifact-format that contradicts the selection: "Artifact is invalid
  or has changed. Get a fresh copy and try again."

Report a missing path as an invalid input_path, an unreadable selection
as access_denied, a non-directory non-.asar file as unsupported_target
with the accepted inputs as remediation, and a contradicting format as an
invalid format with the observed kind as expected value. Failures inside
a selected artifact keep their existing classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-09 09:13:17 +00:00
parent f4c99ab8a5
commit 0a14bbbf05
7 changed files with 186 additions and 18 deletions

View File

@ -76,7 +76,7 @@ describe("JavaScript application failure diagnostics", () => {
expect(JSON.parse(JSON.stringify(projection))).toEqual(projection);
});
it("keeps filesystem failures distinct from engine failures", async () => {
it("reports a missing selection as invalid input, not an engine failure", async () => {
const root = await createTestTempDirectory("rea-js-missing-");
const result = await analyzeJavaScriptApplication({
input_path: join(root, "missing"),
@ -84,8 +84,11 @@ describe("JavaScript application failure diagnostics", () => {
});
if (result.ok) throw new Error("Expected missing artifact failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "artifact_operation_failed",
details: { operation: "analyze_javascript_application", reason: "io" },
code: "invalid_request",
details: {
operation: "analyze_javascript_application",
issues: [{ path: ["input_path"], reason: "invalid_value" }],
},
});
});

View File

@ -5,7 +5,12 @@ import {
analyzeJavaScriptApplicationInputSchema,
parseOwnedJavaScriptApplicationAnalysisSteps,
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import {
AnalysisAccessDeniedError,
AnalysisInputError,
AnalysisOutputError,
AnalysisUnsupportedTargetError,
} from "../../domain/analysisErrorCore.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
@ -100,6 +105,13 @@ export const analyzeJavaScriptApplicationValidated = async (
assertJavaScriptAnalysisActive(options.signal);
return ok(evidence);
} catch (cause: unknown) {
// Input selection failures are already typed by their cause.
if (
cause instanceof AnalysisInputError ||
cause instanceof AnalysisUnsupportedTargetError ||
cause instanceof AnalysisAccessDeniedError
)
return err(cause);
if (cause instanceof ArtifactReaderFailure)
return err(
new ArtifactOperationError(

View File

@ -1,6 +1,12 @@
import { lstat, realpath } from "node:fs/promises";
import { constants } from "node:fs";
import { access, lstat, realpath } from "node:fs/promises";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
AnalysisAccessDeniedError,
AnalysisInputError,
AnalysisUnsupportedTargetError,
} from "../../domain/analysisErrorCore.js";
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";
@ -58,7 +64,7 @@ export const reconstructJavaScriptArtifact = async (
};
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
abortIfNeeded(signal);
const path = await realpath(input.input_path);
const path = await resolveSelectedInput(input.input_path);
const format = await resolveFormat(path, input);
const snapshot = await scanCanonicalArtifactInventory(path, { signal });
if (snapshot.manifest.root_format !== format)
@ -159,6 +165,37 @@ export const reconstructJavaScriptArtifact = async (
}
};
const OPERATION = "analyze_javascript_application";
/**
* Resolve the caller-selected input. A missing or unreadable selection is a
* caller or host-permission failure, not a damaged artifact.
*/
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
try {
const path = await realpath(inputPath);
await access(path, constants.R_OK);
return path;
} catch (cause: unknown) {
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
if (code === "EACCES" || code === "EPERM")
throw new AnalysisAccessDeniedError(OPERATION, inputPath, code, {
cause,
});
if (code === "ENOENT" || code === "ENOTDIR")
throw new AnalysisInputError(OPERATION, { cause }, [
{
path: ["input_path"],
reason: "invalid_value",
message: `No file or directory exists at the selected input path (${code}): ${inputPath}`,
},
]);
throw cause;
}
};
/** The selected input's kind, checked against the caller's requested format. */
const resolveFormat = async (
path: string,
input: JavaScriptArtifactReconstructionInput,
@ -170,15 +207,24 @@ const resolveFormat = async (
? "asar"
: undefined;
if (observed === undefined)
throw new ArtifactReaderFailure(
"format",
`JavaScript artifact reconstruction accepts only a directory or .asar file: ${path}`,
throw new AnalysisUnsupportedTargetError(
OPERATION,
input.input_path,
"JavaScript application analysis accepts only a directory or an .asar file",
{
remediationAction:
"Select an extracted application directory, an app bundle, or an .asar file such as Contents/Resources/app.asar.",
},
);
if (input.format !== "auto" && input.format !== observed)
throw new ArtifactReaderFailure(
"format",
`Requested ${input.format} input but observed ${observed}: ${path}`,
);
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["format"],
reason: "invalid_value",
message: `Requested ${input.format} input, but the selected input is ${observed === "asar" ? "an .asar file" : "a directory"}: ${input.input_path}`,
expected: ["auto", observed],
},
]);
return observed;
};

View File

@ -99,8 +99,8 @@ cliTest(
],
});
expect(output.json).toMatchObject({
code: "artifact_operation_failed",
details: { reason: "io" },
code: "invalid_request",
details: { issues: [{ path: ["input_path"], reason: "invalid_value" }] },
});
},
);

View File

@ -62,7 +62,7 @@ cliTest(
"--json",
],
});
expect(output.json).toMatchObject({ code: "artifact_operation_failed" });
expect(output.json).toMatchObject({ code: "invalid_request" });
const updates = output.stderr
.trim()
.split("\n")

View File

@ -0,0 +1,102 @@
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, onTestFinished } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
const analyzeFailure = async (input: Record<string, unknown>) => {
const result = await analyzeJavaScriptApplication(input);
if (result.ok) throw new Error("Expected the selection to be refused");
return projectAnalysisError(result.error);
};
const application = async () => {
const root = await createTestTempDirectory("rea-javascript-selection-");
const directory = join(root, "app");
await mkdir(directory);
await writeFile(join(directory, "index.js"), "module.exports = 1;\n");
return { root, directory };
};
describe("JavaScript application input selection", () => {
it("reports a missing input path as invalid input, not an artifact read failure", async () => {
const { root } = await application();
const missing = join(root, "missing");
expect(await analyzeFailure({ input_path: missing })).toMatchObject({
code: "invalid_request",
category: "invalid_input",
details: {
issues: [
{
path: ["input_path"],
reason: "invalid_value",
message: expect.stringContaining(missing),
},
],
},
});
});
it("reports a file that is neither a directory nor an .asar as an unsupported target", async () => {
const { root } = await application();
const binary = join(root, "program");
await writeFile(binary, Buffer.from([0xcf, 0xfa, 0xed, 0xfe]));
expect(await analyzeFailure({ input_path: binary })).toMatchObject({
code: "unsupported_target",
category: "unsupported_target",
remediation: { action: expect.stringContaining(".asar") },
details: {
operation: "analyze_javascript_application",
path: binary,
},
});
});
it.each([
["a directory", "directory", "asar"],
["an .asar file", "asar", "directory"],
] as const)(
"reports %s selected with a contradicting format as an invalid request",
async (_label, observed, format) => {
const { root, directory } = await application();
const selected =
observed === "directory" ? directory : join(root, "app.asar");
if (observed === "asar") await writeFile(selected, "not inspected");
expect(
await analyzeFailure({ input_path: selected, format }),
).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["format"],
reason: "invalid_value",
expected: ["auto", observed],
},
],
},
});
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports an unreadable selected directory as a host access denial",
async () => {
const { directory } = await application();
await chmod(directory, 0o000);
onTestFinished(() => chmod(directory, 0o700));
expect(await analyzeFailure({ input_path: directory })).toMatchObject({
code: "access_denied",
details: { path: directory, system_code: "EACCES" },
});
},
);
});

View File

@ -268,8 +268,13 @@ it("preserves cancellation and explicit format diagnostics", async () => {
await expect(
reconstructJavaScriptArtifact({ input_path: root, format: "asar" }),
).rejects.toMatchObject({
reason: "format",
message: expect.stringContaining(root),
issues: [
{
path: ["format"],
reason: "invalid_value",
message: expect.stringContaining(root),
},
],
});
});