fix: report malformed application evidence as input errors

This commit is contained in:
morluto
2026-10-10 03:07:12 +08:00
parent 55a46f7c71
commit 218c13160e
15 changed files with 613 additions and 165 deletions
@@ -8,6 +8,13 @@ import {
parseEvidence,
type Evidence,
} from "../../domain/evidence.js";
import {
type AnalysisInputIssue,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import { z } from "zod";
import {
analyzeJavaScriptApplicationInputSchema,
javascriptApplicationAnalysisResultSchema,
@@ -30,6 +37,24 @@ export interface ApplicationGraphEvidenceSource {
readonly semanticGraph: JavaScriptSemanticGraph | null;
}
/** Caller-owned Evidence failed validation at the application graph boundary. */
export interface ApplicationGraphEvidenceInputFailure {
readonly issues: readonly AnalysisInputIssue[];
}
/** Convert typed parser issues into this workflow's caller-facing input error. */
export const applicationGraphEvidenceInputError = (
operation: string,
failure: ApplicationGraphEvidenceInputFailure,
): AnalysisInputError =>
new AnalysisInputError(operation, undefined, failure.issues);
class InvalidApplicationGraphEvidence extends Error {
constructor(readonly issues: readonly AnalysisInputIssue[]) {
super("Application graph Evidence failed input validation");
}
}
const ownedApplicationSources = new WeakMap<
Evidence,
ApplicationGraphEvidenceSource
@@ -51,7 +76,7 @@ export const rememberOwnedApplicationGraphEvidence = (
);
ownedApplicationSources.set(
evidence,
Object.freeze(staticApplicationSourceForResult(evidence, result)),
Object.freeze(staticApplicationSourceForResult(evidence, result, [])),
);
return evidence;
};
@@ -59,62 +84,79 @@ export const rememberOwnedApplicationGraphEvidence = (
/** Parse and authenticate one REA-produced JavaScript Application Graph Evidence. */
export const parseApplicationGraphEvidence = (
input: unknown,
): ApplicationGraphEvidenceSource => {
const evidence = parseEvidence(input);
const owned = ownedApplicationSources.get(evidence);
if (owned !== undefined) return owned;
if (
evidence.operation === "analyze_javascript_application" &&
evidence.predicate_type === "rea.javascript-application-analysis" &&
providerMatches(evidence, JAVASCRIPT_APPLICATION_PROVIDER)
)
return staticApplicationSource(evidence);
if (
evidence.operation === "reconcile_javascript_runtime" &&
evidence.predicate_type === "rea.javascript-runtime-reconciliation" &&
providerMatches(evidence, JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER)
)
return staticRuntimeSource(evidence);
if (
evidence.operation === "project_managed_application_graph" &&
evidence.predicate_type === "rea.managed-application-graph" &&
providerMatches(evidence, MANAGED_WORKFLOW_PROVIDER)
)
return managedApplicationSource(evidence);
throw new TypeError(
"Application workflow requires authenticated analyze_javascript_application, reconcile_javascript_runtime, or project_managed_application_graph Evidence",
);
};
path: readonly (string | number)[] = [],
): Result<
ApplicationGraphEvidenceSource,
ApplicationGraphEvidenceInputFailure
> =>
captureInputValidation(() => {
const evidence = parseEvidenceInput(input, path);
const owned = ownedApplicationSources.get(evidence);
if (owned !== undefined) return owned;
if (evidence.operation === "analyze_javascript_application") {
requirePredicate(evidence, "rea.javascript-application-analysis", path);
requireProvider(evidence, JAVASCRIPT_APPLICATION_PROVIDER, path);
return staticApplicationSource(evidence, path);
}
if (evidence.operation === "reconcile_javascript_runtime") {
requirePredicate(evidence, "rea.javascript-runtime-reconciliation", path);
requireProvider(
evidence,
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
path,
);
return staticRuntimeSource(evidence, path);
}
if (evidence.operation === "project_managed_application_graph") {
requirePredicate(evidence, "rea.managed-application-graph", path);
requireProvider(evidence, MANAGED_WORKFLOW_PROVIDER, path);
return managedApplicationSource(evidence, path);
}
throw invalidEvidence(
path,
"operation",
"Expected authenticated JavaScript analysis, runtime reconciliation, or managed application graph Evidence.",
);
});
const staticApplicationSource = (
evidence: Evidence,
path: readonly (string | number)[],
): ApplicationGraphEvidenceSource => {
const result = javascriptApplicationAnalysisResultSchema.parse(
const result = parseCallerValue(
(input) => javascriptApplicationAnalysisResultSchema.parse(input),
evidence.normalized_result,
[...path, "normalized_result"],
);
return staticApplicationSourceForResult(evidence, result);
return staticApplicationSourceForResult(evidence, result, path);
};
const staticApplicationSourceForResult = (
evidence: Evidence,
result: JavaScriptApplicationAnalysisResult,
path: readonly (string | number)[],
): ApplicationGraphEvidenceSource => {
if (
evidence.authority !== "shipped-artifact" ||
evidence.confidence !== "derived"
)
throw new TypeError(
"JavaScript application Evidence authority or confidence is invalid",
throw invalidEvidence(
path,
"authority",
"JavaScript application Evidence must use shipped-artifact authority and derived confidence.",
);
if (evidence.predicate_type !== "rea.javascript-application-analysis")
throw new TypeError(
"JavaScript application Evidence predicate does not match its result shape",
throw invalidEvidence(
path,
"predicate_type",
"JavaScript application Evidence predicate does not match its result shape.",
);
analyzeJavaScriptApplicationInputSchema.parse({
input_path: result.input_path,
...evidence.parameters,
});
assertApplicationSubject(evidence, result);
parseCallerValue(
(input) => analyzeJavaScriptApplicationInputSchema.parse(input),
{ input_path: result.input_path, ...evidence.parameters },
[...path, "parameters"],
);
assertApplicationSubject(evidence, result, path);
return {
evidence,
graph: result.graph,
@@ -126,30 +168,40 @@ const staticApplicationSourceForResult = (
const staticRuntimeSource = (
evidence: Evidence,
path: readonly (string | number)[],
): ApplicationGraphEvidenceSource => {
if (
evidence.authority !== "analyst-inference" ||
evidence.confidence !== "inferred"
)
throw new TypeError(
"JavaScript runtime reconciliation Evidence authority or confidence is invalid",
throw invalidEvidence(
path,
"authority",
"Runtime reconciliation Evidence must use analyst-inference authority and inferred confidence.",
);
const result = javascriptRuntimeReconciliationResultSchema.parse(
const result = parseCallerValue(
(input) => javascriptRuntimeReconciliationResultSchema.parse(input),
evidence.normalized_result,
[...path, "normalized_result"],
);
if (
result.evidence_links.some(
(evidenceId) => !evidence.evidence_links.includes(evidenceId),
)
)
throw new TypeError(
"Runtime reconciliation result references Evidence outside its envelope",
const unmatchedLink = result.evidence_links.find(
(evidenceId) => !evidence.evidence_links.includes(evidenceId),
);
if (unmatchedLink !== undefined)
throw invalidEvidence(
[...path, "normalized_result"],
"evidence_links",
`Runtime reconciliation result references Evidence ${unmatchedLink} outside its envelope.`,
);
const applicationLayer = result.static_layers.find(
({ role }) => role === "application",
);
if (applicationLayer === undefined)
throw new TypeError("Runtime reconciliation application layer is missing");
throw invalidEvidence(
[...path, "normalized_result"],
"static_layers",
"Runtime reconciliation Evidence must include its application layer.",
);
return {
evidence,
graph: result.graph,
@@ -161,24 +213,30 @@ const staticRuntimeSource = (
const managedApplicationSource = (
evidence: Evidence,
path: readonly (string | number)[],
): ApplicationGraphEvidenceSource => {
if (
evidence.authority !== "analyst-inference" ||
evidence.confidence !== "inferred"
)
throw new TypeError(
"Managed application graph Evidence authority or confidence is invalid",
throw invalidEvidence(
path,
"authority",
"Managed application graph Evidence must use analyst-inference authority and inferred confidence.",
);
const result = managedApplicationGraphResultSchema.parse(
const result = parseCallerValue(
(input) => managedApplicationGraphResultSchema.parse(input),
evidence.normalized_result,
[...path, "normalized_result"],
);
if (
result.evidence_links.some(
(evidenceId) => !evidence.evidence_links.includes(evidenceId),
)
)
throw new TypeError(
"Managed application graph result references Evidence outside its envelope",
const unmatchedLink = result.evidence_links.find(
(evidenceId) => !evidence.evidence_links.includes(evidenceId),
);
if (unmatchedLink !== undefined)
throw invalidEvidence(
[...path, "normalized_result"],
"evidence_links",
`Managed application graph result references Evidence ${unmatchedLink} outside its envelope.`,
);
return {
evidence,
@@ -192,6 +250,7 @@ const managedApplicationSource = (
const assertApplicationSubject = (
evidence: Evidence,
result: JavaScriptApplicationAnalysisResult,
path: readonly (string | number)[],
): void => {
if (
evidence.subject === null ||
@@ -199,8 +258,10 @@ const assertApplicationSubject = (
evidence.subject.format !== result.format ||
evidence.subject.local_path !== result.input_path
)
throw new TypeError(
"JavaScript application Evidence subject does not match its result",
throw invalidEvidence(
path,
"subject",
"JavaScript application Evidence subject must match the analyzed artifact identity and input path.",
);
};
@@ -216,15 +277,116 @@ const providerMatches = (
evidence.provider.name === provider.name &&
evidence.provider.version === provider.version;
const requirePredicate = (
evidence: Evidence,
expected: string,
path: readonly (string | number)[],
): void => {
if (evidence.predicate_type !== expected)
throw invalidEvidence(
path,
"predicate_type",
`Expected predicate ${expected} for operation ${evidence.operation}.`,
);
};
const requireProvider = (
evidence: Evidence,
expected: {
readonly id: string;
readonly name: string;
readonly version: string;
},
path: readonly (string | number)[],
): void => {
if (!providerMatches(evidence, expected))
throw invalidEvidence(
path,
"provider",
`Expected provider ${expected.id} v${expected.version} for operation ${evidence.operation}.`,
);
};
/** Parse unique, artifact-bound Evidence that may extend a native handoff. */
export const parseNativeApplicationEvidence = (
inputs: readonly unknown[],
): Evidence[] => {
const parsed = inputs.map((input) => parseEvidence(input));
if (parsed.some(({ subject }) => subject === null))
throw new TypeError("Native handoff Evidence requires an artifact subject");
const ids = parsed.map(({ evidence_id: id }) => id);
if (new Set(ids).size !== ids.length)
throw new TypeError("Native handoff Evidence must be unique");
return parsed;
path: readonly (string | number)[] = [],
): Result<readonly Evidence[], ApplicationGraphEvidenceInputFailure> =>
captureInputValidation(() => {
const parsed = inputs.map((input, index) =>
parseEvidenceInput(input, [...path, index]),
);
const withoutSubject = parsed.findIndex(({ subject }) => subject === null);
if (withoutSubject !== -1)
throw invalidEvidence(
[...path, withoutSubject],
"subject",
"Native handoff Evidence requires an artifact subject.",
);
const ids = parsed.map(({ evidence_id: id }) => id);
if (new Set(ids).size !== ids.length)
throw invalidEvidence(
path,
"evidence_id",
"Native handoff Evidence must be unique.",
);
return parsed;
});
const captureInputValidation = <Value>(
operation: () => Value,
): Result<Value, ApplicationGraphEvidenceInputFailure> => {
try {
return ok(operation());
} catch (cause: unknown) {
if (cause instanceof InvalidApplicationGraphEvidence)
return err({ issues: cause.issues });
throw cause;
}
};
const parseEvidenceInput = (
input: unknown,
path: readonly (string | number)[],
): Evidence => {
try {
return parseEvidence(input);
} catch (cause: unknown) {
if (cause instanceof z.ZodError) throw zodInputFailure(cause, input, path);
throw cause;
}
};
const parseCallerValue = <Value>(
parse: (input: unknown) => Value,
input: unknown,
path: readonly (string | number)[],
): Value => {
try {
return parse(input);
} catch (cause: unknown) {
if (cause instanceof z.ZodError) throw zodInputFailure(cause, input, path);
throw cause;
}
};
const zodInputFailure = (
cause: z.ZodError,
input: unknown,
path: readonly (string | number)[],
): InvalidApplicationGraphEvidence =>
new InvalidApplicationGraphEvidence(
projectInputIssues(cause.issues, input).map((issue) => ({
...issue,
path: [...path, ...issue.path],
})),
);
const invalidEvidence = (
path: readonly (string | number)[],
field: string,
message: string,
): InvalidApplicationGraphEvidence =>
new InvalidApplicationGraphEvidence([
{ path: [...path, field], reason: "invalid_value", message },
]);
@@ -17,6 +17,7 @@ import { err, ok, type Result } from "../../domain/result.js";
import { compareSourceToBundle } from "../../domain/javascript/sourceToBundleComparison.js";
import { compareSourceToBundleInputSchema } from "../../domain/javascript/sourceToBundleComparisonSchemas.js";
import {
applicationGraphEvidenceInputError,
parseApplicationGraphEvidence,
parseNativeApplicationEvidence,
} from "./JavaScriptApplicationEvidenceGraph.js";
@@ -45,11 +46,24 @@ export const traceApplicationFeatureEvidenceValidated = (
input: z.output<typeof traceApplicationFeatureInputSchema>,
): Result<Evidence, AnalysisError> => {
const operation = "trace_application_feature";
try {
const source = parseApplicationGraphEvidence(input.application);
const nativeEvidence = parseNativeApplicationEvidence(
input.native_observations,
const sourceResult = parseApplicationGraphEvidence(input.application, [
"application",
]);
if (!sourceResult.ok)
return err(
applicationGraphEvidenceInputError(operation, sourceResult.error),
);
const nativeResult = parseNativeApplicationEvidence(
input.native_observations,
["native_observations"],
);
if (!nativeResult.ok)
return err(
applicationGraphEvidenceInputError(operation, nativeResult.error),
);
try {
const source = sourceResult.value;
const nativeEvidence = nativeResult.value;
const result = traceApplicationFeature({
sourceEvidenceId: source.evidence.evidence_id,
graph: source.graph,
@@ -69,7 +83,7 @@ export const traceApplicationFeatureEvidenceValidated = (
),
);
} catch (cause: unknown) {
return workflowFailure(operation, cause, input);
return workflowFailure(operation, cause);
}
};
@@ -91,15 +105,35 @@ export const compareApplicationVersionsEvidenceValidated = (
input: z.output<typeof compareApplicationVersionsInputSchema>,
): Result<Evidence, AnalysisError> => {
const operation = "compare_application_versions";
const leftResult = parseApplicationGraphEvidence(input.left, ["left"]);
if (!leftResult.ok)
return err(applicationGraphEvidenceInputError(operation, leftResult.error));
const rightResult = parseApplicationGraphEvidence(input.right, ["right"]);
if (!rightResult.ok)
return err(
applicationGraphEvidenceInputError(operation, rightResult.error),
);
const leftNativeResult = parseNativeApplicationEvidence(
input.left_native_observations,
["left_native_observations"],
);
if (!leftNativeResult.ok)
return err(
applicationGraphEvidenceInputError(operation, leftNativeResult.error),
);
const rightNativeResult = parseNativeApplicationEvidence(
input.right_native_observations,
["right_native_observations"],
);
if (!rightNativeResult.ok)
return err(
applicationGraphEvidenceInputError(operation, rightNativeResult.error),
);
try {
const left = parseApplicationGraphEvidence(input.left);
const right = parseApplicationGraphEvidence(input.right);
const leftNative = parseNativeApplicationEvidence(
input.left_native_observations,
);
const rightNative = parseNativeApplicationEvidence(
input.right_native_observations,
);
const left = leftResult.value;
const right = rightResult.value;
const leftNative = leftNativeResult.value;
const rightNative = rightNativeResult.value;
const result = compareJavaScriptApplicationVersions({
left: {
evidenceId: left.evidence.evidence_id,
@@ -128,7 +162,7 @@ export const compareApplicationVersionsEvidenceValidated = (
),
);
} catch (cause: unknown) {
return workflowFailure(operation, cause, input);
return workflowFailure(operation, cause);
}
};
@@ -137,8 +171,15 @@ export const compareSourceToBundleEvidenceValidated = (
input: z.output<typeof compareSourceToBundleInputSchema>,
): Result<Evidence, AnalysisError> => {
const operation = "compare_source_to_bundle";
const applicationResult = parseApplicationGraphEvidence(input.application, [
"application",
]);
if (!applicationResult.ok)
return err(
applicationGraphEvidenceInputError(operation, applicationResult.error),
);
try {
const application = parseApplicationGraphEvidence(input.application);
const application = applicationResult.value;
const result = compareSourceToBundle({
reference: input.reference,
application: {
@@ -157,7 +198,7 @@ export const compareSourceToBundleEvidenceValidated = (
),
);
} catch (cause: unknown) {
return workflowFailure(operation, cause, input);
return workflowFailure(operation, cause);
}
};
@@ -182,9 +223,17 @@ export const compareJavaScriptExportShapesEvidenceValidated = (
input: z.output<typeof compareJavaScriptExportShapesInputSchema>,
): Result<Evidence, AnalysisError> => {
const operation = "compare_javascript_export_shapes";
const leftResult = parseApplicationGraphEvidence(input.left, ["left"]);
if (!leftResult.ok)
return err(applicationGraphEvidenceInputError(operation, leftResult.error));
const rightResult = parseApplicationGraphEvidence(input.right, ["right"]);
if (!rightResult.ok)
return err(
applicationGraphEvidenceInputError(operation, rightResult.error),
);
try {
const left = parseApplicationGraphEvidence(input.left);
const right = parseApplicationGraphEvidence(input.right);
const left = leftResult.value;
const right = rightResult.value;
const result = compareJavaScriptExportShapes({
left: {
evidenceId: left.evidence.evidence_id,
@@ -213,50 +262,17 @@ export const compareJavaScriptExportShapesEvidenceValidated = (
),
);
} catch (cause: unknown) {
return workflowFailure(operation, cause, input);
return workflowFailure(operation, cause);
}
};
const workflowFailure = (
operation: string,
cause: unknown,
input: unknown,
): Result<never, AnalysisError> =>
cause instanceof z.ZodError
? err(analysisInputErrorFromIssues(operation, cause.issues, input))
: cause instanceof TypeError &&
SAFE_APPLICATION_INPUT_CONSTRAINTS.has(cause.message)
? err(
new AnalysisInputError(operation, undefined, [
{ path: [], reason: "invalid_value", message: cause.message },
]),
)
: err(
new AnalysisProtocolError(
"JavaScript application workflow produced an invalid result",
{ cause },
),
);
const SAFE_APPLICATION_INPUT_CONSTRAINTS = new Set([
"Evidence semantic identifier does not match its record",
"Application workflow requires authenticated analyze_javascript_application, reconcile_javascript_runtime, or project_managed_application_graph Evidence",
"JavaScript application Evidence authority or confidence is invalid",
"JavaScript application Evidence predicate does not match its result shape",
"JavaScript application Evidence subject does not match its result",
"Runtime reconciliation Evidence authority or confidence is invalid",
"Runtime reconciliation result references Evidence outside its envelope",
"Runtime reconciliation application layer is missing",
"Managed application graph Evidence authority or confidence is invalid",
"Managed application graph result references Evidence outside its envelope",
"Native handoff Evidence requires an artifact subject",
"Native handoff Evidence must be unique",
...[
"analyze_javascript_application",
"reconcile_javascript_runtime",
"project_managed_application_graph",
].map(
(operation) =>
`Evidence does not match the supported ${operation} contract`,
),
]);
err(
new AnalysisProtocolError(
`JavaScript application workflow failed while producing ${operation}`,
{ cause },
),
);
@@ -85,7 +85,6 @@ const isPortableAbsolutePath = (path: string): boolean =>
/^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(path);
const SAFE_RECONCILIATION_INPUT_CONSTRAINTS = new Set([
"Evidence semantic identifier does not match its record",
"JavaScript application Evidence subject disagrees with its result",
"Runtime reconciliation requires inspect_web_page, inspect_electron_page, observe_javascript_runtime, or capture_electron_scenario Evidence",
"Runtime Evidence target or declared role disagrees with its result",
@@ -5,6 +5,10 @@ import {
AnalysisProtocolError,
} from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import {
applicationGraphEvidenceInputError,
parseApplicationGraphEvidence,
} from "./JavaScriptApplicationEvidenceGraph.js";
import type { Evidence } from "../../domain/evidence.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
@@ -13,7 +17,6 @@ import {
traceJavaScriptSemanticsInputSchema,
} from "../../domain/javascript/javascriptSemanticTraceSchemas.js";
import { err, ok, type Result } from "../../domain/result.js";
import { parseApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js";
import { createJavaScriptSemanticTraceEvidence } from "./JavaScriptApplicationWorkflowEvidence.js";
const OPERATION = "trace_javascript_semantics" as const;
@@ -22,19 +25,26 @@ const OPERATION = "trace_javascript_semantics" as const;
export const traceJavaScriptSemanticsEvidenceValidated = (
input: z.output<typeof traceJavaScriptSemanticsInputSchema>,
): Result<Evidence, AnalysisError> => {
const sourceResult = parseApplicationGraphEvidence(input.application, [
"application",
]);
if (!sourceResult.ok)
return err(
applicationGraphEvidenceInputError(OPERATION, sourceResult.error),
);
const source = sourceResult.value;
if (source.semanticGraph === null)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["application"],
reason: "invalid_value",
expected:
"inline Evidence from analyze_javascript_application with a semantic graph",
},
]),
);
try {
const source = parseApplicationGraphEvidence(input.application);
if (source.semanticGraph === null)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["application"],
reason: "invalid_value",
expected:
"inline Evidence from analyze_javascript_application with a semantic graph",
},
]),
);
const query = queryJavaScriptSemanticGraph(
source.semanticGraph,
input.query,