From bb4db3db8beb3a3f66ed23b9272762a8a079baab Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Wed, 2 Sep 2026 11:54:21 +0200 Subject: [PATCH 1/2] fix(validator): keep an inactive boolean branch out of schemas The suppressed walk of a single-branch boolean's inactive branch pushed the branch's fields into `schemas`, flattened and still `required: true`. Consumers persist that list as the module's resolved form and validate runtime bundles against it without ever seeing the toggle, so the leaked field was demanded at run time. With `fillDefaults` filling the toggle to `false` this hit every module that predates the toggle. --- src/validator.ts | 8 ++++++-- test/boolean-nested.spec.ts | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/validator.ts b/src/validator.ts index 224b5cd..e82e6e4 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -669,7 +669,11 @@ async function handleCollectionType( continue; } if (context.strict && !seen.has(subField.name)) seen.add(subField.name); - if (path.length === 0) { + // A boolean's inactive branch is still walked so stale values keep their type rules, but + // it contributes nothing to `schemas`: consumers persist that list as the module's + // resolved form, and a required field leaked from a branch the toggle left inactive + // would be demanded at runtime by validators that never see the toggle. + if (path.length === 0 && !context.registerOnly && !context.suppressRequired) { context.roots[context.domain]!.schemaFields.push(clampFieldForSchema(subField)); } const result = await validateFormanValue(value[subField.name], subField, { @@ -689,7 +693,7 @@ async function handleCollectionType( continue; } if (context.strict && !seen.has(subField.name)) seen.add(subField.name); - if (path.length === 0 && !context.registerOnly) { + if (path.length === 0 && !context.registerOnly && !context.suppressRequired) { context.roots[context.domain]!.schemaFields.push(clampFieldForSchema(subField)); } const result = await validateFormanValue(value[subField.name], subField, { diff --git a/test/boolean-nested.spec.ts b/test/boolean-nested.spec.ts index db9e5ae..4ab5083 100644 --- a/test/boolean-nested.spec.ts +++ b/test/boolean-nested.spec.ts @@ -48,6 +48,30 @@ describe('Boolean nested conditioning', () => { expect(result.valid).toBe(true); }); + it('should leave the inactive branch out of schemas', async () => { + const result = await validateForman({ advanced: false }, schema, { strict: true, schemas: true }); + expect(result.valid).toBe(true); + expect(result.schemas?.default?.map(field => field.name)).toEqual(['advanced']); + }); + + it('should keep the active branch in schemas', async () => { + const result = await validateForman({ advanced: true, timeout: 30 }, schema, { strict: true, schemas: true }); + expect(result.valid).toBe(true); + expect(result.schemas?.default?.map(field => field.name)).toEqual(['advanced', 'timeout']); + }); + + it('should leave a branch its own filled default left inactive out of schemas', async () => { + const toggleWithDefault: FormanSchemaField[] = [{ ...schema[0]!, required: true, default: false }]; + const result = await validateForman({}, toggleWithDefault, { + strict: true, + schemas: true, + fillDefaults: 'requiredOnly', + }); + expect(result.valid).toBe(true); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'advanced', value: false }]); + expect(result.schemas?.default?.map(field => field.name)).toEqual(['advanced']); + }); + it('should keep nested values of a false toggle known to strict mode', async () => { const result = await validateForman({ advanced: false, timeout: 30 }, schema, { strict: true }); expect(result.valid).toBe(true); From 7208861e12eca73023c21f50bfffc8af35a58325 Mon Sep 17 00:00:00 2001 From: David Chicaiza Date: Wed, 2 Sep 2026 12:04:26 +0200 Subject: [PATCH 2/2] test(validator): pin two-branch and cross-domain schemas, drop unreachable guard The two-branch object form carries no per-branch domain, so the domain-root push site is only reached under `suppressRequired`; the `registerOnly` check there was dead. Pins: two-branch reports only the active branch, and a cross-domain single-branch inactive side stays out of that domain's schemas. --- AGENTS.md | 2 +- src/validator.ts | 2 +- test/boolean-nested.spec.ts | 43 ++++++++++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f692dd2..17c9013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleColl 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`. -**Boolean nested** is conditioned on the toggle value, matching how imt-forman renders it (`docs/inputs/boolean.md`). Single-branch `nested` (spec array or `rpc://` string) applies when the value is `true`, or `false` if `reversedNested: true`; the two-branch object form `{ true?, false? }` applies whichever branch matches. An inactive single-branch is still walked, under `context.suppressRequired`, which disables only the `"Field is mandatory."` check — provided values stay type-checked, `validate` rules still apply, and the fields stay registered for strict mode, so stale values of hidden fields do not become `Unknown field`. `suppressRequired` is transitive: it propagates to the whole subtree, so a toggle that is on inside an inactive parent keeps its own nested fields unenforced. That is intended — nothing under a hidden branch is renderable, so nothing there can be filled in. An inactive two-branch branch is not validated, since both branches may reuse a name for different types; it is walked under `context.registerOnly`, which registers its names for strict mode and does nothing else. Every other type keeps unconditional `handleNestedFields`. +**Boolean nested** is conditioned on the toggle value, matching how imt-forman renders it (`docs/inputs/boolean.md`). Single-branch `nested` (spec array or `rpc://` string) applies when the value is `true`, or `false` if `reversedNested: true`; the two-branch object form `{ true?, false? }` applies whichever branch matches. An inactive single-branch is still walked, under `context.suppressRequired`, which disables only the `"Field is mandatory."` check — provided values stay type-checked, `validate` rules still apply, and the fields stay registered for strict mode, so stale values of hidden fields do not become `Unknown field`. `suppressRequired` is transitive: it propagates to the whole subtree, so a toggle that is on inside an inactive parent keeps its own nested fields unenforced. That is intended — nothing under a hidden branch is renderable, so nothing there can be filled in. An inactive two-branch branch is not validated, since both branches may reuse a name for different types; it is walked under `context.registerOnly`, which registers its names for strict mode and does nothing else. Neither inactive walk contributes to `schemas`/`resolvedSchemas`: consumers persist that list as the module's resolved form, and a `required` field leaked from a hidden branch would be demanded by validators that never see the toggle. Every other type keeps unconditional `handleNestedFields`. **Strict mode** (`options.strict`): checks `values` keys against `seen` set. Unknown keys produce `"Unknown field '${key}'"` errors. diff --git a/src/validator.ts b/src/validator.ts index e82e6e4..e5bed20 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -673,7 +673,7 @@ async function handleCollectionType( // it contributes nothing to `schemas`: consumers persist that list as the module's // resolved form, and a required field leaked from a branch the toggle left inactive // would be demanded at runtime by validators that never see the toggle. - if (path.length === 0 && !context.registerOnly && !context.suppressRequired) { + if (path.length === 0 && !context.suppressRequired) { context.roots[context.domain]!.schemaFields.push(clampFieldForSchema(subField)); } const result = await validateFormanValue(value[subField.name], subField, { diff --git a/test/boolean-nested.spec.ts b/test/boolean-nested.spec.ts index 4ab5083..5a935e0 100644 --- a/test/boolean-nested.spec.ts +++ b/test/boolean-nested.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from '@jest/globals'; import type { FormanSchemaField } from '../src/index.js'; -import { validateForman } from '../src/index.js'; +import { validateForman, validateFormanWithDomains } from '../src/index.js'; describe('Boolean nested conditioning', () => { describe('single-branch nested (applies when true)', () => { @@ -55,12 +55,15 @@ describe('Boolean nested conditioning', () => { }); it('should keep the active branch in schemas', async () => { - const result = await validateForman({ advanced: true, timeout: 30 }, schema, { strict: true, schemas: true }); + const result = await validateForman({ advanced: true, timeout: 30 }, schema, { + strict: true, + schemas: true, + }); expect(result.valid).toBe(true); expect(result.schemas?.default?.map(field => field.name)).toEqual(['advanced', 'timeout']); }); - it('should leave a branch its own filled default left inactive out of schemas', async () => { + it('should leave out of schemas a branch that a filled default left inactive', async () => { const toggleWithDefault: FormanSchemaField[] = [{ ...schema[0]!, required: true, default: false }]; const result = await validateForman({}, toggleWithDefault, { strict: true, @@ -72,6 +75,31 @@ describe('Boolean nested conditioning', () => { expect(result.schemas?.default?.map(field => field.name)).toEqual(['advanced']); }); + it("should leave a cross-domain inactive branch out of that domain's schemas", async () => { + const result = await validateFormanWithDomains( + { + default: { + values: { advanced: false }, + schema: [ + { + name: 'advanced', + type: 'boolean', + label: 'Advanced settings', + nested: { + domain: 'expect', + store: [{ name: 'timeout', type: 'number', label: 'Timeout', required: true }], + }, + }, + ], + }, + expect: { values: {}, schema: [{ name: 'message', type: 'text', label: 'Message' }] }, + }, + { strict: true, schemas: true }, + ); + expect(result.valid).toBe(true); + expect(result.schemas?.expect?.map(field => field.name)).toEqual(['message']); + }); + it('should keep nested values of a false toggle known to strict mode', async () => { const result = await validateForman({ advanced: false, timeout: 30 }, schema, { strict: true }); expect(result.valid).toBe(true); @@ -295,6 +323,15 @@ describe('Boolean nested conditioning', () => { expect(result.valid).toBe(true); }); + it('should report only the active branch in schemas', async () => { + const result = await validateForman({ sendEmail: false, skipReason: 'opted out' }, schema, { + strict: true, + schemas: true, + }); + expect(result.valid).toBe(true); + expect(result.schemas?.default?.map(field => field.name)).toEqual(['sendEmail', 'skipReason']); + }); + it("should keep the inactive branch's values known to strict mode", async () => { const result = await validateForman( { sendEmail: false, skipReason: 'opted out', recipient: 'a@b.c' },