feat: project composite input editors

This commit is contained in:
lda
2026-08-13 23:06:11 +07:00 Verified
parent 0a7996da9f
commit b8a16a0fe7
10 changed files with 703 additions and 79 deletions
@@ -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,
};
}