Port an existing form (a data-graph graph, or a bespoke RHF+zod form like model training) to the form-graph library. Use when asked to move a form's field logic, branching, persistence, or server validation onto form-graph. Encodes the method proven by the generation-form port — oracle-first differential testing, scope mapping, staged cutover.
68
81%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
The method that took the generation form (~45 family graphs, 4 output types, 7
standalone workflows, ~12k differential cases) onto form-graph, distilled so the next
port (e.g. model training) doesn't rediscover it. The worked example is
src/shared/form-graph/generation/ + docs/form-graph-port-plan.md; read the plan
doc's phase structure before starting anything sizable.
C:\work\form-graph\CLAUDE.md carries the library's design invariants (one branch
combinator, sync resolution, wire-named computedKeys, the prepack-after-every-edit rule
for link: consumers). Don't design against an imagined API.
Nothing else starts until parity is measurable.
graph.safeParse. For a bespoke form (training: src/components/Training/Wizard +
src/server/schema/training.schema.ts + the orchestrator validation) it's the submit
payload builder — capture real input→payload fixtures if there's no parse function.assertDifferential pattern: port parse vs
oracle parse over generated cases, plus the parse-fixpoint pin (re-parse the port's
own state → identical data; this is what makes whatIf/cost preview trustworthy)..field('ecosystem', def) then .use(branch('ecosystem', [[keys, member], ...] as const)).
Group related keys into one pair — arm count should equal family count, not key count.{ emit: false } fed
to the tagged branch(key, pick, members, { emit: false }) form.shared.ts (familyScope, text-block factories,
modelIdOf-style raw-or-parsed readers — store state holds RAW inputs, so anything
reading ctx must accept both shapes).Translate the legacy storage-adapter groups (see the v1 createLocalStorageAdapter
config in GenerationFormProvider.tsx for the pattern) into graph/field scope
declarations: graph-level scope for family buckets, rootScope() to opt a field out
to global memory, rootScope(workflow) for per-workflow buckets, relative [modelId]
appends for per-variant refinements. One persisted record per form
(persistedStorage('<key>')).
InferData / InferArm / InferLooseData from the graph type the handlers
(EcosystemData<'X'> pattern in src/shared/form-graph/generation/types.ts). Zero
as never; a residual cast marks a provably-dead path and says so. After type-level
work, measure compiler cost against main (tsc --extendedDiagnostics, delete
tsconfig.tsbuildinfo, NODE_OPTIONS=--max_old_space_size=12288 — default heap OOMs).
Consumer-side module (migrate-v1-storage.ts is the template): read the old records,
pick ONLY the fields worth carrying, build one address→raw-value record with
scopedAddress, write it once iff the new key is absent. Values go in raw — the input
schemas validate on first resolve, so stale garbage degrades to defaults. Never delete
the old records while anything still reads them.
One feature flag (availability: ['mod'] first, widened via its Flipt key) gates the
whole cutover per user: it swaps the form component on the client AND serves the
port's parse on the server (read from the ctx the feature-flag system already
threads — coerce it, a sparse record reads undefined). Every server parse runs BOTH
engines regardless and records the comparison — outcomes counted
(registerCounterWithLabels), divergence logged with diff keys only — never field
values (user content must not reach logs; pin that with a test, one sentinel per
emit path). Comparison noise is fine: it dies with the old engine.
Flag off must be byte-identical. The generation port briefly used three flags (separate shadow/serve Flipt switches) and collapsed them once the parity battery made independent server/client rollback unnecessary — start with one. Deleting the old engine is a separate change after the flag is fully widened.
Until the old graph is deleted, EVERY merge from main needs:
git diff HEAD...origin/main --stat -- src/shared/data-graph — then mirror each
change into the port AND add a differential shape covering the changed path.
The suites only catch drift where shapes exercise it: krea2's
community-checkpoint fix (2026-09-03) passed parity under BOTH the old and new
fallback because no shape used an unknown model id. A mirrored change without
a new shape is unverified.
Cross-field coherence (a selection retargeting another selection) belongs in a
RULE on the graph (.effect({...}) — gesture-aware, fires before resolution,
covers every writer), NOT in transcribed v1 UI handlers. v1 kept it in handlers
because data-graph had no rules; transcribing that architecture reintroduced a
crash the graph could have prevented (see selector-coherence.ts).
The port parse composes as hub.parse(reconcileSelectors(raw).raw, ext) — selector
reconciliation is part of the parse contract, not optional plumbing.
A generic helper over union arms hits TS's weak-type rule when one arm shares no
properties — constrain T extends object and read loosely inside.
useForm must be typed to preserve the store's full type (Store extends FormStore<any, Ext, any, any>), or per-arm emits break DataOf≡Ctx at mounts.
After every form-graph lib edit: pnpm run prepack in the lib, or the link:
consumer runs stale dist.
Pre-PR: publish the lib as ONE version, swap link: → ^x.y.z, remove any
turbopack.root widening / tsconfig react paths pin added for the link, re-run the
full battery.
b4a68ff
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.