From 3b82e029aeab5d5faeab6c673d0f483e3ef633f8 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 19:59:36 -0400 Subject: [PATCH 1/3] feat(schema): add data_class, expressions field, and mutual exclusivity validation Add data_class array (pii, financial, credentials, internal) to all node types and lanes for trace redaction policy. Add expressions object to action nodes for engine-native transforms. Enforce mutual exclusivity: action nodes cannot have more than one of expressions, rules, or entry_points. Update serializer key ordering for deterministic output and add dedicated lane serializer to handle data_class arrays. --- packages/schema/flowprint.schema.json | 71 ++++++++++++++++++++++++++ packages/schema/src/serialize.ts | 53 +++++++++++++++++-- packages/schema/src/structural.ts | 37 +++++++++++--- packages/schema/src/types.generated.ts | 38 ++++++++++++++ 4 files changed, 189 insertions(+), 10 deletions(-) diff --git a/packages/schema/flowprint.schema.json b/packages/schema/flowprint.schema.json index 8267990..66f6580 100644 --- a/packages/schema/flowprint.schema.json +++ b/packages/schema/flowprint.schema.json @@ -114,6 +114,14 @@ "minimum": 0, "description": "Display order (0 = topmost lane)" }, + "data_class": { + "type": "array", + "items": { + "type": "string", + "enum": ["pii", "financial", "credentials", "internal"] + }, + "description": "Data classification labels for trace redaction policy" + }, "height": { "type": "number", "minimum": 140, @@ -201,6 +209,14 @@ "type": "string", "description": "Markdown notes for documentation and design rationale" }, + "data_class": { + "type": "array", + "items": { + "type": "string", + "enum": ["pii", "financial", "credentials", "internal"] + }, + "description": "Data classification labels for trace redaction policy" + }, "metadata": { "type": "object", "additionalProperties": { @@ -216,6 +232,13 @@ "rules": { "$ref": "#/definitions/RulesRef" }, + "expressions": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Key-value pairs of output field name to expression string. Makes this an engine-native transform node." + }, "position": { "$ref": "#/definitions/Position" }, @@ -267,6 +290,14 @@ "type": "string", "description": "Markdown notes for documentation and design rationale" }, + "data_class": { + "type": "array", + "items": { + "type": "string", + "enum": ["pii", "financial", "credentials", "internal"] + }, + "description": "Data classification labels for trace redaction policy" + }, "metadata": { "type": "object", "additionalProperties": { @@ -333,6 +364,14 @@ "type": "string", "description": "Markdown notes for documentation and design rationale" }, + "data_class": { + "type": "array", + "items": { + "type": "string", + "enum": ["pii", "financial", "credentials", "internal"] + }, + "description": "Data classification labels for trace redaction policy" + }, "metadata": { "type": "object", "additionalProperties": { @@ -388,6 +427,14 @@ "type": "string", "description": "Markdown notes for documentation and design rationale" }, + "data_class": { + "type": "array", + "items": { + "type": "string", + "enum": ["pii", "financial", "credentials", "internal"] + }, + "description": "Data classification labels for trace redaction policy" + }, "metadata": { "type": "object", "additionalProperties": { @@ -458,6 +505,14 @@ "type": "string", "description": "Markdown notes for documentation and design rationale" }, + "data_class": { + "type": "array", + "items": { + "type": "string", + "enum": ["pii", "financial", "credentials", "internal"] + }, + "description": "Data classification labels for trace redaction policy" + }, "metadata": { "type": "object", "additionalProperties": { @@ -500,6 +555,14 @@ "type": "string", "description": "Markdown notes for documentation and design rationale" }, + "data_class": { + "type": "array", + "items": { + "type": "string", + "enum": ["pii", "financial", "credentials", "internal"] + }, + "description": "Data classification labels for trace redaction policy" + }, "metadata": { "type": "object", "additionalProperties": { @@ -540,6 +603,14 @@ "type": "string", "description": "Markdown notes for documentation and design rationale" }, + "data_class": { + "type": "array", + "items": { + "type": "string", + "enum": ["pii", "financial", "credentials", "internal"] + }, + "description": "Data classification labels for trace redaction policy" + }, "metadata": { "type": "object", "additionalProperties": { diff --git a/packages/schema/src/serialize.ts b/packages/schema/src/serialize.ts index 91f81eb..7eb5af8 100644 --- a/packages/schema/src/serialize.ts +++ b/packages/schema/src/serialize.ts @@ -25,6 +25,7 @@ const NODE_KEY_PREFIX = [ 'label', 'description', 'notes', + 'data_class', 'metadata', 'position', 'entry_points', @@ -35,7 +36,7 @@ const NODE_KEY_PREFIX = [ * Order matters for deterministic output. */ const NODE_TYPE_FIELDS: Record = { - action: ['rules', 'inputs', 'compensation', 'temporal', 'next', 'error'], + action: ['rules', 'expressions', 'inputs', 'compensation', 'temporal', 'next', 'error'], switch: ['rules', 'cases', 'default'], parallel: ['branches', 'join', 'join_strategy'], wait: ['event', 'event_type', 'event_type_import', 'timeout', 'next', 'timeout_next'], @@ -70,7 +71,7 @@ export function serialize(doc: FlowprintDocument): string { if (key === 'nodes') { rootMap.add(new Pair(key, serializeNodes(doc.nodes))) } else if (key === 'lanes') { - rootMap.add(new Pair(key, serializeOrderedMap(doc.lanes))) + rootMap.add(new Pair(key, serializeLanes(doc.lanes))) } else if (key === 'metadata' && typeof value === 'object') { rootMap.add(new Pair(key, serializeOrderedMap(value as Record))) } else if (key === 'workflow' && typeof value === 'object') { @@ -111,6 +112,44 @@ function serializeNodes(nodes: Record): YAMLMap { return nodesMap } +/** + * Lane key order for deterministic output. + */ +const LANE_KEY_ORDER = ['label', 'visibility', 'order', 'data_class', 'height'] as const + +/** + * Serialize the lanes map with deterministic key ordering per lane. + */ +function serializeLanes( + lanes: Record, +): YAMLMap { + const lanesMap = new YAMLMap() + + for (const [laneId, lane] of Object.entries(lanes)) { + const laneMap = new YAMLMap() + const laneObj = lane as unknown as Record + + for (const key of LANE_KEY_ORDER) { + const value = laneObj[key] + if (value === undefined) continue + + if (key === 'data_class' && Array.isArray(value)) { + const seq = new YAMLSeq() + for (const item of value as string[]) { + seq.add(createScalar(item)) + } + laneMap.add(new Pair(key, seq)) + } else { + laneMap.add(new Pair(key, createScalar(value))) + } + } + + lanesMap.add(new Pair(laneId, laneMap)) + } + + return lanesMap +} + /** * Serialize a single node with deterministic key ordering. */ @@ -124,8 +163,16 @@ function serializeNode(node: Node): YAMLMap { const value = nodeObj[key] if (value === undefined) continue - if (key === 'rules' && typeof value === 'object' && value !== null) { + if (key === 'data_class' && Array.isArray(value)) { + const seq = new YAMLSeq() + for (const item of value as string[]) { + seq.add(createScalar(item)) + } + nodeMap.add(new Pair(key, seq)) + } else if (key === 'rules' && typeof value === 'object' && value !== null) { nodeMap.add(new Pair(key, serializeRulesRef(value as Record))) + } else if (key === 'expressions' && typeof value === 'object' && value !== null) { + nodeMap.add(new Pair(key, serializeOrderedMap(value as Record))) } else if (key === 'entry_points' && Array.isArray(value)) { nodeMap.add(new Pair(key, serializeEntryPoints(value as Record[]))) } else if (key === 'cases' && Array.isArray(value)) { diff --git a/packages/schema/src/structural.ts b/packages/schema/src/structural.ts index 3e489a3..1739904 100644 --- a/packages/schema/src/structural.ts +++ b/packages/schema/src/structural.ts @@ -41,13 +41,36 @@ export function validateStructure(doc: Record): ValidationError const type = node.type as string - // Mutual exclusion: rules vs entry_points/cases - if (type === 'action' && node.rules !== undefined && node.entry_points !== undefined) { - errors.push({ - path: `/nodes/${nodeId}`, - message: 'Action node cannot have both "rules" and "entry_points". Use one or the other', - severity: 'error', - }) + // Mutual exclusion: expressions vs rules vs entry_points on action nodes + if (type === 'action') { + const hasRules = node.rules !== undefined + const hasEntryPoints = node.entry_points !== undefined + const hasExpressions = node.expressions !== undefined + + if (hasExpressions && hasRules) { + errors.push({ + path: `/nodes/${nodeId}`, + message: + 'Action node cannot have both "expressions" and "rules". Use one or the other', + severity: 'error', + }) + } + if (hasExpressions && hasEntryPoints) { + errors.push({ + path: `/nodes/${nodeId}`, + message: + 'Action node cannot have both "expressions" and "entry_points". Use one or the other', + severity: 'error', + }) + } + if (hasRules && hasEntryPoints) { + errors.push({ + path: `/nodes/${nodeId}`, + message: + 'Action node cannot have both "rules" and "entry_points". Use one or the other', + severity: 'error', + }) + } } if (type === 'switch') { const hasCases = node.cases !== undefined diff --git a/packages/schema/src/types.generated.ts b/packages/schema/src/types.generated.ts index e113056..7a8f6d8 100644 --- a/packages/schema/src/types.generated.ts +++ b/packages/schema/src/types.generated.ts @@ -17,6 +17,10 @@ export type TriggerNode = { * Markdown notes for documentation and design rationale */ notes?: string; + /** + * Data classification labels for trace redaction policy + */ + data_class?: ("pii" | "financial" | "credentials" | "internal")[]; metadata?: { [k: string]: string; }; @@ -135,6 +139,10 @@ export interface Lane { * Display order (0 = topmost lane) */ order: number; + /** + * Data classification labels for trace redaction policy + */ + data_class?: ("pii" | "financial" | "credentials" | "internal")[]; /** * Optional custom lane height in pixels (minimum 140) */ @@ -149,11 +157,21 @@ export interface ActionNode { * Markdown notes for documentation and design rationale */ notes?: string; + /** + * Data classification labels for trace redaction policy + */ + data_class?: ("pii" | "financial" | "credentials" | "internal")[]; metadata?: { [k: string]: string; }; entry_points?: EntryPoint[]; rules?: RulesRef; + /** + * Key-value pairs of output field name to expression string. Makes this an engine-native transform node. + */ + expressions?: { + [k: string]: string; + }; position?: Position; /** * Named input mapping: parameter name to expression @@ -270,6 +288,10 @@ export interface SwitchNode { * Markdown notes for documentation and design rationale */ notes?: string; + /** + * Data classification labels for trace redaction policy + */ + data_class?: ("pii" | "financial" | "credentials" | "internal")[]; metadata?: { [k: string]: string; }; @@ -291,6 +313,10 @@ export interface ParallelNode { * Markdown notes for documentation and design rationale */ notes?: string; + /** + * Data classification labels for trace redaction policy + */ + data_class?: ("pii" | "financial" | "credentials" | "internal")[]; metadata?: { [k: string]: string; }; @@ -309,6 +335,10 @@ export interface WaitNode { * Markdown notes for documentation and design rationale */ notes?: string; + /** + * Data classification labels for trace redaction policy + */ + data_class?: ("pii" | "financial" | "credentials" | "internal")[]; metadata?: { [k: string]: string; }; @@ -345,6 +375,10 @@ export interface ErrorNode { * Markdown notes for documentation and design rationale */ notes?: string; + /** + * Data classification labels for trace redaction policy + */ + data_class?: ("pii" | "financial" | "credentials" | "internal")[]; metadata?: { [k: string]: string; }; @@ -360,6 +394,10 @@ export interface TerminalNode { * Markdown notes for documentation and design rationale */ notes?: string; + /** + * Data classification labels for trace redaction policy + */ + data_class?: ("pii" | "financial" | "credentials" | "internal")[]; metadata?: { [k: string]: string; }; From a711d71a4cd7bcea8b024c8c8ef68add05ac3cae Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:04:09 -0400 Subject: [PATCH 2/3] test(schema): add tests for data_class, expressions, and mutual exclusivity Cover schema validation, structural validation, serialization round-trips, and key ordering for the new data_class and expressions fields. 23 new test cases across validation and serialization. --- .../__tests__/data-class-expressions.test.ts | 601 ++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 packages/schema/src/__tests__/data-class-expressions.test.ts diff --git a/packages/schema/src/__tests__/data-class-expressions.test.ts b/packages/schema/src/__tests__/data-class-expressions.test.ts new file mode 100644 index 0000000..f79bf3a --- /dev/null +++ b/packages/schema/src/__tests__/data-class-expressions.test.ts @@ -0,0 +1,601 @@ +import { describe, it, expect } from 'vitest' +import { parse } from 'yaml' +import { validate } from '../validate.js' +import { serialize } from '../serialize.js' +import type { FlowprintDocument } from '../types.js' + +/** + * Helper to create a minimal valid FlowprintDocument. + */ +function makeDoc(overrides: Partial = {}): FlowprintDocument { + return { + schema: 'flowprint/1.0', + name: 'test-blueprint', + version: '1.0.0', + lanes: { + main: { label: 'Main', visibility: 'internal', order: 0 }, + }, + nodes: { + start: { type: 'action', lane: 'main', label: 'Start', next: 'done' }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'success' }, + }, + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// data_class on lanes +// --------------------------------------------------------------------------- + +describe('data_class on lanes', () => { + it('accepts a lane with data_class', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { + main: { label: 'Main', visibility: 'external', order: 0, data_class: ['pii', 'financial'] }, + }, + nodes: { + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('accepts a lane without data_class (backward compat)', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { + main: { label: 'Main', visibility: 'external', order: 0 }, + }, + nodes: { + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('rejects invalid data_class value on lane', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { + main: { + label: 'Main', + visibility: 'external', + order: 0, + data_class: ['invalid_class'], + }, + }, + nodes: { + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + }) +}) + +// --------------------------------------------------------------------------- +// data_class on nodes +// --------------------------------------------------------------------------- + +describe('data_class on nodes', () => { + it('accepts an action node with data_class', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + data_class: ['credentials'], + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('accepts a switch node with data_class', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + decision: { + type: 'switch', + lane: 'main', + label: 'Decision', + data_class: ['pii'], + cases: [{ when: 'yes', next: 'end' }], + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('accepts a terminal node with data_class', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + end: { + type: 'terminal', + lane: 'main', + label: 'End', + data_class: ['internal'], + outcome: 'success', + }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('rejects invalid data_class value on node', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + data_class: ['invalid_class'], + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + }) + + it('accepts multiple valid data_class values on a node', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + data_class: ['pii', 'financial', 'credentials', 'internal'], + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// expressions field on action nodes +// --------------------------------------------------------------------------- + +describe('expressions field', () => { + it('accepts an action node with only expressions', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + transform: { + type: 'action', + lane: 'main', + label: 'Transform', + expressions: { total: 'price * quantity', tax: 'total * 0.1' }, + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('accepts an action node with only rules', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + rules: { file: 'rules/pricing.rules.yaml' }, + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('accepts an action node with only entry_points', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + entry_points: [{ file: 'src/handler.ts', symbol: 'handle' }], + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('accepts an action node with none of expressions, rules, or entry_points', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// Mutual exclusivity: expressions vs rules vs entry_points +// --------------------------------------------------------------------------- + +describe('mutual exclusivity', () => { + it('rejects action node with both expressions and rules', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + expressions: { total: 'price * quantity' }, + rules: { file: 'rules/pricing.rules.yaml' }, + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(false) + expect( + result.errors.some( + (e) => + e.path === '/nodes/step' && + e.message.includes('expressions') && + e.message.includes('rules'), + ), + ).toBe(true) + }) + + it('rejects action node with both expressions and entry_points', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + expressions: { total: 'price * quantity' }, + entry_points: [{ file: 'src/handler.ts', symbol: 'handle' }], + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(false) + expect( + result.errors.some( + (e) => + e.path === '/nodes/step' && + e.message.includes('expressions') && + e.message.includes('entry_points'), + ), + ).toBe(true) + }) + + it('rejects action node with both rules and entry_points', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + rules: { file: 'rules/pricing.rules.yaml' }, + entry_points: [{ file: 'src/handler.ts', symbol: 'handle' }], + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(false) + expect( + result.errors.some( + (e) => + e.path === '/nodes/step' && + e.message.includes('rules') && + e.message.includes('entry_points'), + ), + ).toBe(true) + }) + + it('rejects action node with all three: expressions, rules, and entry_points', () => { + const result = validate({ + schema: 'flowprint/1.0', + name: 'test', + version: '1.0.0', + lanes: { main: { label: 'Main', visibility: 'external', order: 0 } }, + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + expressions: { total: 'price * quantity' }, + rules: { file: 'rules/pricing.rules.yaml' }, + entry_points: [{ file: 'src/handler.ts', symbol: 'handle' }], + next: 'end', + }, + end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' }, + }, + }) + expect(result.valid).toBe(false) + // Should have multiple mutual exclusivity errors + const mutualErrors = result.errors.filter( + (e) => e.path === '/nodes/step' && e.severity === 'error', + ) + expect(mutualErrors.length).toBeGreaterThanOrEqual(3) + }) +}) + +// --------------------------------------------------------------------------- +// Serialization: data_class and expressions +// --------------------------------------------------------------------------- + +describe('serialization', () => { + it('round-trips document with data_class on lanes', () => { + const doc = makeDoc({ + lanes: { + main: { + label: 'Main', + visibility: 'internal', + order: 0, + data_class: ['pii', 'financial'], + }, + }, + }) + const yaml = serialize(doc) + const parsed = parse(yaml) as FlowprintDocument + expect(parsed.lanes.main?.data_class).toEqual(['pii', 'financial']) + }) + + it('round-trips document with data_class on nodes', () => { + const doc = makeDoc({ + nodes: { + step: { + type: 'action', + lane: 'main', + label: 'Step', + data_class: ['credentials'], + next: 'done', + }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'success' }, + }, + }) + const yaml = serialize(doc) + const parsed = parse(yaml) as FlowprintDocument + const node = parsed.nodes.step as { data_class?: string[] } + expect(node.data_class).toEqual(['credentials']) + }) + + it('round-trips document with expressions on action node', () => { + const doc = makeDoc({ + nodes: { + transform: { + type: 'action', + lane: 'main', + label: 'Transform', + expressions: { total: 'price * quantity', tax: 'total * 0.1' }, + next: 'done', + }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'success' }, + }, + }) + const yaml = serialize(doc) + const parsed = parse(yaml) as FlowprintDocument + const node = parsed.nodes.transform as { expressions?: Record } + expect(node.expressions).toEqual({ total: 'price * quantity', tax: 'total * 0.1' }) + }) + + it('round-trips document with data_class and expressions together', () => { + const doc = makeDoc({ + lanes: { + main: { + label: 'Main', + visibility: 'internal', + order: 0, + data_class: ['financial'], + }, + }, + nodes: { + transform: { + type: 'action', + lane: 'main', + label: 'Transform', + data_class: ['pii'], + expressions: { total: 'price * quantity' }, + next: 'done', + }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'success' }, + }, + }) + const yaml = serialize(doc) + const parsed = parse(yaml) as FlowprintDocument + expect(parsed).toEqual(doc) + }) + + describe('key ordering', () => { + it('outputs data_class before metadata in node key order', () => { + const doc = makeDoc({ + nodes: { + my_node: { + type: 'action', + lane: 'main', + label: 'My Node', + description: 'Does things', + data_class: ['pii'], + metadata: { sla: '5m' }, + next: 'done', + }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'success' }, + }, + }) + const yaml = serialize(doc) + + const lines = yaml.split('\n') + const nodeStart = lines.findIndex((l) => l.trimStart().startsWith('my_node:')) + expect(nodeStart).toBeGreaterThan(-1) + + const nodeKeys: string[] = [] + for (let i = nodeStart + 1; i < lines.length; i++) { + const line = lines[i] ?? '' + if (/^ {2}\S/.exec(line) || /^\S/.exec(line)) break + const keyMatch = /^ {4}(\w+):/.exec(line) + if (keyMatch) { + nodeKeys.push(keyMatch[1] ?? '') + } + } + + const dcIdx = nodeKeys.indexOf('data_class') + const mdIdx = nodeKeys.indexOf('metadata') + expect(dcIdx).toBeGreaterThan(-1) + expect(mdIdx).toBeGreaterThan(-1) + expect(dcIdx).toBeLessThan(mdIdx) + }) + + it('outputs expressions after entry_points in action node key order', () => { + const doc = makeDoc({ + nodes: { + my_node: { + type: 'action', + lane: 'main', + label: 'My Node', + expressions: { total: 'price * quantity' }, + next: 'done', + }, + done: { type: 'terminal', lane: 'main', label: 'Done', outcome: 'success' }, + }, + }) + const yaml = serialize(doc) + + const lines = yaml.split('\n') + const nodeStart = lines.findIndex((l) => l.trimStart().startsWith('my_node:')) + expect(nodeStart).toBeGreaterThan(-1) + + const nodeKeys: string[] = [] + for (let i = nodeStart + 1; i < lines.length; i++) { + const line = lines[i] ?? '' + if (/^ {2}\S/.exec(line) || /^\S/.exec(line)) break + const keyMatch = /^ {4}(\w+):/.exec(line) + if (keyMatch) { + nodeKeys.push(keyMatch[1] ?? '') + } + } + + // expressions should appear before next but after type-specific prefix fields + const exprIdx = nodeKeys.indexOf('expressions') + const nextIdx = nodeKeys.indexOf('next') + expect(exprIdx).toBeGreaterThan(-1) + expect(nextIdx).toBeGreaterThan(-1) + expect(exprIdx).toBeLessThan(nextIdx) + }) + + it('outputs data_class in correct position in lane key order', () => { + const doc = makeDoc({ + lanes: { + main: { + label: 'Main', + visibility: 'internal', + order: 0, + data_class: ['pii'], + }, + }, + }) + const yaml = serialize(doc) + + const lines = yaml.split('\n') + const laneStart = lines.findIndex((l) => l.trimStart().startsWith('main:')) + expect(laneStart).toBeGreaterThan(-1) + + const laneKeys: string[] = [] + for (let i = laneStart + 1; i < lines.length; i++) { + const line = lines[i] ?? '' + // Stop if we hit a same-level or top-level key + if (/^ {2}\S/.exec(line) || /^\S/.exec(line)) break + const keyMatch = /^ {4}(\w+):/.exec(line) + if (keyMatch) { + laneKeys.push(keyMatch[1] ?? '') + } + } + + const orderIdx = laneKeys.indexOf('order') + const dcIdx = laneKeys.indexOf('data_class') + expect(orderIdx).toBeGreaterThan(-1) + expect(dcIdx).toBeGreaterThan(-1) + expect(dcIdx).toBeGreaterThan(orderIdx) + }) + }) +}) From 298527c9a9399ca089ad8bd8e31f8ecfba8fd001 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Tue, 17 Mar 2026 20:04:54 -0400 Subject: [PATCH 3/3] style(schema): apply prettier formatting --- packages/schema/src/serialize.ts | 4 +--- packages/schema/src/structural.ts | 6 ++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/schema/src/serialize.ts b/packages/schema/src/serialize.ts index 7eb5af8..a596855 100644 --- a/packages/schema/src/serialize.ts +++ b/packages/schema/src/serialize.ts @@ -120,9 +120,7 @@ const LANE_KEY_ORDER = ['label', 'visibility', 'order', 'data_class', 'height'] /** * Serialize the lanes map with deterministic key ordering per lane. */ -function serializeLanes( - lanes: Record, -): YAMLMap { +function serializeLanes(lanes: Record): YAMLMap { const lanesMap = new YAMLMap() for (const [laneId, lane] of Object.entries(lanes)) { diff --git a/packages/schema/src/structural.ts b/packages/schema/src/structural.ts index 1739904..3931c83 100644 --- a/packages/schema/src/structural.ts +++ b/packages/schema/src/structural.ts @@ -50,8 +50,7 @@ export function validateStructure(doc: Record): ValidationError if (hasExpressions && hasRules) { errors.push({ path: `/nodes/${nodeId}`, - message: - 'Action node cannot have both "expressions" and "rules". Use one or the other', + message: 'Action node cannot have both "expressions" and "rules". Use one or the other', severity: 'error', }) } @@ -66,8 +65,7 @@ export function validateStructure(doc: Record): ValidationError if (hasRules && hasEntryPoints) { errors.push({ path: `/nodes/${nodeId}`, - message: - 'Action node cannot have both "rules" and "entry_points". Use one or the other', + message: 'Action node cannot have both "rules" and "entry_points". Use one or the other', severity: 'error', }) }