Skip to content

POC: Schema IR — library-agnostic core with Valibot + Zod adapters - #197

Open
espetro wants to merge 8 commits into
open-circle:mainfrom
espetro:poc/schema-ir
Open

POC: Schema IR — library-agnostic core with Valibot + Zod adapters#197
espetro wants to merge 8 commits into
open-circle:mainfrom
espetro:poc/schema-ir

Conversation

@espetro

@espetro espetro commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What this proves

@formisch/core can drive identical forms via either a Valibot or Zod adapter, with zero Valibot imports in the core source. The IR (intermediate representation) abstracts over validation library specifics, and both adapters produce structurally identical IR for the same schema shape — verified by parity tests.

⚠️ Do not merge to main

This PR targets main only because GitHub requires an existing branch. Please create a poc/schema-ir (or dev) branch and retarget this PR before merging. The goal is to let pilot/beta users test the POC directly: by keeping it on a dedicated branch, you can publish @formisch/core@beta, @formisch/valibot@beta, and @formisch/zod@beta without affecting the stable release line. Users opt in with npm install @formisch/core@beta. Once validated, the branch merges to main.

Commits (reviewable in order)

  1. Define the IR type systemFormischFieldIR, FormischSchema types; Schema becomes Standard Schema + IR marker
  2. Rewrite core traversal sitesinitializeFieldStore, decodeFormData, createFormStore, validateFormInput consume IR instead of Valibot internals
  3. Add @formisch/valibot adapter — reference adapter, 22 tests
  4. Add @formisch/zod adapter — cross-library proof, 19 tests
  5. Update React useForm + demo harness — single framework change; demo toggles adapters at runtime
  6. Add parity + coercion tests — 8 tests proving both adapters produce identical IR and decodeFormData output
  7. Workspace config + POC README — grep proof, open design questions, alpha publish instructions

The grep proof

grep -r "from 'valibot'" packages/core/src/ --include="*.ts" | grep -v ".test.ts" | grep -v "vitest/"
# → (empty)

Tests

Suite Result
Core 418 pass, 25 fail (all in out-of-scope: tuples, unions, combinators)
@formisch/valibot 22 pass
@formisch/zod 19 pass
Parity + coercion 8 pass

All three packages build successfully.

Out of scope (documented in README)

Unions, variants, intersects, tuples, records, object_with_rest, ArkType/TypeBox/Yup adapters, non-React framework wrappers, client-side number coercion beyond decodeFormData.

Open design questions

  1. How should the IR represent unions/variants/intersects?
  2. Should tuples get a dedicated IR type or model as fixed-length arrays?
  3. getDefault() is a closure — needs serialization strategy for server-to-client transfer.
  4. How to handle records with dynamic keys?

Full details in SCHEMA_IR_POC.md.

Summary by CodeRabbit

  • New Features

    • Added Valibot and Zod adapters for Formisch-compatible schemas.
    • Added library-agnostic validation with standardized results.
    • Added support for nested objects, arrays, optional fields, and defaults.
    • Added a React demo comparing Valibot and Zod integrations.
  • Documentation

    • Added proof-of-concept documentation covering interoperability, integration, limitations, and setup.
  • Tests

    • Added adapter, interoperability, decoding, validation, and default-value coverage.

@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. enhancement New feature or request labels Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This change adds a Formisch schema intermediate representation with Valibot and Zod adapters. Core field initialization and form-data decoding now consume IR metadata. Form parsing, validation issues, and submit output use Standard Schema contracts. React useForm validates through schema['~standard'].validate. Adapter parity tests compare equivalent IR and decoding behavior. A React playground and proof-of-concept documentation demonstrate adapter interchangeability.

Possibly related PRs

Suggested reviewers: fabian-hiller

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: a library-agnostic schema IR with Valibot and Zod adapters.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/core/src/field/setFieldInput/setFieldInput.test.ts (1)

133-140: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Keep tuple fixtures out of the unsupported IR path.

toFormisch(v.tuple(...)) now routes both tuple cases through FormischFieldIR. The supplied IR has properties and one item, but no tuple-element list. packages/core/src/field/initializeFieldStore/initializeFieldStore.ts:11-123 initializes array children through ir.item!. These tests can fail during store creation or lose positional schemas.

Implement tuple IR and adapter support before enabling these fixtures, or remove them from this POC test path. The PR objectives list tuples as unresolved.

Also applies to: 149-164

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/field/setFieldInput/setFieldInput.test.ts` around lines 133
- 140, Remove the tuple-based fixtures and assertions from this POC test path,
including the cases around pairStore and tuple input handling. Do not route
v.tuple(...) through toFormisch/FormischFieldIR until tuple-element schemas and
initialization support are implemented; keep tests limited to supported IR
shapes.
packages/core/src/form/decodeFormData/decodeFormData.test.ts (1)

571-590: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle recursive v.lazy schemas

When a v.lazy getter returns a schema that contains the same lazy schema, transform expands children indefinitely and toFormisch overflows the call stack. Add a recursive-schema test. Support recursion or reject it with a clear error and document the limitation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/form/decodeFormData/decodeFormData.test.ts` around lines
571 - 590, Extend the decodeFormData/toFormisch coverage with a recursive v.lazy
schema whose getter references the same lazy schema, then update the relevant
schema transformation logic to detect recursive expansion and either support it
safely or reject it with a clear, documented error instead of overflowing the
call stack.
🟡 Minor comments (8)
SCHEMA_IR_POC.md-77-77 (1)

77-77: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language tag to the diagram fence.

The fence at Line 77 has no language identifier. markdownlint-cli2 reports MD040. Mark this block as text so the documentation passes Markdown lint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SCHEMA_IR_POC.md` at line 77, Update the diagram code fence in
SCHEMA_IR_POC.md around the documented block to include the text language tag,
changing the untagged fence to a text fence so it satisfies Markdown lint rule
MD040.

Source: Linters/SAST tools

SCHEMA_IR_POC.md-130-138 (1)

130-138: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make each alpha publish command independent.

After Line 136, the shell remains in packages/core. The paths on Lines 137-138 then resolve relative to packages/core, so the adapter publishes fail. Use independent subshells or pnpm -C.

Proposed fix
-cd packages/core && npm publish --tag alpha
-cd packages/adapters/valibot && npm publish --tag alpha
-cd packages/adapters/zod && npm publish --tag alpha
+(cd packages/core && npm publish --tag alpha)
+(cd packages/adapters/valibot && npm publish --tag alpha)
+(cd packages/adapters/zod && npm publish --tag alpha)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SCHEMA_IR_POC.md` around lines 130 - 138, Update the alpha publish commands
in the publishing instructions so each command runs from its intended package
directory independently. Replace the persistent `cd` flow after the core publish
with subshells or equivalent `pnpm -C` invocations for `packages/core`,
`packages/adapters/valibot`, and `packages/adapters/zod`.
playgrounds/ir-demo/package.json-21-26 (1)

21-26: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove the unused Node.js type configuration.

playgrounds/ir-demo/tsconfig.json sets "types": ["node"], but the package and lockfile do not declare @types/node, and the source uses no Node.js globals. Remove "node" from compilerOptions.types, or declare @types/node if Node.js globals are required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@playgrounds/ir-demo/package.json` around lines 21 - 26, Remove the unused
"node" entry from compilerOptions.types in the playground’s tsconfig.json,
keeping the existing type configuration otherwise unchanged; do not add
`@types/node` because the source does not require Node.js globals.
packages/adapters/valibot/package.json-40-51 (1)

40-51: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add @formisch/core as a peer dependency. tsdown preserves imports from external packages in generated declarations, so consumers need @formisch/core installed to resolve the adapter's public types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/adapters/valibot/package.json` around lines 40 - 51, Add
`@formisch/core` to the peerDependencies alongside valibot in
packages/adapters/valibot/package.json, while retaining its existing
devDependency entry for workspace development and type resolution.
packages/core/src/types/schema/schema.test-d.ts-15-26 (1)

15-26: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use satisfies FormSchema instead of as FormSchema. The assertion bypasses structural checking of the positive fixture, so an invalid Standard Schema contract can pass this type test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/types/schema/schema.test-d.ts` around lines 15 - 26, Update
makeFormSchema to use the satisfies FormSchema constraint instead of an as
FormSchema assertion, preserving the fixture while enabling structural
validation of its ~standard and ~formisch contracts.
packages/adapters/valibot/src/transform.ts-40-47 (1)

40-47: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve optionality through lazy schemas.

v.lazy(() => v.optional(v.string())) produces optional: false because isOptional checks only the outer lazy schema. Derive optionality from the schema reached during unwrapping so initialization preserves the optional field state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/adapters/valibot/src/transform.ts` around lines 40 - 47, Update
isOptional to unwrap lazy schemas and evaluate the resolved inner schema’s type,
so v.lazy(() => v.optional(...)) preserves optionality during initialization.
Retain the existing optional, nullable, nullish, exact_optional, and
undefinedable checks for non-lazy schemas.
packages/core/src/form/createFormStore/createFormStore.ts-13-17 (1)

13-17: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add JSDoc for the exported factory.

Document the config and parse parameters, the StandardParseResult contract, and the returned store. Place the JSDoc above // @NO_SIDE_EFFECTS`` so the marker remains immediately before createFormStore.

