feat: project composite input editors
This commit is contained in:
@@ -73,6 +73,30 @@ describe("projectAuthoringGraph", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("counts a composite expression as one input instead of flattening its leaves", () => {
|
||||
const model = projectAuthoringGraph({
|
||||
...draft,
|
||||
steps: {
|
||||
collect: {
|
||||
use: "demo.collect",
|
||||
input: [{
|
||||
target: "items",
|
||||
expression: {
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "path", path: "state.foo" },
|
||||
{ kind: "literal", value: "wowcool" },
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
review: draft.steps.review,
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.nodes.find((node) => node.id === "collect")?.data.summary).toBe("1 input");
|
||||
});
|
||||
|
||||
it("uses singular labels and omits empty binding summaries", () => {
|
||||
const model = projectAuthoringGraph({
|
||||
...draft,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildWorkflowGraph, type WorkflowGraphModel } from "../../graph/graph-model.js";
|
||||
import { inputBindingRows, outputBindingRows } from "./selected-step-dataflow.js";
|
||||
import { outputBindingRows, stepInputBindingRows } from "./selected-step-dataflow.js";
|
||||
|
||||
type JsonRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
@@ -54,7 +54,7 @@ const stepKind = (step: JsonRecord): string => {
|
||||
};
|
||||
|
||||
const bindingSummary = (input: unknown, output: unknown): Readonly<Record<string, string>> => {
|
||||
const inputCount = inputBindingRows(input).filter((row) => row.kind === "canonical").length;
|
||||
const inputCount = stepInputBindingRows(input).filter((row) => row.kind === "canonical").length;
|
||||
const outputCount = outputBindingRows(output).filter((row) => row.kind === "canonical").length;
|
||||
if (inputCount === 0 && outputCount === 0) return {};
|
||||
const inputLabel = `${inputCount} input${inputCount === 1 ? "" : "s"}`;
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
CapabilityNodeFormValue,
|
||||
} from "./CapabilityNodeForm.js";
|
||||
import type {
|
||||
InputExpression,
|
||||
InputPath,
|
||||
LocalInputPath,
|
||||
StepInputBinding,
|
||||
@@ -10,6 +9,7 @@ import type {
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import type { FieldSources } from "../schema-form/schema-values.js";
|
||||
import { formatTOMLPath, parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||
import { parseInputExpression } from "./input-expression-editor.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -89,42 +89,12 @@ const inputPath = (value: unknown): value is InputPath =>
|
||||
value.parts.every((part): part is string => typeof part === "string")
|
||||
);
|
||||
|
||||
const jsonValue = (value: unknown): boolean => {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value)) ||
|
||||
typeof value === "string"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value)) return value.every(jsonValue);
|
||||
return isRecord(value) && Object.values(value).every(jsonValue);
|
||||
};
|
||||
|
||||
const inputExpression = (value: unknown): value is InputExpression => {
|
||||
if (!isRecord(value) || typeof value.kind !== "string") return false;
|
||||
switch (value.kind) {
|
||||
case "literal":
|
||||
return jsonValue(value.value);
|
||||
case "path":
|
||||
return inputPath(value.path);
|
||||
case "array":
|
||||
return Array.isArray(value.items) && value.items.every(inputExpression);
|
||||
case "object":
|
||||
return isRecord(value.fields) && Object.values(value.fields).every(inputExpression);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const inputBinding = (value: JsonRecord): StepInputBinding | null => {
|
||||
if (!localInputPath(value.target)) return null;
|
||||
if (inputPath(value.path)) return { target: value.target, path: value.path };
|
||||
if ("value" in value) return { target: value.target, value: value.value };
|
||||
if (inputExpression(value.expression)) {
|
||||
return { target: value.target, expression: value.expression };
|
||||
}
|
||||
const expression = parseInputExpression(value.expression);
|
||||
if (expression !== null) return { target: value.target, expression };
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InputExpression } from "../domain/draft-workspace-models.js";
|
||||
import {
|
||||
projectExpressionEditorState,
|
||||
serializeExpressionEditorState,
|
||||
validateExpressionEditorState,
|
||||
type ExpressionEditorState,
|
||||
} from "./input-expression-editor.js";
|
||||
|
||||
const concatSchema = {
|
||||
type: "object",
|
||||
required: ["items", "separator"],
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: { type: "string" },
|
||||
},
|
||||
separator: { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
const expression: InputExpression = {
|
||||
kind: "object",
|
||||
fields: {
|
||||
separator: { kind: "literal", value: " " },
|
||||
items: {
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "path", path: { root: "state", parts: ["foo"] } },
|
||||
{ kind: "literal", value: "wowcool" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const editable = (state: ExpressionEditorState) => ({ kind: "editable", state }) as const;
|
||||
|
||||
describe("input expression editor projection", () => {
|
||||
it("round-trips nested arrays and objects while retaining field order and null", () => {
|
||||
const canonical: InputExpression = {
|
||||
kind: "object",
|
||||
fields: {
|
||||
z_last: {
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "literal", value: null },
|
||||
{ kind: "object", fields: { nested: { kind: "literal", value: false } } },
|
||||
],
|
||||
},
|
||||
a_first: { kind: "literal", value: "kept" },
|
||||
},
|
||||
};
|
||||
|
||||
const projected = projectExpressionEditorState(canonical, {
|
||||
type: "object",
|
||||
properties: {
|
||||
z_last: { type: "array", items: {} },
|
||||
a_first: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(projected).toEqual(editable({
|
||||
kind: "object",
|
||||
fields: [
|
||||
{
|
||||
name: "z_last",
|
||||
value: {
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "literal", value: null, touched: false },
|
||||
{
|
||||
kind: "object",
|
||||
fields: [{ name: "nested", value: { kind: "literal", value: false, touched: false } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ name: "a_first", value: { kind: "literal", value: "kept", touched: false } },
|
||||
],
|
||||
}));
|
||||
|
||||
if (projected.kind !== "editable") throw new Error("expected editable expression");
|
||||
expect(serializeExpressionEditorState(projected.state)).toEqual({
|
||||
kind: "object",
|
||||
fields: {
|
||||
z_last: {
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "literal", value: null },
|
||||
{ kind: "object", fields: { nested: { kind: "literal", value: false } } },
|
||||
],
|
||||
},
|
||||
a_first: { kind: "literal", value: "kept" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("projects structural paths through the canonical path formatter", () => {
|
||||
const canonical: InputExpression = {
|
||||
kind: "array",
|
||||
items: [{ kind: "path", path: { root: "context", parts: ["request", "display name"] } }],
|
||||
};
|
||||
const projected = projectExpressionEditorState(canonical, { type: "array", items: {} });
|
||||
|
||||
expect(projected).toEqual(editable({
|
||||
kind: "array",
|
||||
items: [{ kind: "path", path: 'context.request."display name"', touched: false }],
|
||||
}));
|
||||
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"' }],
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
valid: true,
|
||||
issues: [],
|
||||
});
|
||||
expect(validateExpressionEditorState(empty, { type: "array", minItems: 1, items: { type: "string" } })).toMatchObject({
|
||||
valid: false,
|
||||
issues: [expect.objectContaining({ message: expect.stringMatching(/at least 1/i) })],
|
||||
});
|
||||
const tooMany = { kind: "array", items: [
|
||||
{ kind: "literal", value: "a", touched: false },
|
||||
{ kind: "literal", value: "b", touched: false },
|
||||
] } satisfies ExpressionEditorState;
|
||||
expect(validateExpressionEditorState(tooMany, { type: "array", maxItems: 1, items: { type: "string" } })).toMatchObject({
|
||||
valid: false,
|
||||
issues: [expect.objectContaining({ message: expect.stringMatching(/at most 1/i) })],
|
||||
});
|
||||
});
|
||||
|
||||
it("validates required fields and additionalProperties without inventing fields", () => {
|
||||
const missingRequired = {
|
||||
kind: "object",
|
||||
fields: [{ name: "optional", value: { kind: "literal", value: "ok", touched: false } }],
|
||||
} satisfies ExpressionEditorState;
|
||||
expect(validateExpressionEditorState(missingRequired, {
|
||||
type: "object",
|
||||
required: ["required"],
|
||||
properties: { required: { type: "string" }, optional: { type: "string" } },
|
||||
additionalProperties: false,
|
||||
})).toMatchObject({
|
||||
valid: false,
|
||||
issues: [expect.objectContaining({ path: ["required"], message: expect.stringMatching(/required/i) })],
|
||||
});
|
||||
|
||||
const unknownField = {
|
||||
kind: "object",
|
||||
fields: [{ name: "extra", value: { kind: "literal", value: "ok", touched: false } }],
|
||||
} satisfies ExpressionEditorState;
|
||||
expect(validateExpressionEditorState(unknownField, {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
})).toMatchObject({
|
||||
valid: false,
|
||||
issues: [expect.objectContaining({ path: ["extra"], message: expect.stringMatching(/additional|not allowed/i) })],
|
||||
});
|
||||
expect(validateExpressionEditorState(unknownField, {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: { type: "string" },
|
||||
})).toEqual({ valid: true, issues: [] });
|
||||
});
|
||||
|
||||
it("rejects duplicate fields before serialization", () => {
|
||||
expect(serializeExpressionEditorState({
|
||||
kind: "object",
|
||||
fields: [
|
||||
{ name: "same", value: { kind: "literal", value: 1, touched: true } },
|
||||
{ name: "same", value: { kind: "literal", value: 2, touched: true } },
|
||||
],
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns unsupported with the original expression for ref and composition failures", () => {
|
||||
const cases: ReadonlyArray<[string, InputExpression, unknown]> = [
|
||||
["missing ref", { kind: "literal", value: "kept" }, { $ref: "#/$defs/Missing" }],
|
||||
["remote ref", { kind: "literal", value: "kept" }, { $ref: "https://example.test/schema.json" }],
|
||||
["composition", { kind: "literal", value: "kept" }, { oneOf: [{ type: "string" }, { type: "number" }] }],
|
||||
["cycle", { kind: "literal", value: "kept" }, {
|
||||
$defs: { Node: { $ref: "#/$defs/Node" } },
|
||||
$ref: "#/$defs/Node",
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [label, raw, schema] of cases) {
|
||||
expect(projectExpressionEditorState(raw, schema), label).toEqual({
|
||||
kind: "unsupported",
|
||||
raw,
|
||||
reason: expect.any(String),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed editor leaves instead of substituting an empty literal", () => {
|
||||
expect(serializeExpressionEditorState({ kind: "literal", value: undefined, touched: true })).toBeNull();
|
||||
expect(serializeExpressionEditorState({ kind: "path", path: "not-a-source", touched: true })).toBeNull();
|
||||
expect(validateExpressionEditorState({ kind: "path", path: "not-a-source", touched: true }, concatSchema)).toMatchObject({
|
||||
valid: false,
|
||||
issues: [expect.objectContaining({ message: expect.stringMatching(/input\., state\., or context/i) })],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import type {
|
||||
ArrayExpression,
|
||||
JsonValue,
|
||||
InputExpression,
|
||||
InputPath,
|
||||
LiteralExpression,
|
||||
ObjectExpression,
|
||||
PathExpression,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { normalizeSchema, type SchemaField } from "../schema-form/schema-field.js";
|
||||
import { formatTOMLPath, parseGraphSourcePath, parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||
|
||||
export type ExpressionEditorState =
|
||||
| { readonly kind: "literal"; readonly value: unknown; readonly touched: boolean }
|
||||
| { readonly kind: "path"; readonly path: string; readonly touched: boolean }
|
||||
| { readonly kind: "array"; readonly items: ReadonlyArray<ExpressionEditorState> }
|
||||
| { readonly kind: "object"; readonly fields: ReadonlyArray<{ readonly name: string; readonly value: ExpressionEditorState }> };
|
||||
|
||||
export type ExpressionProjection =
|
||||
| { readonly kind: "editable"; readonly state: ExpressionEditorState }
|
||||
| { readonly kind: "unsupported"; readonly raw: InputExpression; readonly reason: string };
|
||||
|
||||
export type ExpressionValidationIssue = {
|
||||
readonly path: ReadonlyArray<string | number>;
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
export type ExpressionValidation = {
|
||||
readonly valid: boolean;
|
||||
readonly issues: ReadonlyArray<ExpressionValidationIssue>;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const hasOwn = (value: JsonRecord, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
const hasExactKeys = (value: JsonRecord, keys: ReadonlyArray<string>): boolean => {
|
||||
const actual = Reflect.ownKeys(value);
|
||||
return actual.length === keys.length && keys.every((key) => actual.includes(key));
|
||||
};
|
||||
|
||||
/** 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 (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));
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
const inputPath = (value: unknown): InputPath | null => {
|
||||
if (typeof value === "string") return parseGraphSourcePath(value) === null ? null : value;
|
||||
if (!isRecord(value) || !hasExactKeys(value, ["root", "parts"])) return null;
|
||||
if (value.root !== "input" && value.root !== "state" && value.root !== "context") return null;
|
||||
if (!Array.isArray(value.parts) || !value.parts.every((part): part is string => typeof part === "string")) return null;
|
||||
const path = formatTOMLPath([value.root, ...value.parts]);
|
||||
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;
|
||||
if (value.kind === "literal" && hasExactKeys(value, ["kind", "value"]) && isJsonValue(value.value)) {
|
||||
return { kind: "literal", value: value.value } satisfies LiteralExpression;
|
||||
}
|
||||
if (value.kind === "path" && hasExactKeys(value, ["kind", "path"])) {
|
||||
const path = inputPath(value.path);
|
||||
return path === null ? null : { kind: "path", path } satisfies PathExpression;
|
||||
}
|
||||
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);
|
||||
if (parsed === null) return null;
|
||||
items.push(parsed);
|
||||
}
|
||||
return { kind: "array", items } satisfies ArrayExpression;
|
||||
}
|
||||
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);
|
||||
if (parsed === null) return null;
|
||||
Object.defineProperty(fields, name, { configurable: true, enumerable: true, value: parsed, writable: true });
|
||||
}
|
||||
return { kind: "object", fields } satisfies ObjectExpression;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Parse an external expression before projecting it, without inventing defaults. */
|
||||
export const parseInputExpression = (value: unknown): InputExpression | null =>
|
||||
parseExpression(value, 0, { count: 0 });
|
||||
|
||||
const pathText = (path: InputPath): string =>
|
||||
typeof path === "string" ? path : formatTOMLPath([path.root, ...path.parts]);
|
||||
|
||||
const unsupported = (raw: InputExpression, reason: string): ExpressionProjection => ({
|
||||
kind: "unsupported",
|
||||
raw,
|
||||
reason,
|
||||
});
|
||||
|
||||
const schemaReason = (field: SchemaField | null): string | null => {
|
||||
if (field?.fallbackReason === null || field?.fallbackReason === undefined) return null;
|
||||
if (field.fallbackReason === "The schema is unconstrained; edit JSON directly.") return null;
|
||||
return field.fallbackReason;
|
||||
};
|
||||
|
||||
const fieldForObjectName = (field: SchemaField, name: string): SchemaField | null => {
|
||||
const declared = field.children.find((child) => child.key === name);
|
||||
if (declared !== undefined) return declared;
|
||||
if (field.additionalPropertiesKind === "schema") return field.additionalProperty;
|
||||
return null;
|
||||
};
|
||||
|
||||
const project = (
|
||||
raw: InputExpression,
|
||||
field: SchemaField | null,
|
||||
): ExpressionProjection => {
|
||||
const reason = schemaReason(field);
|
||||
if (reason !== null) return unsupported(raw, reason);
|
||||
switch (raw.kind) {
|
||||
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 } };
|
||||
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;
|
||||
const items: ExpressionEditorState[] = [];
|
||||
for (const item of raw.items) {
|
||||
const projected = project(item, itemField);
|
||||
if (projected.kind === "unsupported") return projected;
|
||||
items.push(projected.state);
|
||||
}
|
||||
return { kind: "editable", state: { kind: "array", items } };
|
||||
}
|
||||
case "object": {
|
||||
if (field !== null && !unconstrained(field) && field.kind !== "object") return unsupported(raw, "The expression is an object but the target schema is not an object.");
|
||||
const fields: Array<{ readonly name: string; readonly value: ExpressionEditorState }> = [];
|
||||
for (const [name, item] of Object.entries(raw.fields)) {
|
||||
if (field !== null && field.kind === "object" && fieldForObjectName(field, name) === null && field.additionalPropertiesKind === "forbidden") {
|
||||
return unsupported(raw, `The schema does not allow additional property ${name}.`);
|
||||
}
|
||||
const projected = project(item, field?.kind === "object" ? fieldForObjectName(field, name) : null);
|
||||
if (projected.kind === "unsupported") return projected;
|
||||
fields.push({ name, value: projected.state });
|
||||
}
|
||||
return { kind: "editable", state: { kind: "object", fields } };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const projectExpressionEditorState = (
|
||||
expression: InputExpression,
|
||||
schema: unknown,
|
||||
): ExpressionProjection => {
|
||||
const parsed = parseInputExpression(expression);
|
||||
if (parsed === null) return unsupported(expression, "The stored expression is malformed or exceeds editor limits.");
|
||||
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;
|
||||
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 };
|
||||
case "array": {
|
||||
const items: InputExpression[] = [];
|
||||
for (const item of state.items) {
|
||||
const expression = copyStateToExpression(item, depth + 1, nodes);
|
||||
if (expression === null) return null;
|
||||
items.push(expression);
|
||||
}
|
||||
return { kind: "array", items };
|
||||
}
|
||||
case "object": {
|
||||
const fields: Record<string, InputExpression> = {};
|
||||
const names = new Set<string>();
|
||||
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);
|
||||
if (expression === null) return null;
|
||||
Object.defineProperty(fields, field.name, { configurable: true, enumerable: true, value: expression, writable: true });
|
||||
}
|
||||
return { kind: "object", fields };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeExpressionEditorState = (
|
||||
state: ExpressionEditorState,
|
||||
): InputExpression | null => copyStateToExpression(state, 0, { count: 0 });
|
||||
|
||||
const issue = (path: ReadonlyArray<string | number>, message: string): ExpressionValidationIssue => ({ path, message });
|
||||
|
||||
const unconstrained = (field: SchemaField | null): boolean =>
|
||||
field === null || field.fallbackReason === "The schema is unconstrained; edit JSON directly.";
|
||||
|
||||
const literalIssues = (
|
||||
value: unknown,
|
||||
field: SchemaField | null,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): ReadonlyArray<ExpressionValidationIssue> => {
|
||||
if (!isJsonValue(value)) return [issue(path, "Literal value must be finite JSON.")];
|
||||
if (unconstrained(field)) return [];
|
||||
if (field === null || field.fallbackReason !== null) return [issue(path, field?.fallbackReason ?? "The target schema is unsupported.")];
|
||||
if (field.enumValues.length > 0 && !field.enumValues.some((candidate) => Object.is(candidate, value))) return [issue(path, "Literal value is not one of the allowed enum values.")];
|
||||
if (field.kind === "string" && typeof value !== "string") return [issue(path, "Expected a string literal.")];
|
||||
if (field.kind === "number" && (typeof value !== "number" || !Number.isFinite(value))) return [issue(path, "Expected a number literal.")];
|
||||
if (field.kind === "integer" && (typeof value !== "number" || !Number.isInteger(value))) return [issue(path, "Expected an integer literal.")];
|
||||
if (field.kind === "boolean" && typeof value !== "boolean") return [issue(path, "Expected a boolean literal.")];
|
||||
if (field.kind === "array") {
|
||||
if (!Array.isArray(value)) return [issue(path, "Expected an array literal.")];
|
||||
const issues: ExpressionValidationIssue[] = [];
|
||||
if (field.minItems !== null && value.length < field.minItems) issues.push(issue(path, `Array must contain at least ${field.minItems} item${field.minItems === 1 ? "" : "s"}.`));
|
||||
if (field.maxItems !== null && value.length > field.maxItems) issues.push(issue(path, `Array must contain at most ${field.maxItems} item${field.maxItems === 1 ? "" : "s"}.`));
|
||||
value.forEach((item, index) => issues.push(...literalIssues(item, field.item, [...path, index])));
|
||||
return issues;
|
||||
}
|
||||
if (field.kind === "object") {
|
||||
if (!isRecord(value)) return [issue(path, "Expected an object literal.")];
|
||||
const issues: ExpressionValidationIssue[] = [];
|
||||
const required = new Set(field.children.filter((child) => child.required).map((child) => child.key));
|
||||
for (const name of required) if (!hasOwn(value, name)) issues.push(issue([...path, name], "Required property is missing."));
|
||||
for (const [name, item] of Object.entries(value)) {
|
||||
const child = fieldForObjectName(field, name);
|
||||
if (child === null && field.additionalPropertiesKind === "forbidden") issues.push(issue([...path, name], "Additional properties are not allowed."));
|
||||
else issues.push(...literalIssues(item, child, [...path, name]));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const validateState = (
|
||||
state: ExpressionEditorState,
|
||||
field: SchemaField | null,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): ReadonlyArray<ExpressionValidationIssue> => {
|
||||
const reason = schemaReason(field);
|
||||
if (reason !== null) return [issue(path, reason)];
|
||||
switch (state.kind) {
|
||||
case "literal":
|
||||
return literalIssues(state.value, field, path);
|
||||
case "path":
|
||||
return parseGraphSourcePath(state.path) === null
|
||||
? [issue(path, "Path must start with input., state., or context.")]
|
||||
: [];
|
||||
case "array": {
|
||||
if (field !== null && !unconstrained(field) && field.kind !== "array") return [issue(path, "Construct array requires an array schema.")];
|
||||
const issues: ExpressionValidationIssue[] = [];
|
||||
if (field?.minItems !== null && field?.minItems !== undefined && state.items.length < field.minItems) issues.push(issue(path, `Array must contain at least ${field.minItems} item${field.minItems === 1 ? "" : "s"}.`));
|
||||
if (field?.maxItems !== null && field?.maxItems !== undefined && state.items.length > field.maxItems) issues.push(issue(path, `Array must contain at most ${field.maxItems} item${field.maxItems === 1 ? "" : "s"}.`));
|
||||
state.items.forEach((item, index) => issues.push(...validateState(item, field?.item ?? null, [...path, index])));
|
||||
return issues;
|
||||
}
|
||||
case "object": {
|
||||
if (field !== null && !unconstrained(field) && field.kind !== "object") return [issue(path, "Construct object requires an object schema.")];
|
||||
const issues: ExpressionValidationIssue[] = [];
|
||||
const seen = new Set<string>();
|
||||
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 (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]));
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const validateExpressionEditorState = (
|
||||
state: ExpressionEditorState,
|
||||
schema: unknown,
|
||||
): ExpressionValidation => {
|
||||
const issues = validateState(state, normalizeSchema(schema), []);
|
||||
return { valid: issues.length === 0, issues };
|
||||
};
|
||||
@@ -104,6 +104,35 @@ describe("selected-step dataflow projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps one composite expression as one input binding and preserves its legacy row index", () => {
|
||||
const composite = {
|
||||
target: "items",
|
||||
expression: {
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "path", path: "state.foo" },
|
||||
{ kind: "literal", value: "wowcool" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const projected = projectSelectedStepDataflow({
|
||||
...keyedDraft,
|
||||
draft: {
|
||||
steps: {
|
||||
render: { use: "wf.std.concat", input: [bindings.input[0], composite, bindings.input[1]] },
|
||||
},
|
||||
},
|
||||
}, "render");
|
||||
|
||||
expect(projected?.inputs).toHaveLength(3);
|
||||
expect(projected?.inputs[1]).toEqual(composite);
|
||||
expect(inputBindingRows([bindings.input[0], composite, bindings.input[1]])).toEqual([
|
||||
expect.objectContaining({ kind: "canonical", index: 0 }),
|
||||
expect.objectContaining({ kind: "unsupported", index: 1, raw: composite }),
|
||||
expect.objectContaining({ kind: "canonical", index: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts structural paths, whole-payload paths, empty lists, and reports malformed rows", () => {
|
||||
const draft: DraftWorkspace = {
|
||||
...keyedDraft,
|
||||
|
||||
@@ -6,20 +6,17 @@ import type {
|
||||
LocalInputPath,
|
||||
OutputBinding,
|
||||
StatePath,
|
||||
StepInputBinding,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { formatTOMLPath, parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||
import { normalizeSchema, type SchemaField } from "../schema-form/schema-field.js";
|
||||
import { isJsonValue, parseInputExpression } from "./input-expression-editor.js";
|
||||
|
||||
export type { JsonValue } from "../domain/draft-workspace-models.js";
|
||||
export { isJsonValue } from "./input-expression-editor.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| ReadonlyArray<JsonValue>
|
||||
| { readonly [key: string]: JsonValue };
|
||||
|
||||
const isRecord = (value: unknown): value is JsonRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
@@ -31,36 +28,6 @@ const hasExactKeys = (value: JsonRecord, keys: ReadonlyArray<string>): boolean =
|
||||
return actual.length === keys.length && keys.every((key) => actual.includes(key));
|
||||
};
|
||||
|
||||
/** Guard the recursive JSON subset used by literal input bindings. */
|
||||
const MAX_JSON_DEPTH = 64;
|
||||
|
||||
const isJsonValueAtDepth = (value: unknown, depth: number): boolean => {
|
||||
if (depth > MAX_JSON_DEPTH) return false;
|
||||
if (value === null || typeof value === "boolean" || typeof value === "string")
|
||||
return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) return false;
|
||||
for (const item of value) {
|
||||
if (!isJsonValueAtDepth(item, depth + 1)) return false;
|
||||
}
|
||||
return Object.keys(value).every((key) => /^(0|[1-9]\d*)$/.test(key));
|
||||
}
|
||||
if (!isRecord(value)) return false;
|
||||
if (
|
||||
Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null
|
||||
)
|
||||
return false;
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) return false;
|
||||
return Object.values(value).every((item) =>
|
||||
isJsonValueAtDepth(item, depth + 1),
|
||||
);
|
||||
};
|
||||
|
||||
/** Guard the recursive JSON subset used by literal input bindings. */
|
||||
export const isJsonValue = (value: unknown): value is JsonValue =>
|
||||
isJsonValueAtDepth(value, 0);
|
||||
const stringParts = (value: unknown): string[] | null => {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const parts: string[] = [];
|
||||
@@ -154,7 +121,7 @@ const canonicalStatePath = (value: unknown): string | null => {
|
||||
return parts === null ? null : formatTOMLPath(parts);
|
||||
};
|
||||
|
||||
const parsedInputBinding = (value: unknown): InputBinding | null => {
|
||||
const parsedSimpleInputBinding = (value: unknown): InputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const target = localPath(value.target);
|
||||
if (target === null) return null;
|
||||
@@ -170,6 +137,28 @@ const parsedInputBinding = (value: unknown): InputBinding | null => {
|
||||
return !isJsonValue(value.value) ? null : { target, value: value.value };
|
||||
};
|
||||
|
||||
const parsedStepInputBinding = (value: unknown): StepInputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const target = localPath(value.target);
|
||||
if (target === null) return null;
|
||||
const hasPath = hasOwn(value, "path");
|
||||
const hasValue = hasOwn(value, "value");
|
||||
const hasExpression = hasOwn(value, "expression");
|
||||
if (Number(hasPath) + Number(hasValue) + Number(hasExpression) !== 1) return null;
|
||||
if (hasPath) {
|
||||
if (!hasExactKeys(value, ["path", "target"])) return null;
|
||||
const path = inputPath(value.path);
|
||||
return path === null ? null : { path, target };
|
||||
}
|
||||
if (hasValue) {
|
||||
if (!hasExactKeys(value, ["target", "value"])) return null;
|
||||
return !isJsonValue(value.value) ? null : { target, value: value.value };
|
||||
}
|
||||
if (!hasExactKeys(value, ["target", "expression"])) return null;
|
||||
const expression = parseInputExpression(value.expression);
|
||||
return expression === null ? null : { target, expression };
|
||||
};
|
||||
|
||||
const parsedOutputBinding = (value: unknown): OutputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
if (!hasExactKeys(value, ["source", "target"])) return null;
|
||||
@@ -237,7 +226,7 @@ export type SelectedStepDataflow = {
|
||||
readonly description: string | null | undefined;
|
||||
readonly retry: number | null | undefined;
|
||||
readonly timeoutSeconds: number | null | undefined;
|
||||
readonly inputs: ReadonlyArray<InputBinding>;
|
||||
readonly inputs: ReadonlyArray<StepInputBinding>;
|
||||
readonly outputs: ReadonlyArray<OutputBinding>;
|
||||
readonly unsupported: ReadonlyArray<UnsupportedBindingRow>;
|
||||
};
|
||||
@@ -271,7 +260,7 @@ export const projectSelectedStepDataflow = (
|
||||
const { step, compiledNodeIndex } = selected;
|
||||
const capabilityName = typeof step.use === "string" ? step.use : step.node;
|
||||
if (typeof capabilityName !== "string" || capabilityName.length === 0) return null;
|
||||
const inputs = parseRows("input", step.input, parsedInputBinding);
|
||||
const inputs = parseRows("input", step.input, parsedStepInputBinding);
|
||||
const outputs = parseRows("output", step.output, parsedOutputBinding);
|
||||
const description = step.desc === null || typeof step.desc === "string" ? step.desc : undefined;
|
||||
const retry = step.retry === null || typeof step.retry === "number" ? step.retry : undefined;
|
||||
@@ -314,7 +303,13 @@ const rowsFor = <T>(
|
||||
};
|
||||
|
||||
export const inputBindingRows = (raw: unknown): ReadonlyArray<InputBindingRow> =>
|
||||
rowsFor(raw, parsedInputBinding, "input");
|
||||
rowsFor(raw, parsedSimpleInputBinding, "input");
|
||||
|
||||
export type StepInputBindingRow = BindingRow<StepInputBinding>;
|
||||
|
||||
/** Full node-local rows for graph projections; the legacy form remains simple-only until Task 7. */
|
||||
export const stepInputBindingRows = (raw: unknown): ReadonlyArray<StepInputBindingRow> =>
|
||||
rowsFor(raw, parsedStepInputBinding, "input");
|
||||
|
||||
export const outputBindingRows = (raw: unknown): ReadonlyArray<OutputBindingRow> =>
|
||||
rowsFor(raw, parsedOutputBinding, "output");
|
||||
@@ -348,6 +343,24 @@ export const serializeInputBindingRows = (
|
||||
return bindings;
|
||||
};
|
||||
|
||||
export const serializeStepInputBindingRow = (value: unknown): StepInputBinding | null => {
|
||||
const parsed = parsedStepInputBinding(value);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const serializeStepInputBindingRows = (
|
||||
rows: ReadonlyArray<StepInputBindingRow>,
|
||||
): ReadonlyArray<StepInputBinding> | null => {
|
||||
const bindings: StepInputBinding[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.kind === "unsupported") return null;
|
||||
const binding = serializeStepInputBindingRow(row.value);
|
||||
if (binding === null) return null;
|
||||
bindings.push(binding);
|
||||
}
|
||||
return bindings;
|
||||
};
|
||||
|
||||
export const serializeOutputBindingRow = (value: unknown): OutputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
if (!hasExactKeys(value, ["source", "target"])) return null;
|
||||
|
||||
@@ -76,6 +76,25 @@ describe("normalizeSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps array bounds and object additional-property rules for recursive editors", () => {
|
||||
const field = normalizeSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
items: { type: "array", minItems: 1, maxItems: 4, items: { type: "string" } },
|
||||
},
|
||||
additionalProperties: { type: "number" },
|
||||
});
|
||||
|
||||
expect(field).toMatchObject({
|
||||
additionalPropertiesKind: "schema",
|
||||
additionalProperty: { kind: "number" },
|
||||
});
|
||||
expect(field.children.find((child) => child.key === "items")).toMatchObject({
|
||||
minItems: 1,
|
||||
maxItems: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses JSON fallback for unconstrained schemas instead of inventing an object", () => {
|
||||
const field = normalizeSchema({});
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ export type SchemaField = {
|
||||
readonly enumValues: ReadonlyArray<string | number | boolean | null>;
|
||||
readonly children: ReadonlyArray<SchemaField>;
|
||||
readonly item: SchemaField | null;
|
||||
readonly minItems: number | null;
|
||||
readonly maxItems: number | null;
|
||||
readonly additionalPropertiesKind: "allowed" | "forbidden" | "schema";
|
||||
readonly additionalProperty: SchemaField | null;
|
||||
readonly fallbackReason: string | null;
|
||||
};
|
||||
|
||||
@@ -73,6 +77,9 @@ const isEnumValue = (value: unknown): value is EnumValue =>
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value));
|
||||
|
||||
const nonNegativeInteger = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
|
||||
const fallback = (
|
||||
schema: unknown,
|
||||
path: ReadonlyArray<string | number>,
|
||||
@@ -92,6 +99,10 @@ const fallback = (
|
||||
enumValues: [],
|
||||
children: [],
|
||||
item: null,
|
||||
minItems: null,
|
||||
maxItems: null,
|
||||
additionalPropertiesKind: "allowed",
|
||||
additionalProperty: null,
|
||||
fallbackReason: reason,
|
||||
});
|
||||
|
||||
@@ -201,6 +212,10 @@ const normalizeField = (
|
||||
enumValues: enumValue,
|
||||
children: [],
|
||||
item: null,
|
||||
minItems: null,
|
||||
maxItems: null,
|
||||
additionalPropertiesKind: "allowed",
|
||||
additionalProperty: null,
|
||||
fallbackReason: null,
|
||||
};
|
||||
}
|
||||
@@ -230,6 +245,24 @@ const normalizeField = (
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const additionalProperties = resolvedSchema.additionalProperties;
|
||||
const additionalPropertiesKind = additionalProperties === false
|
||||
? "forbidden"
|
||||
: isRecord(additionalProperties)
|
||||
? "schema"
|
||||
: "allowed";
|
||||
const additionalProperty = isRecord(additionalProperties)
|
||||
? normalizeField(
|
||||
rootSchema,
|
||||
additionalProperties,
|
||||
[...path, "*"],
|
||||
"additional property",
|
||||
false,
|
||||
"Additional property",
|
||||
resolvedReferenceAncestry,
|
||||
depth + 1,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
path,
|
||||
key,
|
||||
@@ -242,6 +275,10 @@ const normalizeField = (
|
||||
enumValues: [],
|
||||
children,
|
||||
item: null,
|
||||
minItems: null,
|
||||
maxItems: null,
|
||||
additionalPropertiesKind,
|
||||
additionalProperty,
|
||||
fallbackReason: null,
|
||||
};
|
||||
}
|
||||
@@ -273,6 +310,10 @@ const normalizeField = (
|
||||
enumValues: [],
|
||||
children: [],
|
||||
item,
|
||||
minItems: nonNegativeInteger(resolvedSchema.minItems),
|
||||
maxItems: nonNegativeInteger(resolvedSchema.maxItems),
|
||||
additionalPropertiesKind: "allowed",
|
||||
additionalProperty: null,
|
||||
fallbackReason: null,
|
||||
};
|
||||
}
|
||||
@@ -290,6 +331,10 @@ const normalizeField = (
|
||||
enumValues: [],
|
||||
children: [],
|
||||
item: null,
|
||||
minItems: null,
|
||||
maxItems: null,
|
||||
additionalPropertiesKind: "allowed",
|
||||
additionalProperty: null,
|
||||
fallbackReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user