refactor(analysis-view): consolidate result and budget ownership
Reuse the shared immutable-result reader for Interface Builder while checking its current parent binding on every read. Accept truthful optional schema outputs and validate normalized JSON before freezing or caching it, preserving explicit undefined facts without caching them. Move accumulator construction and inline admission policy into the domain. One prepared projection retains the same budget across admission and projection while preserving standalone defaults, custom budgets, and authentication order.
This commit is contained in:
1 parent
399e2226ca
commit
731109b7d7
9 files changed
+158
-102
No files matched your search
@@ -20,7 +20,7 @@ import { safeParseEvidence } from "../../domain/evidenceValidation.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
inspectAnalysisViewInputSchema,
|
||||
projectAnalysisView,
|
||||
prepareAnalysisViewProjection,
|
||||
type AnalysisViewResult,
|
||||
type AnalysisViewRequest,
|
||||
type InspectAnalysisViewInput,
|
||||
@@ -30,10 +30,6 @@ import {
|
||||
isImmutableJsonSnapshot,
|
||||
} from "../../domain/immutableJson.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js";
|
||||
import { EnvironmentManifestAccumulator } from "../../domain/analysisView/EnvironmentManifestAccumulator.js";
|
||||
import { EnvironmentStateAccumulator } from "../../domain/analysisView/EnvironmentStateAccumulator.js";
|
||||
import { admitAnalysisViewData } from "../../domain/analysisView/analysisViewDataAdmission.js";
|
||||
import {
|
||||
readEvidenceFileSource,
|
||||
type EvidenceFileReadOptions,
|
||||
@@ -177,67 +173,35 @@ export const inspectAnalysisViewValidated = (
|
||||
input: ReadyAnalysisViewInput,
|
||||
lookup?: EvidenceLookup,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
const apiSurfaceAccumulator =
|
||||
input.view.kind === "api-surface"
|
||||
? new WebNetworkApiSurfaceAccumulator(
|
||||
undefined,
|
||||
input.view.include_query_parameter_names === true,
|
||||
)
|
||||
: undefined;
|
||||
const manifestAccumulator =
|
||||
input.view.kind === "environment-manifest"
|
||||
? new EnvironmentManifestAccumulator(
|
||||
undefined,
|
||||
input.view.include_query_parameter_names,
|
||||
input.view.template_rules,
|
||||
)
|
||||
: undefined;
|
||||
const stateAccumulator =
|
||||
input.view.kind === "environment-state" ||
|
||||
input.view.kind === "environment-coverage"
|
||||
? new EnvironmentStateAccumulator(undefined, input.view.template_rules)
|
||||
: undefined;
|
||||
const accumulator =
|
||||
apiSurfaceAccumulator ?? manifestAccumulator ?? stateAccumulator;
|
||||
const projection = prepareAnalysisViewProjection(input.view);
|
||||
if (input.source.kind === "inline") {
|
||||
const admitted =
|
||||
accumulator === undefined
|
||||
? admitAnalysisViewData(input.source.evidence, true)
|
||||
: accumulator.admitInlineEvidence(input.source.evidence);
|
||||
const admitted = projection.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,
|
||||
provider: parent.provider,
|
||||
analysisProfileDigest: parent.analysis_profile?.digest ?? null,
|
||||
normalizedResult:
|
||||
immutableJsonSnapshotMember(parent, "normalized_result") ??
|
||||
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,
|
||||
{
|
||||
apiSurface: apiSurfaceAccumulator,
|
||||
manifest: manifestAccumulator,
|
||||
state: stateAccumulator,
|
||||
},
|
||||
);
|
||||
const projected = projection.project({
|
||||
evidenceId: parent.evidence_id,
|
||||
operation: parent.operation,
|
||||
provider: parent.provider,
|
||||
analysisProfileDigest: parent.analysis_profile?.digest ?? null,
|
||||
normalizedResult:
|
||||
immutableJsonSnapshotMember(parent, "normalized_result") ??
|
||||
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,
|
||||
},
|
||||
});
|
||||
if (!projected.ok) return projected;
|
||||
if (
|
||||
parent.subject !== null &&
|
||||
|
||||
@@ -172,6 +172,7 @@ import {
|
||||
import { projectBinaryLayoutView } from "./binaryLayoutView.js";
|
||||
import { projectJavaScriptApplicationView } from "./javascriptApplicationView.js";
|
||||
import {
|
||||
createApiSurfaceAccumulator,
|
||||
projectWebNetworkApiSurfaceView,
|
||||
apiSurfaceFields,
|
||||
apiSurfaceViewSchema,
|
||||
@@ -232,12 +233,15 @@ import {
|
||||
import type { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
|
||||
import type { EnvironmentManifestAccumulator } from "./EnvironmentManifestAccumulator.js";
|
||||
import type { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js";
|
||||
import { admitAnalysisViewData } from "./analysisViewDataAdmission.js";
|
||||
import {
|
||||
createEnvironmentManifestAccumulator,
|
||||
projectEnvironmentManifestView,
|
||||
environmentManifestFields,
|
||||
environmentManifestViewRequestSchema,
|
||||
} from "./environmentManifestView.js";
|
||||
import {
|
||||
createEnvironmentStateAccumulator,
|
||||
environmentStateFields,
|
||||
environmentStateViewRequestSchema,
|
||||
projectEnvironmentStateView,
|
||||
@@ -1338,13 +1342,47 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
||||
["inventory_artifact", projectArtifactInventoryView],
|
||||
]);
|
||||
|
||||
/** Budgets a caller created before admitting the source; each grouping view uses its own. */
|
||||
/** Budgets retained across source admission and the selected grouping projection. */
|
||||
export interface AnalysisViewAccumulators {
|
||||
readonly apiSurface?: WebNetworkApiSurfaceAccumulator | undefined;
|
||||
readonly manifest?: EnvironmentManifestAccumulator | undefined;
|
||||
readonly state?: EnvironmentStateAccumulator | undefined;
|
||||
}
|
||||
|
||||
/** Prepare one view's source admission and projection under the same owned budget. */
|
||||
export const prepareAnalysisViewProjection = (
|
||||
view: AnalysisViewRequest,
|
||||
): {
|
||||
admitInlineEvidence(value: unknown): Result<void, AnalysisError>;
|
||||
project(
|
||||
parent: AnalysisViewParent,
|
||||
): Result<AnalysisViewResult, AnalysisError>;
|
||||
} => {
|
||||
const accumulators: AnalysisViewAccumulators = {
|
||||
apiSurface:
|
||||
view.kind === "api-surface"
|
||||
? createApiSurfaceAccumulator(view)
|
||||
: undefined,
|
||||
manifest:
|
||||
view.kind === "environment-manifest"
|
||||
? createEnvironmentManifestAccumulator(view)
|
||||
: undefined,
|
||||
state:
|
||||
view.kind === "environment-state" || view.kind === "environment-coverage"
|
||||
? createEnvironmentStateAccumulator(view)
|
||||
: undefined,
|
||||
};
|
||||
const accumulator =
|
||||
accumulators.apiSurface ?? accumulators.manifest ?? accumulators.state;
|
||||
return {
|
||||
admitInlineEvidence: (value) =>
|
||||
accumulator === undefined
|
||||
? admitAnalysisViewData(value, true)
|
||||
: accumulator.admitInlineEvidence(value),
|
||||
project: (parent) => projectAnalysisView(parent, view, accumulators),
|
||||
};
|
||||
};
|
||||
|
||||
/** Views that group a capture's records into tools. */
|
||||
export type GroupingViewKind =
|
||||
| "api-surface"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
freezeOwnedJsonTreeSnapshot,
|
||||
@@ -50,3 +50,46 @@ it("keeps schema-specific normalized facts separate without mutating sealed sour
|
||||
expect(isImmutableJsonSnapshot(b.value)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("borrows omitted optional fields from a sealed JSON source and reuses their parsed facts", () => {
|
||||
const source = freezeOwnedJsonTreeSnapshot({});
|
||||
const schema = z.strictObject({ optional: z.string().optional() });
|
||||
const parse = vi.spyOn(schema, "safeParse");
|
||||
const read = createAnalysisViewResultReader(schema);
|
||||
try {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const result = read(parent(source));
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toBe(source);
|
||||
expect(result.value.optional).toBeUndefined();
|
||||
expect(Object.hasOwn(result.value, "optional")).toBe(false);
|
||||
}
|
||||
expect(parse).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
parse.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit undefined normalization without freezing or caching non-JSON facts", () => {
|
||||
const source = freezeOwnedJsonTreeSnapshot({});
|
||||
const produced: { optional: undefined }[] = [];
|
||||
const schema = z.strictObject({}).transform(() => {
|
||||
const facts = { optional: undefined };
|
||||
produced.push(facts);
|
||||
return facts;
|
||||
});
|
||||
const read = createAnalysisViewResultReader(schema);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const result = read(parent(source));
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toBe(produced[attempt]);
|
||||
expect(Object.hasOwn(result.value, "optional")).toBe(true);
|
||||
expect(result.value.optional).toBeUndefined();
|
||||
expect(Object.isFrozen(result.value)).toBe(false);
|
||||
expect(isImmutableJsonSnapshot(result.value)).toBe(false);
|
||||
}
|
||||
expect(produced).toHaveLength(2);
|
||||
expect(produced[0]).not.toBe(produced[1]);
|
||||
expect(source).toEqual({});
|
||||
expect(Object.hasOwn(source, "optional")).toBe(false);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import type { AnalysisViewParent } from "./analysisView.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { ownedJsonValueSchema } from "../jsonValue.js";
|
||||
import {
|
||||
canBorrowParsedJsonSnapshot,
|
||||
freezeOwnedJsonTreeSnapshot,
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
} from "../immutableJson.js";
|
||||
|
||||
/** One owning schema reuses validated immutable facts for their retained lifetime. */
|
||||
export const createAnalysisViewResultReader = <
|
||||
Value extends JsonValue,
|
||||
>(parser: {
|
||||
export const createAnalysisViewResultReader = <Value>(parser: {
|
||||
readonly safeParse: (input: unknown) => z.ZodSafeParseResult<Value>;
|
||||
}): ((parent: AnalysisViewParent) => Result<Value, AnalysisError>) => {
|
||||
const checked = new WeakMap<object, Value>();
|
||||
@@ -29,11 +27,15 @@ export const createAnalysisViewResultReader = <
|
||||
if (known !== undefined) return ok(known);
|
||||
const parsed = parseAnalysisViewResult(parser, parent);
|
||||
if (!parsed.ok || !reusable) return parsed;
|
||||
const report = canBorrowParsedJsonSnapshot(source, parsed.value)
|
||||
? source
|
||||
: freezeOwnedJsonTreeSnapshot(parsed.value);
|
||||
checked.set(source, report);
|
||||
return ok(report);
|
||||
if (canBorrowParsedJsonSnapshot(source, parsed.value)) {
|
||||
checked.set(source, source);
|
||||
return ok(source);
|
||||
}
|
||||
const normalized = ownedJsonValueSchema.safeParse(parsed.value);
|
||||
if (!normalized.success) return parsed;
|
||||
freezeOwnedJsonTreeSnapshot(normalized.data);
|
||||
checked.set(source, parsed.value);
|
||||
return parsed;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import type {
|
||||
AnalysisViewRequest,
|
||||
UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js";
|
||||
import { templateRulesSchema } from "./environmentManifestView.js";
|
||||
import type { StateView } from "./environmentStateRules.js";
|
||||
import {
|
||||
createEnvironmentStateAccumulator,
|
||||
environmentStateFields,
|
||||
projectEnvironmentStateView,
|
||||
} from "./environmentStateView.js";
|
||||
@@ -152,7 +152,7 @@ export const summarizeEnvironmentCoverage = (state: {
|
||||
export const projectEnvironmentCoverageView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: Extract<AnalysisViewRequest, { kind: "environment-coverage" }>,
|
||||
accumulator = new EnvironmentStateAccumulator(undefined, view.template_rules),
|
||||
accumulator = createEnvironmentStateAccumulator(view),
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
const projected = projectEnvironmentStateView(
|
||||
parent,
|
||||
|
||||
@@ -224,17 +224,23 @@ const addCaptureRecords = (
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
/** Project retained request lines, from a HAR or a browser scenario, into a replayable tool surface; templates are inferences. */
|
||||
export const projectEnvironmentManifestView = (
|
||||
parent: AnalysisViewParent,
|
||||
/** Create the manifest budget with its selected query names and template rules. */
|
||||
export const createEnvironmentManifestAccumulator = (
|
||||
view: AnalysisViewRequest,
|
||||
accumulator = view.kind === "environment-manifest"
|
||||
): EnvironmentManifestAccumulator =>
|
||||
view.kind === "environment-manifest"
|
||||
? new EnvironmentManifestAccumulator(
|
||||
undefined,
|
||||
view.include_query_parameter_names,
|
||||
view.template_rules,
|
||||
)
|
||||
: new EnvironmentManifestAccumulator(),
|
||||
: new EnvironmentManifestAccumulator();
|
||||
|
||||
/** Project retained request lines, from a HAR or a browser scenario, into a replayable tool surface; templates are inferences. */
|
||||
export const projectEnvironmentManifestView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
accumulator = createEnvironmentManifestAccumulator(view),
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "environment-manifest")
|
||||
return err(
|
||||
|
||||
@@ -461,11 +461,20 @@ export const environmentStateFields = {
|
||||
),
|
||||
};
|
||||
|
||||
/** State extraction and coverage share the same budget and template-rule policy. */
|
||||
export const createEnvironmentStateAccumulator = (
|
||||
view: Extract<
|
||||
AnalysisViewRequest,
|
||||
{ kind: "environment-state" | "environment-coverage" }
|
||||
>,
|
||||
): EnvironmentStateAccumulator =>
|
||||
new EnvironmentStateAccumulator(undefined, view.template_rules);
|
||||
|
||||
/** Extract the init state a capture's reads observed and each read tool's render plan; types and identity are inferences. */
|
||||
export const projectEnvironmentStateView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: Extract<AnalysisViewRequest, { kind: "environment-state" }>,
|
||||
accumulator = new EnvironmentStateAccumulator(undefined, view.template_rules),
|
||||
accumulator = createEnvironmentStateAccumulator(view),
|
||||
): Result<
|
||||
Extract<UnsignedAnalysisView, { kind: "environment-state" }>,
|
||||
AnalysisError
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { canBorrowParsedJsonSnapshot } from "../immutableJson.js";
|
||||
import {
|
||||
interfaceBuilderAnalysisSchema,
|
||||
interfaceBuilderDocumentSchema,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
nativeInvestigationProviderSchema,
|
||||
} from "../native/nativeInvestigationGraph.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
|
||||
import { selectViewRows } from "./selectViewRows.js";
|
||||
import type {
|
||||
AnalysisViewParent,
|
||||
@@ -121,23 +120,15 @@ const selectDocumentRows = <
|
||||
(row) => row,
|
||||
);
|
||||
|
||||
// Exact owned snapshot identity, with no second retained graph or live-file cache.
|
||||
// Weak membership ends with the Evidence lifetime; failed/mutable imports are not reused.
|
||||
const checkedReports = new WeakSet<object>();
|
||||
const readReport = createAnalysisViewResultReader(
|
||||
interfaceBuilderAnalysisSchema,
|
||||
);
|
||||
|
||||
/** Admit a well-formed report bound to its recorded target, if one exists. */
|
||||
const parseInterfaceBuilderReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<InterfaceBuilderReport, AnalysisError> => {
|
||||
const source = parent.normalizedResult;
|
||||
const reusable =
|
||||
parent.sealedResult === true &&
|
||||
typeof source === "object" &&
|
||||
source !== null;
|
||||
const known = reusable && checkedReports.has(source);
|
||||
const parsed = known
|
||||
? ok(source as InterfaceBuilderReport)
|
||||
: parseAnalysisViewResult(interfaceBuilderAnalysisSchema, parent);
|
||||
const parsed = readReport(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
@@ -155,11 +146,7 @@ const parseInterfaceBuilderReport = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (!known && reusable && canBorrowParsedJsonSnapshot(source, report)) {
|
||||
checkedReports.add(source);
|
||||
return ok(source);
|
||||
}
|
||||
return ok(report);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/** Every decoded document with graph tallies; rows stay in the retained parent. */
|
||||
|
||||
@@ -75,14 +75,21 @@ export const apiSurfaceFields = {
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
/** Create the budget and query-name policy shared by admission and projection. */
|
||||
export const createApiSurfaceAccumulator = (
|
||||
view: AnalysisViewRequest,
|
||||
): WebNetworkApiSurfaceAccumulator =>
|
||||
new WebNetworkApiSurfaceAccumulator(
|
||||
undefined,
|
||||
view.kind === "api-surface" && view.include_query_parameter_names === true,
|
||||
);
|
||||
|
||||
/** Group retained HAR request lines; format matches do not establish handler identity. */
|
||||
export const projectWebNetworkApiSurfaceView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
accumulator = new WebNetworkApiSurfaceAccumulator(
|
||||
undefined,
|
||||
view.kind === "api-surface" && view.include_query_parameter_names === true,
|
||||
),
|
||||
accumulator = createApiSurfaceAccumulator(view),
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "api-surface")
|
||||
return err(
|
||||
|
||||
Reference in new issue
Block a user