As per coding guidelines, **/*.{ts,tsx} files must add JSDoc to exported functions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/form/createFormStore/createFormStore.ts` around lines 13 -
17, Add JSDoc above the // `@__NO_SIDE_EFFECTS__` marker for the exported
createFormStore factory, documenting the config and parse parameters, the
expected StandardParseResult contract, and the returned InternalFormStore. Keep
the marker immediately adjacent to createFormStore.

Source: Coding guidelines

packages/core/src/form/parity.test.ts-1-7 (1)

1-7: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Break the test-only workspace dependency cycle.

@formisch/core depends on both adapters for tests, while both adapters depend on @formisch/core for types. Move the adapter parity tests out of packages/core/src/form/parity.test.ts. Keep the decodeFormData tests in the core form suite.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/form/parity.test.ts` around lines 1 - 7, Move the adapter
parity tests from the file containing toFormischValibot and toFormischZod into
an adapter-owned test location, removing the core test-only dependency on both
adapters. Keep the decodeFormData tests in the core form suite, preserving their
existing coverage and imports without adapter references.

Source: Coding guidelines

🧹 Nitpick comments (6)
playgrounds/ir-demo/src/main.tsx (1)

26-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Define the FormDemo props with an interface.

The function uses an inline object type for its props. Define a named FormDemoProps interface and use it in the function signature.

As per coding guidelines: Prefer interface over type for defining object shapes.

Proposed refactor
-function FormDemo({ schema, label }: { schema: FormSchema; label: string }) {
+interface FormDemoProps {
+  schema: FormSchema;
+  label: string;
+}
+
+function FormDemo({ schema, label }: FormDemoProps) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@playgrounds/ir-demo/src/main.tsx` at line 26, Define a named FormDemoProps
interface containing the schema and label fields currently declared inline, then
update FormDemo to use FormDemoProps for its parameter type while preserving the
existing field types and behavior.

Source: Coding guidelines

packages/core/src/form/createFormStore/createFormStore.ts (1)

14-18: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Preserve TSchema in the factory signature.

The current signature erases InferStandardOutput<TSchema> by accepting a non-generic FormConfig and returning a non-generic InternalFormStore. packages/core/src/vitest/utils.ts:162-185 must cast the result back to the concrete schema, which hides type mismatches.

Make createFormStore generic over TSchema, use FormConfig<TSchema>, return InternalFormStore<TSchema>, and type parse with StandardParseResult<InferStandardOutput<TSchema>>.

Proposed type-preserving signature
-export function createFormStore(
-  config: FormConfig,
-  parse: (input: unknown) => Promise<StandardParseResult>
-): InternalFormStore {
-  const store: Partial<InternalFormStore> = {};
+export function createFormStore<TSchema extends FormSchema>(
+  config: FormConfig<TSchema>,
+  parse: (
+    input: unknown
+  ) => Promise<StandardParseResult<InferStandardOutput<TSchema>>>
+): InternalFormStore<TSchema> {
+  const store: Partial<InternalFormStore<TSchema>> = {};

Also applies to: 24-24

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/form/createFormStore/createFormStore.ts` around lines 14 -
18, Make createFormStore generic over TSchema and preserve the schema type
through its signature: accept FormConfig<TSchema>, return
InternalFormStore<TSchema>, and type parse as returning
Promise<StandardParseResult<InferStandardOutput<TSchema>>>. Update the store
construction and related local typing as needed so the generic type remains
intact without requiring callers such as vitest utilities to cast the result.
packages/core/src/form/validateFormInput/validateFormInput.ts (1)

13-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Simplify the return type; it is a nested promise.

InternalFormStore['parse'] already returns Promise<StandardParseResult<...>>. Promise<ReturnType<InternalFormStore['parse']>> therefore declares Promise<Promise<StandardParseResult<...>>>. Awaited unwraps this for callers, so it compiles, but the declared type does not describe the value.

♻️ Proposed type simplification
 export async function validateFormInput(
   internalFormStore: InternalFormStore,
   config?: ValidateFormInputConfig
-): Promise<ReturnType<InternalFormStore['parse']>> {
+): ReturnType<InternalFormStore['parse']> {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/form/validateFormInput/validateFormInput.ts` around lines
13 - 16, Update the return type of validateFormInput to use
Awaited<ReturnType<InternalFormStore['parse']>> instead of wrapping ReturnType
in another Promise, so the declaration represents the resolved parse result
rather than a nested promise.
packages/core/src/form/parity.test.ts (2)

47-68: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add parity coverage for nullable and nullish.

FormischFieldIR.optional is documented to collapse optional, nullable, nullish, and non_optional into one boolean. The tests cover only optional. Add cases for v.nullable versus .nullable() and v.nullish versus .nullish(). Those wrappers are the ones most likely to diverge between the two adapters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/form/parity.test.ts` around lines 47 - 68, Extend the
parity coverage in the existing “object with optional fields” test area to
compare Valibot and Zod schemas using nullable and nullish wrappers: use
v.nullable versus z.nullable, and v.nullish versus z.nullish, with matching
field shapes. Assert each converted IR pair with stripGetDefault and
toStrictEqual, following the existing optional parity pattern.

13-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Compare getDefault() results instead of discarding the function.

stripGetDefault drops getDefault from every node, so the parity tests never verify that both adapters resolve the same default value. Valibot and Zod express defaults differently, which makes this the most likely place for the two adapters to diverge. Invoke getDefault() and include its result in the comparison object.

♻️ Proposed change to include resolved defaults in the parity comparison
 function stripGetDefault(ir: FormischFieldIR): Record<string, unknown> {
   const result: Record<string, unknown> = {
     type: ir.type,
     optional: ir.optional,
+    default: ir.getDefault(),
   };

Rename the helper to reflect the new behavior if you apply this change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/form/parity.test.ts` around lines 13 - 29, Update
stripGetDefault to invoke each FormischFieldIR node’s getDefault() and include
the resolved value in the returned comparison object, while preserving the
recursive properties and item traversal. Rename the helper to reflect that it
now includes defaults, and update all parity-test call sites accordingly.
packages/core/src/form/decodeFormData/decodeFormData.test.ts (1)

783-788: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Narrow the result instead of casting to any.

await already normalizes a synchronous return, so Promise.resolve(...) is redundant. Replacing the as any cast with an explicit narrowing check keeps the discriminated union intact and removes the eslint suppression.

♻️ Proposed test refactor
-      const validateResult = await Promise.resolve(
-        schema['~standard'].validate(decodeFormData(schema, formData))
-      );
-      expect(validateResult.issues).toBeUndefined();
-      // eslint-disable-next-line `@typescript-eslint/no-explicit-any`
-      expect((validateResult as any).value).toStrictEqual({
+      const validateResult = await schema['~standard'].validate(
+        decodeFormData(schema, formData)
+      );
+      if (validateResult.issues) {
+        throw new Error('Validation unexpectedly failed');
+      }
+      expect(validateResult.value).toStrictEqual({
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/form/decodeFormData/decodeFormData.test.ts` around lines
783 - 788, Update the validation test around schema['~standard'].validate by
removing the redundant Promise.resolve wrapper and narrowing validateResult
through an explicit check that confirms no issues are present before accessing
its value. Replace the any cast and eslint suppression while preserving the
existing value assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frameworks/react/src/hooks/useForm/useForm.ts`:
- Around line 32-38: Update handleSubmit to follow the StandardParseResult
contract returned by createFormStore: check result.issues for validation
failures and pass result.value to onSubmit for valid submissions, replacing the
unsupported result.success and result.output properties.

In `@packages/adapters/valibot/src/toFormisch.ts`:
- Around line 15-20: Update toFormisch to reject non-object root schemas before
constructing or casting the FormSchema; validate that the transformed root has
type 'object' and throw for leaf or array schemas, while preserving the existing
output for valid object schemas.

In `@packages/adapters/valibot/src/transform.ts`:
- Around line 23-24: Update the lazy-schema handling in unwrapValibot so active
recursive schemas are detected before recursively processing
schema.getter(undefined), preventing unbounded recursion; either throw a clear
recursion error or emit the core-supported recursive IR. Add a regression test
covering a lazy getter whose returned object contains the same lazy schema and
verify toFormisch no longer overflows the call stack.
- Around line 108-115: Update the enum/picklist/literal branch in the transform
function to infer the IR type from unwrapped.literal or unwrapped.options
instead of always returning string. Preserve numeric, boolean, and bigint
primitive types, and return unknown when the schema contains mixed primitive
types; keep optional and getDefault behavior unchanged.
- Around line 117-130: The union, variant, and intersect handling in transform
must not resolve schemas to options[0]. Traverse and combine all representable
options so later branches retain their correct types during FormData decoding;
if the combinator cannot be represented, throw an explicit unsupported-schema
error instead of returning an incomplete IR.

In `@packages/adapters/zod/package.json`:
- Around line 39-41: Add `@formisch/core` to the peerDependencies object in
packages/adapters/zod/package.json, while preserving its existing
devDependencies entry so adapter consumers can resolve the types used by
transform and toFormisch.

In `@packages/adapters/zod/src/transform.ts`:
- Line 22: Add the `// `@__NO_SIDE_EFFECTS__`` annotation immediately before the
`transform` function declaration, preserving the existing signature and
implementation.
- Around line 101-108: The Zod transform currently labels every ZodEnum,
ZodLiteral, and ZodNativeEnum as string. Update the transform logic in
packages/adapters/zod/src/transform.ts lines 101-108 to infer matching IR types
for homogeneous string, number, boolean, and bigint domains, while returning
unknown for nullish, mixed, or unsupported values. Extend
packages/adapters/zod/tests/transform.test.ts lines 33-41 with numeric, boolean,
and bigint literal cases plus numeric and mixed native-enum cases.

In `@packages/core/package.json`:
- Line 79: Update the dependency declarations in packages/core/package.json so
`@standard-schema/spec` is available to consumers of the exported StandardSchemaV1
types: move it from devDependencies to dependencies, or declare it as a peer
dependency with a supported version range, ensuring it is not listed only as a
development dependency.

In `@packages/core/src/field/initializeFieldStore/initializeFieldStore.ts`:
- Around line 11-18: ・Add JSDoc immediately above the exported
initializeFieldStore function documenting its initialization contract and the
ir, initialInput, path, and nullish parameters, including the meaning of the
nullish flag. Keep the existing signature and implementation unchanged.
- Around line 52-59: Pass ir.item!.optional as the nullish/optionality argument
when initializing array children in initializeFieldStore, and apply the same
item-optionality propagation in copyItemState, resetItemState, swapItemState,
setFieldInput, and setInitialFieldInput. Update
packages/core/src/field/initializeFieldStore/initializeFieldStore.ts:52-59 and
packages/core/src/array/copyItemState/copyItemState.ts:76-82 directly; also
update
packages/core/src/field/setInitialFieldInput/setInitialFieldInput.ts:55-62, with
corresponding changes in the resetItemState, swapItemState, and setFieldInput
sites. Preserve undefined optional items as undefined rather than replacing them
with emptyInput.

In `@packages/core/src/form/decodeFormData/decodeFormData.ts`:
- Around line 8-16: Extend the FormischFieldIR contract used by getChildIR in
packages/core/src/form/decodeFormData/decodeFormData.ts:8-16 to represent tuple
positions, union options, variant branches, and intersect members, or explicitly
document those shapes as unsupported. In
packages/core/src/form/decodeFormData/decodeFormData.ts:66-73, guard array
default filling with ir.item before accessing it. In
packages/core/src/form/decodeFormData/decodeFormData.test.ts:377-509, skip
tuple, union, variant, and intersect cases with TODOs. In
packages/core/src/field/setInitialFieldInput/setInitialFieldInput.test.ts:92-108,
verify how initializeFieldStore determines tuple child counts and add IR
positional metadata or skip the test consistently.
- Around line 66-73: Update the array branch in fillDefaults to remove the
non-null assertion on ir.item and only recurse when the item IR is defined;
otherwise preserve the existing parent[key] array initialization and avoid
calling fillDefaults with undefined.

In `@packages/core/src/form/validateFormInput/validateFormInput.ts`:
- Around line 30-44: Update the issue-handling logic in validateFormInput so an
empty issue.path is treated as a root-level error rather than stored under
nestedErrors['[]']; use issue.path?.length to distinguish non-empty nested paths
from root issues. Add a regression test covering a top-level refinement issue
with path [] and verify the message is returned through rootErrors.

In `@packages/core/src/types/form/form.ts`:
- Around line 91-100: Update StandardParseResult to use
StandardSchemaV1.Result<T>, or an equivalent discriminated union, so successful
results require a value and omit issues while failed results require a non-empty
issues array and omit value. Preserve the discriminant consumed by
validateFormInput and prevent empty or missing-field combinations from being
representable.

In `@packages/core/src/vitest/utils.ts`:
- Around line 84-89: Update transformValibot to preserve compound-schema
semantics: represent tuple schemas as arrays, retain record and object_with_rest
schemas so unsupported-schema errors remain observable, and combine all union,
variant, and intersect options rather than selecting only the first. If these
schemas remain outside the POC scope, explicitly skip or remove their affected
tests instead of mapping them to unknown.

In `@playgrounds/ir-demo/src/main.tsx`:
- Around line 26-44: Update FormDemo to render the form through the public Form
and Field APIs: wrap the fields in Form, bind name, age, and email with Field,
render each field’s input and errors, and add a submit control so onSubmit is
user-triggered. Remove the __name cast/display and use the field render APIs
instead.

---

Outside diff comments:
In `@packages/core/src/field/setFieldInput/setFieldInput.test.ts`:
- Around line 133-140: Remove the tuple-based fixtures and assertions from this
POC test path, including the cases around pairStore and tuple input handling. Do
not route v.tuple(...) through toFormisch/FormischFieldIR until tuple-element
schemas and initialization support are implemented; keep tests limited to
supported IR shapes.

In `@packages/core/src/form/decodeFormData/decodeFormData.test.ts`:
- Around line 571-590: Extend the decodeFormData/toFormisch coverage with a
recursive v.lazy schema whose getter references the same lazy schema, then
update the relevant schema transformation logic to detect recursive expansion
and either support it safely or reject it with a clear, documented error instead
of overflowing the call stack.

---

Minor comments:
In `@packages/adapters/valibot/package.json`:
- Around line 40-51: Add `@formisch/core` to the peerDependencies alongside
valibot in packages/adapters/valibot/package.json, while retaining its existing
devDependency entry for workspace development and type resolution.

In `@packages/adapters/valibot/src/transform.ts`:
- Around line 40-47: Update isOptional to unwrap lazy schemas and evaluate the
resolved inner schema’s type, so v.lazy(() => v.optional(...)) preserves
optionality during initialization. Retain the existing optional, nullable,
nullish, exact_optional, and undefinedable checks for non-lazy schemas.

In `@packages/core/src/form/createFormStore/createFormStore.ts`:
- Around line 13-17: Add JSDoc above the // `@__NO_SIDE_EFFECTS__` marker for the
exported createFormStore factory, documenting the config and parse parameters,
the expected StandardParseResult contract, and the returned InternalFormStore.
Keep the marker immediately adjacent to createFormStore.

In `@packages/core/src/form/parity.test.ts`:
- Around line 1-7: Move the adapter parity tests from the file containing
toFormischValibot and toFormischZod into an adapter-owned test location,
removing the core test-only dependency on both adapters. Keep the decodeFormData
tests in the core form suite, preserving their existing coverage and imports
without adapter references.

In `@packages/core/src/types/schema/schema.test-d.ts`:
- Around line 15-26: Update makeFormSchema to use the satisfies FormSchema
constraint instead of an as FormSchema assertion, preserving the fixture while
enabling structural validation of its ~standard and ~formisch contracts.

In `@playgrounds/ir-demo/package.json`:
- Around line 21-26: Remove the unused "node" entry from compilerOptions.types
in the playground’s tsconfig.json, keeping the existing type configuration
otherwise unchanged; do not add `@types/node` because the source does not require
Node.js globals.

In `@SCHEMA_IR_POC.md`:
- Line 77: Update the diagram code fence in SCHEMA_IR_POC.md around the
documented block to include the text language tag, changing the untagged fence
to a text fence so it satisfies Markdown lint rule MD040.
- Around line 130-138: Update the alpha publish commands in the publishing
instructions so each command runs from its intended package directory
independently. Replace the persistent `cd` flow after the core publish with
subshells or equivalent `pnpm -C` invocations for `packages/core`,
`packages/adapters/valibot`, and `packages/adapters/zod`.

---

Nitpick comments:
In `@packages/core/src/form/createFormStore/createFormStore.ts`:
- Around line 14-18: Make createFormStore generic over TSchema and preserve the
schema type through its signature: accept FormConfig<TSchema>, return
InternalFormStore<TSchema>, and type parse as returning
Promise<StandardParseResult<InferStandardOutput<TSchema>>>. Update the store
construction and related local typing as needed so the generic type remains
intact without requiring callers such as vitest utilities to cast the result.

In `@packages/core/src/form/decodeFormData/decodeFormData.test.ts`:
- Around line 783-788: Update the validation test around
schema['~standard'].validate by removing the redundant Promise.resolve wrapper
and narrowing validateResult through an explicit check that confirms no issues
are present before accessing its value. Replace the any cast and eslint
suppression while preserving the existing value assertion.

In `@packages/core/src/form/parity.test.ts`:
- Around line 47-68: Extend the parity coverage in the existing “object with
optional fields” test area to compare Valibot and Zod schemas using nullable and
nullish wrappers: use v.nullable versus z.nullable, and v.nullish versus
z.nullish, with matching field shapes. Assert each converted IR pair with
stripGetDefault and toStrictEqual, following the existing optional parity
pattern.
- Around line 13-29: Update stripGetDefault to invoke each FormischFieldIR
node’s getDefault() and include the resolved value in the returned comparison
object, while preserving the recursive properties and item traversal. Rename the
helper to reflect that it now includes defaults, and update all parity-test call
sites accordingly.

In `@packages/core/src/form/validateFormInput/validateFormInput.ts`:
- Around line 13-16: Update the return type of validateFormInput to use
Awaited<ReturnType<InternalFormStore['parse']>> instead of wrapping ReturnType
in another Promise, so the declaration represents the resolved parse result
rather than a nested promise.

In `@playgrounds/ir-demo/src/main.tsx`:
- Line 26: Define a named FormDemoProps interface containing the schema and
label fields currently declared inline, then update FormDemo to use
FormDemoProps for its parameter type while preserving the existing field types
and behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aa8d5e95-c905-4c9e-b61a-ecc1e5f5704d

📥 Commits

Reviewing files that changed from the base of the PR and between 3964c66 and 910df80.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (63)
  • SCHEMA_IR_POC.md
  • frameworks/react/src/hooks/useForm/useForm.ts
  • packages/adapters/valibot/package.json
  • packages/adapters/valibot/src/index.ts
  • packages/adapters/valibot/src/toFormisch.ts
  • packages/adapters/valibot/src/transform.ts
  • packages/adapters/valibot/tests/transform.test.ts
  • packages/adapters/valibot/tsconfig.json
  • packages/adapters/valibot/tsdown.config.ts
  • packages/adapters/valibot/vitest.config.ts
  • packages/adapters/zod/package.json
  • packages/adapters/zod/src/index.ts
  • packages/adapters/zod/src/toFormisch.ts
  • packages/adapters/zod/src/transform.ts
  • packages/adapters/zod/tests/transform.test.ts
  • packages/adapters/zod/tsconfig.json
  • packages/adapters/zod/tsdown.config.ts
  • packages/adapters/zod/vitest.config.ts
  • packages/core/package.json
  • packages/core/src/array/copyItemState/copyItemState.test.ts
  • packages/core/src/array/copyItemState/copyItemState.ts
  • packages/core/src/array/resetItemState/resetItemState.test.ts
  • packages/core/src/array/resetItemState/resetItemState.ts
  • packages/core/src/array/swapItemState/swapItemState.test.ts
  • packages/core/src/array/swapItemState/swapItemState.ts
  • packages/core/src/field/focusFieldElement/focusFieldElement.test.ts
  • packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts
  • packages/core/src/field/getElementInput/getElementInput.test.ts
  • packages/core/src/field/getFieldBool/getFieldBool.test.ts
  • packages/core/src/field/getFieldInput/getFieldInput.test.ts
  • packages/core/src/field/getFieldStore/getFieldStore.test.ts
  • packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts
  • packages/core/src/field/initializeFieldStore/initializeFieldStore.ts
  • packages/core/src/field/setFieldBool/setFieldBool.test.ts
  • packages/core/src/field/setFieldInput/setFieldInput.test.ts
  • packages/core/src/field/setFieldInput/setFieldInput.ts
  • packages/core/src/field/setInitialFieldInput/setInitialFieldInput.test.ts
  • packages/core/src/field/setInitialFieldInput/setInitialFieldInput.ts
  • packages/core/src/field/walkFieldStore/walkFieldStore.test.ts
  • packages/core/src/form/createFormStore/createFormStore.test.ts
  • packages/core/src/form/createFormStore/createFormStore.ts
  • packages/core/src/form/decodeFormData/decodeFormData.test.ts
  • packages/core/src/form/decodeFormData/decodeFormData.ts
  • packages/core/src/form/parity.test.ts
  • packages/core/src/form/validateFormInput/validateFormInput.test.ts
  • packages/core/src/form/validateFormInput/validateFormInput.ts
  • packages/core/src/form/validateIfRequired/validateIfRequired.test.ts
  • packages/core/src/types/field/field.ts
  • packages/core/src/types/form/form.qwik.ts
  • packages/core/src/types/form/form.react.ts
  • packages/core/src/types/form/form.ts
  • packages/core/src/types/schema/index.ts
  • packages/core/src/types/schema/ir.ts
  • packages/core/src/types/schema/schema.test-d.ts
  • packages/core/src/types/schema/schema.ts
  • packages/core/src/vitest/utils.ts
  • packages/core/vitest.config.ts
  • playgrounds/ir-demo/index.html
  • playgrounds/ir-demo/package.json
  • playgrounds/ir-demo/src/main.tsx
  • playgrounds/ir-demo/tsconfig.json
  • playgrounds/ir-demo/vite.config.ts
  • pnpm-workspace.yaml

Comment thread frameworks/react/src/hooks/useForm/useForm.ts
Comment on lines +15 to +20
export function toFormisch(schema: v.GenericSchema): FormSchema {
const root = transform(schema);
return {
'~standard': (schema as unknown as { '~standard': unknown })['~standard'],
'~formisch': { version: 1 as const, root },
} as FormSchema;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 '\btoFormisch\s*\(' packages --glob '*.ts'

Repository: open-circle/formisch

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="packages/adapters/valibot/src/toFormisch.ts"
printf '%s\n' '--- candidate files ---'
fd -t f -e ts -e tsx 'toFormisch|transform|FormSchema' packages/adapters/valibot packages/core packages | head -80

printf '%s\n' '--- file outline ---'
ast-grep outline "$file" --lang typescript

printf '%s\n' '--- implementation ---'
cat -n "$file"

printf '%s\n' '--- related declarations and uses ---'
rg -n -C 5 'interface FormSchema|type FormSchema|FormSchema|function transform|const transform|export function toFormisch' packages/adapters/valibot packages/core packages/formisch --glob '*.ts' --glob '*.tsx' 2>/dev/null | head -500

Repository: open-circle/formisch

Length of output: 25384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Valibot transform outline ---'
ast-grep outline packages/adapters/valibot/src/transform.ts --lang typescript

printf '%s\n' '--- Valibot transform implementation ---'
cat -n packages/adapters/valibot/src/transform.ts

printf '%s\n' '--- Valibot adapter tests around wrapper and root schemas ---'
cat -n packages/adapters/valibot/tests/transform.test.ts | sed -n '1,190p'

printf '%s\n' '--- Comparable Zod adapter ---'
cat -n packages/adapters/zod/src/toFormisch.ts
cat -n packages/adapters/zod/src/transform.ts | sed -n '1,130p'

printf '%s\n' '--- Root-type assertions and adapter API declarations ---'
rg -n -C 4 'root\.type|toFormisch\(v\.|toFormisch\(z\.|GenericSchema|FormSchemaIR' packages/adapters packages/core --glob '*.ts' --glob '*.tsx' --glob '*.d.ts' | head -500

printf '%s\n' '--- Static behavioral verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

transform = Path('packages/adapters/valibot/src/transform.ts').read_text()
to_formisch = Path('packages/adapters/valibot/src/toFormisch.ts').read_text()
schema = Path('packages/core/src/types/schema/ir.ts').read_text()

cases = {}
for case, ir_type in re.findall(r"case '([^']+)':\s*return[\s\S]*?\{\s*type:\s*'([^']+)'", transform):
    cases[case] = ir_type

print('transform switch mappings:', cases)
print('toFormisch input is GenericSchema:', 'schema: v.GenericSchema' in to_formisch)
print('toFormisch returns FormSchema:', '): FormSchema' in to_formisch)
print('FormSchema root requires object:', "root: FormischFieldIR & { readonly type: 'object' }" in schema)
print('non-object transform cases:', sorted(set(cases.values()) - {'object'}))
PY

Repository: open-circle/formisch

Length of output: 50376


Reject non-object root schemas.

v.GenericSchema accepts leaf and array schemas, but FormSchemaIR requires ~formisch.root.type to be 'object'. The cast currently allows toFormisch(v.string()) to return an invalid FormSchema. Add a guard before the cast or constrain the input type to object schemas.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/adapters/valibot/src/toFormisch.ts` around lines 15 - 20, Update
toFormisch to reject non-object root schemas before constructing or casting the
FormSchema; validate that the transformed root has type 'object' and throw for
leaf or array schemas, while preserving the existing output for valid object
schemas.

Comment on lines +23 to +24
case 'lazy':
return unwrapValibot(schema.getter(undefined));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- target file outline ---'
ast-grep outline packages/adapters/valibot/src/transform.ts || true
printf '%s\n' '--- target file ---'
cat -n packages/adapters/valibot/src/transform.ts
printf '%s\n' '--- relevant files ---'
git ls-files | rg '(^|/)(package\.json|.*valibot.*|.*transform.*|.*test.*|.*spec.*)$' | head -200
printf '%s\n' '--- transform references ---'
rg -n --glob '!node_modules' 'unwrapValibot|transform\(|schema\.getter|lazy\(' packages/adapters/valibot packages | head -300
printf '%s\n' '--- Valibot dependency declarations ---'
rg -n --glob 'package.json' --glob 'pnpm-lock.yaml' --glob 'yarn.lock' --glob 'package-lock.json' '"valibot"|valibot@' . | head -100

Repository: open-circle/formisch

Length of output: 25779


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- adapter tests ---'
cat -n packages/adapters/valibot/tests/transform.test.ts
printf '%s\n' '--- adapter entry point ---'
cat -n packages/adapters/valibot/src/toFormisch.ts
printf '%s\n' '--- core IR/type definitions ---'
rg -n --glob '*.ts' 'FormischFieldIR|interface .*FieldIR|type .*FieldIR|initializeFieldStore|toFormisch' packages/core packages/adapters | head -200
printf '%s\n' '--- core initialization context ---'
sed -n '280,350p' packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts
sed -n '430,515p' packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts
printf '%s\n' '--- recursive/lazy references ---'
rg -n --glob '*.ts' --glob '*.tsx' 'recursive|recursive schema|v\.lazy\(|lazy\(\(\) =>' packages/adapters/valibot packages/core/src | head -300
printf '%s\n' '--- relevant package scripts ---'
cat -n package.json | sed -n '1,180p'
cat -n packages/adapters/valibot/package.json

Repository: open-circle/formisch

Length of output: 44279


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Formisch IR ---'
cat -n packages/core/src/types/schema/ir.ts
printf '%s\n' '--- initializeFieldStore ---'
cat -n packages/core/src/field/initializeFieldStore/initializeFieldStore.ts
printf '%s\n' '--- standalone recursion verifier ---'
python3 - <<'PY'
import sys

sys.setrecursionlimit(250)

class Schema:
    def __init__(self, kind, **kwargs):
        self.type = kind
        self.__dict__.update(kwargs)

def lazy(getter):
    return Schema("lazy", getter=getter)

def obj(entries):
    return Schema("object", entries=entries)

node = None
node = lazy(lambda: obj({"value": Schema("string"), "next": node}))

unwrap_calls = 0
transform_calls = 0

def unwrap(schema):
    global unwrap_calls
    unwrap_calls += 1
    if schema.type == "lazy":
        return unwrap(schema.getter(None))
    return schema

def transform(schema):
    global transform_calls
    transform_calls += 1
    unwrapped = unwrap(schema)
    if unwrapped.type == "object":
        return {
            "type": "object",
            "properties": {key: transform(child)
                           for key, child in unwrapped.entries.items()},
        }
    return {"type": unwrapped.type}

try:
    transform(node)
except RecursionError as error:
    print("result=unbounded-recursion")
    print(f"unwrap_calls={unwrap_calls}")
    print(f"transform_calls={transform_calls}")
    print(f"error={type(error).__name__}")
PY

Repository: open-circle/formisch

Length of output: 8756


🌐 Web query:

Valibot 1.4.2 lazy schema getter recursive schema implementation

💡 Result:

In Valibot, including version 1.4.2, recursive schemas are implemented using the v.lazy function [1][2]. This function accepts a getter, a callback that returns the actual schema when called [1][3]. Using a lazy getter is necessary to resolve circular dependencies that occur when a schema refers to itself, as it defers the evaluation of the schema until the validation process actually occurs [4][5]. Implementation Example: To implement a recursive schema (e.g., for a tree structure), define your schema and use v.lazy within the recursive property [1][4]. Because of TypeScript limitations, recursive types cannot always be inferred automatically, so you should explicitly specify the schema type using v.GenericSchema or an interface [1][4]. import * as v from 'valibot'; type BinaryTree = { element: string; left: BinaryTree | null; right: BinaryTree | null; }; // Use v.GenericSchema to explicitly define the recursive type const BinaryTreeSchema: v.GenericSchema = v.object({ element: v.string, left: v.nullable(v.lazy( => BinaryTreeSchema)), right: v.nullable(v.lazy( => BinaryTreeSchema)), }); Key Considerations: 1. TypeScript Inference: Since recursive types cannot be inferred automatically in all cases, you must manually define the type (e.g., using a TypeScript type or interface) and apply it to the schema variable using v.GenericSchema [1][4][6]. 2. Runtime Input Access: The getter function receives the input as its first argument (v.lazy((input) =>...)). While this is primarily used for recursion, it can also be used to create dynamic or conditional schemas based on the runtime input value [1][7]. 3. Recommendation: While v.lazy is effective for recursion, the Valibot maintainers often recommend using v.union or v.variant if they are sufficient for your use case, as they may be more performant or predictable [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
import sys

sys.setrecursionlimit(250)

class Schema:
    def __init__(self, kind, **kwargs):
        self.type = kind
        self.__dict__.update(kwargs)

def lazy(getter):
    return Schema("lazy", getter=getter)

def obj(entries):
    return Schema("object", entries=entries)

node = None
node = lazy(lambda _input: obj({
    "value": Schema("string"),
    "next": node,
}))

unwrap_calls = 0
transform_calls = 0

def unwrap(schema):
    global unwrap_calls
    unwrap_calls += 1
    if schema.type == "lazy":
        return unwrap(schema.getter(None))
    return schema

def transform(schema):
    global transform_calls
    transform_calls += 1
    unwrapped = unwrap(schema)
    if unwrapped.type == "object":
        return {
            "type": "object",
            "properties": {
                key: transform(child)
                for key, child in unwrapped.entries.items()
            },
        }
    return {"type": unwrapped.type}

try:
    transform(node)
except RecursionError as error:
    print("result=unbounded-recursion")
    print(f"unwrap_calls={unwrap_calls}")
    print(f"transform_calls={transform_calls}")
    print(f"error={type(error).__name__}")
else:
    raise SystemExit("expected recursive schema to exceed recursion limit")
PY

Repository: open-circle/formisch

Length of output: 243


Handle recursive lazy schemas without unbounded recursion.

When a lazy getter returns an object that contains the same lazy schema, transform recurses indefinitely and toFormisch fails with RangeError: Maximum call stack size exceeded. Detect active recursive schemas and either reject them with a clear error or use a recursive IR representation supported by the core. Add a regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/adapters/valibot/src/transform.ts` around lines 23 - 24, Update the
lazy-schema handling in unwrapValibot so active recursive schemas are detected
before recursively processing schema.getter(undefined), preventing unbounded
recursion; either throw a clear recursion error or emit the core-supported
recursive IR. Add a regression test covering a lazy getter whose returned object
contains the same lazy schema and verify toFormisch no longer overflows the call
stack.

Comment on lines +108 to +115
case 'enum':
case 'picklist':
case 'literal':
return {
type: 'string',
optional,
getDefault: () => v.getDefault(schema),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target file outline ---'
ast-grep outline packages/adapters/valibot/src/transform.ts 2>/dev/null || true

printf '%s\n' '--- target implementation ---'
cat -n packages/adapters/valibot/src/transform.ts | sed -n '1,180p'

printf '%s\n' '--- Valibot package metadata and related references ---'
fd -i 'package.json|README|transform' packages/adapters/valibot . | head -80
rg -n --glob '!node_modules/**' "v\.enum|v\.picklist|v\.literal|transformValibot|type: 'string'|getDefault" packages/adapters/valibot packages | head -240

Repository: open-circle/formisch

Length of output: 15223


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- adapter metadata ---'
cat packages/adapters/valibot/package.json
printf '%s\n' '--- adapter tests ---'
cat -n packages/adapters/valibot/tests/transform.test.ts | sed -n '1,180p'

printf '%s\n' '--- IR type definition ---'
cat -n packages/core/src/types/schema/ir.ts | sed -n '1,120p'

printf '%s\n' '--- consumers of IR type ---'
rg -n --glob '!node_modules/**' "FormischFieldIR|\.type === ['\"]string['\"]|case ['\"]string['\"]|type: 'unknown'|type: 'number'" packages/core packages/adapters | head -260

printf '%s\n' '--- lockfile Valibot version ---'
rg -n -C 3 '"valibot"|valibot@|valibot:' pnpm-lock.yaml package-lock.json yarn.lock bun.lockb 2>/dev/null | head -100 || true

Repository: open-circle/formisch

Length of output: 18380


🌐 Web query:

Valibot 1.4.2 enum picklist literal schema runtime values numeric boolean API

💡 Result:

In Valibot, enum, picklist, and literal are schema functions used for runtime validation of specific values [1][2][3]. While they share the goal of restricting input to a set of allowed values, they are intended for different data structures [1][4]. Enum Schema The v.enum function is designed to validate against TypeScript enums or plain JavaScript objects [1][5]. Because TypeScript enums are transpiled into objects at runtime, v.enum treats these objects as the source of truth for valid values [1][4]. Example: import * as v from 'valibot'; enum Direction { Left, Right } const DirectionSchema = v.enum(Direction); Picklist Schema The v.picklist function is used when your allowed values are stored in an array [1][6]. It is often more convenient than v.enum when working with dynamic lists or simple arrays of primitives [6][2]. Example: import * as v from 'valibot'; const DirectionSchema = v.picklist(['LEFT', 'RIGHT'] as const); Literal Schema The v.literal function validates that an input matches exactly one specific value [2]. It is commonly used for fixed, single-value requirements [3]. Example: const LiteralSchema = v.literal('admin'); Runtime Values (Numeric and Boolean) Valibot schemas operate at runtime by validating that input matches the defined constraints [7]. For numeric and boolean values: - Numeric: You can validate numbers using v.number and further restrict them using pipes, such as v.integer, v.minValue, or v.maxValue [8]. If you need to validate that a value is exactly one of a few specific numbers, you would typically use v.picklist([1][6][5] as const) or a union of literals [1][6][3]. - Boolean: Use v.boolean for basic type validation [3]. Like numbers, specific boolean requirements can be handled via literals or picklists if you need to restrict them to exactly true or false, though v.boolean is sufficient for general type checking. API Summary - v.enum(enumObject, [message]): Validates against values in a TS/JS enum object [5][2]. - v.picklist(array, [message]): Validates that the input is one of the elements in the array [6][2]. - v.literal(value, [message]): Validates that the input matches the exact value [2]. - v.number / v.boolean: Used for general type validation of primitive values, often combined with pipes for additional constraints [8][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- form-data decoding ---'
cat -n packages/core/src/form/decodeFormData/decodeFormData.ts | sed -n '1,180p'

printf '%s\n' '--- field initialization ---'
cat -n packages/core/src/field/initializeFieldStore/initializeFieldStore.ts | sed -n '1,150p'

printf '%s\n' '--- tests for scalar decoding and initialization ---'
rg -n -C 8 --glob '*.test.ts' "number|boolean|bigint|unknown|enum|picklist|literal" \
  packages/core/src/form packages/core/src/field packages/adapters/valibot/tests | head -320

printf '%s\n' '--- Valibot type declarations/source references if vendored ---'
fd -i 'valibot' . --exclude node_modules --exclude .git | head -100

Repository: open-circle/formisch

Length of output: 37121


🏁 Script executed:

#!/bin/bash
set -eu

tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT

curl -fsSL https://unpkg.com/valibot@1.4.2/dist/index.d.ts > "$tmp"
printf '%s\n' '--- relevant Valibot declarations ---'
rg -n -C 12 "interface (Literal|Picklist|Enum)Schema|type (Literal|Picklist|Enum)Schema|declare function (literal|picklist|enum)" "$tmp" | head -260

printf '%s\n' '--- relevant Valibot runtime implementation ---'
curl -fsSL https://unpkg.com/valibot@1.4.2/dist/index.js |
  rg -n -C 10 "function (literal|picklist|enum)|const (literal|picklist|enum)" | head -260

Repository: open-circle/formisch

Length of output: 209


🏁 Script executed:

#!/bin/bash
set -eu

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

metadata="$tmpdir/metadata.json"
curl -fsSL https://registry.npmjs.org/valibot/1.4.2 > "$metadata"
tarball="$(jq -r '.dist.tarball' "$metadata")"
curl -fsSL "$tarball" -o "$tmpdir/valibot.tgz"
tar -xzf "$tmpdir/valibot.tgz" -C "$tmpdir"

printf '%s\n' '--- package files ---'
find "$tmpdir/package" -maxdepth 3 -type f | sort | head -80

printf '%s\n' '--- relevant declarations ---'
rg -n -C 12 "interface (Literal|Picklist|Enum)Schema|type (Literal|Picklist|Enum)Schema|declare function (literal|picklist|enum)" "$tmpdir/package" | head -300

printf '%s\n' '--- relevant runtime implementation ---'
rg -n -C 10 "function (literal|picklist|enum)|const (literal|picklist|enum)" "$tmpdir/package" | head -300

Repository: open-circle/formisch

Length of output: 44640


🏁 Script executed:

#!/bin/bash
set -eu

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

metadata="$tmpdir/metadata.json"
curl -fsSL https://registry.npmjs.org/valibot/1.4.2 > "$metadata"
curl -fsSL "$(jq -r '.dist.tarball' "$metadata")" -o "$tmpdir/valibot.tgz"
tar -xzf "$tmpdir/valibot.tgz" -C "$tmpdir"

node --input-type=module - "$tmpdir/package/dist/index.mjs" <<'JS'
const modulePath = process.argv[2];
const v = await import(modulePath);

const numericEnum = { A: 1, B: 2, 1: 'A', 2: 'B' };
const schemas = {
  literalNumber: v.literal(1),
  literalBoolean: v.literal(true),
  literalBigint: v.literal(1n),
  picklistNumbers: v.picklist([1, 2]),
  enumNumbers: v.enum(numericEnum),
  picklistMixed: v.picklist(['1', 1]),
};

function inferType(schema) {
  const values =
    schema.type === 'literal'
      ? [schema.literal]
      : schema.type === 'picklist' || schema.type === 'enum'
        ? schema.options
        : [];

  const kinds = new Set(values.map((value) => {
    if (typeof value === 'string') return 'string';
    if (typeof value === 'number') return 'number';
    if (typeof value === 'boolean') return 'boolean';
    if (typeof value === 'bigint') return 'bigint';
    return 'unknown';
  }));

  return kinds.size === 1 ? [...kinds][0] : 'unknown';
}

for (const [name, schema] of Object.entries(schemas)) {
  console.log(name, JSON.stringify({
    schemaType: schema.type,
    literal: typeof schema.literal === 'bigint' ? `${schema.literal}n` : schema.literal,
    options: schema.options?.map((value) =>
      typeof value === 'bigint' ? `${value}n` : value
    ),
    inferredType: inferType(schema),
  }));
}
JS

Repository: open-circle/formisch

Length of output: 630


Preserve primitive types for literal, picklist, and enum schemas.

These schemas can accept numeric, boolean, or bigint values, but this branch always emits type: 'string'. Form data then remains a string, so numeric value 1 reaches Valibot as '1' and fails validation. Infer the IR type from unwrapped.literal or unwrapped.options; use unknown for mixed primitive types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/adapters/valibot/src/transform.ts` around lines 108 - 115, Update
the enum/picklist/literal branch in the transform function to infer the IR type
from unwrapped.literal or unwrapped.options instead of always returning string.
Preserve numeric, boolean, and bigint primitive types, and return unknown when
the schema contains mixed primitive types; keep optional and getDefault behavior
unchanged.

Comment on lines +117 to +130
case 'union':
case 'variant':
case 'intersect': {
// Resolve to first option (POC limitation — see plan's open design questions)
if (unwrapped.options?.[0]) {
const inner = transform(unwrapped.options[0] as v.GenericSchema);
return { ...inner, optional, getDefault: () => v.getDefault(schema) };
}
return {
type: 'unknown',
optional,
getDefault: () => v.getDefault(schema),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- file map ---'
fd -i 'transform.ts|package.json|.*test.*|.*spec.*' packages/adapters/valibot
printf '%s\n' '--- transform outline ---'
ast-grep outline packages/adapters/valibot/src/transform.ts
printf '%s\n' '--- transform source ---'
cat -n packages/adapters/valibot/src/transform.ts
printf '%s\n' '--- related symbols ---'
rg -n "transform\(|unwrapValibot|GenericSchema|getDefault|case 'union'|case 'variant'|case 'intersect'" packages/adapters/valibot

Repository: open-circle/formisch

Length of output: 9498


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- repository files ---'
git ls-files packages/adapters/valibot
printf '%s\n' '--- transform outline ---'
ast-grep outline packages/adapters/valibot/src/transform.ts
printf '%s\n' '--- transform source ---'
cat -n packages/adapters/valibot/src/transform.ts
printf '%s\n' '--- package metadata and call sites ---'
cat -n packages/adapters/valibot/package.json
rg -n "transform\(|unwrapValibot|GenericSchema|getDefault|case 'union'|case 'variant'|case 'intersect'" packages/adapters/valibot

Repository: open-circle/formisch

Length of output: 11263


🏁 Script executed:

#!/bin/bash
set -e
sed -n '1,220p' packages/adapters/valibot/src/transform.ts
printf '%s\n' '--- package metadata ---'
cat packages/adapters/valibot/package.json
printf '%s\n' '--- all adapter references ---'
rg -n "transform\(|unwrapValibot|getDefault|GenericSchema|union|variant|intersect" packages/adapters/valibot

Repository: open-circle/formisch

Length of output: 9306


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
p = Path("packages/adapters/valibot/src/transform.ts")
print(p.read_text())
PY

Repository: open-circle/formisch

Length of output: 3586


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Formisch IR definitions and consumers ---'
rg -n "interface FormischFieldIR|type FormischFieldIR|FormischFieldIR|\.properties|\.item|field\.type|root" packages --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- Valibot adapter tests ---'
cat -n packages/adapters/valibot/tests/transform.test.ts
printf '%s\n' '--- comparable Zod adapter handling ---'
rg -n -C 5 "union|intersection|discriminated|options\\[0\\]|unsupported|unknown" packages/adapters/zod packages --glob '*.ts' --glob '*.tsx' | head -240
printf '%s\n' '--- package lock entries ---'
rg -n -A3 -B2 '"valibot"|valibot@' pnpm-lock.yaml package-lock.json yarn.lock 2>/dev/null | head -80

Repository: open-circle/formisch

Length of output: 50376


🌐 Web query:

Valibot 1.4.1 documentation union variant intersect schema semantics options

💡 Result:

In Valibot 1.4.1, the union, variant, and intersect schemas serve distinct purposes for managing complex data types. Union (v.union) The union schema represents a logical OR relationship among multiple schemas provided as an array [1]. During validation, Valibot attempts to validate the input against the provided schemas in order; it returns the result of the first schema that successfully validates the input [1][2]. If no schema matches, it returns a general issue containing the individual issues from each sub-schema [1][2]. Variant (v.variant) The variant schema is a specialized version of union intended for discriminated unions [1]. It requires a discriminator key (a common property used to distinguish between types) and an array of object schemas [1][3]. Because it uses the discriminator key to determine which schema to use, it is more performant and provides more targeted error messages than a standard union [1][3]. If an input cannot be clearly assigned via the discriminator, or if it does not match, a custom error message can be provided [3]. Variant schemas can be deeply nested [1][3]. Intersect (v.intersect) The intersect schema represents a logical AND relationship [4]. It validates that the input matches all provided schemas [5][4]. If all validations succeed, it merges the results of the individual schemas [4]. Notably, v.intersect in version 1.4.1 includes fixes to correctly infer types for non-tuple array options and ensures that input values are not mutated, allowing for the validation of frozen objects [6]. Unlike merging object schemas (which happens during initialization), intersect performs validation at runtime [4]. If properties conflict between intersected schemas, validation will fail [4]. Common Patterns - Recommended usage: Use variant over union whenever possible for discriminated object unions [1]. - Factoring out shared properties: Since v.variant does not natively support factoring out shared properties, a common best practice is to compose a shared schema with a variant schema using v.intersect (e.g., v.intersect([SharedSchema, v.variant(...)])) [7].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- core IR declarations ---'
rg -n -C 12 "FormischFieldIR|FormSchema" packages --glob '*.ts' | head -240
printf '%s\n' '--- all schema adapter transforms ---'
rg -n -C 8 "export function transform|case .*union|case .*intersection|options\\[0\\]|unsupported|type: 'unknown'" packages/adapters --glob '*.ts' | head -320
printf '%s\n' '--- form construction and validation paths ---'
cat -n packages/adapters/valibot/src/toFormisch.ts
rg -n -C 8 "FormischFieldIR|fieldIR|schema.*parse|safeParse|validate|decode|submit" packages --glob '*.ts' --glob '*.tsx' | head -320

Repository: open-circle/formisch

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- IR type ---'
cat -n packages/core/src/types/schema/ir.ts
printf '%s\n' '--- decoder ---'
cat -n packages/core/src/form/decodeFormData/decodeFormData.ts
printf '%s\n' '--- validation ---'
cat -n packages/core/src/form/validateFormInput/validateFormInput.ts
printf '%s\n' '--- intersect decoder test context ---'
sed -n '470,535p' packages/core/src/form/decodeFormData/decodeFormData.test.ts
printf '%s\n' '--- source-level probe ---'
python3 - <<'PY'
from pathlib import Path
src = Path("packages/adapters/valibot/src/transform.ts").read_text()
required = [
    "case 'union':",
    "case 'variant':",
    "case 'intersect':",
    "transform(unwrapped.options[0] as v.GenericSchema)",
]
for item in required:
    print(f"{item}: {item in src}")
print("first-option transformations:", src.count("transform(unwrapped.options[0] as v.GenericSchema)"))
PY

Repository: open-circle/formisch

Length of output: 14841


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- core test schema transformer ---'
sed -n '35,155p' packages/core/src/vitest/utils.ts
printf '%s\n' '--- decoder test setup and intersect/variant block ---'
sed -n '1,45p' packages/core/src/form/decodeFormData/decodeFormData.test.ts
sed -n '400,525p' packages/core/src/form/decodeFormData/decodeFormData.test.ts
printf '%s\n' '--- adapter entry points ---'
cat -n packages/adapters/valibot/src/index.ts packages/adapters/valibot/src/toFormisch.ts
printf '%s\n' '--- all Valibot intersect/variant references ---'
rg -n -C 6 "v\\.intersect|v\\.variant|intersect options|variant options" packages --glob '*.ts'

Repository: open-circle/formisch

Length of output: 31268


Do not reduce combinator schemas to options[0].

The IR drives FormData decoding. For example, a later boolean branch is treated as an unknown string, so 'on' remains 'on' instead of becoming true. Traverse all representable options, or throw an explicit unsupported-schema error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/adapters/valibot/src/transform.ts` around lines 117 - 130, The
union, variant, and intersect handling in transform must not resolve schemas to
options[0]. Traverse and combine all representable options so later branches
retain their correct types during FormData decoding; if the combinator cannot be
represented, throw an explicit unsupported-schema error instead of returning an
incomplete IR.

Comment on lines +66 to 73
} else if (ir.type === 'array') {
if (Array.isArray(parent[key])) {
for (let index = 0; index < parent[key].length; index++) {
fillDefaults(unwrappedSchema.item!, parent[key], index);
fillDefaults(ir.item!, parent[key], index);
}
} else {
parent[key] = [];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Remove the non-null assertion on ir.item; it can throw a TypeError.

Line 69 asserts ir.item!. FormischFieldIR declares item as optional, and any type: 'array' node without item (for example a tuple encoding) passes undefined into the recursive call. The next call then reads ir.type on undefined and throws. Guard the recursion instead.

🐛 Proposed guard for the missing array item IR
   } else if (ir.type === 'array') {
     if (Array.isArray(parent[key])) {
-      for (let index = 0; index < parent[key].length; index++) {
-        fillDefaults(ir.item!, parent[key], index);
-      }
+      if (ir.item) {
+        for (let index = 0; index < parent[key].length; index++) {
+          fillDefaults(ir.item, parent[key], index);
+        }
+      }
     } else {
       parent[key] = [];
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (ir.type === 'array') {
if (Array.isArray(parent[key])) {
for (let index = 0; index < parent[key].length; index++) {
fillDefaults(unwrappedSchema.item!, parent[key], index);
fillDefaults(ir.item!, parent[key], index);
}
} else {
parent[key] = [];
}
} else if (ir.type === 'array') {
if (Array.isArray(parent[key])) {
if (ir.item) {
for (let index = 0; index < parent[key].length; index++) {
fillDefaults(ir.item, parent[key], index);
}
}
} else {
parent[key] = [];
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/form/decodeFormData/decodeFormData.ts` around lines 66 -
73, Update the array branch in fillDefaults to remove the non-null assertion on
ir.item and only recurse when the item IR is defined; otherwise preserve the
existing parent[key] array initialization and avoid calling fillDefaults with
undefined.

Comment thread packages/core/src/form/validateFormInput/validateFormInput.ts
Comment thread packages/core/src/types/form/form.ts
Comment on lines +84 to +89
default:
return {
type: 'unknown',
optional,
getDefault: () => v.getDefault(schema),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the shared test transformer and the production Valibot transformer.
ast-grep outline packages/core/src/vitest/utils.ts --items all --type function
ast-grep outline packages/adapters/valibot/src/transform.ts --items all --type function

# Compare handling for compound schema types.
rg -n -C 6 \
  "case '(tuple|record|object_with_rest|union|variant|intersect)'|options\?\.\[0\]" \
  packages/core/src/vitest/utils.ts \
  packages/adapters/valibot/src/transform.ts

# Identify migrated tests that require these semantics.
rg -n -C 3 \
  "v\.(tuple|record|objectWithRest|union|variant|intersect)" \
  packages/core/src \
  -g '*.test.ts'

Repository: open-circle/formisch

Length of output: 29233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- packages/core/src/vitest/utils.ts ---'
cat -n packages/core/src/vitest/utils.ts | sed -n '1,180p'

printf '%s\n' '--- packages/adapters/valibot/src/transform.ts ---'
cat -n packages/adapters/valibot/src/transform.ts | sed -n '1,160p'

printf '%s\n' '--- IR definitions and relevant test expectations ---'
rg -n -C 5 \
  "interface FormischFieldIR|type FormischFieldIR|type: 'unknown'|record.*not supported|object_with_rest.*not supported|initialize fixed tuple items|should initialize for each union option|should descend into union options|should descend into intersect options|should descend into variant options" \
  packages/core/src packages/adapters/valibot/src \
  -g '*.ts' -g '*.tsx'

Repository: open-circle/formisch

Length of output: 22233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- IR and field initialization behavior ---'
cat -n packages/core/src/types/schema/ir.ts | sed -n '1,130p'
rg -n -C 8 \
  "case 'unknown'|ir\.type|field\.type|unsupported|not supported|kind: 'array'|kind: 'value'" \
  packages/core/src/field packages/core/src/form \
  -g '*.ts' -g '*.tsx'

printf '%s\n' '--- Exact affected assertions ---'
cat -n packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts | sed -n '280,335p'
cat -n packages/core/src/form/decodeFormData/decodeFormData.test.ts | sed -n '446,515p'
cat -n packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts | sed -n '505,525p'

printf '%s\n' '--- Schema transform tests and repository change summary ---'
fd -i 'transform*.test.ts' packages
git diff --stat

Repository: open-circle/formisch

Length of output: 29998


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- complete initialization switch ---'
cat -n packages/core/src/field/initializeFieldStore/initializeFieldStore.ts | sed -n '1,135p'

printf '%s\n' '--- Valibot adapter transform expectations ---'
cat -n packages/adapters/valibot/tests/transform.test.ts | sed -n '1,260p'

printf '%s\n' '--- all unsupported-schema handling ---'
rg -n -C 5 \
  "record|object_with_rest|unsupported|not supported|case 'unknown'|throw new Error" \
  packages/core/src packages/adapters/valibot \
  -g '*.ts' -g '*.tsx'

Repository: open-circle/formisch

Length of output: 24022


Preserve compound schema semantics in transformValibot.

packages/core/src/vitest/utils.ts maps tuple, record, and object_with_rest schemas to unknown. The core treats unknown as a value field, so tuple fields are not arrays and record tests do not throw the expected unsupported-schema errors.

The helper also reduces union, variant, and intersect schemas to the first option. This omits later fields during initialization and form-data decoding.

Implement the required IR behavior for these schemas. If the POC excludes them, explicitly skip or remove the affected tests instead of silently changing their semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/vitest/utils.ts` around lines 84 - 89, Update
transformValibot to preserve compound-schema semantics: represent tuple schemas
as arrays, retain record and object_with_rest schemas so unsupported-schema
errors remain observable, and combine all union, variant, and intersect options
rather than selecting only the first. If these schemas remain outside the POC
scope, explicitly skip or remove their affected tests instead of mapping them to
unknown.

Comment on lines +26 to +44
function FormDemo({ schema, label }: { schema: FormSchema; label: string }) {
const form = useForm({
schema,
onSubmit: (output) => {
console.log(`[${label}] Submitted:`, output);
},
});

return (
<div style={{ flex: 1, minWidth: 300 }}>
<h3>{label}</h3>
<pre>
name (string): {(form as Record<string, unknown>).__name ?? 'N/A'}
{'\n'}
errors: {JSON.stringify(form.errors)}
</pre>
</div>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- target file ---'
sed -n '1,130p' playgrounds/ir-demo/src/main.tsx
printf '%s\n' '--- related documentation ---'
rg -n -C 4 'IR|name|age|email|FormDemo|submit' playgrounds/ir-demo SCHEMA_IR_POC.md 2>/dev/null || true
printf '%s\n' '--- formisch/react API references ---'
rg -n -C 3 'useForm|Field|Form|form\.|bind|name|age|email' --glob '*.{ts,tsx,md}' . | head -n 400

Repository: open-circle/formisch

Length of output: 41709


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- React package files ---'
git ls-files 'frameworks/react/*' 'packages/core/src/types/form/*' | head -n 200
printf '%s\n' '--- React API outlines ---'
for f in $(git ls-files 'frameworks/react/*' | grep -E '/(Form|Field|useForm)/.*\.(ts|tsx)$' | head -n 30); do
  echo "--- $f"
  ast-grep outline "$f" 2>/dev/null || true
done
printf '%s\n' '--- Form, Field, and useForm implementations ---'
rg -l 'export.*(Form|Field|useForm)|function (Form|Field)|const (Form|Field)' frameworks/react packages | head -n 80
printf '%s\n' '--- form methods and field-state accessors ---'
rg -n -C 5 'handleSubmit|onSubmit|useField|field\.props|field\.input|getInput|getField|errors' frameworks/react packages/core/src --glob '*.{ts,tsx}' | head -n 500
printf '%s\n' '--- documentation target lines ---'
sed -n '28,95p' SCHEMA_IR_POC.md

Repository: open-circle/formisch

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Form component ---'
cat -n frameworks/react/src/components/Form/Form.tsx
printf '%s\n' '--- Field component ---'
cat -n frameworks/react/src/components/Field/Field.tsx
printf '%s\n' '--- useField hook ---'
cat -n frameworks/react/src/hooks/useField/useField.ts
printf '%s\n' '--- React form and field types ---'
cat -n frameworks/react/src/types/form.ts
cat -n frameworks/react/src/types/field.ts
printf '%s\n' '--- React exports ---'
cat -n frameworks/react/src/index.ts
cat -n frameworks/react/src/components/index.ts
printf '%s\n' '--- POC instructions ---'
sed -n '28,95p' SCHEMA_IR_POC.md
printf '%s\n' '--- IR and public schema types ---'
cat -n packages/core/src/types/schema/ir.ts
cat -n packages/core/src/types/schema/schema.ts
cat -n packages/core/src/types/form/form.react.ts

Repository: open-circle/formisch

Length of output: 21972


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- submit path ---'
cat -n packages/methods/src/handleSubmit/handleSubmit.ts
cat -n packages/methods/src/handleSubmit/handleSubmit.react.ts
printf '%s\n' '--- form-data decoding ---'
cat -n packages/core/src/form/decodeFormData/decodeFormData.ts
printf '%s\n' '--- element input conversion ---'
cat -n packages/core/src/field/getElementInput/getElementInput.ts
printf '%s\n' '--- read-only source verifier ---'
python3 - <<'PY'
from pathlib import Path

demo = Path("playgrounds/ir-demo/src/main.tsx").read_text()
form = Path("frameworks/react/src/components/Form/Form.tsx").read_text()
field = Path("frameworks/react/src/components/Field/Field.tsx").read_text()
field_types = Path("frameworks/react/src/types/field.ts").read_text()

checks = {
    "demo has no Form component": "<Form" not in demo,
    "demo has no Field component": "<Field" not in demo,
    "demo has no native form": "<form" not in demo,
    "demo has no input control": "<input" not in demo,
    "demo has no submit control": 'type="submit"' not in demo,
    "demo accesses private-looking __name property": ".__name" in demo,
    "Form wraps native form": "<form" in form,
    "Form wires onSubmit": "onSubmit={handleSubmit(of, onSubmit)}" in form,
    "Field calls useField": "const field = useField(of, { path });" in field,
    "Field exposes input/errors/props": all(
        token in field_types for token in ("readonly input", "readonly errors", "readonly props")
    ),
}
for name, result in checks.items():
    print(f"{'PASS' if result else 'FAIL'}: {name}")
PY

Repository: open-circle/formisch

Length of output: 14515


Render a real form with Form and Field.

onSubmit is not user-triggered without the Form component. Bind name, age, and email with Field, and render each field's input and errors through the public API. Add a submit control. Remove the __name cast because FormStore does not expose that property.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@playgrounds/ir-demo/src/main.tsx` around lines 26 - 44, Update FormDemo to
render the form through the public Form and Field APIs: wrap the fields in Form,
bind name, age, and email with Field, render each field’s input and errors, and
add a submit control so onSubmit is user-triggered. Remove the __name
cast/display and use the field render APIs instead.

Introduces the intermediate representation (FormischFieldIR) that abstracts
over validation library specifics. The core schema type becomes a Standard
Schema augmented with the ~formisch marker, replacing the direct Valibot
type dependency in the type layer.

This is a types-only commit; runtime code does not yet consume the IR, so
tsc will report errors in the traversal sites until the next commit rewires
them.
Replaces all Valibot-specific schema traversal in initializeFieldStore,
decodeFormData, createFormStore, validateFormInput, and the array/field
utilities with IR-driven recursion. The core now reads field types,
optionality, and structure from FormischFieldIR instead of Valibot schema
internals.

Test helpers (vitest/utils.ts) gain a toFormisch wrapper so existing tests
can pass IR-augmented schemas. 410 of 435 core tests pass; the 25 failures
are exclusively in out-of-scope areas (tuples, unions, combinators) and are
documented in the POC README.
Creates the reference adapter that transforms Valibot schemas into the
Formisch IR. The transform walks Valibot schema types (object, array,
string, number, boolean, date, bigint, wrappers, lazy, pipe) and emits
FormischFieldIR nodes with optionality resolved at transform time.

The toFormisch entry point wraps a schema with the ~formisch marker while
preserving the ~standard passthrough for validation. 22 transform tests
pass.
Creates the Zod adapter proving cross-library compatibility. The transform
walks Zod's _def.typeName discriminant, mapping ZodObject, ZodArray,
ZodString, ZodNumber, ZodBoolean, ZodDate, ZodBigInt, ZodOptional,
ZodNullable, ZodDefault, and ZodEffects to the same FormischFieldIR
structure the Valibot adapter produces.

19 transform tests pass.
The React useForm hook now calls schema['~standard'].validate instead of
v.safeParseAsync, making it compatible with any Standard Schema (Valibot
or Zod via their respective adapters).

Adds a Vite + React demo (playgrounds/ir-demo) that renders the same form
shape with either adapter, toggleable at runtime. The age field has IR
type 'number', demonstrating type info flowing through from both libraries.
The parity suite defines identical form shapes in Valibot and Zod,
transforms both via their adapters, and asserts the resulting IR trees are
deeply equal (5 tests). The coercion suite feeds FormData through
decodeFormData with each adapter's schema and verifies identical decoded
output, including number coercion and boolean defaults (3 tests).

All 8 tests pass.
Updates pnpm-workspace.yaml to include packages/adapters/*. Adds the POC
README documenting: what this proves, the grep proof for zero library
imports in core, how to run tests and the demo, out-of-scope features,
open design questions, and alpha publish instructions.

💘 Generated with Crush

Assisted-by: Crush:zai-coding-plan/glm-5.2

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

24 issues found across 64 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/adapters/valibot/package.json">

<violation number="1" location="packages/adapters/valibot/package.json:43">
P3: The Valibot adapter lists `@formisch/zod` and `zod` as devDependencies, but no file in this package imports or references them. This looks like copy-paste from the Zod adapter and needlessly couples the two adapter packages; remove both entries. Valibot itself is already covered as both a peer and dev dependency.</violation>
</file>

<file name="packages/core/src/form/parity.test.ts">

<violation number="1" location="packages/core/src/form/parity.test.ts:13">
P2: The parity tests deliberately strip `getDefault()` via `stripGetDefault`, so default-value parity between the Valibot and Zod adapters is never actually asserted even though `getDefault` is a core IR field consumed by initialization. Both closures can be invoked and their results compared (they return a value), so the tests should assert `getDefault()` equality instead of dropping it entirely — otherwise a subtle default mismatch between the two adapters passes the 'identical IR' claim silently.</violation>
</file>

<file name="packages/core/src/types/form/form.qwik.ts">

<violation number="1" location="packages/core/src/types/form/form.qwik.ts:42">
P2: `initialInput` loses schema type checking for every adapter-produced schema because `toFormisch()` returns an unparameterized `FormSchema`; preserve Standard Schema input/output generics through `FormSchema` and both adapters so invalid initial values are rejected.</violation>
</file>

<file name="packages/core/src/field/setInitialFieldInput/setInitialFieldInput.test.ts">

<violation number="1" location="packages/core/src/field/setInitialFieldInput/setInitialFieldInput.test.ts:94">
P2: This tuple test asserts array behavior that the IR produced by toFormisch cannot deliver: transformValibot maps `v.tuple` to type 'unknown', so initializeFieldStore initializes `pair` as a value field (no `initialItems`, no children) rather than an array. The `kind === 'array'`, `initialItems.value`, and `children[0].initialInput` assertions fail once the schema is wrapped. Either add a tuple case to the transform so it produces array IR (with fixed-length handling in setInitialFieldInput, currently unreachable for tuples), or update the test to match the 'value'/unknown kind that tuples actually produce.</violation>
</file>

<file name="playgrounds/ir-demo/src/main.tsx">

<violation number="1" location="playgrounds/ir-demo/src/main.tsx:29">
P3: The demo never actually demonstrates validation or submission. `onSubmit` in the React wrapper is only fired through the `<Form>` component's submit handler (frameworks/react/src/components/Form/Form.tsx), but `FormDemo` renders no `<Form>`, no `<Field>` inputs, and no submit button—only a `<pre>` showing `name`/`errors`. As a result `console.log('[..] Submitted:', output)` never runs and `form.errors` is always `null`, despite the UI text telling users to 'Open the console to see submit output.' Consider wrapping the content in `<Form of={form} onSubmit={...}>` with a real submit button (and ideally `<Field>` inputs) so the parity demo actually exercises validation/submission through both adapters.</violation>

<violation number="2" location="playgrounds/ir-demo/src/main.tsx:38">
P3: The demo reads form.__name through a `as Record<string, unknown>` cast, but no '__name' property exists on the form store (fields live under getFieldStore/form[INTERNAL]), so this always shows 'N/A'. It also hides the missing property behind a cast. Read the field value through the store API or drop the display rather than casting to an untyped record.</violation>
</file>

<file name="packages/adapters/valibot/src/transform.ts">

<violation number="1" location="packages/adapters/valibot/src/transform.ts:24">
P1: Wrapping a self-recursive `v.lazy` schema overflows the stack during `toFormisch`, before a form can be created. Track visited lazy/schema nodes (or explicitly reject recursive lazy schemas) rather than recursively expanding the same getter result.</violation>

<violation number="2" location="packages/adapters/valibot/src/transform.ts:60">
P2: The `optional` flag is computed from the *original* schema via `isOptional(schema)`, while `unwrapValibot` strips wrappers to pick the concrete type. When a `pipe` wraps an optional wrapper — e.g. `v.pipe(v.optional(v.string()), v.minLength(1))` — the schema's `type` is `'pipe'`, so `isOptional` returns `false` even though the schema accepts `undefined`. The resulting IR marks the field non-optional (`nullish=false` at store init), so an empty/omitted value is not treated as nullish for this field. The same happens for any wrapper nested as a pipe's first stage. `isOptional` should detect optionality through `pipe` (the common `v.optional(v.pipe(...))` order happens to work only because the wrapper sits on the outside).</violation>

<violation number="3" location="packages/adapters/valibot/src/transform.ts:73">
P3: The `enum`/`picklist`/`literal` branch always emits `type: 'string'`, but these schemas can carry non-string values, e.g. `v.literal(5)`, `v.picklist([1, 2, 3])`, or a boolean literal. The IR then describes a numeric field as `'string'` while `getDefault()` still returns the number (e.g. `5`), and the core's `emptyInput[ir.type]` will seed it with `''`. This is acknowledged as a POC simplification, but it's a silent value/type mismatch worth capturing for the open design questions, since the numeric picklist case is common and could be mapped to `'number'` without much effort.</violation>
</file>

<file name="packages/core/src/types/form/form.ts">

<violation number="1" location="packages/core/src/types/form/form.ts:191">
P1: Adapter-backed forms now pass `unknown` to submit handlers, losing the library’s schema-derived output typing; preserve Standard Schema input/output generics through `FormSchema`/`toFormisch` so this handler receives the concrete parsed output type.</violation>
</file>

<file name="packages/adapters/zod/src/transform.ts">

<violation number="1" location="packages/adapters/zod/src/transform.ts:50">
P1: Numeric, boolean, or bigint literals and numeric native enums are decoded as strings, so valid Zod schemas can fail validation after form submission. Derive the IR type from the literal value or native-enum values instead of always using `'string'`.</violation>

<violation number="2" location="packages/adapters/zod/src/transform.ts:126">
P1: A lazy Zod object is treated as an unknown scalar, so recursive or lazily declared forms do not get their object fields initialized. Unwrap `ZodLazy` (with recursion protection) or explicitly reject/document this schema shape before attaching the IR.</violation>
</file>

<file name="packages/core/src/form/decodeFormData/decodeFormData.ts">

<violation number="1" location="packages/core/src/form/decodeFormData/decodeFormData.ts:41">
P2: Core format check fails because newly compressed control-flow blocks are not Prettier-formatted; run the configured formatter on this file before merging.</violation>
</file>

<file name="packages/adapters/valibot/src/toFormisch.ts">

<violation number="1" location="packages/adapters/valibot/src/toFormisch.ts:15">
P2: `toFormisch(v.string())` is typed as a valid `FormSchema` although it produces a scalar root; reject non-object roots (or narrow the accepted schema type) before returning so invalid form schemas cannot enter form APIs.</violation>
</file>

<file name="packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts">

<violation number="1" location="packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts:35">
P2: This piped-string test now routes through the test-only `toFormisch` in vitest/utils.ts, whose `unwrapValibot` omits the `'pipe'` case that the real valibot adapter (`adapters/valibot/src/transform.ts`) handles. As a result a piped schema is typed `'unknown'` instead of `'string'`, so `initializeFieldStore` looks up `emptyInput['unknown']` and the expected `''` empty-string default no longer matches — the test either fails or validates different IR than the adapter produces. Add the `pipe` unwrap step to the test helper so core tests mirror the adapter.</violation>
</file>

<file name="packages/core/src/form/decodeFormData/decodeFormData.test.ts">

<violation number="1" location="packages/core/src/form/decodeFormData/decodeFormData.test.ts:24">
P2: These out-of-scope tests (unions/variants/intersects and tuples) were rewritten to build schemas with toFormisch but their assertions still encode the old full Valibot behavior, which the POC's IR-based decoder can no longer produce (the IR only keeps the first union/intersect/variant option and collapses tuples to 'unknown'). The core test suite will therefore fail on these cases; either drop them from this POC file, `it.skip`/update them to assert the reduced IR behavior, or extend the IR transform to capture all combinator options and tuple item schemas.</violation>
</file>

<file name="packages/core/src/form/createFormStore/createFormStore.ts">

<violation number="1" location="packages/core/src/form/createFormStore/createFormStore.ts:16">
P1: Successful submissions in the non-React wrappers lose their parsed output because those wrappers still pass Valibot's `{ success, output }` result to this new StandardParseResult API, while core consumers read `result.value`. Updating every framework wrapper to call `config.schema['~standard'].validate` and normalize `{ issues, value }` like the React wrapper would preserve submitted data.</violation>

<violation number="2" location="packages/core/src/form/createFormStore/createFormStore.ts:30">
P1: Qwik forms fail during initialization because the schema clone strips the IR's `Map` properties before this line reads the root and hands it to `initializeFieldStore`. Preserving or rehydrating the IR (including `Map` and `getDefault`) across Qwik serialization would avoid the non-iterable `properties` error.</violation>
</file>

<file name="packages/core/src/field/initializeFieldStore/initializeFieldStore.ts">

<violation number="1" location="packages/core/src/field/initializeFieldStore/initializeFieldStore.ts:19">
P2: Configured schema defaults are ignored because this IR is stored but `ir.getDefault()` is never resolved before empty-input fallback. Resolve undefined initial input through `getDefault()` before initializing the field.</violation>

<violation number="2" location="packages/core/src/field/initializeFieldStore/initializeFieldStore.ts:55">
P1: Optional array items are initialized as required, converting missing values to configured empty input. Forward `ir.item!.optional` as the recursive nullish argument, matching object properties.</violation>
</file>

<file name="packages/core/src/vitest/utils.ts">

<violation number="1" location="packages/core/src/vitest/utils.ts:82">
P3: `getDefault()` reports synthetic empty values not defined by the schema, diverging from the adapter and the IR contract; return `v.getDefault(schema)` unchanged.</violation>
</file>

<file name="packages/core/src/types/schema/ir.ts">

<violation number="1" location="packages/core/src/types/schema/ir.ts:38">
P2: The single `optional` boolean conflates `optional` with `nullable`/`nullish`, losing the null-vs-undefined distinction. `initializeFieldStore` passes `childIR.optional` as its `nullish` flag (initializeFieldStore.ts:93), so a required `z.string().nullable()` field (both adapters set `optional: true`) initializes to `undefined` instead of the empty-input string, and `z.string().nullable()` rejects `undefined` on an empty submit — the field fails validation even when untouched. Consider modeling nullishness as its own flag (e.g. `readonly optional: boolean; readonly nullish: boolean;`) so nullable and optional fields are not collapsed.</violation>
</file>

<file name="packages/core/src/types/form/form.react.ts">

<violation number="1" location="packages/core/src/types/form/form.react.ts:20">
P2: The Standard Schema input/output type helpers are defined twice verbatim — `InferStandardInput`/`InferStandardOutput` are duplicated in both `form.ts` and `form.qwik.ts` — and the React entry point gets around the duplication with an indirect reflection trick: `FormOutput` re-derives the output type by matching against the base `SubmitHandler` signature (`extends (output: infer O) => unknown`) rather than consuming an exported helper. This spreads one concept across three files with subtly different mechanisms (two copies of the conditional types plus one inference hack), so a change to how the output type is modeled (e.g. how `types` optionality or the standard result is shaped) must be kept in sync in all three places, and the reflection silently breaks if the base `SubmitHandler` signature ever changes its parameter structure. Consider exporting `InferStandardInput`/`InferStandardOutput` once from `form.ts` and importing them in `form.qwik.ts` and `form.react.ts`, replacing `FormOutput` with the shared helper.</violation>
</file>

<file name="packages/core/src/array/resetItemState/resetItemState.ts">

<violation number="1" location="packages/core/src/array/resetItemState/resetItemState.ts:78">
P2: `isTuple` is now always `false`, silently disabling the fixed-length tuple handling this branch is documented to provide. The IR type system (`FormischFieldType` in packages/core/src/types/schema/ir.ts) only contains `'array'` — there is no `'tuple'` type — so inside the array kind branch `ir.type` is always `'array'` and `ir.type !== 'array'` can never be true. The comment above this line ('Tuples have a fixed number of children...') describes behavior that is now unreachable. Either tuples are explicitly out of scope (then this dead branch and its comment should be removed, and the tuple reset tests deleted) or the IR/adapter needs a tuple representation to restore the fixed-children reset path.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

case 'non_optional':
return unwrapValibot(schema.wrapped);
case 'lazy':
return unwrapValibot(schema.getter(undefined));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Wrapping a self-recursive v.lazy schema overflows the stack during toFormisch, before a form can be created. Track visited lazy/schema nodes (or explicitly reject recursive lazy schemas) rather than recursively expanding the same getter result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/adapters/valibot/src/transform.ts, line 24:

<comment>Wrapping a self-recursive `v.lazy` schema overflows the stack during `toFormisch`, before a form can be created. Track visited lazy/schema nodes (or explicitly reject recursive lazy schemas) rather than recursively expanding the same getter result.</comment>

<file context>
@@ -0,0 +1,139 @@
+    case 'non_optional':
+      return unwrapValibot(schema.wrapped);
+    case 'lazy':
+      return unwrapValibot(schema.getter(undefined));
+    case 'pipe':
+      return unwrapValibot(schema.items[0]);
</file context>

Comment thread packages/core/src/types/form/form.ts
return transform(def.schema);
}

default:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A lazy Zod object is treated as an unknown scalar, so recursive or lazily declared forms do not get their object fields initialized. Unwrap ZodLazy (with recursion protection) or explicitly reject/document this schema shape before attaching the IR.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/adapters/zod/src/transform.ts, line 126:

<comment>A lazy Zod object is treated as an unknown scalar, so recursive or lazily declared forms do not get their object fields initialized. Unwrap `ZodLazy` (with recursion protection) or explicitly reject/document this schema shape before attaching the IR.</comment>

<file context>
@@ -0,0 +1,133 @@
+      return transform(def.schema);
+    }
+
+    default:
+      return {
+        type: 'unknown',
</file context>


case z.ZodFirstPartyTypeKind.ZodString:
return {
type: 'string',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Numeric, boolean, or bigint literals and numeric native enums are decoded as strings, so valid Zod schemas can fail validation after form submission. Derive the IR type from the literal value or native-enum values instead of always using 'string'.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/adapters/zod/src/transform.ts, line 50:

<comment>Numeric, boolean, or bigint literals and numeric native enums are decoded as strings, so valid Zod schemas can fail validation after form submission. Derive the IR type from the literal value or native-enum values instead of always using `'string'`.</comment>

<file context>
@@ -0,0 +1,133 @@
+
+    case z.ZodFirstPartyTypeKind.ZodString:
+      return {
+        type: 'string',
+        optional: false,
+        getDefault: () => getZodDefault(schema),
</file context>

store.isValidating = createSignal(false);

// Initialize field store hierarchy from schema
const ir = config.schema['~formisch'].root as FormischFieldIR;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Qwik forms fail during initialization because the schema clone strips the IR's Map properties before this line reads the root and hands it to initializeFieldStore. Preserving or rehydrating the IR (including Map and getDefault) across Qwik serialization would avoid the non-iterable properties error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/form/createFormStore/createFormStore.ts, line 30:

<comment>Qwik forms fail during initialization because the schema clone strips the IR's `Map` properties before this line reads the root and hands it to `initializeFieldStore`. Preserving or rehydrating the IR (including `Map` and `getDefault`) across Qwik serialization would avoid the non-iterable `properties` error.</comment>

<file context>
@@ -1,61 +1,40 @@
   store.isValidating = createSignal(false);
 
-  // Initialize field store hierarchy from schema
+  const ir = config.schema['~formisch'].root as FormischFieldIR;
   initializeFieldStore(
     store as InternalFormStore,
</file context>

"devDependencies": {
"@formisch/core": "workspace:*",
"@formisch/eslint-config": "workspace:*",
"@formisch/zod": "workspace:*",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The Valibot adapter lists @formisch/zod and zod as devDependencies, but no file in this package imports or references them. This looks like copy-paste from the Zod adapter and needlessly couples the two adapter packages; remove both entries. Valibot itself is already covered as both a peer and dev dependency.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/adapters/valibot/package.json, line 43:

<comment>The Valibot adapter lists `@formisch/zod` and `zod` as devDependencies, but no file in this package imports or references them. This looks like copy-paste from the Zod adapter and needlessly couples the two adapter packages; remove both entries. Valibot itself is already covered as both a peer and dev dependency.</comment>

<file context>
@@ -0,0 +1,53 @@
+  "devDependencies": {
+    "@formisch/core": "workspace:*",
+    "@formisch/eslint-config": "workspace:*",
+    "@formisch/zod": "workspace:*",
+    "tsdown": "^0.16.8",
+    "typescript": "~5.9.3",
</file context>

<div style={{ flex: 1, minWidth: 300 }}>
<h3>{label}</h3>
<pre>
name (string): {(form as Record<string, unknown>).__name ?? 'N/A'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The demo reads form.__name through a as Record<string, unknown> cast, but no '__name' property exists on the form store (fields live under getFieldStore/form[INTERNAL]), so this always shows 'N/A'. It also hides the missing property behind a cast. Read the field value through the store API or drop the display rather than casting to an untyped record.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At playgrounds/ir-demo/src/main.tsx, line 38:

<comment>The demo reads form.__name through a `as Record<string, unknown>` cast, but no '__name' property exists on the form store (fields live under getFieldStore/form[INTERNAL]), so this always shows 'N/A'. It also hides the missing property behind a cast. Read the field value through the store API or drop the display rather than casting to an untyped record.</comment>

<file context>
@@ -0,0 +1,98 @@
+    <div style={{ flex: 1, minWidth: 300 }}>
+      <h3>{label}</h3>
+      <pre>
+        name (string): {(form as Record<string, unknown>).__name ?? 'N/A'}
+        {'\n'}
+        errors: {JSON.stringify(form.errors)}
</file context>

return {
type: unwrapped.type,
optional,
getDefault: () => v.getDefault(schema) ?? EMPTY_DEFAULTS[unwrapped.type],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: getDefault() reports synthetic empty values not defined by the schema, diverging from the adapter and the IR contract; return v.getDefault(schema) unchanged.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/vitest/utils.ts, line 82:

<comment>`getDefault()` reports synthetic empty values not defined by the schema, diverging from the adapter and the IR contract; return `v.getDefault(schema)` unchanged.</comment>

<file context>
@@ -16,13 +19,142 @@ interface CreateTestStoreConfig {
+      return {
+        type: unwrapped.type,
+        optional,
+        getDefault: () => v.getDefault(schema) ?? EMPTY_DEFAULTS[unwrapped.type],
+      };
+    default:
</file context>

function FormDemo({ schema, label }: { schema: FormSchema; label: string }) {
const form = useForm({
schema,
onSubmit: (output) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The demo never actually demonstrates validation or submission. onSubmit in the React wrapper is only fired through the <Form> component's submit handler (frameworks/react/src/components/Form/Form.tsx), but FormDemo renders no <Form>, no <Field> inputs, and no submit button—only a <pre> showing name/errors. As a result console.log('[..] Submitted:', output) never runs and form.errors is always null, despite the UI text telling users to 'Open the console to see submit output.' Consider wrapping the content in <Form of={form} onSubmit={...}> with a real submit button (and ideally <Field> inputs) so the parity demo actually exercises validation/submission through both adapters.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At playgrounds/ir-demo/src/main.tsx, line 29:

<comment>The demo never actually demonstrates validation or submission. `onSubmit` in the React wrapper is only fired through the `<Form>` component's submit handler (frameworks/react/src/components/Form/Form.tsx), but `FormDemo` renders no `<Form>`, no `<Field>` inputs, and no submit button—only a `<pre>` showing `name`/`errors`. As a result `console.log('[..] Submitted:', output)` never runs and `form.errors` is always `null`, despite the UI text telling users to 'Open the console to see submit output.' Consider wrapping the content in `<Form of={form} onSubmit={...}>` with a real submit button (and ideally `<Field>` inputs) so the parity demo actually exercises validation/submission through both adapters.</comment>

<file context>
@@ -0,0 +1,98 @@
+function FormDemo({ schema, label }: { schema: FormSchema; label: string }) {
+  const form = useForm({
+    schema,
+    onSubmit: (output) => {
+      console.log(`[${label}] Submitted:`, output);
+    },
</file context>

transform(unwrapped.entries[key] as v.GenericSchema)
);
}
return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The enum/picklist/literal branch always emits type: 'string', but these schemas can carry non-string values, e.g. v.literal(5), v.picklist([1, 2, 3]), or a boolean literal. The IR then describes a numeric field as 'string' while getDefault() still returns the number (e.g. 5), and the core's emptyInput[ir.type] will seed it with ''. This is acknowledged as a POC simplification, but it's a silent value/type mismatch worth capturing for the open design questions, since the numeric picklist case is common and could be mapped to 'number' without much effort.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/adapters/valibot/src/transform.ts, line 73:

<comment>The `enum`/`picklist`/`literal` branch always emits `type: 'string'`, but these schemas can carry non-string values, e.g. `v.literal(5)`, `v.picklist([1, 2, 3])`, or a boolean literal. The IR then describes a numeric field as `'string'` while `getDefault()` still returns the number (e.g. `5`), and the core's `emptyInput[ir.type]` will seed it with `''`. This is acknowledged as a POC simplification, but it's a silent value/type mismatch worth capturing for the open design questions, since the numeric picklist case is common and could be mapped to `'number'` without much effort.</comment>

<file context>
@@ -0,0 +1,139 @@
+          transform(unwrapped.entries[key] as v.GenericSchema)
+        );
+      }
+      return {
+        type: 'object',
+        optional,
</file context>

Upstream main added a React Native adapter, dirty-state recomputation
(open-circle#193), and concurrent validation race fix (open-circle#190) since the POC branched.
After rebasing, these new files still referenced the old valibot-coupled
types. This commit:
- Updates field.react-native.ts and form.react-native.ts to use IR + Standard Schema types
- Wraps new test schemas with toFormisch() and converts valibot-specific
  SafeParseResult types to StandardParseResult
- Fixes adapter package.json exports to match built .mts/.d.mts output
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
packages/core/src/types/form/form.react-native.ts (1)

14-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use StandardSchemaV1.InferInput and StandardSchemaV1.InferOutput.

Replace the duplicated aliases in form.ts, form.qwik.ts, and form.react-native.ts. This aligns inference with the public Standard Schema type contract and removes duplicate code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/types/form/form.react-native.ts` around lines 14 - 28,
Replace the local InferStandardInput and InferStandardOutput aliases in the form
type modules with StandardSchemaV1.InferInput and StandardSchemaV1.InferOutput,
updating references and imports as needed. Apply the same change consistently in
form.ts, form.qwik.ts, and form.react-native.ts while preserving existing type
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/field/getElementInput/getElementInput.test.ts`:
- Around line 219-220: Update the multiple-file test using createTestStore and
toFormisch so its documents field uses the array schema v.array(v.any()) instead
of v.any(), preserving the expected files-array behavior from getElementInput.

In `@packages/core/src/field/initializeFieldStore/initializeFieldStore.ts`:
- Around line 33-72: Add end-to-end tuple support by extending FormischFieldType
and updating both adapters to emit tuple IR, ensuring tuple schemas initialize
through the array-store path and tuple-specific tests/branches remain valid. In
initializeFieldStore, when reinitializing an existing array store, clear or
replace internalFieldStore.children before rebuilding from initialInput so
initialItems matches the new input length.

In `@SCHEMA_IR_POC.md`:
- Around line 17-26: Align the import-verification claims in SCHEMA_IR_POC.md
with the commands’ actual scope: either narrow the statement about mentions to
non-test source and exact single-quoted imports, or broaden the checks to
include both quote styles, re-exports, and dynamic imports before retaining the
broader claim.
- Around line 77-81: Update the data-flow code fence containing the Valibot,
Zod, FormischFieldIR, and decodeFormData diagram to declare the text language
identifier, resolving the Markdownlint MD040 warning.

---

Nitpick comments:
In `@packages/core/src/types/form/form.react-native.ts`:
- Around line 14-28: Replace the local InferStandardInput and
InferStandardOutput aliases in the form type modules with
StandardSchemaV1.InferInput and StandardSchemaV1.InferOutput, updating
references and imports as needed. Apply the same change consistently in form.ts,
form.qwik.ts, and form.react-native.ts while preserving existing type behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dc6f08d8-0e41-415c-b422-bb0c800ddc18

📥 Commits

Reviewing files that changed from the base of the PR and between 3964c66 and 18d4e18.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (66)
  • SCHEMA_IR_POC.md
  • frameworks/react/src/hooks/useForm/useForm.ts
  • packages/adapters/valibot/package.json
  • packages/adapters/valibot/src/index.ts
  • packages/adapters/valibot/src/toFormisch.ts
  • packages/adapters/valibot/src/transform.ts
  • packages/adapters/valibot/tests/transform.test.ts
  • packages/adapters/valibot/tsconfig.json
  • packages/adapters/valibot/tsdown.config.ts
  • packages/adapters/valibot/vitest.config.ts
  • packages/adapters/zod/package.json
  • packages/adapters/zod/src/index.ts
  • packages/adapters/zod/src/toFormisch.ts
  • packages/adapters/zod/src/transform.ts
  • packages/adapters/zod/tests/transform.test.ts
  • packages/adapters/zod/tsconfig.json
  • packages/adapters/zod/tsdown.config.ts
  • packages/adapters/zod/vitest.config.ts
  • packages/core/package.json
  • packages/core/src/array/copyItemState/copyItemState.test.ts
  • packages/core/src/array/copyItemState/copyItemState.ts
  • packages/core/src/array/resetItemState/resetItemState.test.ts
  • packages/core/src/array/resetItemState/resetItemState.ts
  • packages/core/src/array/swapItemState/swapItemState.test.ts
  • packages/core/src/array/swapItemState/swapItemState.ts
  • packages/core/src/field/focusFieldElement/focusFieldElement.react-native.test.ts
  • packages/core/src/field/focusFieldElement/focusFieldElement.test.ts
  • packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts
  • packages/core/src/field/getElementInput/getElementInput.test.ts
  • packages/core/src/field/getFieldBool/getFieldBool.test.ts
  • packages/core/src/field/getFieldInput/getFieldInput.test.ts
  • packages/core/src/field/getFieldStore/getFieldStore.test.ts
  • packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts
  • packages/core/src/field/initializeFieldStore/initializeFieldStore.ts
  • packages/core/src/field/setFieldBool/setFieldBool.test.ts
  • packages/core/src/field/setFieldInput/setFieldInput.test.ts
  • packages/core/src/field/setFieldInput/setFieldInput.ts
  • packages/core/src/field/setInitialFieldInput/setInitialFieldInput.test.ts
  • packages/core/src/field/setInitialFieldInput/setInitialFieldInput.ts
  • packages/core/src/field/walkFieldStore/walkFieldStore.test.ts
  • packages/core/src/form/createFormStore/createFormStore.test.ts
  • packages/core/src/form/createFormStore/createFormStore.ts
  • packages/core/src/form/decodeFormData/decodeFormData.test.ts
  • packages/core/src/form/decodeFormData/decodeFormData.ts
  • packages/core/src/form/parity.test.ts
  • packages/core/src/form/validateFormInput/validateFormInput.test.ts
  • packages/core/src/form/validateFormInput/validateFormInput.ts
  • packages/core/src/form/validateIfRequired/validateIfRequired.test.ts
  • packages/core/src/types/field/field.react-native.ts
  • packages/core/src/types/field/field.ts
  • packages/core/src/types/form/form.qwik.ts
  • packages/core/src/types/form/form.react-native.ts
  • packages/core/src/types/form/form.react.ts
  • packages/core/src/types/form/form.ts
  • packages/core/src/types/schema/index.ts
  • packages/core/src/types/schema/ir.ts
  • packages/core/src/types/schema/schema.test-d.ts
  • packages/core/src/types/schema/schema.ts
  • packages/core/src/vitest/utils.ts
  • packages/core/vitest.config.ts
  • playgrounds/ir-demo/index.html
  • playgrounds/ir-demo/package.json
  • playgrounds/ir-demo/src/main.tsx
  • playgrounds/ir-demo/tsconfig.json
  • playgrounds/ir-demo/vite.config.ts
  • pnpm-workspace.yaml
🚧 Files skipped from review as they are similar to previous changes (59)
  • packages/core/package.json
  • packages/adapters/zod/tsdown.config.ts
  • pnpm-workspace.yaml
  • packages/core/src/types/schema/index.ts
  • packages/adapters/valibot/vitest.config.ts
  • packages/core/src/array/swapItemState/swapItemState.ts
  • packages/adapters/valibot/tsdown.config.ts
  • packages/core/src/field/getFieldInput/getFieldInput.test.ts
  • frameworks/react/src/hooks/useForm/useForm.ts
  • packages/adapters/valibot/src/toFormisch.ts
  • playgrounds/ir-demo/index.html
  • packages/core/src/array/copyItemState/copyItemState.ts
  • packages/adapters/zod/tests/transform.test.ts
  • packages/adapters/zod/src/index.ts
  • packages/adapters/valibot/src/index.ts
  • packages/core/src/field/walkFieldStore/walkFieldStore.test.ts
  • packages/core/src/array/copyItemState/copyItemState.test.ts
  • playgrounds/ir-demo/tsconfig.json
  • packages/core/src/field/setInitialFieldInput/setInitialFieldInput.test.ts
  • packages/adapters/zod/vitest.config.ts
  • packages/core/src/field/setFieldBool/setFieldBool.test.ts
  • packages/adapters/zod/tsconfig.json
  • packages/adapters/zod/src/toFormisch.ts
  • packages/adapters/zod/package.json
  • packages/core/src/form/createFormStore/createFormStore.ts
  • packages/core/src/field/focusFieldElement/focusFieldElement.test.ts
  • packages/core/src/field/setInitialFieldInput/setInitialFieldInput.ts
  • packages/core/src/array/resetItemState/resetItemState.test.ts
  • packages/adapters/valibot/tsconfig.json
  • packages/core/src/types/field/field.ts
  • packages/core/src/array/swapItemState/swapItemState.test.ts
  • packages/core/src/array/resetItemState/resetItemState.ts
  • packages/adapters/zod/src/transform.ts
  • packages/core/src/types/form/form.react.ts
  • playgrounds/ir-demo/src/main.tsx
  • packages/adapters/valibot/src/transform.ts
  • packages/adapters/valibot/tests/transform.test.ts
  • packages/core/vitest.config.ts
  • packages/core/src/types/schema/schema.ts
  • packages/core/src/form/decodeFormData/decodeFormData.ts
  • packages/core/src/types/schema/ir.ts
  • packages/core/src/form/validateIfRequired/validateIfRequired.test.ts
  • packages/core/src/field/setFieldInput/setFieldInput.test.ts
  • packages/core/src/form/createFormStore/createFormStore.test.ts
  • packages/adapters/valibot/package.json
  • packages/core/src/form/validateFormInput/validateFormInput.ts
  • packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts
  • packages/core/src/field/getFieldStore/getFieldStore.test.ts
  • packages/core/src/types/form/form.ts
  • packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts
  • playgrounds/ir-demo/vite.config.ts
  • packages/core/src/field/setFieldInput/setFieldInput.ts
  • playgrounds/ir-demo/package.json
  • packages/core/src/types/schema/schema.test-d.ts
  • packages/core/src/types/form/form.qwik.ts
  • packages/core/src/form/decodeFormData/decodeFormData.test.ts
  • packages/core/src/vitest/utils.ts
  • packages/core/src/form/parity.test.ts
  • packages/core/src/field/getFieldBool/getFieldBool.test.ts

Comment on lines 219 to +220
test('should return files array for multiple file input', () => {
const store = createTestStore(v.object({ documents: v.array(v.any()) }));
const store = createTestStore(toFormisch(v.object({ documents: v.any() })));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate test ---'
sed -n '1,280p' packages/core/src/field/getElementInput/getElementInput.test.ts

printf '%s\n' '--- related implementation and symbols ---'
fd -t f 'getElementInput' packages/core/src
rg -n -C 8 'getElementInput|files\[0\]|kind.*array|toFormisch|createTestStore' packages/core/src/field packages/core/src/form packages/core/src/types

Repository: open-circle/formisch

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target test ---'
sed -n '190,245p' packages/core/src/field/getElementInput/getElementInput.test.ts

printf '%s\n' '--- implementation files ---'
fd -t f . packages/core/src/field/getElementInput packages/core/src/field/getFieldInput packages/core/src/vitest
for file in $(fd -t f . packages/core/src/field/getElementInput); do
  printf '\n--- %s ---\n' "$file"
  sed -n '1,220p' "$file"
done

printf '%s\n' '--- focused references ---'
rg -n -C 6 'element\.files|files\[0\]|kind === .array.|return .*files' packages/core/src/field/getElementInput packages/core/src/field/getFieldInput packages/core/src

Repository: open-circle/formisch

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

test_path = Path("packages/core/src/field/getElementInput/getElementInput.test.ts")
impl_path = Path("packages/core/src/field/getElementInput/getElementInput.ts")

test = test_path.read_text()
impl = impl_path.read_text()

test_match = re.search(
    r"test\('should return files array for multiple file input', \(\) => \{(.*?)\n    \}\);",
    test,
    re.S,
)
assert test_match, "multiple-file test not found"
case = test_match.group(1)

assert "documents: v.any()" in case
assert "input.multiple = true" in case
assert ").toStrictEqual([file1, file2]);" in case

file_branch = re.search(
    r"if \(element\.type === 'file'\) \{(.*?)\n  \}\n\n  // Return element value",
    impl,
    re.S,
)
assert file_branch, "file-input branch not found"
branch = file_branch.group(1)

assert "if (internalFieldStore.kind === 'array')" in branch
assert "return element.files[0]" in branch

print("multiple-file test uses v.any(): yes")
print("v.any() selects the non-array file branch: yes")
print("non-array branch returns element.files[0]: yes")
print("expected [file1, file2] therefore requires an array schema: yes")
PY

Repository: open-circle/formisch

Length of output: 353


Restore the array schema for the multiple-file test.

Use v.array(v.any()); v.any() makes getElementInput return only element.files[0].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/field/getElementInput/getElementInput.test.ts` around lines
219 - 220, Update the multiple-file test using createTestStore and toFormisch so
its documents field uses the array schema v.array(v.any()) instead of v.any(),
preserving the expected files-array behavior from getElementInput.

Comment on lines +33 to +72
switch (ir.type) {
case 'array': {
if (internalFieldStore.kind && internalFieldStore.kind !== 'array') {
throw new Error(
`Store initialized as "${internalFieldStore.kind}" cannot be reinitialized as "array"`
);
}

// Set kind to array
internalFieldStore.kind = 'array';

// Initialize array-specific properties
if (internalFieldStore.kind === 'array') {
// Initialize children array if not exists
internalFieldStore.children ??= [];

// If schema is dynamic array, initialize children from input
if (schema.type === 'array') {
// If initial input provided, initialize children
if (initialInput) {
// Initialize child for each input item
for (
let index = 0;
// @ts-expect-error
index < initialInput.length;
index++
) {
// Create empty child object
// @ts-expect-error
internalFieldStore.children[index] = {};

// Initialize field store for child
initializeFieldStore(
internalFormStore,
internalFieldStore.children[index],
schema.item as FieldSchema,
// @ts-expect-error
initialInput[index],
[...path, index]
);
}
}

// Otherwise, if schema is fixed tuple, initialize children from schema
} else {
// Initialize child for each tuple item
for (let index = 0; index < schema.items.length; index++) {
// Create empty child object
if (initialInput) {
for (
let index = 0;
// @ts-expect-error
index < initialInput.length;
index++
) {
// @ts-expect-error
internalFieldStore.children[index] = {};

// Initialize field store for child
initializeFieldStore(
internalFormStore,
internalFieldStore.children[index],
schema.items[index] as FieldSchema,
ir.item!,
// @ts-expect-error
initialInput?.[index],
initialInput[index],
[...path, index]
);
}
}

// Set array input (nullish or true)
const arrayInput =
nullish && initialInput == null ? initialInput : true;
const arrayInput = nullish && initialInput == null ? initialInput : true;
internalFieldStore.initialInput = createSignal(arrayInput);
internalFieldStore.startInput = createSignal(arrayInput);
internalFieldStore.input = createSignal(arrayInput);

// Set items with unique IDs for each child
const initialItems = internalFieldStore.children.map(createId);
internalFieldStore.initialItems = createSignal(initialItems);
internalFieldStore.startItems = createSignal(initialItems);
internalFieldStore.items = createSignal(initialItems);
}

// Otherwise, if schema is object, initialize as object field
} else if (
schema.type === 'loose_object' ||
schema.type === 'object' ||
schema.type === 'strict_object'
) {
// If already initialized as different kind, throw error
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- FormischFieldType definition ---'
fd -t f 'ir.ts' packages/core/src/types --exec cat -n {}

echo '--- tuple handling across core ---'
rg -n -C 5 "ir\.type\s*(!==|===)\s*'array'|'tuple'" packages/core/src packages/adapters

Repository: open-circle/formisch

Length of output: 8995


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- initializeFieldStore implementation and callers ---'
cat -n packages/core/src/field/initializeFieldStore/initializeFieldStore.ts
rg -n -C 4 "initializeFieldStore\(" packages/core/src

echo '--- tuple-related store logic ---'
cat -n packages/core/src/field/setFieldInput/setFieldInput.ts
cat -n packages/core/src/field/setInitialFieldInput/setInitialFieldInput.ts
cat -n packages/core/src/array/resetItemState/resetItemState.ts

echo '--- field-store shape and IR construction ---'
rg -n -C 5 "interface InternalFieldStore|type InternalFieldStore|kind:|FormischFieldIR|type: 'array'|type: 'object'" packages/core/src packages/adapters

Repository: open-circle/formisch

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- adapter IR type production ---'
rg -n -C 6 "type:\s*['\"](array|object|tuple)['\"]|tuple|FormischFieldType" packages/adapters packages/core/src --glob '*.ts' --glob '*.tsx'

echo '--- initializeFieldStore exports and test coverage ---'
rg -n -C 4 "initializeFieldStore|setInitialFieldInput|resetItemState" packages/core/src/index.ts packages/core/src packages/*/src --glob '*.test.ts' --glob '*.test.tsx' --glob '*.ts' | head -n 400

