fix: close task 6 rereview gaps
This commit is contained in:
@@ -99,7 +99,7 @@ describe("input expression editor projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("projects structural paths through the canonical path formatter", () => {
|
||||
it("normalizes structural paths through the canonical path formatter", () => {
|
||||
const canonical: InputExpression = {
|
||||
kind: "array",
|
||||
items: [{ kind: "path", path: { root: "context", parts: ["request", "display name"] } }],
|
||||
@@ -113,8 +113,16 @@ describe("input expression editor projection", () => {
|
||||
if (projected.kind !== "editable") throw new Error("expected editable expression");
|
||||
expect(serializeExpressionEditorState(projected.state)).toEqual({
|
||||
kind: "array",
|
||||
items: [{ kind: "path", path: { root: "context", parts: ["request", "display name"] } }],
|
||||
items: [{ kind: "path", path: 'context.request."display name"' }],
|
||||
});
|
||||
expect(serializeExpressionEditorState({ ...projected.state })).toEqual({
|
||||
kind: "array",
|
||||
items: [{ kind: "path", path: 'context.request."display name"' }],
|
||||
});
|
||||
expect(projectExpressionEditorState({
|
||||
kind: "array",
|
||||
items: [{ kind: "path", path: 'context.request."display name"' }],
|
||||
}, { type: "array", items: {} })).toEqual(projected);
|
||||
});
|
||||
|
||||
it("serializes an edited structural path from its current string", () => {
|
||||
@@ -269,6 +277,29 @@ describe("input expression editor projection", () => {
|
||||
expect(serializeExpressionEditorState({ kind: "literal", value: sparse, touched: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts every browser literal within the canonical literal-expression budget", () => {
|
||||
const nestedLiteralValue = (containerDepth: number): unknown => {
|
||||
let value: unknown = "leaf";
|
||||
for (let depth = 0; depth < containerDepth; depth += 1) value = { nested: value };
|
||||
return value;
|
||||
};
|
||||
|
||||
const values: ReadonlyArray<unknown> = [
|
||||
null,
|
||||
false,
|
||||
"text",
|
||||
3.14,
|
||||
["item"],
|
||||
{ key: "value" },
|
||||
nestedLiteralValue(63),
|
||||
];
|
||||
for (const value of values) {
|
||||
expect(isJsonValue(value)).toBe(true);
|
||||
expect(parseInputExpression({ kind: "literal", value })).not.toBeNull();
|
||||
}
|
||||
expect(isJsonValue(nestedLiteralValue(64))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the shared budget for parsing, projection, validation, and serialization", () => {
|
||||
const oversized = {
|
||||
kind: "literal",
|
||||
@@ -283,7 +314,7 @@ describe("input expression editor projection", () => {
|
||||
expect(serializeExpressionEditorState({ kind: "literal", value: oversized.value, touched: true })).toBeNull();
|
||||
expect(validateExpressionEditorState({ kind: "literal", value: oversized.value, touched: true }, {})).toMatchObject({
|
||||
valid: false,
|
||||
issues: [expect.objectContaining({ message: expect.stringMatching(/budget/i) })],
|
||||
issues: [expect.objectContaining({ message: expect.stringMatching(/finite JSON|budget/i) })],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
PathExpression,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import {
|
||||
hasBoundedInputExpressionLiteralValue,
|
||||
hasBoundedInputExpressionNodeBudget,
|
||||
MAX_INPUT_EXPRESSION_DEPTH,
|
||||
} from "@lda/workflow-rpc/input-expression-limits";
|
||||
import { normalizeSchema, type SchemaField } from "../schema-form/schema-field.js";
|
||||
import { formatTOMLPath, parseGraphSourcePath, parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||
@@ -48,26 +48,8 @@ const hasExactKeys = (value: JsonRecord, keys: ReadonlyArray<string>): boolean =
|
||||
};
|
||||
|
||||
/** Keep editor literals within the same finite JSON subset as canonical bindings. */
|
||||
export const isJsonValue = (value: unknown): value is JsonValue => {
|
||||
const visit = (current: unknown, depth: number): boolean => {
|
||||
if (depth > MAX_INPUT_EXPRESSION_DEPTH) return false;
|
||||
if (current === null || typeof current === "boolean" || typeof current === "string") return true;
|
||||
if (typeof current === "number") return Number.isFinite(current);
|
||||
if (Array.isArray(current)) {
|
||||
if (Object.getOwnPropertySymbols(current).length > 0) return false;
|
||||
if (!Object.keys(current).every((key) => /^(0|[1-9]\d*)$/.test(key))) return false;
|
||||
for (let index = 0; index < current.length; index += 1) {
|
||||
if (!Object.prototype.hasOwnProperty.call(current, index) || !visit(current[index], depth + 1)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!isRecord(current)) return false;
|
||||
if (Object.getPrototypeOf(current) !== Object.prototype && Object.getPrototypeOf(current) !== null) return false;
|
||||
if (Object.getOwnPropertySymbols(current).length > 0) return false;
|
||||
return Object.values(current).every((item) => visit(item, depth + 1));
|
||||
};
|
||||
return visit(value, 0);
|
||||
};
|
||||
export const isJsonValue = (value: unknown): value is JsonValue =>
|
||||
hasBoundedInputExpressionLiteralValue(value);
|
||||
|
||||
const inputPath = (value: unknown): InputPath | null => {
|
||||
if (typeof value === "string") return parseGraphSourcePath(value) === null ? null : value;
|
||||
@@ -115,11 +97,6 @@ export const parseInputExpression = (value: unknown): InputExpression | null =>
|
||||
const pathText = (path: InputPath): string =>
|
||||
typeof path === "string" ? path : formatTOMLPath([path.root, ...path.parts]);
|
||||
|
||||
// The public editor union intentionally stores paths as strings. Keep the wire
|
||||
// representation only for untouched projected states so structural paths do
|
||||
// not get normalized unless an editor actually recreates them.
|
||||
const structuralPathByState = new WeakMap<object, InputPath>();
|
||||
|
||||
const unsupported = (raw: InputExpression, reason: string): ExpressionProjection => ({
|
||||
kind: "unsupported",
|
||||
raw,
|
||||
@@ -149,11 +126,7 @@ const project = (
|
||||
case "literal":
|
||||
return { kind: "editable", state: { kind: "literal", value: raw.value, touched: false } };
|
||||
case "path":
|
||||
{
|
||||
const state = { kind: "path", path: pathText(raw.path), touched: false } as const;
|
||||
if (typeof raw.path !== "string") structuralPathByState.set(state, raw.path);
|
||||
return { kind: "editable", state };
|
||||
}
|
||||
return { kind: "editable", state: { kind: "path", path: pathText(raw.path), touched: false } };
|
||||
case "array": {
|
||||
if (field !== null && !unconstrained(field) && field.kind !== "array") return unsupported(raw, "The expression is an array but the target schema is not an array.");
|
||||
const itemField = field?.item ?? null;
|
||||
@@ -196,7 +169,7 @@ const copyStateToExpression = (state: ExpressionEditorState): InputExpression |
|
||||
return isJsonValue(state.value) ? { kind: "literal", value: state.value } : null;
|
||||
case "path":
|
||||
if (parseGraphSourcePath(state.path) === null) return null;
|
||||
return { kind: "path", path: state.touched ? state.path : structuralPathByState.get(state) ?? state.path };
|
||||
return { kind: "path", path: state.path };
|
||||
case "array": {
|
||||
const items: InputExpression[] = [];
|
||||
for (const item of state.items) {
|
||||
|
||||
Reference in New Issue
Block a user