fix(environment): retain path segments counters and parent diagnostics

This commit is contained in:
morluto
2026-10-24 02:47:03 +08:00
parent 65e423eba6
commit f49e2eeacc
9 changed files with 126 additions and 40 deletions
+5
View File
@@ -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);