echo '--- all direct initializeFieldStore call sites ---'
rg -n "initializeFieldStore\(" packages --glob '*.ts' --glob '*.tsx'

echo '--- standalone state-shape probe ---'
python3 - <<'PY'
def initialize(children, initial_input):
    # Model only the array branch at issue.
    children = list(children)
    if initial_input:
        for index in range(len(initial_input)):
            children[index] = f'new-{index}'
    initial_items = list(children)
    return children, initial_items

for old, new in [(['a', 'b', 'c'], [1]), (['a', 'b', 'c'], [])]:
    children, items = initialize(old, new)
    print(f'old={old!r}, input={new!r} -> children={children!r}, items={items!r}, input_length={len(new)}')
PY

Repository: open-circle/formisch

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- Valibot transform cases ---'
cat -n packages/adapters/valibot/src/transform.ts | sed -n '1,150p'

echo '--- Zod transform cases ---'
cat -n packages/adapters/zod/src/transform.ts | sed -n '1,120p'

echo '--- tuple test expectations and test helper ---'
cat -n packages/core/src/field/initializeFieldStore/initializeFieldStore.test.ts | sed -n '1,40p;285,315p;540,595p'
rg -n -C 8 "function createTestStore|const createTestStore|createTestStore" packages/core/src/vitest packages/core/src/field/initializeFieldStore --glob '*.ts'

