Files
rea/src/application/analysisView/AnalysisViewService.ts
T
morluto 16e1cc65ed fix(analysis-view): remove contract initialization cycles
Move the shared input-error constructor and operation identity to a leaf contract. View leaves no longer import their runtime composition owner. Replace a duplicate graph assertion with compiled CLI summary/facet coverage for omitted roots and unresolved hierarchy endpoints.
2026-10-14 14:47:26 +08:00

298 lines
10 KiB
TypeScript

import { INSPECT_ANALYSIS_VIEW_OPERATION } from "../../domain/analysisView/analysisViewErrors.js";
import { z } from "zod";
import { ANALYSIS_VIEW_PROVIDER } from "../InvestigationProviders.js";
import type { EvidenceLookup } from "../EvidenceInputResolver.js";
import { resolveEvidenceInput } from "../EvidenceInputResolver.js";
import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../domain/evidence.js";
import {
inspectAnalysisViewInputSchema,
projectAnalysisView,
type AnalysisViewResult,
type InspectAnalysisViewInput,
} from "../../domain/analysisView/analysisView.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
import { err, ok, type Result } from "../../domain/result.js";
import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js";
/** Authenticate a selected-view request and project already completed Evidence. */
export const inspectAnalysisView = (
rawInput: unknown,
lookup?: EvidenceLookup,
): Result<Evidence, AnalysisError> => {
const parsed = inspectAnalysisViewInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
INSPECT_ANALYSIS_VIEW_OPERATION,
parsed.error.issues,
rawInput,
{ cause: parsed.error },
),
);
return inspectAnalysisViewValidated(parsed.data, lookup);
};
/** Authenticate borrowed inline Evidence after adapter envelope validation. */
export const inspectAnalysisViewValidated = (
input: InspectAnalysisViewInput,
lookup?: EvidenceLookup,
): Result<Evidence, AnalysisError> => {
const accumulator =
input.view.kind === "api-surface"
? new WebNetworkApiSurfaceAccumulator(
undefined,
input.view.include_query_parameter_names === true,
)
: undefined;
if (input.source.kind === "inline" && accumulator !== undefined) {
const admitted = accumulator.admitInlineEvidence(input.source.evidence);
if (!admitted.ok) return admitted;
}
const resolved = resolveAnalysisViewSource(input.source, lookup);
if (!resolved.ok) return resolved;
const parent = resolved.value;
const projected = projectAnalysisView(
{
evidenceId: parent.evidence_id,
operation: parent.operation,
normalizedResult: parent.normalized_result,
// Sealing an Evidence root deep-freezes it; only the root is registered.
sealedResult: isImmutableJsonSnapshot(parent),
subject: parent.subject,
limitations: parent.limitations,
parameters: parent.parameters,
artifact:
parent.subject === null
? null
: {
path: parent.subject.local_path,
sha256: parent.subject.digest.sha256,
},
},
input.view,
accumulator,
);
if (!projected.ok) return projected;
if (
parent.subject !== null &&
parent.subject.digest.sha256 !== projected.value.artifact.sha256
)
return err(
new EvidenceIntegrityError(
`Parent subject SHA-256 ${parent.subject.digest.sha256} differs from analysis artifact SHA-256 ${projected.value.artifact.sha256}.`,
),
);
return ok(createAnalysisViewEvidence(input, parent, projected.value));
};
/** Session Evidence ownership required to deliver a view instead of a complete record. */
export interface AnalysisViewRetention {
readonly evidenceById: EvidenceLookup | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
}
/** Fail before analysis when selected delivery cannot keep its complete source. */
export const requireAnalysisViewRetention = (
operation: string,
retention: AnalysisViewRetention,
): Result<
{
readonly evidenceById: EvidenceLookup;
readonly recordEvidence: EvidenceWriter["recordEvidence"];
},
AnalysisError
> => {
if (
retention.recordEvidence === undefined ||
retention.evidenceById === undefined
)
return err(
new AnalysisCapabilityUnavailableError(
ANALYSIS_VIEW_PROVIDER.id,
operation,
"summary detail requires a server session that retains Evidence for later views",
{
userMessage:
"This server does not retain Evidence. Select detail complete to receive the complete analysis inline.",
},
),
);
return ok({
evidenceById: retention.evidenceById,
recordEvidence: retention.recordEvidence,
});
};
/**
* Retain complete analysis Evidence in the session, then project its summary
* view from the retained record. Callers page or select the rest through
* inspect_analysis_view with the view's parent Evidence ID.
*/
export const summarizeRetainedAnalysis = (
evidence: Evidence,
retention: AnalysisViewRetention,
): Result<Evidence, AnalysisError> => {
const ready = requireAnalysisViewRetention(evidence.operation, retention);
if (!ready.ok) return ready;
const recorded = ready.value.recordEvidence(evidence);
if (!recorded.ok) return recorded;
return inspectAnalysisViewValidated(
{
source: { kind: "retained-evidence", evidence_id: evidence.evidence_id },
view: { kind: "summary" },
},
ready.value.evidenceById,
);
};
const resolveAnalysisViewSource = (
source: InspectAnalysisViewInput["source"],
lookup: EvidenceLookup | undefined,
): Result<Evidence, AnalysisError> => {
if (source.kind === "retained-evidence")
return resolveEvidenceInput(source, lookup);
const invalidDataPath = inlineEvidenceInvalidDataPath(source.evidence);
if (invalidDataPath !== undefined)
return err(
new AnalysisInputError(INSPECT_ANALYSIS_VIEW_OPERATION, undefined, [
{
path: ["source", "evidence", ...invalidDataPath],
reason: "invalid_type",
message:
"Inline Evidence must contain plain JSON data properties without accessors or cycles.",
},
]),
);
try {
return ok(parseEvidence(source.evidence));
} catch (cause: unknown) {
if (cause instanceof z.ZodError)
return err(
analysisInputErrorFromIssues(
INSPECT_ANALYSIS_VIEW_OPERATION,
cause.issues.map((issue) => ({
...issue,
path: ["source", "evidence", ...issue.path],
})),
{ source },
{ cause },
),
);
if (cause instanceof TypeError)
return err(
new EvidenceIntegrityError(
cause.message,
cause.message.length > 0 ? { userMessage: cause.message } : undefined,
),
);
throw cause;
}
};
/** Reject non-JSON object behavior before Zod reads caller-owned Evidence. */
const inlineEvidenceInvalidDataPath = (
root: unknown,
): readonly (string | number)[] | undefined => {
const active = new Set<object>();
const pending: {
readonly value: unknown;
readonly path: readonly (string | number)[];
readonly exit?: object;
}[] = [{ value: root, path: [] }];
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined) continue;
if (current.exit !== undefined) {
active.delete(current.exit);
continue;
}
const { value, path } = current;
if (typeof value !== "object" || value === null) continue;
const isArray = Array.isArray(value);
const prototype = Object.getPrototypeOf(value);
if (
(isArray && prototype !== Array.prototype) ||
(!isArray && prototype !== Object.prototype && prototype !== null) ||
active.has(value)
)
return path;
active.add(value);
pending.push({ value, path, exit: value });
const keys = Object.getOwnPropertyNames(value);
for (let index = keys.length - 1; index >= 0; index -= 1) {
const key = keys[index];
if (key === undefined) continue;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
const childPath = [
...path,
isArray && /^(0|[1-9]\d*)$/u.test(key) ? Number(key) : key,
];
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
return childPath;
pending.push({ value: descriptor.value, path: childPath });
}
}
return undefined;
};
const createAnalysisViewEvidence = (
input: InspectAnalysisViewInput,
parent: Evidence,
result: AnalysisViewResult,
): Evidence => {
const target =
parent.subject === null
? undefined
: {
path: parent.subject.local_path,
sha256: parent.subject.digest.sha256,
format: parent.subject.format,
...(parent.subject.architecture === null
? {}
: { architecture: parent.subject.architecture }),
};
return createEvidence(target, ANALYSIS_VIEW_PROVIDER, {
predicateType: "rea.analysis-view",
operation: INSPECT_ANALYSIS_VIEW_OPERATION,
parameters: {
parent_evidence_id: parent.evidence_id,
view: jsonValueSchema.parse(input.view),
},
result: jsonValueSchema.parse(result),
rawResult: null,
confidence:
// Paging follows the full grouping, so any templated group makes every page inferred.
result.kind === "api-surface" && result.templated_groups > 0
? "inferred"
: "derived",
authority: parent.authority,
limitations: result.limitations,
locations: [
...(result.artifact.path.length > 0
? [{ kind: "artifact-path" as const, path: result.artifact.path }]
: []),
...(result.kind === "native" &&
result.procedure_address !== null &&
result.procedure_address.length > 0
? [{ kind: "address" as const, address: result.procedure_address }]
: []),
],
evidenceLinks: [parent.evidence_id],
});
};