diff --git a/README.md b/README.md index ea479e28..a61e0a0a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,13 @@ could then read the DSN file back to Circuit JSON. This module has the zod definitions and conversion functions for using circuit json, and is the primary way that Circuit JSON is defined and maintained. +> **Zod v4 migration:** `circuit-json` schemas now require `zod@^4`. Because +> Zod v3 and v4 schemas cannot be composed, downstream packages that pass these +> schemas between package boundaries must upgrade together. Consumers that inspect +> raw Zod errors should migrate to Zod v4's error APIs, and should account for Zod +> v4 rejecting non-finite numbers and unsafe integers where earlier versions +> accepted them. + https://github.com/user-attachments/assets/2f28b7ba-689e-4d80-85b2-5bdef84b41f8 > To quickly generate Circuit JSON with tscircuit, use [tscircuit/eval](https://github.com/tscircuit/eval) diff --git a/bun.lock b/bun.lock index 41317702..08e37c52 100644 --- a/bun.lock +++ b/bun.lock @@ -19,10 +19,11 @@ "ts-node": "^10.9.2", "tsup": "^8.3.0", "typescript": "^5.7.2", - "zod": "3", + "zod": "^4.0.0", }, "peerDependencies": { "format-si-unit": "*", + "zod": "^4.0.0", }, }, }, @@ -583,7 +584,7 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.103", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hHTHp+sEz6SxFsp+SA+Tqrua3AbmlAw+Y//aEwdHrdZkYVRWdvWD3y5uPZ0flYOkgskaFWqZ/YGFm3FaFQ0pRw=="], diff --git a/package.json b/package.json index 9f6b11ff..c6688fc4 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,11 @@ "ts-node": "^10.9.2", "tsup": "^8.3.0", "typescript": "^5.7.2", - "zod": "3" + "zod": "^4.0.0" }, "peerDependencies": { - "format-si-unit": "*" + "format-si-unit": "*", + "zod": "^4.0.0" }, "description": "Definitions for the tscircuit intermediary JSON format", "files": [ diff --git a/scripts/check-snake-case.ts b/scripts/check-snake-case.ts index 9c2d9111..63a7a756 100644 --- a/scripts/check-snake-case.ts +++ b/scripts/check-snake-case.ts @@ -1,7 +1,7 @@ -import type { z } from "zod" +import { z } from "zod" import { any_circuit_element } from "../src/any_circuit_element" -const seen = new Set() +const seen = new Set() const errors: string[] = [] const SNAKE_CASE_IGNORED_PATH_FRAGMENTS = [ @@ -24,52 +24,48 @@ function checkSnakeCase(name: string, path: string) { } } -function traverse(schema: z.ZodTypeAny, path: string) { +function traverse(schema: z.core.$ZodType, path: string) { if (seen.has(schema)) return seen.add(schema) - const def = schema._def - const typeName = def.typeName - - if (typeName === "ZodObject") { - const shape = def.shape() - for (const key in shape) { + if (schema instanceof z.ZodObject) { + for (const key in schema.shape) { checkSnakeCase(key, `${path}.${key}`) - traverse(shape[key], `${path}.${key}`) + traverse(schema.shape[key], `${path}.${key}`) } - } else if (typeName === "ZodUnion" || typeName === "ZodDiscriminatedUnion") { - const options = - def.options || (def.optionsMap ? Array.from(def.optionsMap.values()) : []) - options.forEach((opt: any, i: number) => { - traverse(opt, `${path}[union:${i}]`) + } else if ( + schema instanceof z.ZodUnion || + schema instanceof z.ZodDiscriminatedUnion + ) { + schema.options.forEach((option, index) => { + traverse(option, `${path}[union:${index}]`) }) - } else if (typeName === "ZodArray") { - traverse(def.type, `${path}[]`) + } else if (schema instanceof z.ZodArray) { + traverse(schema.element, `${path}[]`) } else if ( - typeName === "ZodOptional" || - typeName === "ZodNullable" || - typeName === "ZodBranded" + schema instanceof z.ZodOptional || + schema instanceof z.ZodNullable || + schema instanceof z.ZodDefault || + schema instanceof z.ZodPrefault ) { - traverse(def.innerType, path) - } else if (typeName === "ZodEnum") { - for (const v of def.values as string[]) { - checkSnakeCase(v, `${path} (enum value)`) + traverse(schema.unwrap(), path) + } else if (schema instanceof z.ZodEnum) { + for (const value of schema.options) { + if (typeof value === "string") { + checkSnakeCase(value, `${path} (enum value)`) + } } - } else if (typeName === "ZodRecord") { - traverse(def.keyType, `${path}[key]`) - traverse(def.valueType, `${path}[value]`) - } else if (typeName === "ZodLazy") { - traverse(def.getter(), path) - } else if (typeName === "ZodEffects") { - traverse(def.schema, path) - } else if (typeName === "ZodPipeline") { - traverse(def.in, path) - traverse(def.out, path) - } else if (typeName === "ZodIntersection") { - traverse(def.left, path) - traverse(def.right, path) - } else if (typeName === "ZodDefault") { - traverse(def.innerType, path) + } else if (schema instanceof z.ZodRecord) { + traverse(schema.keyType, `${path}[key]`) + traverse(schema.valueType, `${path}[value]`) + } else if (schema instanceof z.ZodLazy) { + traverse(schema.unwrap(), path) + } else if (schema instanceof z.ZodPipe) { + traverse(schema.in, path) + traverse(schema.out, path) + } else if (schema instanceof z.ZodIntersection) { + traverse(schema.def.left, path) + traverse(schema.def.right, path) } } diff --git a/src/pcb/pcb_copper_text.ts b/src/pcb/pcb_copper_text.ts index 2cbfa5c5..50c5baeb 100644 --- a/src/pcb/pcb_copper_text.ts +++ b/src/pcb/pcb_copper_text.ts @@ -15,10 +15,10 @@ export const pcb_copper_text = z pcb_group_id: z.string().optional(), subcircuit_id: z.string().optional(), font: z.literal("tscircuit2024").default("tscircuit2024"), - font_size: distance.default("0.2mm"), + font_size: distance.prefault("0.2mm"), pcb_component_id: z.string(), text: z.string(), - is_knockout: z.boolean().default(false).optional(), + is_knockout: z.boolean().optional(), knockout_padding: z .object({ left: length, @@ -26,16 +26,10 @@ export const pcb_copper_text = z bottom: length, right: length, }) - .default({ - left: "0.2mm", - top: "0.2mm", - bottom: "0.2mm", - right: "0.2mm", - }) .optional(), ccw_rotation: z.number().optional(), layer: layer_ref, - is_mirrored: z.boolean().default(false).optional(), + is_mirrored: z.boolean().optional(), anchor_position: point.default({ x: 0, y: 0 }), anchor_alignment: ninePointAnchor.default("center"), }) diff --git a/src/pcb/pcb_fabrication_note_dimension.ts b/src/pcb/pcb_fabrication_note_dimension.ts index 78f9899e..e4bf30f1 100644 --- a/src/pcb/pcb_fabrication_note_dimension.ts +++ b/src/pcb/pcb_fabrication_note_dimension.ts @@ -27,9 +27,9 @@ export const pcb_fabrication_note_dimension = z }) .optional(), font: z.literal("tscircuit2024").default("tscircuit2024"), - font_size: length.default("1mm"), + font_size: length.prefault("1mm"), color: z.string().optional(), - arrow_size: length.default("1mm"), + arrow_size: length.prefault("1mm"), }) .describe("Defines a measurement annotation within PCB fabrication notes") diff --git a/src/pcb/pcb_fabrication_note_rect.ts b/src/pcb/pcb_fabrication_note_rect.ts index 6b00e691..8341b44d 100644 --- a/src/pcb/pcb_fabrication_note_rect.ts +++ b/src/pcb/pcb_fabrication_note_rect.ts @@ -17,7 +17,7 @@ export const pcb_fabrication_note_rect = z width: length, height: length, layer: visible_layer, - stroke_width: length.default("0.1mm"), + stroke_width: length.prefault("0.1mm"), corner_radius: length.optional(), is_filled: z.boolean().optional(), has_stroke: z.boolean().optional(), diff --git a/src/pcb/pcb_fabrication_note_text.ts b/src/pcb/pcb_fabrication_note_text.ts index b0a33040..009af443 100644 --- a/src/pcb/pcb_fabrication_note_text.ts +++ b/src/pcb/pcb_fabrication_note_text.ts @@ -14,7 +14,7 @@ export const pcb_fabrication_note_text = z subcircuit_id: z.string().optional(), pcb_group_id: z.string().optional(), font: z.literal("tscircuit2024").default("tscircuit2024"), - font_size: distance.default("1mm"), + font_size: distance.prefault("1mm"), pcb_component_id: z.string(), text: z.string(), ccw_rotation: z.number().optional(), diff --git a/src/pcb/pcb_note_dimension.ts b/src/pcb/pcb_note_dimension.ts index af9b5512..42358418 100644 --- a/src/pcb/pcb_note_dimension.ts +++ b/src/pcb/pcb_note_dimension.ts @@ -24,10 +24,10 @@ export const pcb_note_dimension = z }) .optional(), font: z.literal("tscircuit2024").default("tscircuit2024"), - font_size: length.default("1mm"), + font_size: length.prefault("1mm"), layer: visible_layer.default("top"), color: z.string().optional(), - arrow_size: length.default("1mm"), + arrow_size: length.prefault("1mm"), }) .describe("Defines a measurement annotation within PCB documentation notes") diff --git a/src/pcb/pcb_note_line.ts b/src/pcb/pcb_note_line.ts index f5c6b6c3..7828f646 100644 --- a/src/pcb/pcb_note_line.ts +++ b/src/pcb/pcb_note_line.ts @@ -18,7 +18,7 @@ export const pcb_note_line = z x2: distance, y2: distance, layer: visible_layer.default("top"), - stroke_width: distance.default("0.1mm"), + stroke_width: distance.prefault("0.1mm"), color: z.string().optional(), is_dashed: z.boolean().optional(), }) diff --git a/src/pcb/pcb_note_path.ts b/src/pcb/pcb_note_path.ts index f4da94c8..4c78235c 100644 --- a/src/pcb/pcb_note_path.ts +++ b/src/pcb/pcb_note_path.ts @@ -15,7 +15,7 @@ export const pcb_note_path = z text: z.string().optional(), route: z.array(point), layer: visible_layer.default("top"), - stroke_width: length.default("0.1mm"), + stroke_width: length.prefault("0.1mm"), color: z.string().optional(), }) .describe("Defines a polyline documentation note on the PCB") diff --git a/src/pcb/pcb_note_rect.ts b/src/pcb/pcb_note_rect.ts index 45c7e436..6219543f 100644 --- a/src/pcb/pcb_note_rect.ts +++ b/src/pcb/pcb_note_rect.ts @@ -17,7 +17,7 @@ export const pcb_note_rect = z width: length, height: length, layer: visible_layer.default("top"), - stroke_width: length.default("0.1mm"), + stroke_width: length.prefault("0.1mm"), corner_radius: length.optional(), is_filled: z.boolean().optional(), has_stroke: z.boolean().optional(), diff --git a/src/pcb/pcb_note_text.ts b/src/pcb/pcb_note_text.ts index 49410759..11a4c187 100644 --- a/src/pcb/pcb_note_text.ts +++ b/src/pcb/pcb_note_text.ts @@ -13,7 +13,7 @@ export const pcb_note_text = z subcircuit_id: z.string().optional(), name: z.string().optional(), font: z.literal("tscircuit2024").default("tscircuit2024"), - font_size: distance.default("1mm"), + font_size: distance.prefault("1mm"), text: z.string().optional(), anchor_position: point.default({ x: 0, y: 0 }), anchor_alignment: z diff --git a/src/pcb/pcb_silkscreen_circle.ts b/src/pcb/pcb_silkscreen_circle.ts index 4a6ea8de..e55f1a35 100644 --- a/src/pcb/pcb_silkscreen_circle.ts +++ b/src/pcb/pcb_silkscreen_circle.ts @@ -21,7 +21,7 @@ export const pcb_silkscreen_circle = z center: point, radius: length, layer: visible_layer, - stroke_width: length.default("1mm"), + stroke_width: length.prefault("1mm"), is_filled: z.boolean().optional(), }) .describe("Defines a silkscreen circle on the PCB") diff --git a/src/pcb/pcb_silkscreen_line.ts b/src/pcb/pcb_silkscreen_line.ts index a2f16dbc..48c53871 100644 --- a/src/pcb/pcb_silkscreen_line.ts +++ b/src/pcb/pcb_silkscreen_line.ts @@ -16,7 +16,7 @@ export const pcb_silkscreen_line = z pcb_component_id: z.string(), pcb_group_id: z.string().optional(), subcircuit_id: z.string().optional(), - stroke_width: distance.default("0.1mm"), + stroke_width: distance.prefault("0.1mm"), x1: distance, y1: distance, x2: distance, diff --git a/src/pcb/pcb_silkscreen_rect.ts b/src/pcb/pcb_silkscreen_rect.ts index 59bf890d..a365ac69 100644 --- a/src/pcb/pcb_silkscreen_rect.ts +++ b/src/pcb/pcb_silkscreen_rect.ts @@ -15,9 +15,9 @@ export const pcb_silkscreen_rect = z width: length, height: length, layer: layer_ref, - stroke_width: length.default("1mm"), + stroke_width: length.prefault("1mm"), corner_radius: length.optional(), - is_filled: z.boolean().default(true).optional(), + is_filled: z.boolean().optional(), has_stroke: z.boolean().optional(), is_stroke_dashed: z.boolean().optional(), ccw_rotation: z.number().optional(), diff --git a/src/pcb/pcb_silkscreen_text.ts b/src/pcb/pcb_silkscreen_text.ts index fcdd527d..a35dbd03 100644 --- a/src/pcb/pcb_silkscreen_text.ts +++ b/src/pcb/pcb_silkscreen_text.ts @@ -15,10 +15,10 @@ export const pcb_silkscreen_text = z pcb_group_id: z.string().optional(), subcircuit_id: z.string().optional(), font: z.literal("tscircuit2024").default("tscircuit2024"), - font_size: distance.default("0.2mm"), + font_size: distance.prefault("0.2mm"), pcb_component_id: z.string(), text: z.string(), - is_knockout: z.boolean().default(false).optional(), + is_knockout: z.boolean().optional(), knockout_padding: z .object({ left: length, @@ -26,16 +26,10 @@ export const pcb_silkscreen_text = z bottom: length, right: length, }) - .default({ - left: "0.2mm", - top: "0.2mm", - bottom: "0.2mm", - right: "0.2mm", - }) .optional(), ccw_rotation: z.number().optional(), layer: layer_ref, - is_mirrored: z.boolean().default(false).optional(), + is_mirrored: z.boolean().optional(), anchor_position: point.default({ x: 0, y: 0 }), anchor_alignment: ninePointAnchor.default("center"), }) diff --git a/src/pcb/pcb_trace.ts b/src/pcb/pcb_trace.ts index 3a667da5..cd03b42c 100644 --- a/src/pcb/pcb_trace.ts +++ b/src/pcb/pcb_trace.ts @@ -54,10 +54,7 @@ export const pcb_trace = z pcb_trace_id: getZodPrefixedIdWithDefault("pcb_trace"), pcb_group_id: z.string().optional(), subcircuit_id: z.string().optional(), - route_thickness_mode: z - .enum(["constant", "interpolated"]) - .default("constant") - .optional(), + route_thickness_mode: z.enum(["constant", "interpolated"]).optional(), route_order_index: z.number().optional(), should_round_corners: z.boolean().optional(), trace_length: z.number().optional(), diff --git a/src/pcb/pcb_via.ts b/src/pcb/pcb_via.ts index ddff216e..8f9cb214 100644 --- a/src/pcb/pcb_via.ts +++ b/src/pcb/pcb_via.ts @@ -13,8 +13,8 @@ export const pcb_via = z subcircuit_connectivity_map_key: z.string().optional(), x: distance, y: distance, - outer_diameter: distance.default("0.6mm"), - hole_diameter: distance.default("0.25mm"), + outer_diameter: distance.prefault("0.6mm"), + hole_diameter: distance.prefault("0.25mm"), /** @deprecated */ from_layer: layer_ref.optional(), /** @deprecated */ diff --git a/src/schematic/schematic_component.ts b/src/schematic/schematic_component.ts index 55263e8a..c30df064 100644 --- a/src/schematic/schematic_component.ts +++ b/src/schematic/schematic_component.ts @@ -5,6 +5,7 @@ import { length } from "../units" import { expectTypesMatch } from "src/utils/expect-types-match" export const schematic_pin_styles = z.record( + z.string(), z.object({ left_margin: length.optional(), right_margin: length.optional(), @@ -130,7 +131,7 @@ export const schematic_component = z.object({ box_width: length.optional(), symbol_name: z.string().optional(), port_arrangement: port_arrangement.optional(), - port_labels: z.record(z.string()).optional(), + port_labels: z.record(z.string(), z.string()).optional(), symbol_display_value: z.string().optional(), subcircuit_id: z.string().optional(), schematic_group_id: z.string().optional(), diff --git a/src/source/base/source_component_base.ts b/src/source/base/source_component_base.ts index 5867445d..11ac2ef6 100644 --- a/src/source/base/source_component_base.ts +++ b/src/source/base/source_component_base.ts @@ -27,7 +27,7 @@ export const source_component_base = z.object({ name: z.string(), manufacturer_part_number: z.string().optional(), supplier_part_numbers: z - .record(supplier_name, z.array(z.string())) + .partialRecord(supplier_name, z.array(z.string())) .optional(), display_value: z.string().optional(), display_name: z.string().optional(), diff --git a/tests/pcb-zod-v4-optional-defaults.test.ts b/tests/pcb-zod-v4-optional-defaults.test.ts new file mode 100644 index 00000000..de24c8ec --- /dev/null +++ b/tests/pcb-zod-v4-optional-defaults.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test" +import { pcb_silkscreen_rect } from "../src/pcb/pcb_silkscreen_rect" +import { pcb_silkscreen_text } from "../src/pcb/pcb_silkscreen_text" +import { pcb_trace } from "../src/pcb/pcb_trace" + +test("pcb_silkscreen_text leaves optional defaults absent", () => { + const text = pcb_silkscreen_text.parse({ + type: "pcb_silkscreen_text", + pcb_component_id: "pcb_component_1", + text: "R1", + layer: "top", + }) + + expect(text).not.toHaveProperty("is_knockout") + expect(text).not.toHaveProperty("knockout_padding") + expect(text).not.toHaveProperty("is_mirrored") +}) + +test("pcb_silkscreen_rect leaves is_filled absent", () => { + const rect = pcb_silkscreen_rect.parse({ + type: "pcb_silkscreen_rect", + pcb_component_id: "pcb_component_1", + center: { x: 0, y: 0 }, + width: 2, + height: 1, + layer: "top", + }) + + expect(rect).not.toHaveProperty("is_filled") +}) + +test("pcb_trace leaves route_thickness_mode absent", () => { + const trace = pcb_trace.parse({ + type: "pcb_trace", + route: [], + }) + + expect(trace).not.toHaveProperty("route_thickness_mode") +}) diff --git a/tests/pcb_via.test.ts b/tests/pcb_via.test.ts index 434b36e3..4251cdbc 100644 --- a/tests/pcb_via.test.ts +++ b/tests/pcb_via.test.ts @@ -14,6 +14,18 @@ test("pcb_via allows subcircuit_connectivity_map_key", () => { expect(via.subcircuit_connectivity_map_key).toBe("foo") }) +test("pcb_via converts default dimensions to numbers", () => { + const via = pcb_via.parse({ + type: "pcb_via", + x: 1, + y: 2, + layers: ["top", "bottom"], + }) + + expect(via.outer_diameter).toBeCloseTo(0.6) + expect(via.hole_diameter).toBeCloseTo(0.25) +}) + test("any_circuit_element includes pcb_via with subcircuit_connectivity_map_key", () => { const via = any_circuit_element.parse({ type: "pcb_via", diff --git a/tests/schematic_component.test.ts b/tests/schematic_component.test.ts index c7531b03..7dac81b8 100644 --- a/tests/schematic_component.test.ts +++ b/tests/schematic_component.test.ts @@ -31,3 +31,18 @@ test("schematic_component allows schematic_symbol_id", () => { expect(component.schematic_symbol_id).toBe("schematic_symbol_1") }) + +test("schematic_component accepts dynamic pin styles and port labels", () => { + const component = schematic_component.parse({ + ...baseComponent, + pin_styles: { + pin1: { left_margin: 1 }, + }, + port_labels: { + pin1: "VCC", + }, + }) + + expect(component.pin_styles?.pin1?.left_margin).toBe(1) + expect(component.port_labels).toEqual({ pin1: "VCC" }) +}) diff --git a/tests/source-component-base.test.ts b/tests/source-component-base.test.ts new file mode 100644 index 00000000..0dfa7337 --- /dev/null +++ b/tests/source-component-base.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test" +import { source_component_base } from "../src/source/base/source_component_base" + +test("source_component_base allows partial supplier part numbers", () => { + const component = source_component_base.parse({ + type: "source_component", + source_component_id: "source_component_1", + name: "Resistor", + supplier_part_numbers: { + jlcpcb: ["C12345"], + }, + }) + + expect(component.supplier_part_numbers).toEqual({ + jlcpcb: ["C12345"], + }) +})