echo '--- direct call sites and exported API ---'
rg -n "initializeFieldStore" packages/core/src --glob '*.ts' --glob '!*.test.ts'
rg -n "from .*initializeFieldStore|initializeFieldStore" packages/core/src/index.ts packages/core/src/field/index.ts packages/core/src/field/initializeFieldStore/index.ts

Repository: open-circle/formisch

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

ir = Path("packages/core/src/types/schema/ir.ts").read_text()
enum_values = re.findall(r"\| '([^']+)'", ir.split("export type FormischFieldType =", 1)[1].split(";", 1)[0])
print("FormischFieldType members:", enum_values)
for path in [
    "packages/adapters/valibot/src/transform.ts",
    "packages/adapters/zod/src/transform.ts",
]:
    text = Path(path).read_text()
    produced = sorted(set(re.findall(r"type:\s*'([^']+)'", text)))
    print(f"{path} produces:", produced)
print("tuple in FormischFieldType:", "tuple" in enum_values)
PY

python3 - <<'PY'
def array_state(existing_children, initial_input):
    children = list(existing_children)
    if initial_input:
        for index in range(len(initial_input)):
            children[index] = f"child-{index}"
    initial_items = list(children)
    return len(children), len(initial_items)

for existing, initial in [(["a", "b", "c"], [1]), (["a", "b", "c"], [])]:
    child_count, item_count = array_state(existing, initial)
    print({
        "existing_children": len(existing),
        "initial_input": len(initial),
        "children_after": child_count,
        "initial_items": item_count,
        "coupled": child_count == len(initial),
    })
