mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
fix(environment): retain path segments counters and parent diagnostics
This commit is contained in:
@@ -442,6 +442,11 @@ A read with repeated query values uses replay, because the state query model
|
||||
does not infer repeated-parameter semantics. A repeated-query write is refused
|
||||
before it can change state for the same reason.
|
||||
|
||||
List counters retain their recorded number or digit-string representation.
|
||||
Changing a digit-string counter preserves its observed zero-padding width;
|
||||
query-total inference remains limited to canonical decimal counts. Path matching
|
||||
uses the same segment parser as extraction, preserving empty internal segments.
|
||||
|
||||
An item or list view that always spells a clock field otherwise than the
|
||||
entity's stored value names that spelling in `clock_formats`, and a value it
|
||||
never recorded, such as a created entity's clock, is rendered in it: a list that
|
||||
|
||||
@@ -52,6 +52,21 @@ const answer = (
|
||||
return result.value;
|
||||
};
|
||||
|
||||
it("finds state entities using the recorded path's repeated separators", () => {
|
||||
const serving = servingFrom([
|
||||
["GET", "/api//books/1", { id: 1, title: "Book" }],
|
||||
]);
|
||||
const tool = serving.source.manifest.tools.find(
|
||||
(candidate) => candidate.path_template === "/api//books/{id}",
|
||||
);
|
||||
if (tool === undefined) throw new Error("Missing double-separator item tool");
|
||||
const result = answerFromState(serving, tool, { id: "1" });
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { body: { id: 1, title: "Book" }, state: { source: "state" } },
|
||||
});
|
||||
});
|
||||
|
||||
const refusal = (
|
||||
serving: StateServing,
|
||||
name: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
allLines,
|
||||
collectionPathOf,
|
||||
collectionMatch,
|
||||
segmentsOf,
|
||||
type SuppliedSegment,
|
||||
type StateListView,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
@@ -27,13 +28,11 @@ export const callSegments = (
|
||||
const names = environmentToolArguments(tool).path;
|
||||
const params = new Map(tool.params.map((param) => [param.index, param]));
|
||||
const segments: SuppliedSegment[] = [];
|
||||
const template = tool.path_template
|
||||
.slice(tool.path_template.indexOf("/") + 1)
|
||||
.split("/");
|
||||
const template = segmentsOf(tool.path_template);
|
||||
for (const [index, text] of template.entries()) {
|
||||
const param = params.get(index);
|
||||
if (param === undefined) {
|
||||
if (text !== "") segments.push({ text, supplied: false });
|
||||
segments.push({ text, supplied: false });
|
||||
continue;
|
||||
}
|
||||
const value = args[names.get(param.name) ?? param.name];
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../webNetworkCapture.js";
|
||||
import { admitAnalysisViewData } from "./analysisViewDataAdmission.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import { analysisViewResultInputError } from "./analysisViewParent.js";
|
||||
import type { AnalysisViewParent } from "./analysisView.js";
|
||||
import {
|
||||
ENTRY_BYTES,
|
||||
@@ -124,14 +125,7 @@ export const admitHarCaptureParent = (
|
||||
const parsed = safeParseOwnedWebNetworkCapture(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Parent Evidence result does not match inspect_web_network_capture.",
|
||||
},
|
||||
]),
|
||||
analysisViewResultInputError(parent.normalizedResult, parsed.error),
|
||||
);
|
||||
if (parsed.data.format !== "har")
|
||||
return unsupported(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isObject,
|
||||
lastSegmentValue,
|
||||
listItems,
|
||||
recordedItemCount,
|
||||
renderStateEntity,
|
||||
renderStateList,
|
||||
replaceAt,
|
||||
@@ -280,17 +281,22 @@ const listFacts = (
|
||||
items.forEach((item) => union.add(item));
|
||||
const parent = plan.pointer === "" || !isObject(body) ? {} : body;
|
||||
const counted = Object.keys(parent).filter(
|
||||
(name) => isItemTotal(name) && countOf(parent[name]) !== undefined,
|
||||
(name) =>
|
||||
isItemTotal(name) && recordedItemCount(parent[name]) !== undefined,
|
||||
);
|
||||
counting.set(
|
||||
record.ordinal,
|
||||
counted.filter((name) => parent[name] === items.length),
|
||||
counted.filter(
|
||||
(name) => recordedItemCount(parent[name]) === items.length,
|
||||
),
|
||||
);
|
||||
// Only a line that pages at most can hold its whole collection; a total that differs from
|
||||
// the number listed, or a next page, shows it is partial.
|
||||
if (
|
||||
!startsAtBeginning(record.query) ||
|
||||
counted.some((name) => countOf(parent[name]) !== items.length) ||
|
||||
counted.some(
|
||||
(name) => recordedItemCount(parent[name]) !== items.length,
|
||||
) ||
|
||||
Object.keys(parent).some((name) => continues(name, parent[name]))
|
||||
)
|
||||
return;
|
||||
@@ -332,14 +338,6 @@ const isItemTotal = (name: string): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/** A count recorded as a number or as a string of digits. */
|
||||
const countOf = (value: JsonValue | undefined): number | undefined =>
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && /^\d+$/u.test(value)
|
||||
? Number(value)
|
||||
: undefined;
|
||||
|
||||
const CONTINUATION_NAMES = new Set([
|
||||
"hasmore",
|
||||
"next",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js
|
||||
import { literalSubstringPredicate } from "../analysisSearch.js";
|
||||
import {
|
||||
isObject,
|
||||
recordedItemCount,
|
||||
formatItemCount,
|
||||
pointerTo,
|
||||
renderStateList,
|
||||
replaceAt,
|
||||
@@ -569,13 +571,11 @@ export const learnQuery = (
|
||||
export const queryTotalCount = (
|
||||
value: JsonValue | undefined,
|
||||
): number | undefined => {
|
||||
const count =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && /^(?:0|[1-9]\d*)$/u.test(value)
|
||||
? Number(value)
|
||||
: undefined;
|
||||
return count !== undefined && Number.isSafeInteger(count) && count >= 0
|
||||
const count = recordedItemCount(value);
|
||||
return count !== undefined &&
|
||||
Number.isSafeInteger(count) &&
|
||||
count >= 0 &&
|
||||
(typeof value !== "string" || String(count) === value)
|
||||
? count
|
||||
: undefined;
|
||||
};
|
||||
@@ -594,11 +594,7 @@ export const renderQueryList = (
|
||||
isObject(base) &&
|
||||
Object.hasOwn(base, name) &&
|
||||
queryTotalCount(base[name]) !== undefined
|
||||
? replaceAt(
|
||||
body,
|
||||
pointerTo(name),
|
||||
typeof base[name] === "string" ? String(total) : total,
|
||||
)
|
||||
? replaceAt(body, pointerTo(name), formatItemCount(total, base[name]!))
|
||||
: body,
|
||||
renderStateList(view, base, items),
|
||||
);
|
||||
|
||||
@@ -596,6 +596,25 @@ export const listItems = (
|
||||
return Array.isArray(items) && items.every(isObject) ? items : undefined;
|
||||
};
|
||||
|
||||
/** An observed item count, retaining the original value separately for rendering. */
|
||||
export const recordedItemCount = (
|
||||
value: JsonValue | undefined,
|
||||
): number | undefined =>
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && /^\d+$/u.test(value)
|
||||
? Number(value)
|
||||
: undefined;
|
||||
|
||||
/** Spell a changed count with the recorded numeric/string type and digit width. */
|
||||
export const formatItemCount = (
|
||||
count: number,
|
||||
observed: JsonValue,
|
||||
): JsonValue =>
|
||||
typeof observed === "string"
|
||||
? String(count).padStart(observed.length, "0")
|
||||
: count;
|
||||
|
||||
/** One entity as a view shows it: the view's fields it has, view-specific values first; volatile fields take `observed` values when given. */
|
||||
export const renderStateEntity = (
|
||||
entity: StateEntity,
|
||||
@@ -650,10 +669,13 @@ export const renderStateList = (
|
||||
): JsonValue => {
|
||||
const listed = listItems(base, view.pointer)?.length;
|
||||
return view.counters.reduce(
|
||||
(body, name) =>
|
||||
at(base, pointerTo(name)) === listed
|
||||
? replaceAt(body, pointerTo(name), items.length)
|
||||
: body,
|
||||
(body, name) => {
|
||||
const observed = at(base, pointerTo(name));
|
||||
if (listed === undefined || recordedItemCount(observed) !== listed)
|
||||
return body;
|
||||
const count = formatItemCount(items.length, observed!);
|
||||
return replaceAt(body, pointerTo(name), count);
|
||||
},
|
||||
replaceAt(base, view.pointer, [...items]),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
at,
|
||||
identityCandidates,
|
||||
replaceAt,
|
||||
renderStateList,
|
||||
type JsonObject,
|
||||
} from "./environmentStateRules.js";
|
||||
import { environmentStateFields } from "./environmentStateView.js";
|
||||
@@ -332,6 +333,25 @@ it("lists the whole collection with its counter only when every recording did",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, "1", "001"])(
|
||||
"retains item count representation %j in extracted list plans",
|
||||
(total) => {
|
||||
const body = { total, items: [{ id: 1, title: "One" }] };
|
||||
const state = stateOf(captureOf([["GET", "/api/items", body]]));
|
||||
const view = state.views.find((candidate) => candidate.shape === "list");
|
||||
if (view?.shape !== "list") throw new Error("Expected an item list view");
|
||||
expect(view.counters).toEqual(["total"]);
|
||||
expect(renderStateList(view, body, body.items)).toEqual(body);
|
||||
const added = renderStateList(view, body, [
|
||||
...body.items,
|
||||
{ id: 2, title: "Two" },
|
||||
]);
|
||||
expect(added).toMatchObject({
|
||||
total: typeof total === "string" ? "2".padStart(total.length, "0") : 2,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves recorded the views it cannot reproduce, with the reason", () => {
|
||||
const state = stateOf(
|
||||
captureOf([
|
||||
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
analysisViewCaptureFixture,
|
||||
analysisViewCaptureEvidence,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import { projectAnalysisView } from "./analysisView.js";
|
||||
import {
|
||||
analysisViewRequestSchema,
|
||||
projectAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import { projectAnalysisError } from "../analysisErrorProjection.js";
|
||||
|
||||
const project = (
|
||||
capture = analysisViewCaptureFixture(),
|
||||
@@ -208,6 +212,39 @@ it("rejects native captures and malformed parents with typed errors", () => {
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
});
|
||||
|
||||
it.each(["api-surface", "environment-manifest", "environment-state"])(
|
||||
"preserves nested capture-schema diagnostics for %s",
|
||||
(kind) => {
|
||||
const capture = analysisViewCaptureFixture();
|
||||
const result = projectAnalysisView(
|
||||
{
|
||||
evidenceId: `ev_${"a".repeat(64)}`,
|
||||
operation: "inspect_web_network_capture",
|
||||
limitations: [],
|
||||
parameters: { sensitive_value_count: 0 },
|
||||
normalizedResult: {
|
||||
...capture,
|
||||
artifact: { ...capture.artifact, bytes: "invalid" },
|
||||
},
|
||||
},
|
||||
analysisViewRequestSchema.parse({ kind }),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected parent admission to fail");
|
||||
expect(result.error.cause).toMatchObject({ name: "ZodError" });
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: {
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["source", "normalized_result", "artifact", "bytes"],
|
||||
reason: "invalid_type",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves partial response facts and explains unavailable requests", () => {
|
||||
const capture = analysisViewCaptureFixture();
|
||||
capture.records = capture.records.slice(0, 5);
|
||||
|
||||
Reference in New Issue
Block a user