fix: repair task 6 composite expression projection

This commit is contained in:
lda
2026-08-14 00:16:32 +07:00 Verified
parent b8a16a0fe7
commit 29d7a11da9
10 changed files with 278 additions and 54 deletions
@@ -177,6 +177,38 @@ describe("SelectedCapabilityInspector", () => {
expect(controller.setStepOutputs).not.toHaveBeenCalled();
});
it("blocks Save and Clear for a persisted composite input row", async () => {
const user = userEvent.setup();
const workspace = draft("read", [{
target: "title",
expression: { kind: "array", items: [{ kind: "literal", value: "composite" }] },
}], []);
const controller = controllerFor(workspace);
render(
<SelectedCapabilityInspector
capabilityDetail={detail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={controller}
draft={workspace}
nodeKind="use"
nodeRef="demo.read"
stepId="read"
/>,
);
await user.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("region", { name: "Raw unsupported input row 1" })).toHaveTextContent("composite");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
await user.click(screen.getByRole("button", { name: "Clear inputs" }));
expect(controller.setStepInputs).not.toHaveBeenCalled();
expect(screen.getAllByRole("alert").some((alert) =>
alert.textContent?.includes("unsupported input row") ?? false,
)).toBe(true);
});
it("keeps diagnostic ids unique across failing setup and hidden binding forms", async () => {
const user = userEvent.setup();
const workspace = draft(
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import type { InputExpression } from "../domain/draft-workspace-models.js";
import {
isJsonValue,
parseInputExpression,
projectExpressionEditorState,
serializeExpressionEditorState,
validateExpressionEditorState,
@@ -111,10 +113,38 @@ 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: 'context.request."display name"' }],
items: [{ kind: "path", path: { root: "context", parts: ["request", "display name"] } }],
});
});
it("serializes an edited structural path from its current string", () => {
const projected = projectExpressionEditorState(
{ kind: "path", path: { root: "state", parts: ["foo"] } },
{},
);
if (projected.kind !== "editable" || projected.state.kind !== "path") {
throw new Error("expected editable path expression");
}
expect(serializeExpressionEditorState({ ...projected.state, path: "state.bar", touched: true })).toEqual({
kind: "path",
path: "state.bar",
});
});
it("allows empty and nonempty arrays when item schema is omitted", () => {
expect(projectExpressionEditorState({ kind: "array", items: [] }, { type: "array" })).toEqual(
editable({ kind: "array", items: [] }),
);
expect(projectExpressionEditorState({
kind: "array",
items: [{ kind: "literal", value: "free-form" }],
}, { type: "array" })).toEqual(editable({
kind: "array",
items: [{ kind: "literal", value: "free-form", touched: false }],
}));
});
it("allows empty arrays when no minimum is declared and validates declared bounds", () => {
const empty = { kind: "array", items: [] } satisfies ExpressionEditorState;
expect(validateExpressionEditorState(empty, { type: "array", items: { type: "string" } })).toEqual({
@@ -207,4 +237,53 @@ describe("input expression editor projection", () => {
issues: [expect.objectContaining({ message: expect.stringMatching(/input\., state\., or context/i) })],
});
});
it("validates nested object leaves under unconstrained schemas", () => {
const state: ExpressionEditorState = {
kind: "object",
fields: [{
name: "nested",
value: {
kind: "object",
fields: [
{ name: "badPath", value: { kind: "path", path: "not-a-source", touched: true } },
{ name: "badLiteral", value: { kind: "literal", value: undefined, touched: true } },
],
},
}],
};
expect(validateExpressionEditorState(state, {})).toMatchObject({
valid: false,
issues: [
expect.objectContaining({ path: ["nested", "badPath"] }),
expect.objectContaining({ path: ["nested", "badLiteral"] }),
],
});
});
it("rejects sparse literal arrays", () => {
const sparse = [] as unknown[];
sparse.length = 1;
expect(isJsonValue(sparse)).toBe(false);
expect(serializeExpressionEditorState({ kind: "literal", value: sparse, touched: true })).toBeNull();
});
it("uses the shared budget for parsing, projection, validation, and serialization", () => {
const oversized = {
kind: "literal",
value: { items: Array.from({ length: 1022 }, () => ({})) },
} as const;
expect(parseInputExpression(oversized)).toBeNull();
expect(projectExpressionEditorState(oversized as InputExpression, {})).toMatchObject({
kind: "unsupported",
reason: expect.stringMatching(/limits/i),
});
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) })],
});
});
});
@@ -7,6 +7,10 @@ import type {
ObjectExpression,
PathExpression,
} from "../domain/draft-workspace-models.js";
import {
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";
@@ -32,9 +36,6 @@ export type ExpressionValidation = {
type JsonRecord = Record<string, unknown>;
const MAX_JSON_DEPTH = 64;
const MAX_EXPRESSION_NODES = 1024;
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -49,13 +50,16 @@ 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_JSON_DEPTH) return false;
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;
return current.every((item) => visit(item, depth + 1));
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;
@@ -74,9 +78,8 @@ const inputPath = (value: unknown): InputPath | null => {
return parseGraphSourcePath(path) === null ? null : { root: value.root, parts: [...value.parts] };
};
const parseExpression = (value: unknown, depth: number, nodes: { count: number }): InputExpression | null => {
if (depth > MAX_JSON_DEPTH || nodes.count >= MAX_EXPRESSION_NODES || !isRecord(value)) return null;
nodes.count += 1;
const parseExpression = (value: unknown): InputExpression | null => {
if (!isRecord(value)) return null;
if (value.kind === "literal" && hasExactKeys(value, ["kind", "value"]) && isJsonValue(value.value)) {
return { kind: "literal", value: value.value } satisfies LiteralExpression;
}
@@ -87,7 +90,7 @@ const parseExpression = (value: unknown, depth: number, nodes: { count: number }
if (value.kind === "array" && hasExactKeys(value, ["kind", "items"]) && Array.isArray(value.items)) {
const items: InputExpression[] = [];
for (const item of value.items) {
const parsed = parseExpression(item, depth + 1, nodes);
const parsed = parseExpression(item);
if (parsed === null) return null;
items.push(parsed);
}
@@ -96,7 +99,7 @@ const parseExpression = (value: unknown, depth: number, nodes: { count: number }
if (value.kind === "object" && hasExactKeys(value, ["kind", "fields"]) && isRecord(value.fields)) {
const fields: Record<string, InputExpression> = {};
for (const [name, item] of Object.entries(value.fields)) {
const parsed = parseExpression(item, depth + 1, nodes);
const parsed = parseExpression(item);
if (parsed === null) return null;
Object.defineProperty(fields, name, { configurable: true, enumerable: true, value: parsed, writable: true });
}
@@ -107,11 +110,16 @@ const parseExpression = (value: unknown, depth: number, nodes: { count: number }
/** Parse an external expression before projecting it, without inventing defaults. */
export const parseInputExpression = (value: unknown): InputExpression | null =>
parseExpression(value, 0, { count: 0 });
hasBoundedInputExpressionNodeBudget(value) ? parseExpression(value) : 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,
@@ -141,7 +149,11 @@ const project = (
case "literal":
return { kind: "editable", state: { kind: "literal", value: raw.value, touched: false } };
case "path":
return { kind: "editable", state: { kind: "path", path: pathText(raw.path), touched: false } };
{
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 };
}
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;
@@ -178,22 +190,17 @@ export const projectExpressionEditorState = (
return project(parsed, normalizeSchema(schema));
};
const copyStateToExpression = (
state: ExpressionEditorState,
depth: number,
nodes: { count: number },
): InputExpression | null => {
if (depth > MAX_JSON_DEPTH || nodes.count >= MAX_EXPRESSION_NODES) return null;
nodes.count += 1;
const copyStateToExpression = (state: ExpressionEditorState): InputExpression | null => {
switch (state.kind) {
case "literal":
return isJsonValue(state.value) ? { kind: "literal", value: state.value } : null;
case "path":
return parseGraphSourcePath(state.path) === null ? null : { kind: "path", path: state.path };
if (parseGraphSourcePath(state.path) === null) return null;
return { kind: "path", path: state.touched ? state.path : structuralPathByState.get(state) ?? state.path };
case "array": {
const items: InputExpression[] = [];
for (const item of state.items) {
const expression = copyStateToExpression(item, depth + 1, nodes);
const expression = copyStateToExpression(item);
if (expression === null) return null;
items.push(expression);
}
@@ -205,7 +212,7 @@ const copyStateToExpression = (
for (const field of state.fields) {
if (names.has(field.name) || field.name.length === 0) return null;
names.add(field.name);
const expression = copyStateToExpression(field.value, depth + 1, nodes);
const expression = copyStateToExpression(field.value);
if (expression === null) return null;
Object.defineProperty(fields, field.name, { configurable: true, enumerable: true, value: expression, writable: true });
}
@@ -216,7 +223,10 @@ const copyStateToExpression = (
export const serializeExpressionEditorState = (
state: ExpressionEditorState,
): InputExpression | null => copyStateToExpression(state, 0, { count: 0 });
): InputExpression | null => {
const expression = copyStateToExpression(state);
return expression !== null && hasBoundedInputExpressionNodeBudget(expression) ? expression : null;
};
const issue = (path: ReadonlyArray<string | number>, message: string): ExpressionValidationIssue => ({ path, message });
@@ -288,14 +298,18 @@ const validateState = (
for (const entry of state.fields) {
if (seen.has(entry.name)) issues.push(issue([...path, entry.name], "Duplicate object field name."));
seen.add(entry.name);
if (entry.name.length === 0) issues.push(issue(path, "Object field name is required."));
}
if (field?.kind === "object") {
const required = field.children.filter((child) => child.required).map((child) => child.key);
for (const name of required) if (!seen.has(name)) issues.push(issue([...path, name], "Required property is missing."));
for (const entry of state.fields) {
const child = fieldForObjectName(field, entry.name);
if (child === null && field.additionalPropertiesKind === "forbidden") issues.push(issue([...path, entry.name], "Additional properties are not allowed."));
else issues.push(...validateState(entry.value, child, [...path, entry.name]));
}
for (const entry of state.fields) {
const child = field?.kind === "object" ? fieldForObjectName(field, entry.name) : null;
if (field?.kind === "object" && child === null && field.additionalPropertiesKind === "forbidden") {
issues.push(issue([...path, entry.name], "Additional properties are not allowed."));
} else {
issues.push(...validateState(entry.value, child, [...path, entry.name]));
}
}
return issues;
@@ -307,6 +321,12 @@ export const validateExpressionEditorState = (
state: ExpressionEditorState,
schema: unknown,
): ExpressionValidation => {
const issues = validateState(state, normalizeSchema(schema), []);
const issues = [...validateState(state, normalizeSchema(schema), [])];
if (issues.length === 0) {
const expression = copyStateToExpression(state);
if (expression === null || !hasBoundedInputExpressionNodeBudget(expression)) {
issues.push(issue([], "Expression exceeds the canonical depth or node budget."));
}
}
return { valid: issues.length === 0, issues };
};
@@ -285,19 +285,18 @@ const normalizeField = (
if (type === "array") {
const itemSchema = resolvedSchema.items;
if (itemSchema === undefined) {
return fallback(resolvedSchema, path, key, required, title, "The array has no item schema; edit JSON directly.");
}
const item = normalizeField(
rootSchema,
itemSchema,
[...path, 0],
"item",
true,
`${title} item`,
resolvedReferenceAncestry,
depth + 1,
);
const item = itemSchema === undefined
? null
: normalizeField(
rootSchema,
itemSchema,
[...path, 0],
"item",
true,
`${title} item`,
resolvedReferenceAncestry,
depth + 1,
);
return {
path,
key,