PY

echo '--- direct non-test call sites ---'
rg -n "initializeFieldStore\(" packages/core/src --glob '*.ts' --glob '!*.test.ts'
echo '--- initializer export ---'
cat -n packages/core/src/field/initializeFieldStore/index.ts

Repository: open-circle/formisch

Length of output: 1817


Support tuples in the IR and adapters.

FormischFieldType has no 'tuple' member, and neither adapter emits tuple IR. Tuple schemas therefore become value stores, while tuple-specific branches and tests expect array stores. Add tuple support or remove the tuple-specific logic and tests.

When same-store array reinitialization is supported, clear stale children before rebuilding. Otherwise, initialItems can exceed the new input length.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/field/initializeFieldStore/initializeFieldStore.ts` around
lines 33 - 72, Add end-to-end tuple support by extending FormischFieldType and
updating both adapters to emit tuple IR, ensuring tuple schemas initialize
through the array-store path and tuple-specific tests/branches remain valid. In
initializeFieldStore, when reinitializing an existing array store, clear or
replace internalFieldStore.children before rebuilding from initialInput so
initialItems matches the new input length.

Comment thread SCHEMA_IR_POC.md
Comment on lines +17 to +26
# Zero valibot imports in core source (excluding tests)
grep -r "from 'valibot'" packages/core/src/ --include="*.ts" | grep -v ".test.ts" | grep -v "vitest/"
# Result: (empty)

# Zero zod imports in core source
grep -r "from 'zod'" packages/core/src/ --include="*.ts" | grep -v ".test.ts" | grep -v "vitest/"
# Result: (empty)
```

