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 224b5cd..e5bed20 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.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..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)', () => { @@ -48,6 +48,58 @@ 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 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, + 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 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); @@ -271,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' },