From 3b1df93efadd8d5624468b28f5ef0f6ccd239948 Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Wed, 26 Aug 2026 16:31:44 +0200 Subject: [PATCH 01/11] feat(validator): fill declared defaults for required fields during validation Opt-in via options.fillDefaults: 'requiredOnly'. An omitted required field whose schema declares a usable default validates as that default instead of failing as mandatory. The filled value flows through the walk, so a filled boolean arms its own nested branch and defaults under it fill recursively, in a single pass. Results gain normalizedValues (per domain, never mutating the input) and appliedDefaults. Off by default; existing behavior unchanged. MAIA-1286 --- src/types.ts | 25 ++++ src/utils.ts | 33 +++++ src/validator.ts | 64 ++++++++-- test/fill-defaults.spec.ts | 249 +++++++++++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 11 deletions(-) create mode 100644 test/fill-defaults.spec.ts diff --git a/src/types.ts b/src/types.ts index a8cd686..994aec6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -292,6 +292,22 @@ export type FormanValidationResult = { * which fields it rejected. */ resolvedSchemas?: Record; + /** + * The input values with filled defaults applied, per domain. Requires `options.fillDefaults`, + * and is present whether or not validation succeeded, so a caller can persist or repair the + * filled configuration alongside any remaining errors. The input `values` are never mutated; + * subtrees no default was written into are shared with the input. + */ + normalizedValues?: Record>; + /** The defaults that were filled, in walk order. Requires `options.fillDefaults`. */ + appliedDefaults?: { + /** Field domain */ + domain: string; + /** Field path */ + path: string; + /** The default that was filled in */ + value: FormanSchemaValue; + }[]; }; export type FormanSchemaFieldState = { @@ -379,6 +395,15 @@ export type FormanValidationOptions = { schema: JSONSchema7, value: unknown, ): FormanExternalValidationResult | Promise; + /** Fill declared defaults for required fields the caller omitted, instead of failing them as + * mandatory. `'requiredOnly'` fills a required field whose value is absent and whose schema + * declares a default that can satisfy the required check (`null` and `''` cannot). The filled + * value flows through the rest of the walk, so a filled boolean arms its own nested branch + * and defaults nested under it fill recursively, in the same single pass. The result then + * carries `normalizedValues` and `appliedDefaults`. Values the caller provided are never + * overwritten, and an explicit `null`/`''` still fails as mandatory — it is a provided + * value, not an omission. Optional fields are never filled. */ + fillDefaults?: 'requiredOnly'; /** Maps domain names used in nested.domain to actual domain keys passed to validateFormanWithDomains */ domainAliases?: Record; /** Whether to allow dynamic values (IML expressions, unresolved RPC options). diff --git a/src/utils.ts b/src/utils.ts index 3cb5c23..67a86bf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -192,6 +192,39 @@ export function findValueInSelectOptions( return found as FormanSchemaOption | undefined; // If there was a value, then it has to be an option, because option groups don't have values } +function setIn(container: unknown, path: Array, value: unknown): unknown { + const [head, ...rest] = path; + if (head === undefined) return value; + if (typeof head === 'number') { + const items = Array.isArray(container) ? container.slice() : []; + items[head] = setIn(items[head], rest, value); + return items; + } + const record: Record = isObject>(container) ? { ...container } : {}; + record[head] = setIn(record[head], rest, value); + return record; +} + +/** + * Returns a copy of `values` with `value` written at `path` (string segments for object keys, + * numbers for array indices). Containers along the path are cloned (and created when missing); + * everything off the path is shared with the input, which is never mutated. + * @param values The object to write into + * @param path The path to write at + * @param value The value to write + */ +export function setValueAtPath( + values: Record, + path: Array, + value: unknown, +): Record { + const [head] = path; + if (typeof head !== 'string') return values; + const record = { ...values }; + record[head] = setIn(record[head], path.slice(1), value); + return record; +} + /** * Converts a path array to a string representation, joining elements with dots and using brackets for numeric indices. * @param path diff --git a/src/validator.ts b/src/validator.ts index c11245a..e0ec0cc 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -29,6 +29,7 @@ import { IML_FILTER_OPERATORS, findValueInSelectOptions, pathToString, + setValueAtPath, stringToPath, } from './utils'; import { udttypeExpand } from './composites/udttype'; @@ -58,6 +59,8 @@ export interface ValidationContext { path: (string | number)[]; /** Unknown fields are not allowed when strict is true */ strict: boolean; + /** Fill declared defaults for omitted required fields (see FormanValidationOptions.fillDefaults) */ + fillDefaults?: 'requiredOnly'; suppressRequired?: boolean; registerOnly?: boolean; /** Maps domain names used in nested.domain to actual domain keys */ @@ -98,6 +101,8 @@ export interface DomainRoot { }>; /** Resolved schema fields collected during validation */ schemaFields: FormanSchemaField[]; + /** Defaults filled during validation (`options.fillDefaults`), with raw path segments */ + appliedDefaults: Array<{ path: Array; value: FormanSchemaValue }>; /** Whether the domain allows dynamic values (IML expressions, unresolved RPC select options) */ allowDynamicValues: boolean; } @@ -251,6 +256,7 @@ export async function validateFormanWithDomainsInternal( seenFields: new Set(), fieldStates: [], schemaFields: [], + appliedDefaults: [], allowDynamicValues: domains[domain]!.allowDynamicValues ?? options?.allowDynamicValues ?? false, validateFields: (fields: FormanSchemaField[], context: ValidationContext) => { return validateFormanValue( @@ -290,6 +296,7 @@ export async function validateFormanWithDomainsInternal( path: [], tail: [], strict: options?.strict === true, + fillDefaults: options?.fillDefaults, domainAliases: options?.domainAliases ?? {}, validateNestedFields: () => { throw new Error('Cannot validate nested fields without parent field.'); @@ -381,6 +388,30 @@ export async function validateFormanWithDomainsInternal( resolvedSchemas: options?.schemas ? Object.fromEntries(Object.keys(domains).map(domain => [domain, roots[domain]!.schemaFields])) : undefined, + // Both fill outputs stay present on the failure path for the same reason: a filled default + // can arm nested requirements, and the caller repairing those needs the filled values the + // errors were computed against. + normalizedValues: options?.fillDefaults + ? Object.fromEntries( + Object.keys(domains).map(domain => [ + domain, + (roots[domain]?.appliedDefaults ?? []).reduce( + (values, { path, value }) => setValueAtPath(values, path, value), + domains[domain]?.values ?? {}, + ), + ]), + ) + : undefined, + appliedDefaults: options?.fillDefaults + ? Object.keys(domains).flatMap( + domain => + roots[domain]?.appliedDefaults.map(({ path, value }) => ({ + domain, + path: path.join('.'), + value, + })) ?? [], + ) + : undefined, }; } @@ -435,17 +466,28 @@ async function validateFormanValue( const normalizedField = normalizeFormanFieldType(field); if (normalizedField.required && !context.suppressRequired && (value == null || value === '')) { - return { - valid: false, - errors: [ - { - domain: context.domain, - path: context.path.join('.'), - message: 'Field is mandatory.', - }, - ], - warnings: [], - }; + // With `fillDefaults`, an omitted required field validates as its declared default instead + // of failing. Only an absent value qualifies — an explicit `null`/`''` is a provided value, + // not an omission — and only a default that can itself satisfy the required check (`null` + // and `''` cannot) is filled. The filled value continues through the walk below, so a + // filled boolean arms its own nested branch and defaults under it fill recursively. + const fillable = + context.fillDefaults === 'requiredOnly' && value === undefined ? normalizedField.default : undefined; + if (fillable == null || fillable === '') { + return { + valid: false, + errors: [ + { + domain: context.domain, + path: context.path.join('.'), + message: 'Field is mandatory.', + }, + ], + warnings: [], + }; + } + value = fillable; + context.roots[context.domain]?.appliedDefaults.push({ path: [...context.path], value: fillable }); } if (value == null || value === '') { diff --git a/test/fill-defaults.spec.ts b/test/fill-defaults.spec.ts new file mode 100644 index 0000000..a2ff0fb --- /dev/null +++ b/test/fill-defaults.spec.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from '@jest/globals'; +import { validateForman, validateFormanWithDomains } from '../src/index.js'; +import type { FormanSchemaField } from '../src/index.js'; + +describe('fillDefaults: requiredOnly', () => { + // A required toggle with a declared default and a required field revealed only when it is on. + const fallbackToggle: FormanSchemaField[] = [ + { + name: 'fallbackEnabled', + type: 'boolean', + required: true, + default: false, + nested: [{ name: 'fallbackConnectionId', type: 'text', required: true }], + }, + ]; + + it('fills an omitted required field from its declared default and reports it', async () => { + const result = await validateForman({}, fallbackToggle, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.normalizedValues).toEqual({ default: { fallbackEnabled: false } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'fallbackEnabled', value: false }]); + }); + + it('fills defaults revealed by a default it just filled, in the same pass', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'toggle', + type: 'boolean', + required: true, + default: true, + nested: [{ name: 'label', type: 'text', required: true, default: 'fallback label' }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { toggle: true, label: 'fallback label' } }); + expect(result.appliedDefaults).toEqual([ + { domain: 'default', path: 'toggle', value: true }, + { domain: 'default', path: 'label', value: 'fallback label' }, + ]); + }); + + it('reports requirements armed by a filled default alongside the fill', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'compactionEnabled', + type: 'boolean', + required: true, + default: true, + nested: [{ name: 'compactionThreshold', type: 'number', required: true }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + { domain: 'default', path: 'compactionThreshold', message: 'Field is mandatory.' }, + ]); + expect(result.normalizedValues).toEqual({ default: { compactionEnabled: true } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'compactionEnabled', value: true }]); + }); + + it('never overwrites a provided value, falsy included', async () => { + const schema: FormanSchemaField[] = [ + { name: 'retries', type: 'number', required: true, default: 3 }, + { name: 'verbose', type: 'boolean', required: true, default: true }, + ]; + const result = await validateForman({ retries: 0, verbose: false }, schema, { + strict: true, + fillDefaults: 'requiredOnly', + }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { retries: 0, verbose: false } }); + expect(result.appliedDefaults).toEqual([]); + }); + + it('still fails a value the caller explicitly cleared', async () => { + const schema: FormanSchemaField[] = [{ name: 'mode', type: 'text', required: true, default: 'select' }]; + for (const cleared of [null, '']) { + const result = await validateForman({ mode: cleared }, schema, { + strict: true, + fillDefaults: 'requiredOnly', + }); + expect(result.errors).toEqual([{ domain: 'default', path: 'mode', message: 'Field is mandatory.' }]); + expect(result.appliedDefaults).toEqual([]); + } + }); + + it('treats null and empty-string defaults as no default', async () => { + const schema: FormanSchemaField[] = [ + { name: 'source', type: 'text', required: true, default: null }, + { name: 'label', type: 'text', required: true, default: '' }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.errors).toEqual([ + { domain: 'default', path: 'source', message: 'Field is mandatory.' }, + { domain: 'default', path: 'label', message: 'Field is mandatory.' }, + ]); + expect(result.appliedDefaults).toEqual([]); + }); + + it('fills falsy defaults rather than treating them as absent', async () => { + const schema: FormanSchemaField[] = [ + { name: 'retries', type: 'number', required: true, default: 0 }, + { name: 'verbose', type: 'boolean', required: true, default: false }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { retries: 0, verbose: false } }); + }); + + it('never fills optional fields', async () => { + const schema: FormanSchemaField[] = [{ name: 'reasoningEffort', type: 'text', default: 'low' }]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: {} }); + expect(result.appliedDefaults).toEqual([]); + }); + + it('fills defaults on fields injected by a remote-resolved spec', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'model', + type: 'select', + required: true, + options: [{ value: 'gpt', nested: 'rpc://modelParams' }], + }, + ]; + const result = await validateForman({ model: 'gpt' }, schema, { + strict: true, + fillDefaults: 'requiredOnly', + resolveRemote: async path => + path === 'rpc://modelParams' + ? [{ name: 'fallbackEnabled', type: 'boolean', required: true, default: false }] + : [], + }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { model: 'gpt', fallbackEnabled: false } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'fallbackEnabled', value: false }]); + }); + + it('does not fill under a branch its own filled default left inactive', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'advanced', + type: 'boolean', + required: true, + default: false, + nested: [{ name: 'level', type: 'text', required: true, default: 'high' }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { advanced: false } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'advanced', value: false }]); + }); + + it('routes a filled two-branch boolean to the matching branch', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'enabled', + type: 'boolean', + required: true, + default: false, + nested: { + true: [{ name: 'target', type: 'text', required: true }], + false: [{ name: 'reason', type: 'text', required: true, default: 'disabled by default' }], + }, + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { enabled: false, reason: 'disabled by default' } }); + expect(result.appliedDefaults).toEqual([ + { domain: 'default', path: 'enabled', value: false }, + { domain: 'default', path: 'reason', value: 'disabled by default' }, + ]); + }); + + it('fills inside collections and array items at the right path', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'rows', + type: 'array', + spec: [ + { name: 'title', type: 'text', required: true }, + { name: 'mode', type: 'text', required: true, default: 'select' }, + ], + }, + ]; + const result = await validateForman({ rows: [{ title: 'first' }, { title: 'second' }] }, schema, { + strict: true, + fillDefaults: 'requiredOnly', + }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ + default: { + rows: [ + { title: 'first', mode: 'select' }, + { title: 'second', mode: 'select' }, + ], + }, + }); + expect(result.appliedDefaults).toEqual([ + { domain: 'default', path: 'rows.0.mode', value: 'select' }, + { domain: 'default', path: 'rows.1.mode', value: 'select' }, + ]); + }); + + it('fills each domain independently and never mutates the input values', async () => { + const parameterValues = {}; + const expectValues = { message: 'hi' }; + const result = await validateFormanWithDomains( + { + parameters: { + values: parameterValues, + schema: [{ name: 'kind', type: 'text', required: true, default: 'basic' }], + }, + expect: { + values: expectValues, + schema: [ + { name: 'message', type: 'text', required: true }, + { name: 'aiCompactionEnabled', type: 'boolean', required: true, default: true }, + ], + }, + }, + { strict: true, fillDefaults: 'requiredOnly' }, + ); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ + parameters: { kind: 'basic' }, + expect: { message: 'hi', aiCompactionEnabled: true }, + }); + expect(result.appliedDefaults).toEqual([ + { domain: 'parameters', path: 'kind', value: 'basic' }, + { domain: 'expect', path: 'aiCompactionEnabled', value: true }, + ]); + expect(parameterValues).toEqual({}); + expect(expectValues).toEqual({ message: 'hi' }); + }); + + it('changes nothing when the option is off', async () => { + const result = await validateForman({}, fallbackToggle, { strict: true }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([{ domain: 'default', path: 'fallbackEnabled', message: 'Field is mandatory.' }]); + expect(result.normalizedValues).toBeUndefined(); + expect(result.appliedDefaults).toBeUndefined(); + }); +}); From 91be2b32f7549a26aa22263062c8d59717d77168 Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Wed, 26 Aug 2026 16:32:50 +0200 Subject: [PATCH 02/11] docs: document the fillDefaults validation option MAIA-1286 --- AGENTS.md | 4 +++- README.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 05ff892..410d991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,9 @@ Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleColl `validateFormanWithDomainsInternal` is the core; it builds a `roots` map per domain then calls `validateFormanValue` recursively. Handlers: `handleCollectionType`, `handleArrayType`, `handleSelectType`, `handleFilterType`, `handlePathType`, `handlePrimitiveType`, `handleNestedFields`, `handleBooleanNestedFields`. `resolveRemote` is wrapped into a closure that merges `context.tail` into the `data` argument. -`validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas? }`. `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain. +`validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas?, resolvedSchemas?, normalizedValues?, appliedDefaults? }`. `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain. `resolvedSchemas` is the same data as `schemas` but present on the failure path too, so a rejection can name remote-resolved fields the caller never saw. + +**Default filling** (`options.fillDefaults: 'requiredOnly'`, off by default): an omitted required field whose schema declares a default that can satisfy the required check (`null`/`''` cannot) validates as that default instead of failing `"Field is mandatory."`. The substitution happens at the mandatory check in `validateFormanValue`, so the filled value flows through the rest of the walk — a filled boolean arms its own nested branch and required defaults under it fill recursively, in the same single pass; this includes fields injected by `rpc://`-resolved specs. Only truly absent values fill: an explicit `null`/`''` is a provided value and still fails, provided values are never overwritten, optional fields are never filled, and nothing fills under `suppressRequired` (inactive branches). Fills are recorded per domain root (`appliedDefaults`, raw path segments) and the result exposes `appliedDefaults` (dot-joined paths, matching error paths) plus `normalizedValues` — the input values with fills written via `setValueAtPath` (`src/utils.ts`, copy-on-write along the path; inputs never mutated). Both are present on success and failure, since a filled default can arm requirements the caller still has to repair. Per-domain inputs accept `restoreExtras` (extra values injected into restore states, keyed by dot-notation path) and `allowDynamicValues` (when true, IML expressions and unresolved RPC select options produce warnings instead of errors; default false). `allowDynamicValues` can also be set globally via `FormanValidationOptions`. diff --git a/README.md b/README.md index b0d147e..8092200 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,37 @@ const result = await validateForman(values, schema, { }); ``` +#### Filling required defaults + +With `fillDefaults: 'requiredOnly'`, an omitted required field whose schema declares a usable +default (`null` and `''` cannot satisfy a required check) validates as that default instead of +failing as mandatory. The filled value participates in the rest of the walk, so a filled boolean +arms its own nested branch and required defaults under it fill recursively — including fields +injected by `rpc://`-resolved specs. The result carries `normalizedValues` (the values with fills +applied; the input is never mutated) and `appliedDefaults`, on the failure path too, so remaining +errors can be repaired on top of the filled values. Values you provide are never overwritten, an +explicit `null`/`''` still fails as mandatory, and optional fields are never filled. + +```typescript +const schema = [ + { + name: 'fallbackEnabled', + type: 'boolean', + required: true, + default: false, + nested: [{ name: 'fallbackConnectionId', type: 'text', required: true }], + }, +]; + +const result = await validateForman({}, schema, { fillDefaults: 'requiredOnly' }); +// { +// valid: true, +// errors: [], +// normalizedValues: { default: { fallbackEnabled: false } }, +// appliedDefaults: [{ domain: 'default', path: 'fallbackEnabled', value: false }] +// } +``` + #### Multi-domain validation Use `validateFormanWithDomains` to validate cross-domain schemas (e.g., `default` and `additional`). From b0e2fdc537289f0c96ed45b66c5b70ec37d98813 Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Wed, 26 Aug 2026 16:43:04 +0200 Subject: [PATCH 03/11] refactor(validator): apply adversarial review and trim findings Clone runtime object/array defaults at the fill site so results never alias the schema's default instance, pin cross-domain fills and a filled default failing its own validation with tests, tighten comments and docs that duplicated the option JSDoc. MAIA-1286 --- AGENTS.md | 2 +- src/types.ts | 2 +- src/utils.ts | 3 --- src/validator.ts | 17 +++++++---------- test/fill-defaults.spec.ts | 38 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 410d991..8eb6d84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleColl `validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas?, resolvedSchemas?, normalizedValues?, appliedDefaults? }`. `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain. `resolvedSchemas` is the same data as `schemas` but present on the failure path too, so a rejection can name remote-resolved fields the caller never saw. -**Default filling** (`options.fillDefaults: 'requiredOnly'`, off by default): an omitted required field whose schema declares a default that can satisfy the required check (`null`/`''` cannot) validates as that default instead of failing `"Field is mandatory."`. The substitution happens at the mandatory check in `validateFormanValue`, so the filled value flows through the rest of the walk — a filled boolean arms its own nested branch and required defaults under it fill recursively, in the same single pass; this includes fields injected by `rpc://`-resolved specs. Only truly absent values fill: an explicit `null`/`''` is a provided value and still fails, provided values are never overwritten, optional fields are never filled, and nothing fills under `suppressRequired` (inactive branches). Fills are recorded per domain root (`appliedDefaults`, raw path segments) and the result exposes `appliedDefaults` (dot-joined paths, matching error paths) plus `normalizedValues` — the input values with fills written via `setValueAtPath` (`src/utils.ts`, copy-on-write along the path; inputs never mutated). Both are present on success and failure, since a filled default can arm requirements the caller still has to repair. +**Default filling** (`options.fillDefaults: 'requiredOnly'`, off by default; contract in the `FormanValidationOptions` JSDoc): the substitution happens at the mandatory check in `validateFormanValue` — only a strictly `undefined` value with a non-`null`/`''` default fills — so the filled value flows through the rest of the walk and required defaults under an armed nested branch fill recursively, `rpc://`-resolved specs included. Nothing fills under `suppressRequired` (inactive branches). Fills are recorded per domain root (`DomainRoot.appliedDefaults`, raw path segments); the result exposes `appliedDefaults` (dot-joined paths, matching error paths) and `normalizedValues`, built with `setValueAtPath` (`src/utils.ts`, copy-on-write along the path; inputs never mutated). Both are present on success and failure, since a filled default can arm requirements the caller still has to repair. Per-domain inputs accept `restoreExtras` (extra values injected into restore states, keyed by dot-notation path) and `allowDynamicValues` (when true, IML expressions and unresolved RPC select options produce warnings instead of errors; default false). `allowDynamicValues` can also be set globally via `FormanValidationOptions`. diff --git a/src/types.ts b/src/types.ts index 994aec6..cb3e342 100644 --- a/src/types.ts +++ b/src/types.ts @@ -299,7 +299,7 @@ export type FormanValidationResult = { * subtrees no default was written into are shared with the input. */ normalizedValues?: Record>; - /** The defaults that were filled, in walk order. Requires `options.fillDefaults`. */ + /** The defaults that were filled, in walk order within each domain. Requires `options.fillDefaults`. */ appliedDefaults?: { /** Field domain */ domain: string; diff --git a/src/utils.ts b/src/utils.ts index 67a86bf..7a70845 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -209,9 +209,6 @@ function setIn(container: unknown, path: Array, value: unknown) * Returns a copy of `values` with `value` written at `path` (string segments for object keys, * numbers for array indices). Containers along the path are cloned (and created when missing); * everything off the path is shared with the input, which is never mutated. - * @param values The object to write into - * @param path The path to write at - * @param value The value to write */ export function setValueAtPath( values: Record, diff --git a/src/validator.ts b/src/validator.ts index e0ec0cc..a510720 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -388,9 +388,6 @@ export async function validateFormanWithDomainsInternal( resolvedSchemas: options?.schemas ? Object.fromEntries(Object.keys(domains).map(domain => [domain, roots[domain]!.schemaFields])) : undefined, - // Both fill outputs stay present on the failure path for the same reason: a filled default - // can arm nested requirements, and the caller repairing those needs the filled values the - // errors were computed against. normalizedValues: options?.fillDefaults ? Object.fromEntries( Object.keys(domains).map(domain => [ @@ -466,11 +463,8 @@ async function validateFormanValue( const normalizedField = normalizeFormanFieldType(field); if (normalizedField.required && !context.suppressRequired && (value == null || value === '')) { - // With `fillDefaults`, an omitted required field validates as its declared default instead - // of failing. Only an absent value qualifies — an explicit `null`/`''` is a provided value, - // not an omission — and only a default that can itself satisfy the required check (`null` - // and `''` cannot) is filled. The filled value continues through the walk below, so a - // filled boolean arms its own nested branch and defaults under it fill recursively. + // Strictly `undefined`: an explicit `null`/`''` is a provided value, not an omission, + // and a `null`/`''` default could not satisfy the required check it is filling for. const fillable = context.fillDefaults === 'requiredOnly' && value === undefined ? normalizedField.default : undefined; if (fillable == null || fillable === '') { @@ -486,8 +480,11 @@ async function validateFormanValue( warnings: [], }; } - value = fillable; - context.roots[context.domain]?.appliedDefaults.push({ path: [...context.path], value: fillable }); + // `default` is typed as a primitive, but schemas are JSON at source: a runtime object or + // array default is cloned so the result never aliases the schema's own default instance. + const filled = typeof fillable === 'object' ? structuredClone(fillable) : fillable; + value = filled; + context.roots[context.domain]?.appliedDefaults.push({ path: [...context.path], value: filled }); } if (value == null || value === '') { diff --git a/test/fill-defaults.spec.ts b/test/fill-defaults.spec.ts index a2ff0fb..7aec3cc 100644 --- a/test/fill-defaults.spec.ts +++ b/test/fill-defaults.spec.ts @@ -239,6 +239,44 @@ describe('fillDefaults: requiredOnly', () => { expect(expectValues).toEqual({ message: 'hi' }); }); + it('records a cross-domain fill under the domain that owns the field', async () => { + const result = await validateFormanWithDomains( + { + source: { + values: { host: 'localhost' }, + schema: [ + { + name: 'host', + type: 'text', + nested: { + store: [{ name: 'port', type: 'number', required: true, default: 8080 }], + domain: 'default', + }, + }, + ], + }, + default: { values: {}, schema: [] }, + }, + { strict: true, fillDefaults: 'requiredOnly' }, + ); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ source: { host: 'localhost' }, default: { port: 8080 } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'port', value: 8080 }]); + }); + + it('reports a filled default that fails its own validation, with the value it tried', async () => { + const schema: FormanSchemaField[] = [ + { name: 'level', type: 'text', required: true, default: 'extreme', validate: { enum: ['low', 'high'] } }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + { domain: 'default', path: 'level', message: 'Value must be one of the following: low, high' }, + ]); + expect(result.normalizedValues).toEqual({ default: { level: 'extreme' } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'level', value: 'extreme' }]); + }); + it('changes nothing when the option is off', async () => { const result = await validateForman({}, fallbackToggle, { strict: true }); expect(result.valid).toBe(false); From 8955361e42326d8f853c90c5a30eb303c282b729 Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Wed, 26 Aug 2026 16:50:07 +0200 Subject: [PATCH 04/11] fix(validator): widen appliedDefaults value typing and clarify the runtime clone guard Copilot review: isObject + Array.isArray instead of an always-false-narrowed typeof comparison, appliedDefaults[].value widened to unknown since JSON-sourced schemas can carry object/array defaults at runtime, spec case pinning the clone. MAIA-1286 --- src/types.ts | 6 ++++-- src/validator.ts | 4 ++-- test/fill-defaults.spec.ts | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/types.ts b/src/types.ts index cb3e342..8ef9040 100644 --- a/src/types.ts +++ b/src/types.ts @@ -305,8 +305,10 @@ export type FormanValidationResult = { domain: string; /** Field path */ path: string; - /** The default that was filled in */ - value: FormanSchemaValue; + /** The default that was filled in. Loosely typed on purpose: `default` is declared as + * `FormanSchemaValue`, but schemas are JSON at source and may carry object or array + * defaults at runtime, which are filled as (cloned) values too. */ + value: unknown; }[]; }; diff --git a/src/validator.ts b/src/validator.ts index a510720..b1cdec8 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -102,7 +102,7 @@ export interface DomainRoot { /** Resolved schema fields collected during validation */ schemaFields: FormanSchemaField[]; /** Defaults filled during validation (`options.fillDefaults`), with raw path segments */ - appliedDefaults: Array<{ path: Array; value: FormanSchemaValue }>; + appliedDefaults: Array<{ path: Array; value: unknown }>; /** Whether the domain allows dynamic values (IML expressions, unresolved RPC select options) */ allowDynamicValues: boolean; } @@ -482,7 +482,7 @@ async function validateFormanValue( } // `default` is typed as a primitive, but schemas are JSON at source: a runtime object or // array default is cloned so the result never aliases the schema's own default instance. - const filled = typeof fillable === 'object' ? structuredClone(fillable) : fillable; + const filled = isObject(fillable) || Array.isArray(fillable) ? structuredClone(fillable) : fillable; value = filled; context.roots[context.domain]?.appliedDefaults.push({ path: [...context.path], value: filled }); } diff --git a/test/fill-defaults.spec.ts b/test/fill-defaults.spec.ts index 7aec3cc..c48896e 100644 --- a/test/fill-defaults.spec.ts +++ b/test/fill-defaults.spec.ts @@ -277,6 +277,26 @@ describe('fillDefaults: requiredOnly', () => { expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'level', value: 'extreme' }]); }); + it('clones a runtime object default instead of aliasing the schema instance', async () => { + // `default` is typed as a primitive, but JSON-sourced schemas can carry object defaults. + const objectDefault = { depth: 1 }; + const schema: FormanSchemaField[] = [ + { + name: 'options', + type: 'collection', + required: true, + default: objectDefault as unknown as FormanSchemaField['default'], + spec: [{ name: 'depth', type: 'number' }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + const filled = result.normalizedValues?.default?.options; + expect(filled).toEqual({ depth: 1 }); + expect(filled).not.toBe(objectDefault); + expect(result.appliedDefaults?.[0]?.value).not.toBe(objectDefault); + }); + it('changes nothing when the option is off', async () => { const result = await validateForman({}, fallbackToggle, { strict: true }); expect(result.valid).toBe(false); From c2d94b9999055f98d29da571b9d3890a23da96f6 Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Thu, 27 Aug 2026 08:19:29 +0200 Subject: [PATCH 05/11] test(validator): pin reversedNested conditioning on a filled default MAIA-1286 --- test/fill-defaults.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/fill-defaults.spec.ts b/test/fill-defaults.spec.ts index c48896e..b195a08 100644 --- a/test/fill-defaults.spec.ts +++ b/test/fill-defaults.spec.ts @@ -155,6 +155,25 @@ describe('fillDefaults: requiredOnly', () => { expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'advanced', value: false }]); }); + it('conditions a reversedNested branch on the filled default', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'simple', + type: 'boolean', + required: true, + default: true, + reversedNested: true, + nested: [{ name: 'advancedConfig', type: 'text', required: true }], + }, + ]; + // With reversedNested the branch applies on `false`, so the filled `true` leaves it + // inactive and its requirement must not fire. + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { simple: true } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'simple', value: true }]); + }); + it('routes a filled two-branch boolean to the matching branch', async () => { const schema: FormanSchemaField[] = [ { From f9a097cbe4d1eac9fd90d0add3e4479d40b0330f Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Thu, 27 Aug 2026 09:40:23 +0200 Subject: [PATCH 06/11] fix(validator): align the fillable predicate with BlueprintValidator An empty string now counts as an omission and fills, matching useDefaults in @integromat/blueprint and the builder UI predicate it cites; an explicit null remains a provided value and still fails as mandatory. MAIA-1286 --- AGENTS.md | 2 +- README.md | 3 ++- src/types.ts | 15 ++++++++------- src/validator.ts | 9 ++++++--- test/fill-defaults.spec.ts | 26 +++++++++++++++++--------- 5 files changed, 34 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8eb6d84..726e569 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleColl `validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas?, resolvedSchemas?, normalizedValues?, appliedDefaults? }`. `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain. `resolvedSchemas` is the same data as `schemas` but present on the failure path too, so a rejection can name remote-resolved fields the caller never saw. -**Default filling** (`options.fillDefaults: 'requiredOnly'`, off by default; contract in the `FormanValidationOptions` JSDoc): the substitution happens at the mandatory check in `validateFormanValue` — only a strictly `undefined` value with a non-`null`/`''` default fills — so the filled value flows through the rest of the walk and required defaults under an armed nested branch fill recursively, `rpc://`-resolved specs included. Nothing fills under `suppressRequired` (inactive branches). Fills are recorded per domain root (`DomainRoot.appliedDefaults`, raw path segments); the result exposes `appliedDefaults` (dot-joined paths, matching error paths) and `normalizedValues`, built with `setValueAtPath` (`src/utils.ts`, copy-on-write along the path; inputs never mutated). Both are present on success and failure, since a filled default can arm requirements the caller still has to repair. +**Default filling** (`options.fillDefaults: 'requiredOnly'`, off by default; contract in the `FormanValidationOptions` JSDoc): the substitution happens at the mandatory check in `validateFormanValue` — an `undefined` or `''` value with a non-`null`/`''` default fills (BlueprintValidator's `useDefaults` predicate; explicit `null` stays a provided value) — so the filled value flows through the rest of the walk and required defaults under an armed nested branch fill recursively, `rpc://`-resolved specs included. Nothing fills under `suppressRequired` (inactive branches). Fills are recorded per domain root (`DomainRoot.appliedDefaults`, raw path segments); the result exposes `appliedDefaults` (dot-joined paths, matching error paths) and `normalizedValues`, built with `setValueAtPath` (`src/utils.ts`, copy-on-write along the path; inputs never mutated). Both are present on success and failure, since a filled default can arm requirements the caller still has to repair. Per-domain inputs accept `restoreExtras` (extra values injected into restore states, keyed by dot-notation path) and `allowDynamicValues` (when true, IML expressions and unresolved RPC select options produce warnings instead of errors; default false). `allowDynamicValues` can also be set globally via `FormanValidationOptions`. diff --git a/README.md b/README.md index 8092200..4904cde 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,8 @@ arms its own nested branch and required defaults under it fill recursively — i injected by `rpc://`-resolved specs. The result carries `normalizedValues` (the values with fills applied; the input is never mutated) and `appliedDefaults`, on the failure path too, so remaining errors can be repaired on top of the filled values. Values you provide are never overwritten, an -explicit `null`/`''` still fails as mandatory, and optional fields are never filled. +explicit `null` still fails as mandatory, and optional fields are never filled. `''` counts as an +omission and fills, matching the platform's blueprint validation and the builder UI. ```typescript const schema = [ diff --git a/src/types.ts b/src/types.ts index 8ef9040..d6f25d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -398,13 +398,14 @@ export type FormanValidationOptions = { value: unknown, ): FormanExternalValidationResult | Promise; /** Fill declared defaults for required fields the caller omitted, instead of failing them as - * mandatory. `'requiredOnly'` fills a required field whose value is absent and whose schema - * declares a default that can satisfy the required check (`null` and `''` cannot). The filled - * value flows through the rest of the walk, so a filled boolean arms its own nested branch - * and defaults nested under it fill recursively, in the same single pass. The result then - * carries `normalizedValues` and `appliedDefaults`. Values the caller provided are never - * overwritten, and an explicit `null`/`''` still fails as mandatory — it is a provided - * value, not an omission. Optional fields are never filled. */ + * mandatory. `'requiredOnly'` fills a required field whose value is `undefined` or `''` and + * whose schema declares a default that can satisfy the required check (`null` and `''` + * cannot) — the same fillable predicate as BlueprintValidator's `useDefaults` and the + * builder UI. The filled value flows through the rest of the walk, so a filled boolean arms + * its own nested branch and defaults nested under it fill recursively, in the same single + * pass. The result then carries `normalizedValues` and `appliedDefaults`. Real values the + * caller provided are never overwritten, an explicit `null` still fails as mandatory, and + * optional fields are never filled. */ fillDefaults?: 'requiredOnly'; /** Maps domain names used in nested.domain to actual domain keys passed to validateFormanWithDomains */ domainAliases?: Record; diff --git a/src/validator.ts b/src/validator.ts index b1cdec8..edefe41 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -463,10 +463,13 @@ async function validateFormanValue( const normalizedField = normalizeFormanFieldType(field); if (normalizedField.required && !context.suppressRequired && (value == null || value === '')) { - // Strictly `undefined`: an explicit `null`/`''` is a provided value, not an omission, - // and a `null`/`''` default could not satisfy the required check it is filling for. + // Same fillable predicate as BlueprintValidator's `useDefaults` (and the builder UI it + // cites): `undefined` and `''` fill, an explicit `null` stays a provided value. A + // `null`/`''` default could not satisfy the required check it is filling for. const fillable = - context.fillDefaults === 'requiredOnly' && value === undefined ? normalizedField.default : undefined; + context.fillDefaults === 'requiredOnly' && (value === undefined || value === '') + ? normalizedField.default + : undefined; if (fillable == null || fillable === '') { return { valid: false, diff --git a/test/fill-defaults.spec.ts b/test/fill-defaults.spec.ts index b195a08..dc5c4b6 100644 --- a/test/fill-defaults.spec.ts +++ b/test/fill-defaults.spec.ts @@ -74,16 +74,24 @@ describe('fillDefaults: requiredOnly', () => { expect(result.appliedDefaults).toEqual([]); }); - it('still fails a value the caller explicitly cleared', async () => { + it('fills over an explicit empty string, matching the BlueprintValidator predicate', async () => { const schema: FormanSchemaField[] = [{ name: 'mode', type: 'text', required: true, default: 'select' }]; - for (const cleared of [null, '']) { - const result = await validateForman({ mode: cleared }, schema, { - strict: true, - fillDefaults: 'requiredOnly', - }); - expect(result.errors).toEqual([{ domain: 'default', path: 'mode', message: 'Field is mandatory.' }]); - expect(result.appliedDefaults).toEqual([]); - } + const input = { mode: '' }; + const result = await validateForman(input, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { mode: 'select' } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'mode', value: 'select' }]); + expect(input).toEqual({ mode: '' }); + }); + + it('still fails an explicit null, which is a provided value', async () => { + const schema: FormanSchemaField[] = [{ name: 'mode', type: 'text', required: true, default: 'select' }]; + const result = await validateForman({ mode: null }, schema, { + strict: true, + fillDefaults: 'requiredOnly', + }); + expect(result.errors).toEqual([{ domain: 'default', path: 'mode', message: 'Field is mandatory.' }]); + expect(result.appliedDefaults).toEqual([]); }); it('treats null and empty-string defaults as no default', async () => { From 0065b1486fbcc299a24d69cd4d32614c466d33d3 Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Thu, 27 Aug 2026 13:50:49 +0200 Subject: [PATCH 07/11] test: assert the verdict shape with toMatchObject instead of exact equality Mechanical, no intent change: these assertions pin valid/errors/warnings (plus states/schemas where relevant), and exact-equality made them also assert the absence of every other result key. The next commit adds two result fields, so they are widened to subset matching first, separately, to keep that diff readable. MAIA-1286 --- test/chosen-option-nested.spec.ts | 10 +-- test/directives/filter.spec.ts | 6 +- test/directives/nested.spec.ts | 2 +- test/directives/rpc.spec.ts | 4 +- test/json.spec.ts | 4 +- test/restore.spec.ts | 16 ++-- test/validator-comprehensive.spec.ts | 54 ++++++------ test/validator-extended.spec.ts | 120 ++++++++++++++------------- test/validator-manifest.spec.ts | 12 +-- 9 files changed, 117 insertions(+), 111 deletions(-) diff --git a/test/chosen-option-nested.spec.ts b/test/chosen-option-nested.spec.ts index 4f0e6cb..1d4de5d 100644 --- a/test/chosen-option-nested.spec.ts +++ b/test/chosen-option-nested.spec.ts @@ -36,7 +36,7 @@ describe('Chosen option nested spec in field states', () => { }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -64,7 +64,7 @@ describe('Chosen option nested spec in field states', () => { { states: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -92,7 +92,7 @@ describe('Chosen option nested spec in field states', () => { { states: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -127,7 +127,7 @@ describe('Chosen option nested spec in field states', () => { { states: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -164,7 +164,7 @@ describe('Chosen option nested spec in field states', () => { // Dependent field states are siblings of the select state; the spec array on the // select's state must survive buildRestoreStructure untouched. - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], diff --git a/test/directives/filter.spec.ts b/test/directives/filter.spec.ts index 3a1faed..5b41c80 100644 --- a/test/directives/filter.spec.ts +++ b/test/directives/filter.spec.ts @@ -238,7 +238,7 @@ describe('Filter Type with Configurable Options and Operators', () => { }; const result = await validateForman(value, [formanSchema]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate filter with unary operator', async () => { @@ -252,7 +252,7 @@ describe('Filter Type with Configurable Options and Operators', () => { }; const result = await validateForman(value, [formanSchema]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate filter with multiple conditions', async () => { @@ -271,7 +271,7 @@ describe('Filter Type with Configurable Options and Operators', () => { }; const result = await validateForman(value, [formanSchema]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); }); diff --git a/test/directives/nested.spec.ts b/test/directives/nested.spec.ts index 02cec75..849420b 100644 --- a/test/directives/nested.spec.ts +++ b/test/directives/nested.spec.ts @@ -124,7 +124,7 @@ describe('Nested', () => { }, }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], diff --git a/test/directives/rpc.spec.ts b/test/directives/rpc.spec.ts index 82c3b9c..5809ddc 100644 --- a/test/directives/rpc.spec.ts +++ b/test/directives/rpc.spec.ts @@ -452,7 +452,7 @@ describe('RPC', () => { ]); }, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [ @@ -480,7 +480,7 @@ describe('RPC', () => { return Promise.resolve([{ value: 'a', label: 'Option A' }]); }, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [ diff --git a/test/json.spec.ts b/test/json.spec.ts index 4950c3f..b975be3 100644 --- a/test/json.spec.ts +++ b/test/json.spec.ts @@ -194,9 +194,7 @@ describe('json type', () => { const result = await validateForman({ input: { name: 'Alice' } }, schema, { validateJson }); expect(result.valid).toBe(false); - expect(result.errors).toEqual([ - { domain: 'default', path: 'input', message: 'validator crashed' }, - ]); + expect(result.errors).toEqual([{ domain: 'default', path: 'input', message: 'validator crashed' }]); }); it('passes without a callback (schema cannot be enforced)', async () => { diff --git a/test/restore.spec.ts b/test/restore.spec.ts index 1438ab3..67304aa 100644 --- a/test/restore.spec.ts +++ b/test/restore.spec.ts @@ -18,7 +18,7 @@ describe('Restore state for IML-mapped fields', () => { { states: true, allowDynamicValues: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -37,7 +37,7 @@ describe('Restore state for IML-mapped fields', () => { { states: true, allowDynamicValues: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -55,7 +55,7 @@ describe('Restore state for IML-mapped fields', () => { allowDynamicValues: true, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -73,7 +73,7 @@ describe('Restore state for IML-mapped fields', () => { allowDynamicValues: true, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -91,7 +91,7 @@ describe('Restore state for IML-mapped fields', () => { allowDynamicValues: true, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -119,7 +119,7 @@ describe('Restore state for IML-mapped fields', () => { }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [ @@ -153,7 +153,7 @@ describe('Restore state for IML-mapped fields', () => { }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [ @@ -187,7 +187,7 @@ describe('Restore state for IML-mapped fields', () => { { states: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], diff --git a/test/validator-comprehensive.spec.ts b/test/validator-comprehensive.spec.ts index 04ea212..0f92954 100644 --- a/test/validator-comprehensive.spec.ts +++ b/test/validator-comprehensive.spec.ts @@ -52,7 +52,7 @@ describe('Forman Schema Comprehensive Coverage', () => { { name: 'checkbox', type: 'checkbox' }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -74,7 +74,7 @@ describe('Forman Schema Comprehensive Coverage', () => { { name: 'email', type: 'email' }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -116,7 +116,7 @@ describe('Forman Schema Comprehensive Coverage', () => { { name: 'invalidFiles', type: 'upload' }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -162,7 +162,7 @@ describe('Forman Schema Comprehensive Coverage', () => { { name: 'invalidFlatFilter', type: 'filter', logic: 'or' as const }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ errors: [ { domain: 'default', @@ -225,7 +225,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -254,7 +254,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -294,7 +294,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema, { states: true })).toEqual({ + expect(await validateForman(formanValue, formanSchema, { states: true })).toMatchObject({ valid: true, errors: [], warnings: [], @@ -378,7 +378,7 @@ describe('Forman Schema Comprehensive Coverage', () => { throw new Error(`Unknown path: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -421,7 +421,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -453,7 +453,7 @@ describe('Forman Schema Comprehensive Coverage', () => { const wrappedValue = { data: formanValue }; - expect(await validateForman(wrappedValue, formanSchema)).toEqual({ + expect(await validateForman(wrappedValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -512,7 +512,7 @@ describe('Forman Schema Comprehensive Coverage', () => { throw new Error(`Unknown path: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -559,7 +559,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, }; - expect(await validateFormanWithDomains(domains)).toEqual({ + expect(await validateFormanWithDomains(domains)).toMatchObject({ valid: false, errors: [ { @@ -607,7 +607,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -640,7 +640,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -684,7 +684,7 @@ describe('Forman Schema Comprehensive Coverage', () => { ]; // The specific option's nested should override global nested - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -695,17 +695,17 @@ describe('Forman Schema Comprehensive Coverage', () => { describe('Checkbox Type', () => { it('should validate checkbox with true value', async () => { const result = await validateForman({ enabled: true }, [{ name: 'enabled', type: 'checkbox' }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate checkbox with false value', async () => { const result = await validateForman({ disabled: false }, [{ name: 'disabled', type: 'checkbox' }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should reject non-boolean values for checkbox', async () => { const result = await validateForman({ enabled: 'true' }, [{ name: 'enabled', type: 'checkbox' }]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [ { @@ -720,7 +720,7 @@ describe('Forman Schema Comprehensive Coverage', () => { it('should reject number values for checkbox', async () => { const result = await validateForman({ enabled: 1 }, [{ name: 'enabled', type: 'checkbox' }]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [ { @@ -737,7 +737,7 @@ describe('Forman Schema Comprehensive Coverage', () => { const result = await validateForman({ enabled: null }, [ { name: 'enabled', type: 'checkbox', required: true }, ]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [ { @@ -752,12 +752,12 @@ describe('Forman Schema Comprehensive Coverage', () => { it('should allow null for optional checkbox', async () => { const result = await validateForman({ enabled: null }, [{ name: 'enabled', type: 'checkbox' }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should support default value for checkbox', async () => { const result = await validateForman({}, [{ name: 'enabled', type: 'checkbox', default: true }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate checkbox in nested objects', async () => { @@ -768,7 +768,7 @@ describe('Forman Schema Comprehensive Coverage', () => { spec: [{ name: 'notifications', type: 'checkbox' }], }, ]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate checkbox in arrays', async () => { @@ -779,7 +779,7 @@ describe('Forman Schema Comprehensive Coverage', () => { spec: { type: 'collection', spec: [{ name: 'active', type: 'checkbox' }] }, }, ]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); }); @@ -789,7 +789,7 @@ describe('Forman Schema Comprehensive Coverage', () => { // from `normalizeFormanFieldType`. Same production data that broke schema conversion. const result = await validateForman({ f: 'x' }, [{ name: 'f' } as never]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [{ domain: 'default', path: 'f', message: 'Field type is required.' }], warnings: [], @@ -801,13 +801,13 @@ describe('Forman Schema Comprehensive Coverage', () => { // JSON type, so the type check is skipped rather than failing. The converter now matches. const result = await validateForman({ f: 'x' }, [{ name: 'f', type: 'bogusType' }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should still enforce required on a field with an unknown type', async () => { const result = await validateForman({ f: '' }, [{ name: 'f', type: 'bogusType', required: true }]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [{ domain: 'default', path: 'f', message: 'Field is mandatory.' }], warnings: [], diff --git a/test/validator-extended.spec.ts b/test/validator-extended.spec.ts index 7a3674f..21a2246 100644 --- a/test/validator-extended.spec.ts +++ b/test/validator-extended.spec.ts @@ -26,7 +26,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -56,7 +56,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -101,7 +101,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -142,7 +142,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -187,7 +187,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -240,7 +240,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -296,7 +296,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -371,7 +371,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema, { allowDynamicValues: true })).toEqual({ + expect(await validateForman(formanValue, formanSchema, { allowDynamicValues: true })).toMatchObject({ valid: false, errors: [ { @@ -414,7 +414,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -453,7 +453,11 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ valid: true, errors: [], warnings: [] }); + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ + valid: true, + errors: [], + warnings: [], + }); }); it('should not attempt to validate or coerce values for visual types', async () => { @@ -479,7 +483,11 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ valid: true, errors: [], warnings: [] }); + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ + valid: true, + errors: [], + warnings: [], + }); }); }); @@ -528,7 +536,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -587,7 +595,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -621,7 +629,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error('Network error'); }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -658,7 +666,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error('Nested resource error'); }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -685,7 +693,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -750,7 +758,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -793,7 +801,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -827,7 +835,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -852,7 +860,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -875,7 +883,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -924,7 +932,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -1024,7 +1032,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1052,7 +1060,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1077,7 +1085,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1112,7 +1120,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1145,7 +1153,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1189,7 +1197,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1217,7 +1225,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1245,7 +1253,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1290,7 +1298,7 @@ describe('Forman Schema Extended Validation', () => { }, }; - expect(await validateFormanWithDomains(domains)).toEqual({ + expect(await validateFormanWithDomains(domains)).toMatchObject({ valid: false, errors: [ { @@ -1321,7 +1329,7 @@ describe('Forman Schema Extended Validation', () => { }, }; - expect(await validateFormanWithDomains(domains)).toEqual({ + expect(await validateFormanWithDomains(domains)).toMatchObject({ valid: false, errors: [ { @@ -1359,7 +1367,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1402,7 +1410,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1428,7 +1436,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1450,7 +1458,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1490,7 +1498,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1530,7 +1538,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1562,7 +1570,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1618,7 +1626,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [ @@ -1650,7 +1658,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1703,7 +1711,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ // emptyPath: '' is valid for non-required file fields (means "no selection") @@ -1735,7 +1743,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1768,7 +1776,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [ @@ -1802,7 +1810,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1830,7 +1838,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1862,7 +1870,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error('Connection timeout'); }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -1906,7 +1914,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1944,7 +1952,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1979,7 +1987,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -2015,7 +2023,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -2139,7 +2147,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ errors: [], warnings: [], states: { @@ -2216,7 +2224,7 @@ describe('Forman Schema Extended Validation', () => { { resolveRemote }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -2258,7 +2266,7 @@ describe('Forman Schema Extended Validation', () => { const result = await validateForman({ choice: '2026-04-10T11:40:11.000Z' }, schema, { resolveRemote }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -2285,7 +2293,7 @@ describe('Forman Schema Extended Validation', () => { { resolveRemote }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -2313,7 +2321,7 @@ describe('Forman Schema Extended Validation', () => { resolveRemote: async () => standardOptions, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], diff --git a/test/validator-manifest.spec.ts b/test/validator-manifest.spec.ts index 7635ae7..78050ea 100644 --- a/test/validator-manifest.spec.ts +++ b/test/validator-manifest.spec.ts @@ -41,7 +41,7 @@ describe('Forman Schema Manifest Validation', () => { schema: googleSheetsAddRowMock.expect, }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -76,7 +76,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -171,7 +171,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -280,7 +280,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -353,7 +353,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -432,7 +432,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], From 68aca7fadf1c13c870d604249fa09127a02d6493 Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Thu, 27 Aug 2026 13:50:56 +0200 Subject: [PATCH 08/11] feat(validator): add fillDefaults 'always' and return normalizedValues always Mirrors BlueprintValidator's useDefaults modes: 'requiredOnly' fills required fields only, 'always' fills omitted optional fields too. The fill moves just ahead of the mandatory check so both modes share one gate. normalizedValues and appliedDefaults are now on every entry-point result (FormanNormalizedValidationResult), so consuming code does not fork on whether the option is set; with no fills they echo the input values. MAIA-1286 --- src/index.ts | 7 +-- src/types.ts | 45 ++++++++++------ src/validator.ts | 107 ++++++++++++++++++++----------------- test/fill-defaults.spec.ts | 104 +++++++++++++++++++++++++++++++++-- 4 files changed, 193 insertions(+), 70 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3fd911c..05b1814 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import type { JSONSchema7 } from 'json-schema'; import { toJSONSchemaInternal, createDefaultContext } from './forman'; import type { FormanSchemaField, - FormanValidationResult, + FormanNormalizedValidationResult, FormanValidationOptions, FormanJsonSchemaOptions, FormanJsonSchemaResult, @@ -24,6 +24,7 @@ export type { FormanSchemaRPCButton, FormanValidationOptions, FormanValidationResult, + FormanNormalizedValidationResult, FormanExternalValidationResult, FormanJsonSchemaOptions, FormanJsonSchemaResult, @@ -130,7 +131,7 @@ export function validateFormanWithDomains( } >, options?: FormanValidationOptions, -): Promise { +): Promise { return validateFormanWithDomainsInternal(domains, options); } @@ -149,7 +150,7 @@ export function validateForman( schema: FormanSchemaField[], options?: FormanValidationOptions, restoreExtras?: Record>, -): Promise { +): Promise { return validateFormanWithDomains( { default: { values, schema, restoreExtras, allowDynamicValues: options?.allowDynamicValues } }, options, diff --git a/src/types.ts b/src/types.ts index d6f25d3..f3ea04a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -293,13 +293,18 @@ export type FormanValidationResult = { */ resolvedSchemas?: Record; /** - * The input values with filled defaults applied, per domain. Requires `options.fillDefaults`, - * and is present whether or not validation succeeded, so a caller can persist or repair the - * filled configuration alongside any remaining errors. The input `values` are never mutated; - * subtrees no default was written into are shared with the input. + * The input values with filled defaults applied, per domain. Always present on results + * returned by `validateForman`/`validateFormanWithDomains` (see + * {@link FormanNormalizedValidationResult}), whether or not validation succeeded, so a caller + * can persist or repair the filled configuration alongside any remaining errors — and its + * usage does not change with `options.fillDefaults`: with no fills (or the option off) it + * passes the input values through as-is. The input `values` are never mutated; subtrees no + * default was written into are shared with the input. */ normalizedValues?: Record>; - /** The defaults that were filled, in walk order within each domain. Requires `options.fillDefaults`. */ + /** The defaults that were filled (`options.fillDefaults`), in walk order within each domain. + * Always present on results returned by `validateForman`/`validateFormanWithDomains`; empty + * when nothing was filled. */ appliedDefaults?: { /** Field domain */ domain: string; @@ -312,6 +317,15 @@ export type FormanValidationResult = { }[]; }; +/** + * A {@link FormanValidationResult} whose `normalizedValues` and `appliedDefaults` are guaranteed + * present — the type returned by `validateForman` and `validateFormanWithDomains`. The fields stay + * optional on the base type because intermediate results assembled during the walk do not carry + * them. + */ +export type FormanNormalizedValidationResult = FormanValidationResult & + Required>; + export type FormanSchemaFieldState = { mode?: 'chose' | 'edit'; label?: string; @@ -397,16 +411,17 @@ export type FormanValidationOptions = { schema: JSONSchema7, value: unknown, ): FormanExternalValidationResult | Promise; - /** Fill declared defaults for required fields the caller omitted, instead of failing them as - * mandatory. `'requiredOnly'` fills a required field whose value is `undefined` or `''` and - * whose schema declares a default that can satisfy the required check (`null` and `''` - * cannot) — the same fillable predicate as BlueprintValidator's `useDefaults` and the - * builder UI. The filled value flows through the rest of the walk, so a filled boolean arms - * its own nested branch and defaults nested under it fill recursively, in the same single - * pass. The result then carries `normalizedValues` and `appliedDefaults`. Real values the - * caller provided are never overwritten, an explicit `null` still fails as mandatory, and - * optional fields are never filled. */ - fillDefaults?: 'requiredOnly'; + /** Fill declared defaults for fields the caller omitted, mirroring BlueprintValidator's + * `useDefaults` modes. `'requiredOnly'` fills only required fields (instead of failing them + * as mandatory); `'always'` also fills omitted optional fields. A field is fillable when its + * value is `undefined` or `''` and its schema declares a default that is not `null` or `''` + * (a default that could not satisfy a required check) — the same fillable predicate as + * BlueprintValidator and the builder UI. The filled value flows through the rest of the + * walk, so a filled boolean arms its own nested branch and defaults nested under it fill + * recursively, in the same single pass. Fills are reported on `normalizedValues` and + * `appliedDefaults`. Real values the caller provided are never overwritten, an explicit + * `null` still fails as mandatory, and inactive branches are never filled. */ + fillDefaults?: 'requiredOnly' | 'always'; /** Maps domain names used in nested.domain to actual domain keys passed to validateFormanWithDomains */ domainAliases?: Record; /** Whether to allow dynamic values (IML expressions, unresolved RPC options). diff --git a/src/validator.ts b/src/validator.ts index edefe41..e710d84 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -2,6 +2,7 @@ import type { JSONSchema7 } from 'json-schema'; import type { FormanSchemaField, FormanValidationResult, + FormanNormalizedValidationResult, FormanExternalValidationResult, FormanSchemaExtendedNested, FormanSchemaExtendedOptions, @@ -59,8 +60,8 @@ export interface ValidationContext { path: (string | number)[]; /** Unknown fields are not allowed when strict is true */ strict: boolean; - /** Fill declared defaults for omitted required fields (see FormanValidationOptions.fillDefaults) */ - fillDefaults?: 'requiredOnly'; + /** Fill declared defaults for omitted fields (see FormanValidationOptions.fillDefaults) */ + fillDefaults?: 'requiredOnly' | 'always'; suppressRequired?: boolean; registerOnly?: boolean; /** Maps domain names used in nested.domain to actual domain keys */ @@ -246,7 +247,7 @@ export async function validateFormanWithDomainsInternal( } >, options?: FormanValidationOptions, -): Promise { +): Promise { const errors: FormanValidationResult['errors'] = []; const warnings: FormanValidationResult['warnings'] = []; @@ -388,27 +389,26 @@ export async function validateFormanWithDomainsInternal( resolvedSchemas: options?.schemas ? Object.fromEntries(Object.keys(domains).map(domain => [domain, roots[domain]!.schemaFields])) : undefined, - normalizedValues: options?.fillDefaults - ? Object.fromEntries( - Object.keys(domains).map(domain => [ - domain, - (roots[domain]?.appliedDefaults ?? []).reduce( - (values, { path, value }) => setValueAtPath(values, path, value), - domains[domain]?.values ?? {}, - ), - ]), - ) - : undefined, - appliedDefaults: options?.fillDefaults - ? Object.keys(domains).flatMap( - domain => - roots[domain]?.appliedDefaults.map(({ path, value }) => ({ - domain, - path: path.join('.'), - value, - })) ?? [], - ) - : undefined, + // Always present, `fillDefaults` or not, so the caller-side pattern + // (`if (valid) use(normalizedValues)`) is the same with and without the option. With no + // fills the input values are passed through as-is. + normalizedValues: Object.fromEntries( + Object.keys(domains).map(domain => [ + domain, + (roots[domain]?.appliedDefaults ?? []).reduce( + (values, { path, value }) => setValueAtPath(values, path, value), + domains[domain]?.values ?? {}, + ), + ]), + ), + appliedDefaults: Object.keys(domains).flatMap( + domain => + roots[domain]?.appliedDefaults.map(({ path, value }) => ({ + domain, + path: path.join('.'), + value, + })) ?? [], + ), }; } @@ -462,32 +462,41 @@ async function validateFormanValue( // Normalize field type (handle prefixed types) const normalizedField = normalizeFormanFieldType(field); - if (normalizedField.required && !context.suppressRequired && (value == null || value === '')) { - // Same fillable predicate as BlueprintValidator's `useDefaults` (and the builder UI it - // cites): `undefined` and `''` fill, an explicit `null` stays a provided value. A - // `null`/`''` default could not satisfy the required check it is filling for. - const fillable = - context.fillDefaults === 'requiredOnly' && (value === undefined || value === '') - ? normalizedField.default - : undefined; - if (fillable == null || fillable === '') { - return { - valid: false, - errors: [ - { - domain: context.domain, - path: context.path.join('.'), - message: 'Field is mandatory.', - }, - ], - warnings: [], - }; + // Same fillable predicate as BlueprintValidator's `useDefaults` (and the builder UI it + // cites): `undefined` and `''` fill, an explicit `null` stays a provided value. + // `'requiredOnly'` fills required fields only, `'always'` fills optional ones too. A + // `null`/`''` default is never filled: it could not satisfy a required check, and on an + // optional field it is indistinguishable from the omission itself. Branches left inactive + // (`suppressRequired`) never fill. + if ( + context.fillDefaults != null && + !context.suppressRequired && + (value === undefined || value === '') && + (context.fillDefaults === 'always' || normalizedField.required) + ) { + const fillable = normalizedField.default; + if (fillable != null && fillable !== '') { + // `default` is typed as a primitive, but schemas are JSON at source: a runtime object or + // array default is cloned so the result never aliases the schema's own default instance. + const filled = + isObject(fillable) || Array.isArray(fillable) ? structuredClone(fillable) : fillable; + value = filled; + context.roots[context.domain]?.appliedDefaults.push({ path: [...context.path], value: filled }); } - // `default` is typed as a primitive, but schemas are JSON at source: a runtime object or - // array default is cloned so the result never aliases the schema's own default instance. - const filled = isObject(fillable) || Array.isArray(fillable) ? structuredClone(fillable) : fillable; - value = filled; - context.roots[context.domain]?.appliedDefaults.push({ path: [...context.path], value: filled }); + } + + if (normalizedField.required && !context.suppressRequired && (value == null || value === '')) { + return { + valid: false, + errors: [ + { + domain: context.domain, + path: context.path.join('.'), + message: 'Field is mandatory.', + }, + ], + warnings: [], + }; } if (value == null || value === '') { diff --git a/test/fill-defaults.spec.ts b/test/fill-defaults.spec.ts index dc5c4b6..bfe40a1 100644 --- a/test/fill-defaults.spec.ts +++ b/test/fill-defaults.spec.ts @@ -324,11 +324,109 @@ describe('fillDefaults: requiredOnly', () => { expect(result.appliedDefaults?.[0]?.value).not.toBe(objectDefault); }); - it('changes nothing when the option is off', async () => { + it('changes no validation outcome when the option is off', async () => { const result = await validateForman({}, fallbackToggle, { strict: true }); expect(result.valid).toBe(false); expect(result.errors).toEqual([{ domain: 'default', path: 'fallbackEnabled', message: 'Field is mandatory.' }]); - expect(result.normalizedValues).toBeUndefined(); - expect(result.appliedDefaults).toBeUndefined(); + expect(result.appliedDefaults).toEqual([]); + }); +}); + +describe('fillDefaults: always', () => { + it('fills omitted optional fields too', async () => { + const schema: FormanSchemaField[] = [ + { name: 'kind', type: 'text', required: true, default: 'basic' }, + { name: 'reasoningEffort', type: 'text', default: 'low' }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'always' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { kind: 'basic', reasoningEffort: 'low' } }); + expect(result.appliedDefaults).toEqual([ + { domain: 'default', path: 'kind', value: 'basic' }, + { domain: 'default', path: 'reasoningEffort', value: 'low' }, + ]); + }); + + it('fills an optional field over an explicit empty string', async () => { + const schema: FormanSchemaField[] = [{ name: 'mode', type: 'text', default: 'select' }]; + const result = await validateForman({ mode: '' }, schema, { strict: true, fillDefaults: 'always' }); + expect(result.normalizedValues).toEqual({ default: { mode: 'select' } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'mode', value: 'select' }]); + }); + + it('arms the nested branch of a filled optional toggle', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'advanced', + type: 'boolean', + default: true, + nested: [{ name: 'level', type: 'text', required: true, default: 'high' }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'always' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { advanced: true, level: 'high' } }); + expect(result.appliedDefaults).toEqual([ + { domain: 'default', path: 'advanced', value: true }, + { domain: 'default', path: 'level', value: 'high' }, + ]); + }); + + it('never overwrites provided values and skips null/empty-string defaults', async () => { + const schema: FormanSchemaField[] = [ + { name: 'retries', type: 'number', default: 3 }, + { name: 'source', type: 'text', default: null }, + { name: 'label', type: 'text', default: '' }, + ]; + const result = await validateForman({ retries: 0 }, schema, { strict: true, fillDefaults: 'always' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { retries: 0 } }); + expect(result.appliedDefaults).toEqual([]); + }); + + it('does not fill optional defaults under a branch left inactive', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'advanced', + type: 'boolean', + required: true, + default: false, + nested: [{ name: 'level', type: 'text', default: 'high' }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'always' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { advanced: false } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'advanced', value: false }]); + }); + + it('still fails a required field whose default is unusable', async () => { + const schema: FormanSchemaField[] = [{ name: 'source', type: 'text', required: true, default: null }]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'always' }); + expect(result.errors).toEqual([{ domain: 'default', path: 'source', message: 'Field is mandatory.' }]); + expect(result.appliedDefaults).toEqual([]); + }); +}); + +describe('normalizedValues without fillDefaults', () => { + it('passes the input values through, per domain, so the caller-side pattern never changes', async () => { + const parameterValues = { kind: 'basic' }; + const result = await validateFormanWithDomains( + { + parameters: { values: parameterValues, schema: [{ name: 'kind', type: 'text', required: true }] }, + expect: { values: {}, schema: [{ name: 'message', type: 'text' }] }, + }, + { strict: true }, + ); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ parameters: { kind: 'basic' }, expect: {} }); + expect(result.appliedDefaults).toEqual([]); + }); + + it('is present on the failure path too', async () => { + const result = await validateForman({}, [{ name: 'kind', type: 'text', required: true }], { strict: true }); + expect(result.valid).toBe(false); + expect(result.normalizedValues).toEqual({ default: {} }); + expect(result.appliedDefaults).toEqual([]); }); }); From 7686e77dfa83cac864cd5acd2c6421232117aeff Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Thu, 27 Aug 2026 13:50:56 +0200 Subject: [PATCH 09/11] docs: fillDefaults 'always' mode and always-present normalizedValues MAIA-1286 --- AGENTS.md | 4 ++-- README.md | 44 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 726e569..f692dd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,9 +77,9 @@ Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleColl `validateFormanWithDomainsInternal` is the core; it builds a `roots` map per domain then calls `validateFormanValue` recursively. Handlers: `handleCollectionType`, `handleArrayType`, `handleSelectType`, `handleFilterType`, `handlePathType`, `handlePrimitiveType`, `handleNestedFields`, `handleBooleanNestedFields`. `resolveRemote` is wrapped into a closure that merges `context.tail` into the `data` argument. -`validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas?, resolvedSchemas?, normalizedValues?, appliedDefaults? }`. `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain. `resolvedSchemas` is the same data as `schemas` but present on the failure path too, so a rejection can name remote-resolved fields the caller never saw. +`validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas?, resolvedSchemas?, normalizedValues, appliedDefaults }` (`FormanNormalizedValidationResult`; `normalizedValues`/`appliedDefaults` are always present on entry-point results — without fills they echo the input values / an empty list). `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain. `resolvedSchemas` is the same data as `schemas` but present on the failure path too, so a rejection can name remote-resolved fields the caller never saw. -**Default filling** (`options.fillDefaults: 'requiredOnly'`, off by default; contract in the `FormanValidationOptions` JSDoc): the substitution happens at the mandatory check in `validateFormanValue` — an `undefined` or `''` value with a non-`null`/`''` default fills (BlueprintValidator's `useDefaults` predicate; explicit `null` stays a provided value) — so the filled value flows through the rest of the walk and required defaults under an armed nested branch fill recursively, `rpc://`-resolved specs included. Nothing fills under `suppressRequired` (inactive branches). Fills are recorded per domain root (`DomainRoot.appliedDefaults`, raw path segments); the result exposes `appliedDefaults` (dot-joined paths, matching error paths) and `normalizedValues`, built with `setValueAtPath` (`src/utils.ts`, copy-on-write along the path; inputs never mutated). Both are present on success and failure, since a filled default can arm requirements the caller still has to repair. +**Default filling** (`options.fillDefaults: 'requiredOnly' | 'always'`, off by default; contract in the `FormanValidationOptions` JSDoc): the substitution happens just before the mandatory check in `validateFormanValue` — an `undefined` or `''` value with a non-`null`/`''` default fills (BlueprintValidator's `useDefaults` predicate and modes; explicit `null` stays a provided value). `'requiredOnly'` fills required fields only; `'always'` fills omitted optional fields too. The filled value flows through the rest of the walk and defaults under an armed nested branch fill recursively, `rpc://`-resolved specs included. Nothing fills under `suppressRequired` (inactive branches). Fills are recorded per domain root (`DomainRoot.appliedDefaults`, raw path segments); the result exposes `appliedDefaults` (dot-joined paths, matching error paths) and `normalizedValues`, built with `setValueAtPath` (`src/utils.ts`, copy-on-write along the path; inputs never mutated). Both are present on success and failure, since a filled default can arm requirements the caller still has to repair. Per-domain inputs accept `restoreExtras` (extra values injected into restore states, keyed by dot-notation path) and `allowDynamicValues` (when true, IML expressions and unresolved RPC select options produce warnings instead of errors; default false). `allowDynamicValues` can also be set globally via `FormanValidationOptions`. diff --git a/README.md b/README.md index 4904cde..f5c725d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,21 @@ Conversion and validation utilities for Forman Schema. +## v2.0.0 — validated values on every result + +Breaking in practice, though nothing was removed or renamed: `validateForman` and +`validateFormanWithDomains` now always return `normalizedValues` (the values per domain, with any +filled defaults applied) and `appliedDefaults`, whether or not default filling is enabled. Reads of +`valid`/`errors`/`warnings` are unaffected, but a caller that deep-compares the whole result object, +or forwards it verbatim into a fixed-shape response, will see the two new keys. + +Also in this release: `fillDefaults` accepts `'always'` alongside `'requiredOnly'`, matching the +modes of the platform's BlueprintValidator `useDefaults` option. See +[Filling defaults](#filling-defaults). + +To migrate, assert on the fields you care about (`toMatchObject` rather than `toEqual` in tests), or +drop the two keys before forwarding the result where a downstream shape is fixed. + ## v1.14.0 — advanced field tracking Non-breaking minor release. New surface for working with `advanced: true` Forman fields: @@ -137,7 +152,16 @@ Validate Forman values against a Forman Schema. Two entry points are available: - `validateForman(values, schema, options?)` — validate without domains. - `validateFormanWithDomains(domains, options?)` — validate multiple domains at once. -Both return `{ valid: boolean, errors: { path: string, message: string }[] }`. +Both return `{ valid: boolean, errors: { path: string, message: string }[] }`, plus +`normalizedValues` (the input values per domain, with any filled defaults applied — see +[Filling defaults](#filling-defaults)) and `appliedDefaults` (what was filled, empty when +nothing was). The two are always present, so the consuming pattern is the same whether or +not default filling is enabled: + +```typescript +const { valid, errors, normalizedValues } = await validateForman(values, schema); +if (valid) persist(normalizedValues.default); +``` #### Basic validation @@ -219,17 +243,19 @@ const result = await validateForman(values, schema, { }); ``` -#### Filling required defaults +#### Filling defaults With `fillDefaults: 'requiredOnly'`, an omitted required field whose schema declares a usable default (`null` and `''` cannot satisfy a required check) validates as that default instead of -failing as mandatory. The filled value participates in the rest of the walk, so a filled boolean -arms its own nested branch and required defaults under it fill recursively — including fields -injected by `rpc://`-resolved specs. The result carries `normalizedValues` (the values with fills -applied; the input is never mutated) and `appliedDefaults`, on the failure path too, so remaining -errors can be repaired on top of the filled values. Values you provide are never overwritten, an -explicit `null` still fails as mandatory, and optional fields are never filled. `''` counts as an -omission and fills, matching the platform's blueprint validation and the builder UI. +failing as mandatory. With `fillDefaults: 'always'`, omitted optional fields with usable defaults +are filled too — the same modes as the platform's BlueprintValidator `useDefaults` option. The +filled value participates in the rest of the walk, so a filled boolean arms its own nested branch +and defaults under it fill recursively — including fields injected by `rpc://`-resolved specs. +Fills land in `normalizedValues` (the values with fills applied; the input is never mutated) and +are itemized in `appliedDefaults`, on the failure path too, so remaining errors can be repaired on +top of the filled values. Values you provide are never overwritten, an explicit `null` still fails +as mandatory, and inactive nested branches are never filled. `''` counts as an omission and fills, +matching blueprint validation and the builder UI. ```typescript const schema = [ From a57f0560124903b8e64d2c5e3889148a708fa3fc Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Thu, 27 Aug 2026 15:02:58 +0200 Subject: [PATCH 10/11] refactor: apply trim review to comments, docs and duplicate test cases Adversarial pass over the branch. Comment/JSDoc duplication of the option contract cut back to the constraints the code cannot show, the v2.0.0 note reduced to what breaks and how to migrate, and six test cases removed that a named earlier case already pinned (the 'always' block was a cross-product over predicate arms that do not vary by mode). Also corrects two claims: a filled boolean conditions its nested branch rather than arming it unconditionally ('false' leaves it inactive), and normalizedValues shares untouched subtrees with the input rather than being a deep copy. MAIA-1286 --- README.md | 27 +++++++---------- src/types.ts | 26 ++++++---------- src/validator.ts | 13 +++----- test/fill-defaults.spec.ts | 61 -------------------------------------- test/json.spec.ts | 4 ++- 5 files changed, 27 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index f5c725d..0f402f4 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,12 @@ Conversion and validation utilities for Forman Schema. ## v2.0.0 — validated values on every result -Breaking in practice, though nothing was removed or renamed: `validateForman` and -`validateFormanWithDomains` now always return `normalizedValues` (the values per domain, with any -filled defaults applied) and `appliedDefaults`, whether or not default filling is enabled. Reads of -`valid`/`errors`/`warnings` are unaffected, but a caller that deep-compares the whole result object, -or forwards it verbatim into a fixed-shape response, will see the two new keys. +`validateForman` and `validateFormanWithDomains` now always return `normalizedValues` and +`appliedDefaults`. Nothing was removed or renamed and `valid`/`errors`/`warnings` are unaffected, +but a caller that deep-compares the whole result, or forwards it into a fixed-shape response, will +see two new keys — assert on the fields you care about, or drop the keys before forwarding. -Also in this release: `fillDefaults` accepts `'always'` alongside `'requiredOnly'`, matching the -modes of the platform's BlueprintValidator `useDefaults` option. See -[Filling defaults](#filling-defaults). - -To migrate, assert on the fields you care about (`toMatchObject` rather than `toEqual` in tests), or -drop the two keys before forwarding the result where a downstream shape is fixed. +Also new: `fillDefaults: 'always'`. See [Filling defaults](#filling-defaults). ## v1.14.0 — advanced field tracking @@ -249,11 +243,12 @@ With `fillDefaults: 'requiredOnly'`, an omitted required field whose schema decl default (`null` and `''` cannot satisfy a required check) validates as that default instead of failing as mandatory. With `fillDefaults: 'always'`, omitted optional fields with usable defaults are filled too — the same modes as the platform's BlueprintValidator `useDefaults` option. The -filled value participates in the rest of the walk, so a filled boolean arms its own nested branch -and defaults under it fill recursively — including fields injected by `rpc://`-resolved specs. -Fills land in `normalizedValues` (the values with fills applied; the input is never mutated) and -are itemized in `appliedDefaults`, on the failure path too, so remaining errors can be repaired on -top of the filled values. Values you provide are never overwritten, an explicit `null` still fails +filled value participates in the rest of the walk, so a filled boolean conditions its nested branch +exactly as a provided one would, and defaults under an armed branch fill recursively — including +fields injected by `rpc://`-resolved specs. Fills land in `normalizedValues` (the values with fills +applied; the input is never mutated, though subtrees nothing was written into are shared with it) +and are itemized in `appliedDefaults`, on the failure path too, so remaining errors can be repaired +on top of the filled values. Values you provide are never overwritten, an explicit `null` still fails as mandatory, and inactive nested branches are never filled. `''` counts as an omission and fills, matching blueprint validation and the builder UI. diff --git a/src/types.ts b/src/types.ts index f3ea04a..bf5dff4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -293,22 +293,15 @@ export type FormanValidationResult = { */ resolvedSchemas?: Record; /** - * The input values with filled defaults applied, per domain. Always present on results - * returned by `validateForman`/`validateFormanWithDomains` (see - * {@link FormanNormalizedValidationResult}), whether or not validation succeeded, so a caller - * can persist or repair the filled configuration alongside any remaining errors — and its - * usage does not change with `options.fillDefaults`: with no fills (or the option off) it - * passes the input values through as-is. The input `values` are never mutated; subtrees no - * default was written into are shared with the input. + * The input values with filled defaults applied, per domain, so a caller can persist or repair + * the filled configuration alongside any remaining errors. The input `values` are never + * mutated, but subtrees no default was written into are shared with the input — with no fills + * the domain's entry IS the caller's own object. */ normalizedValues?: Record>; - /** The defaults that were filled (`options.fillDefaults`), in walk order within each domain. - * Always present on results returned by `validateForman`/`validateFormanWithDomains`; empty - * when nothing was filled. */ + /** The defaults that were filled (`options.fillDefaults`), in walk order within each domain. */ appliedDefaults?: { - /** Field domain */ domain: string; - /** Field path */ path: string; /** The default that was filled in. Loosely typed on purpose: `default` is declared as * `FormanSchemaValue`, but schemas are JSON at source and may carry object or array @@ -318,10 +311,8 @@ export type FormanValidationResult = { }; /** - * A {@link FormanValidationResult} whose `normalizedValues` and `appliedDefaults` are guaranteed - * present — the type returned by `validateForman` and `validateFormanWithDomains`. The fields stay - * optional on the base type because intermediate results assembled during the walk do not carry - * them. + * The type returned by `validateForman` and `validateFormanWithDomains`. The fields stay optional + * on the base type because intermediate results assembled during the walk do not carry them. */ export type FormanNormalizedValidationResult = FormanValidationResult & Required>; @@ -417,7 +408,8 @@ export type FormanValidationOptions = { * value is `undefined` or `''` and its schema declares a default that is not `null` or `''` * (a default that could not satisfy a required check) — the same fillable predicate as * BlueprintValidator and the builder UI. The filled value flows through the rest of the - * walk, so a filled boolean arms its own nested branch and defaults nested under it fill + * walk, so a filled boolean conditions its nested branch exactly as a provided one would + * (`false` leaves the branch inactive), and defaults under an armed branch fill * recursively, in the same single pass. Fills are reported on `normalizedValues` and * `appliedDefaults`. Real values the caller provided are never overwritten, an explicit * `null` still fails as mandatory, and inactive branches are never filled. */ diff --git a/src/validator.ts b/src/validator.ts index e710d84..42a746c 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -389,9 +389,6 @@ export async function validateFormanWithDomainsInternal( resolvedSchemas: options?.schemas ? Object.fromEntries(Object.keys(domains).map(domain => [domain, roots[domain]!.schemaFields])) : undefined, - // Always present, `fillDefaults` or not, so the caller-side pattern - // (`if (valid) use(normalizedValues)`) is the same with and without the option. With no - // fills the input values are passed through as-is. normalizedValues: Object.fromEntries( Object.keys(domains).map(domain => [ domain, @@ -462,12 +459,10 @@ async function validateFormanValue( // Normalize field type (handle prefixed types) const normalizedField = normalizeFormanFieldType(field); - // Same fillable predicate as BlueprintValidator's `useDefaults` (and the builder UI it - // cites): `undefined` and `''` fill, an explicit `null` stays a provided value. - // `'requiredOnly'` fills required fields only, `'always'` fills optional ones too. A - // `null`/`''` default is never filled: it could not satisfy a required check, and on an - // optional field it is indistinguishable from the omission itself. Branches left inactive - // (`suppressRequired`) never fill. + // Same fillable predicate as BlueprintValidator's `useDefaults` (and the builder UI it cites), + // hence `=== undefined` rather than `== null`: an explicit `null` stays a provided value. A + // `null`/`''` default could not satisfy a required check, and on an optional field it is + // indistinguishable from the omission itself. if ( context.fillDefaults != null && !context.suppressRequired && diff --git a/test/fill-defaults.spec.ts b/test/fill-defaults.spec.ts index bfe40a1..afd92ef 100644 --- a/test/fill-defaults.spec.ts +++ b/test/fill-defaults.spec.ts @@ -107,16 +107,6 @@ describe('fillDefaults: requiredOnly', () => { expect(result.appliedDefaults).toEqual([]); }); - it('fills falsy defaults rather than treating them as absent', async () => { - const schema: FormanSchemaField[] = [ - { name: 'retries', type: 'number', required: true, default: 0 }, - { name: 'verbose', type: 'boolean', required: true, default: false }, - ]; - const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); - expect(result.valid).toBe(true); - expect(result.normalizedValues).toEqual({ default: { retries: 0, verbose: false } }); - }); - it('never fills optional fields', async () => { const schema: FormanSchemaField[] = [{ name: 'reasoningEffort', type: 'text', default: 'low' }]; const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); @@ -347,43 +337,6 @@ describe('fillDefaults: always', () => { ]); }); - it('fills an optional field over an explicit empty string', async () => { - const schema: FormanSchemaField[] = [{ name: 'mode', type: 'text', default: 'select' }]; - const result = await validateForman({ mode: '' }, schema, { strict: true, fillDefaults: 'always' }); - expect(result.normalizedValues).toEqual({ default: { mode: 'select' } }); - expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'mode', value: 'select' }]); - }); - - it('arms the nested branch of a filled optional toggle', async () => { - const schema: FormanSchemaField[] = [ - { - name: 'advanced', - type: 'boolean', - default: true, - nested: [{ name: 'level', type: 'text', required: true, default: 'high' }], - }, - ]; - const result = await validateForman({}, schema, { strict: true, fillDefaults: 'always' }); - expect(result.valid).toBe(true); - expect(result.normalizedValues).toEqual({ default: { advanced: true, level: 'high' } }); - expect(result.appliedDefaults).toEqual([ - { domain: 'default', path: 'advanced', value: true }, - { domain: 'default', path: 'level', value: 'high' }, - ]); - }); - - it('never overwrites provided values and skips null/empty-string defaults', async () => { - const schema: FormanSchemaField[] = [ - { name: 'retries', type: 'number', default: 3 }, - { name: 'source', type: 'text', default: null }, - { name: 'label', type: 'text', default: '' }, - ]; - const result = await validateForman({ retries: 0 }, schema, { strict: true, fillDefaults: 'always' }); - expect(result.valid).toBe(true); - expect(result.normalizedValues).toEqual({ default: { retries: 0 } }); - expect(result.appliedDefaults).toEqual([]); - }); - it('does not fill optional defaults under a branch left inactive', async () => { const schema: FormanSchemaField[] = [ { @@ -399,13 +352,6 @@ describe('fillDefaults: always', () => { expect(result.normalizedValues).toEqual({ default: { advanced: false } }); expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'advanced', value: false }]); }); - - it('still fails a required field whose default is unusable', async () => { - const schema: FormanSchemaField[] = [{ name: 'source', type: 'text', required: true, default: null }]; - const result = await validateForman({}, schema, { strict: true, fillDefaults: 'always' }); - expect(result.errors).toEqual([{ domain: 'default', path: 'source', message: 'Field is mandatory.' }]); - expect(result.appliedDefaults).toEqual([]); - }); }); describe('normalizedValues without fillDefaults', () => { @@ -422,11 +368,4 @@ describe('normalizedValues without fillDefaults', () => { expect(result.normalizedValues).toEqual({ parameters: { kind: 'basic' }, expect: {} }); expect(result.appliedDefaults).toEqual([]); }); - - it('is present on the failure path too', async () => { - const result = await validateForman({}, [{ name: 'kind', type: 'text', required: true }], { strict: true }); - expect(result.valid).toBe(false); - expect(result.normalizedValues).toEqual({ default: {} }); - expect(result.appliedDefaults).toEqual([]); - }); }); diff --git a/test/json.spec.ts b/test/json.spec.ts index b975be3..4950c3f 100644 --- a/test/json.spec.ts +++ b/test/json.spec.ts @@ -194,7 +194,9 @@ describe('json type', () => { const result = await validateForman({ input: { name: 'Alice' } }, schema, { validateJson }); expect(result.valid).toBe(false); - expect(result.errors).toEqual([{ domain: 'default', path: 'input', message: 'validator crashed' }]); + expect(result.errors).toEqual([ + { domain: 'default', path: 'input', message: 'validator crashed' }, + ]); }); it('passes without a callback (schema cannot be enforced)', async () => { From cb9c76918ca7f3a6c24030f5a90eaacc307637a4 Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Fri, 28 Aug 2026 14:58:28 +0200 Subject: [PATCH 11/11] fix(validator): surface impossible fill states instead of degrading silently Review feedback. The fill record and setValueAtPath both failed open on states that cannot occur, and either would have reported a fill in appliedDefaults that normalizedValues does not contain - the one invariant this feature sells. The fill record now uses the non-null assertion the rest of the file uses, and setValueAtPath throws on a path it cannot write. Also states the '' exception once instead of asserting the opposite alongside it: '' counts as an omission, so under 'always' a deliberately cleared optional field comes back with its default. Pinned by a test, with 'requiredOnly' shown as the escape hatch. MAIA-1286 --- README.md | 8 +++++--- src/types.ts | 6 ++++-- src/utils.ts | 7 ++++++- src/validator.ts | 2 +- test/fill-defaults.spec.ts | 15 +++++++++++++++ test/utils.spec.ts | 18 ++++++++++++++++++ 6 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0f402f4..e6e156a 100644 --- a/README.md +++ b/README.md @@ -248,9 +248,11 @@ exactly as a provided one would, and defaults under an armed branch fill recursi fields injected by `rpc://`-resolved specs. Fills land in `normalizedValues` (the values with fills applied; the input is never mutated, though subtrees nothing was written into are shared with it) and are itemized in `appliedDefaults`, on the failure path too, so remaining errors can be repaired -on top of the filled values. Values you provide are never overwritten, an explicit `null` still fails -as mandatory, and inactive nested branches are never filled. `''` counts as an omission and fills, -matching blueprint validation and the builder UI. +on top of the filled values. Values you provide are never overwritten, **except `''`, which counts as +an omission and fills** — matching blueprint validation and the builder UI. Under `'always'` that +means an optional field you deliberately cleared comes back with its default; pass `'requiredOnly'` +if you need a cleared optional field left alone. An explicit `null` is a provided value: it never +fills and still fails as mandatory. Inactive nested branches are never filled. ```typescript const schema = [ diff --git a/src/types.ts b/src/types.ts index bf5dff4..c4395ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -411,8 +411,10 @@ export type FormanValidationOptions = { * walk, so a filled boolean conditions its nested branch exactly as a provided one would * (`false` leaves the branch inactive), and defaults under an armed branch fill * recursively, in the same single pass. Fills are reported on `normalizedValues` and - * `appliedDefaults`. Real values the caller provided are never overwritten, an explicit - * `null` still fails as mandatory, and inactive branches are never filled. */ + * `appliedDefaults`. Values the caller provided are never overwritten, except `''`, which + * counts as an omission and fills — so under `'always'` a deliberately cleared optional + * field comes back with its default. An explicit `null` is a provided value: it never fills + * and still fails as mandatory. Inactive branches are never filled. */ fillDefaults?: 'requiredOnly' | 'always'; /** Maps domain names used in nested.domain to actual domain keys passed to validateFormanWithDomains */ domainAliases?: Record; diff --git a/src/utils.ts b/src/utils.ts index 7a70845..f08c1d8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -216,7 +216,12 @@ export function setValueAtPath( value: unknown, ): Record { const [head] = path; - if (typeof head !== 'string') return values; + // Unreachable by construction: a domain root is a named collection, so every recorded fill + // path starts with a field name. Loud rather than silent, because returning `values` here + // would leave a fill reported in `appliedDefaults` that `normalizedValues` does not contain. + if (typeof head !== 'string') { + throw new Error(`Cannot write a value at path '${path.join('.')}': the first segment must be a field name.`); + } const record = { ...values }; record[head] = setIn(record[head], path.slice(1), value); return record; diff --git a/src/validator.ts b/src/validator.ts index 42a746c..04c31b6 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -476,7 +476,7 @@ async function validateFormanValue( const filled = isObject(fillable) || Array.isArray(fillable) ? structuredClone(fillable) : fillable; value = filled; - context.roots[context.domain]?.appliedDefaults.push({ path: [...context.path], value: filled }); + context.roots[context.domain]!.appliedDefaults.push({ path: [...context.path], value: filled }); } } diff --git a/test/fill-defaults.spec.ts b/test/fill-defaults.spec.ts index afd92ef..51dedd9 100644 --- a/test/fill-defaults.spec.ts +++ b/test/fill-defaults.spec.ts @@ -337,6 +337,21 @@ describe('fillDefaults: always', () => { ]); }); + // Pins the one case where a fill overrides caller intent: `''` counts as an omission (the + // BlueprintValidator predicate), so under `'always'` a deliberately cleared optional field + // comes back with its default. Documented in the README and the `fillDefaults` JSDoc. + it('fills a deliberately cleared optional field, per the blueprint predicate', async () => { + const schema: FormanSchemaField[] = [{ name: 'note', type: 'text', default: 'Hello' }]; + const cleared = { note: '' }; + expect((await validateForman(cleared, schema, { fillDefaults: 'always' })).normalizedValues).toEqual({ + default: { note: 'Hello' }, + }); + // `'requiredOnly'` leaves it alone, which is the escape hatch the docs point at. + expect((await validateForman(cleared, schema, { fillDefaults: 'requiredOnly' })).normalizedValues).toEqual({ + default: { note: '' }, + }); + }); + it('does not fill optional defaults under a branch left inactive', async () => { const schema: FormanSchemaField[] = [ { diff --git a/test/utils.spec.ts b/test/utils.spec.ts index aab8d56..1b09027 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@jest/globals'; import { noEmpty, + setValueAtPath, isObject, isOptionGroup, containsIMLExpression, @@ -564,3 +565,20 @@ describe('Utils Functions', () => { }); }); }); + +describe('setValueAtPath', () => { + it('writes copy-on-write, sharing subtrees it did not touch', () => { + const input = { kept: { deep: 1 }, rows: [{ mode: 'a' }, { mode: 'b' }] }; + const out = setValueAtPath(input, ['rows', 1, 'mode'], 'filled'); + expect(out).toEqual({ kept: { deep: 1 }, rows: [{ mode: 'a' }, { mode: 'filled' }] }); + expect(input.rows[1]!.mode).toBe('b'); + expect(out.kept).toBe(input.kept); + }); + + it('throws rather than silently declining a path it cannot write', () => { + // Unreachable by construction, but a silent no-op would report a fill in + // `appliedDefaults` that `normalizedValues` does not contain. + expect(() => setValueAtPath({ a: 1 }, [], 'X')).toThrow(/must be a field name/); + expect(() => setValueAtPath({ a: 1 }, [0], 'X')).toThrow(/must be a field name/); + }); +});