The only mentions of "valibot" or "zod" in `packages/core/src/` are in JSDoc comments, not imports.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Limit the import proof to the scope it scans.

The commands exclude test files and match only exact single-quoted static imports. They do not prove the broader claim on Line 26 about all mentions in packages/core/src/. Either narrow the claim to non-test source or expand the search to cover quote styles, re-exports, and dynamic imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SCHEMA_IR_POC.md` around lines 17 - 26, Align the import-verification claims
in SCHEMA_IR_POC.md with the commands’ actual scope: either narrow the statement
about mentions to non-test source and exact single-quoted imports, or broaden
the checks to include both quote styles, re-exports, and dynamic imports before
retaining the broader claim.

Comment thread SCHEMA_IR_POC.md
Comment on lines +77 to +81
```
Valibot schema ──┐ ┌── initializeFieldStore (IR-driven)
├──→ FormischFieldIR ──→ decodeFormData (IR-driven)
Zod schema ──────┘ └── validateFormInput (~standard.validate)
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language to the data-flow code fence.

Markdownlint reports MD040 for the fence starting on Line 77. Use a text language identifier.

Suggested fix
-```
+```text
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
Valibot schema ──┐ ┌── initializeFieldStore (IR-driven)
├──→ FormischFieldIR ──→ decodeFormData (IR-driven)
Zod schema ──────┘ └── validateFormInput (~standard.validate)
```
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 77-77: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SCHEMA_IR_POC.md` around lines 77 - 81, Update the data-flow code fence
containing the Valibot, Zod, FormischFieldIR, and decodeFormData diagram to
declare the text language identifier, resolving the Markdownlint MD040 warning.

Source: Linters/SAST tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant