(value: unknown, depth = 0)
| 47 | |
| 48 | /** Infer the shape of one observed value. Reads structure only, never values. */ |
| 49 | export const inferShape = (value: unknown, depth = 0): InferredShape => { |
| 50 | if (value === null || value === undefined) return { type: "null" }; |
| 51 | if (typeof value === "boolean") return { type: "boolean" }; |
| 52 | if (typeof value === "number") return { type: "number" }; |
| 53 | if (typeof value === "string") return { type: "string" }; |
| 54 | if (depth >= MAX_DEPTH) return UNKNOWN; |
| 55 | |
| 56 | if (Array.isArray(value)) { |
| 57 | if (value.length === 0) return { type: "array" }; |
| 58 | const sampled = value |
| 59 | .slice(0, MAX_ARRAY_SAMPLE) |
| 60 | .map((item) => inferShape(item, depth + 1)) |
| 61 | .reduce((left, right) => mergeShapes(left, right)); |
| 62 | return { type: "array", items: sampled }; |
| 63 | } |
| 64 | |
| 65 | if (typeof value === "object") { |
| 66 | const entries = Object.entries(value as Record<string, unknown>); |
| 67 | if (entries.length > MAX_OBJECT_KEYS) { |
| 68 | const merged = entries |
| 69 | .slice(0, MAX_ARRAY_SAMPLE) |
| 70 | .map(([, item]) => inferShape(item, depth + 1)) |
| 71 | .reduce((left, right) => mergeShapes(left, right)); |
| 72 | return { type: "object", additionalProperties: merged }; |
| 73 | } |
| 74 | const properties: Record<string, InferredShape> = {}; |
| 75 | for (const [key, item] of entries) { |
| 76 | properties[key] = inferShape(item, depth + 1); |
| 77 | } |
| 78 | return { type: "object", properties, required: entries.map(([key]) => key).sort() }; |
| 79 | } |
| 80 | |
| 81 | // function / symbol / bigint — nothing useful to say structurally. |
| 82 | return UNKNOWN; |
| 83 | }; |
| 84 | |
| 85 | const mergeObjectShapes = (left: InferredShape, right: InferredShape): InferredShape => { |
| 86 | // A map-shaped observation absorbs struct-shaped ones: once keys look like |
no test coverage detected