Files
rea/src/domain/analysisErrorProjection.ts
T
Kaoru0822 e06be06530 fix(cli): report JSON Lines selection refusals as caller errors (#7124)
* fix(cli): report JSON Lines selection refusals as caller errors

A --format jsonl refusal because the selected value is not a record
collection is the caller's output selection, not unreadable analysis
output. Report it as invalid_request/invalid_input that names the
selection and its observed shape, keep the --format json /
--filter-output remediation, and drop the rea doctor advice.

AnalysisInputError gains an optional producer remediation, mirroring
the other refusal classes. The JSON Lines owner examines its documented
boundaries once for both admission and refusal facts.

docs/cli.md now states that columnar results such as
inspect-sqlite-database --table rows are not record collections.

Fixes #7110

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): carry JSON Lines refusal facts as structured observations

JSON Lines refusals now put the location and shape of each refused value
in details.issues[].observed, and the message is built from those facts.
Tests assert the classification, issue path, observed facts, and the
shared remediation constant instead of fixed sentences.

The filtered analysis-view export check now uses a non-empty request
and compares the emitted rows with the unfiltered export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 05:26:13 -07:00

686 lines
23 KiB
TypeScript

import { projectArtifactIntegrityDetails } from "./artifactIntegrity.js";
import type { AnalysisErrorRecovery } from "./analysisErrorRecovery.js";
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
import { isImmutableJsonSnapshot } from "./immutableJson.js";
import {
analysisErrorCategory,
analysisErrorRemediationAction,
analysisErrorUserMessage,
assertKnownAnalysisErrorTag,
} from "./analysisErrorPresentation.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisUnsupportedTargetError,
AnalysisInputError,
AnalysisOutputError,
AnalysisTimeoutError,
AnalysisResourceConstraintError,
} from "./analysisErrorCore.js";
import { ArtifactOperationError } from "./artifactOperationError.js";
import {
BinaryTargetError,
ConfigurationError,
} from "./configurationErrors.js";
import { BrowserObservationError } from "./browserObservationError.js";
import {
EvidenceFileError,
EvidenceIntegrityError,
EvidenceReferenceError,
} from "./evidenceErrors.js";
import {
HopperCancelledError,
HopperProcessError,
HopperRemoteError,
HopperStartError,
HopperTimeoutError,
} from "./hopperErrors.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
import { ProviderSelectionError } from "./providerSelectionError.js";
import { UnknownRegistryError } from "./unknownRegistryError.js";
import { providerRetryAction } from "./providerOperationHealth.js";
import {
primaryAnalysisError,
resolvedAnalysisError,
type AnalysisError,
type AnalysisErrorTag,
} from "./analysisErrorBase.js";
/** Project expected failures into exhaustive, secret-safe caller fields. */
export const projectAnalysisError = (
error: AnalysisError,
): AnalysisErrorProjection => projectError(error, true);
/** Cleanup history references output retained by its enclosing error owner. */
export const projectAnalysisErrorWithoutCapturedOutput = (
error: AnalysisError,
): AnalysisErrorProjection => projectError(error, false);
const projectError = (
error: AnalysisError,
includeCapturedOutput: boolean,
): AnalysisErrorProjection => {
const composed = error;
error = resolvedAnalysisError(error);
let diagnosticSource = error;
while (
diagnosticSource.diagnostics === undefined &&
diagnosticSource.cleanupPrimary !== undefined
)
diagnosticSource = diagnosticSource.cleanupPrimary;
const diagnostics = diagnosticSource.diagnostics;
const capturedOutput = composed.capturedOutput ?? error.capturedOutput;
const partialObservation =
composed.partialObservation ?? error.partialObservation;
// A finalizer can add a report above the semantic primary, including while
// its cleanup is pending or beneath another cleanup composition.
let cleanupReport = composed.cleanupReport;
for (
let owner = composed.cleanupPrimary;
cleanupReport === undefined && owner !== undefined;
owner = owner.cleanupPrimary
)
cleanupReport = owner.cleanupReport;
assertKnownAnalysisErrorTag(error._tag);
const primary = primaryAnalysisError(error);
assertKnownAnalysisErrorTag(primary._tag);
const underlyingCode = underlyingErrorCode(primary);
const code = error.cleanupIncomplete ? "cleanup_incomplete" : underlyingCode;
const primaryDetails = errorDetails(primary);
const details = {
...primaryDetails,
...(primary.limit === undefined ? {} : { limit: { ...primary.limit } }),
...(cleanupReport === undefined
? {}
: { cleanup_report: jsonValueSchema.parse(cleanupReport) }),
...(diagnostics === undefined
? {}
: {
diagnostics,
...(primaryDetails?.diagnostics === undefined
? {}
: { provider_diagnostics: primaryDetails.diagnostics }),
}),
...(composed === error || composed.diagnostics === undefined
? {}
: { cleanup_history: composed.diagnostics }),
...(error.cleanupIncomplete
? {
cleanup: "incomplete",
resources: [...error.cleanupResources],
...(error.cleanup === undefined
? {}
: { cleanup_reason: error.cleanup.reason }),
execution_failure:
primaryDetails?.execution_failure ?? underlyingCode,
}
: {}),
...(!includeCapturedOutput || capturedOutput === undefined
? {}
: {
captured_output: {
...capturedOutput,
},
}),
...(partialObservation === undefined
? {}
: {
partial_observation: isImmutableJsonSnapshot(partialObservation)
? partialObservation
: jsonValueSchema.parse(partialObservation),
}),
};
const recovery = errorRecovery(error);
return {
code,
category: analysisErrorCategory(error),
message: analysisErrorUserMessage(error),
retryable:
RETRYABLE_CODES.has(code) ||
(error instanceof AnalysisCapabilityUnavailableError &&
error.retryable) ||
(code === "resource_constraint" &&
error instanceof AnalysisResourceConstraintError &&
(error.retryable ?? error.resource === "capacity")),
remediation: {
action: analysisErrorRemediationAction(error),
},
...(recovery === undefined ? {} : { recovery }),
...(Object.keys(details).length === 0 ? {} : { details }),
};
};
const errorRecovery = (
error: AnalysisError,
): AnalysisErrorRecovery | undefined => {
if (error instanceof AnalysisInputError)
return {
kind: "inspect_input_contract",
operation: error.operation,
};
if (error instanceof AnalysisTimeoutError)
return {
kind: "inspect_timeout_state",
operation: error.operation,
timeout_ms: error.timeoutMs,
};
return undefined;
};
const underlyingErrorCode = (
error: AnalysisError,
): AnalysisErrorProjection["code"] => {
if (
error instanceof HopperRemoteError &&
error.diagnosticType === "invalid_request"
)
return "invalid_request";
if (error instanceof ProviderSelectionError)
return error.reason === "provider_unavailable"
? "provider_unavailable"
: "capability_unavailable";
if (error instanceof BrowserObservationError)
return error.userCategory === "cancelled"
? "cancelled"
: BROWSER_ERROR_CODES[error.reason];
if (error instanceof ArtifactOperationError)
return artifactOperationCode(error);
if (error instanceof BinaryTargetError)
return error.constraint === "load_selection_rejected"
? "invalid_request"
: error.systemCode === undefined
? "target_unavailable"
: "access_denied";
if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason);
if (error instanceof UnknownRegistryError)
return unknownRegistryCode(error.reason);
if (error._tag === "ProcessCaptureError") return processCaptureCode(error);
return staticErrorCode(error._tag);
};
const BROWSER_ERROR_CODES: Readonly<
Record<BrowserObservationError["reason"], AnalysisErrorProjection["code"]>
> = {
endpoint_unreachable: "provider_unavailable",
invalid_endpoint_response: "unreadable_output",
target_not_found: "target_unavailable",
target_not_allowed: "target_unavailable",
service_worker_blocked: "target_unavailable",
target_changed: "target_unavailable",
target_session_detached: "target_unavailable",
protocol_error: "unreadable_output",
disconnected: "provider_unavailable",
payload_limit: "truncated",
window_not_found: "unreadable_output",
window_metadata_missing: "unreadable_output",
process_ownership_unavailable: "unreadable_output",
cleanup_failed: "execution_failure",
cancelled: "cancelled",
timeout: "provider_timeout",
};
const artifactOperationCode = (
error: ArtifactOperationError,
): AnalysisErrorProjection["code"] => {
if (error.reason === "integrity" && error.artifactDetails !== undefined)
return "artifact_integrity_mismatch";
if (error.reason === "limit") return "truncated";
if (error.reason === "cancelled") return "cancelled";
return "artifact_operation_failed";
};
const evidenceFileCode = (
reason: EvidenceFileError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "invalid-json") return "evidence_integrity_mismatch";
if (reason === "path-limit") return "resource_constraint";
// A missing, non-file or already-existing path is the caller's selection.
if (reason === "io") return "execution_failure";
return "invalid_request";
};
const unknownRegistryCode = (
reason: UnknownRegistryError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "revision-conflict") return "revision_conflict";
if (reason === "already-exists") return "invalid_request";
if (reason === "limit") return "truncated";
if (reason === "integrity") return "evidence_integrity_mismatch";
return "execution_failure";
};
const processCaptureCode = (
error: AnalysisError,
): AnalysisErrorProjection["code"] => {
if (error.cleanupIncomplete) return "cleanup_incomplete";
if (error.userCategory === "cancelled") return "cancelled";
return "process_capture_failed";
};
type SpecializedErrorTag =
| "ArtifactOperationError"
| "BinaryTargetError"
| "BrowserObservationError"
| "EvidenceFileError"
| "ProcessCaptureError"
| "ProviderSelectionError"
| "UnknownRegistryError";
const STATIC_ERROR_CODES = {
AnalysisProtocolError: "unreadable_output",
AnalysisOutputError: "unreadable_output",
HopperProtocolError: "unreadable_output",
AnalysisInputError: "invalid_request",
AnalysisAccessDeniedError: "access_denied",
AnalysisArtifactChangedError: "artifact_changed",
AnalysisCapabilityUnavailableError: "capability_unavailable",
AnalysisUnsupportedTargetError: "unsupported_target",
AnalysisCancelledError: "cancelled",
HopperCancelledError: "cancelled",
AnalysisTimeoutError: "provider_timeout",
AnalysisResourceConstraintError: "resource_constraint",
HopperTimeoutError: "provider_timeout",
HopperProcessError: "provider_unavailable",
HopperStartError: "provider_unavailable",
ConfigurationError: "configuration_invalid",
NoBinaryOpenError: "target_unavailable",
EvidenceIntegrityError: "evidence_integrity_mismatch",
ProviderAdapterError: "execution_failure",
HopperRemoteError: "execution_failure",
} as const satisfies Readonly<
Record<
Exclude<AnalysisErrorTag, SpecializedErrorTag>,
AnalysisErrorProjection["code"]
>
>;
const staticErrorCode = (
tag: AnalysisErrorTag,
): AnalysisErrorProjection["code"] => {
switch (tag) {
case "ArtifactOperationError":
case "BinaryTargetError":
case "BrowserObservationError":
case "EvidenceFileError":
case "ProcessCaptureError":
case "ProviderSelectionError":
case "UnknownRegistryError":
throw new TypeError(`Unhandled specialized analysis error: ${tag}`);
default:
return STATIC_ERROR_CODES[tag];
}
};
const errorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined =>
requestErrorDetails(error) ??
artifactStateErrorDetails(error) ??
providerErrorDetails(error) ??
lifecycleErrorDetails(error);
const requestErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof AnalysisUnsupportedTargetError)
return {
operation: error.operation,
path: error.path,
reason: error.reason,
};
if (error instanceof AnalysisResourceConstraintError)
return {
operation: error.operation,
resource: error.resource,
reason: error.reason,
reported_limits: error.reportedLimits,
};
if (error instanceof AnalysisArtifactChangedError)
return {
operation: error.operation,
path: error.path,
reason: error.reason,
boundary: "stable-artifact-read",
};
if (error instanceof AnalysisAccessDeniedError)
return {
operation: error.operation,
path: error.path,
system_code: error.systemCode,
boundary: "filesystem-read",
};
if (error instanceof AnalysisOutputError)
return {
operation: error.operation,
reason: error.reason,
...(error.diagnostics === undefined
? {}
: { diagnostics: error.diagnostics }),
};
if (error instanceof AnalysisInputError && error.issues.length > 0)
return {
operation: error.operation,
issues: error.issues.map((issue) => ({
path: [...issue.path],
reason: issue.reason,
...(issue.message === undefined ? {} : { message: issue.message }),
...(issue.expected === undefined ? {} : { expected: issue.expected }),
...(issue.observed === undefined ? {} : { observed: issue.observed }),
...(issue.minimum === undefined ? {} : { minimum: issue.minimum }),
...(issue.maximum === undefined ? {} : { maximum: issue.maximum }),
})),
};
if (error instanceof EvidenceReferenceError)
return {
evidence_id: error.evidenceId,
reason: error.reason,
expected: error.expected,
actual: error.actual,
};
if (error instanceof EvidenceIntegrityError) return { reason: error.message };
return undefined;
};
const artifactStateErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof ArtifactOperationError && error.artifactDetails)
return projectArtifactIntegrityDetails(error.artifactDetails);
if (error instanceof ArtifactOperationError)
return {
operation: error.operation,
reason: error.reason,
...(error.reason === "limit" ? { truncated: true } : {}),
...(error.detail === undefined ? {} : { detail: error.detail }),
...(error.boundary === undefined ? {} : { boundary: error.boundary }),
...(error.admission === undefined
? {}
: {
admission: error.admission.kind,
maximum_header_bytes: error.admission.maximumBytes,
maximum_header_bytes_source: error.admission.source,
setting: "REA_ASAR_MAX_HEADER_BYTES",
}),
};
if (error instanceof UnknownRegistryError) return { reason: error.reason };
if (error instanceof EvidenceFileError)
return {
operation: error.operation,
reason: error.reason,
...(error.reason === "path-limit" ? { boundary: "filesystem-path" } : {}),
...(error.path === undefined ? {} : { path: error.path }),
...(error.contentIssues === undefined
? {}
: {
content_issues: error.contentIssues.map((issue) => ({
...issue,
path: [...issue.path],
})),
}),
...(error.published === undefined
? {}
: {
published: true,
bytes: error.published.bytes,
published_path: error.published.path,
}),
};
return undefined;
};
const providerErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof AnalysisCapabilityUnavailableError)
return {
provider_id: error.providerId,
operation: error.operation,
reason: error.reason,
};
if (error instanceof ProviderSelectionError)
return {
operation: error.operation,
selection_reason: error.reason,
requested_provider_id: error.requestedProviderId,
candidate_ids: [...error.candidateIds],
rejections: error.rejections.map((rejection) => ({
provider_id: rejection.providerId,
code: rejection.code,
reason: rejection.reason,
diagnostics: rejection.diagnostics,
})),
};
if (error instanceof ProviderAdapterError)
return {
provider_id: error.providerId,
operation: error.operation,
...(error.cleanupIncomplete
? {
cleanup: "incomplete",
resources: [...error.cleanupResources],
}
: {}),
};
if (error instanceof BrowserObservationError) {
const primaryReason = primaryBrowserFailureReason(error);
return {
operation: error.operation,
reason: error.reason,
...(error.cleanupIncomplete
? {
cleanup: "incomplete",
resources: [...error.cleanupResources],
...(primaryReason === undefined
? {}
: { primary_reason: primaryReason }),
}
: {}),
};
}
if (error instanceof HopperRemoteError)
return {
stage: "analysis",
provider_code: error.code,
diagnostic_type: error.diagnosticType,
...(error.operation === undefined ? {} : { operation: error.operation }),
...(error.requestId === undefined ? {} : { request_id: error.requestId }),
};
if (error instanceof HopperProcessError) {
const stage = error.stage;
return {
exit_code: error.exitCode,
stage,
provider_state: error.providerState,
retry_action: providerRetryAction(
error.providerState,
error.failureCode !== undefined || error.stage === "launch",
),
...(error.failureCode === undefined
? {}
: { failure_code: error.failureCode }),
...(error.operation === undefined ? {} : { operation: error.operation }),
...(error.requestId === undefined ? {} : { request_id: error.requestId }),
...(error.diagnostic === undefined
? {}
: { diagnostics: { ...error.diagnostic } }),
...(error.launcherFailure === undefined
? {}
: { launcher: error.launcherFailure }),
};
}
if (error instanceof HopperStartError)
return {
stage: "launch",
provider_state: "unknown",
...(error.launchRejection === undefined
? {}
: { launch_rejection: { ...error.launchRejection } }),
retry_action: error.ownerRunId === undefined ? "retry" : "unknown",
...(error.ownerRunId === undefined
? {}
: { owner_run_id: error.ownerRunId }),
...(error.launcherFailure === undefined
? {}
: { launcher: error.launcherFailure }),
};
return undefined;
};
const primaryBrowserFailureReason = (
error: BrowserObservationError,
): string | undefined => {
const aggregate = error.cause instanceof AggregateError;
const primary =
error.cause instanceof AggregateError ? error.cause.errors[0] : error.cause;
if (primary instanceof BrowserObservationError) return primary.reason;
if (primary instanceof AnalysisCancelledError) return "cancelled";
if (primary instanceof AnalysisTimeoutError) return "timeout";
if (typeof primary === "object" && primary !== null && "_tag" in primary)
return typeof primary._tag === "string"
? primary._tag
: "execution_failure";
return aggregate ? "execution_failure" : undefined;
};
const lifecycleErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof AnalysisCancelledError)
return {
operation: error.operation,
cleanup: error.cleanupIncomplete ? "incomplete" : "complete",
...(error.cleanup === undefined || !error.cleanupIncomplete
? {}
: {
cleanup_reason: error.cleanup.reason,
resources: [...error.cleanup.resources],
execution_failure: "cancelled",
}),
};
if (error instanceof HopperCancelledError)
return { operation: "hopper", cleanup: "complete" };
if (error instanceof AnalysisTimeoutError)
return {
operation: error.operation,
timeout_ms: error.timeoutMs,
...(error.cleanup === undefined || !error.cleanupIncomplete
? {}
: {
cleanup: "incomplete",
cleanup_reason: error.cleanup.reason,
resources: [...error.cleanup.resources],
}),
};
if (error instanceof HopperTimeoutError)
return {
stage: error.operation === undefined ? "startup" : error.stage,
timeout_ms: error.timeoutMs,
...(error.launcherOutcome === undefined
? {}
: { launcher: error.launcherOutcome }),
provider_state: error.providerState,
retry_action: error.providerState === "busy" ? "wait" : "retry",
...(error.operation === undefined ? {} : { operation: error.operation }),
...(error.requestId === undefined ? {} : { request_id: error.requestId }),
};
if (error._tag === "ProcessCaptureError" && error.cleanupIncomplete)
return {
cleanup: "incomplete",
resources: [...error.cleanupResources],
...(error.cleanupReport === undefined
? {}
: { cleanup_report: jsonValueSchema.parse(error.cleanupReport) }),
...(error.executionFailure === undefined
? {}
: { execution_failure: error.executionFailure }),
};
if (error._tag === "ProcessCaptureError") {
const details = {
...(error.userCategory === "cancelled"
? { operation: "process_capture", cleanup: "complete" }
: {}),
...(error.cleanupReport === undefined
? {}
: { cleanup_report: jsonValueSchema.parse(error.cleanupReport) }),
...(error.executionFailure === undefined
? {}
: { execution_failure: error.executionFailure }),
};
return Object.keys(details).length === 0 ? undefined : details;
}
if (error instanceof ConfigurationError && error.settings.length > 0)
return {
settings: error.settings.map(({ setting, constraint }) => ({
setting,
constraint,
})),
};
if (error instanceof BinaryTargetError)
return {
path: error.path,
reason: error.reason,
...(error.constraint === undefined
? {}
: { constraint: error.constraint }),
...(error.systemCode === undefined
? {}
: { system_code: error.systemCode, boundary: "filesystem-read" }),
};
return undefined;
};
const RETRYABLE_CODES: ReadonlySet<AnalysisErrorProjection["code"]> = new Set([
"artifact_changed",
"provider_timeout",
"cancelled",
"revision_conflict",
"provider_unavailable",
]);
export interface AnalysisErrorProjection extends Readonly<
Record<string, JsonValue>
> {
readonly code:
| "invalid_request"
| "access_denied"
| "artifact_changed"
| "unreadable_output"
| "capability_unavailable"
| "unsupported_target"
| "provider_unavailable"
| "provider_timeout"
| "resource_constraint"
| "cancelled"
| "artifact_integrity_mismatch"
| "artifact_operation_failed"
| "evidence_integrity_mismatch"
| "truncated"
| "process_capture_failed"
| "cleanup_incomplete"
| "revision_conflict"
| "configuration_invalid"
| "target_unavailable"
| "execution_failure";
readonly category:
| "invalid_input"
| "unsupported_provider"
| "unsupported_target"
| "integrity_mismatch"
| "truncated"
| "cancelled"
| "timeout"
| "resource_constraint"
| "unavailable"
| "execution_failure";
readonly message: string;
readonly retryable: boolean;
readonly remediation: Readonly<{
action: string;
}>;
readonly recovery?: AnalysisErrorRecovery;
readonly details?: Readonly<Record<string, JsonValue>>;
}