Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 6 additions & 2 deletions src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment thread
david0723 marked this conversation as resolved.
}
const result = await validateFormanValue(value[subField.name], subField, {
Expand All @@ -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, {
Expand Down
63 changes: 62 additions & 1 deletion test/boolean-nested.spec.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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' },
Expand Down