@pithy-sh/core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/audit/composed.d.ts +18 -0
  2. package/dist/audit/composed.d.ts.map +1 -0
  3. package/dist/audit/composed.js +28 -0
  4. package/dist/audit/recorder.d.ts +10 -2
  5. package/dist/audit/recorder.d.ts.map +1 -1
  6. package/dist/capability/capability.d.ts +23 -0
  7. package/dist/capability/capability.d.ts.map +1 -1
  8. package/dist/capability/capability.js +2 -0
  9. package/dist/capability/purge.d.ts +122 -0
  10. package/dist/capability/purge.d.ts.map +1 -0
  11. package/dist/capability/purge.js +131 -0
  12. package/dist/cli/commandCatalog.generated.d.ts +54 -2
  13. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  14. package/dist/cli/commandCatalog.generated.js +61 -0
  15. package/dist/cli/commandFacts.d.ts +1 -0
  16. package/dist/cli/commandFacts.d.ts.map +1 -1
  17. package/dist/cli/commandFacts.js +1 -0
  18. package/dist/controlPlane/capability.d.ts.map +1 -1
  19. package/dist/controlPlane/capability.js +7 -0
  20. package/dist/controlPlane/data/connection.d.ts +2 -12
  21. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  22. package/dist/controlPlane/data/connection.js +1 -1
  23. package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
  24. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  25. package/dist/controlPlane/discovery/adminRoute.js +3 -0
  26. package/dist/controlPlane/error/errors.d.ts +4 -3
  27. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  28. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  29. package/dist/controlPlane/http/guard.js +40 -1
  30. package/dist/controlPlane/http/handlers.d.ts +79 -0
  31. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  32. package/dist/controlPlane/http/handlers.js +37 -3
  33. package/dist/controlPlane/http/responses.d.ts +98 -0
  34. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  35. package/dist/controlPlane/http/responses.js +41 -1
  36. package/dist/controlPlane/http/routes.d.ts +1 -0
  37. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  38. package/dist/controlPlane/http/routes.js +17 -3
  39. package/dist/controlPlane/scope/scope.d.ts +13 -3
  40. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  41. package/dist/controlPlane/scope/scope.js +14 -4
  42. package/dist/createBackend.d.ts.map +1 -1
  43. package/dist/createBackend.js +2 -0
  44. package/dist/createEntrypoint.d.ts +7 -1
  45. package/dist/createEntrypoint.d.ts.map +1 -1
  46. package/dist/createEntrypoint.js +15 -5
  47. package/dist/crypto/envelope.d.ts +86 -0
  48. package/dist/crypto/envelope.d.ts.map +1 -0
  49. package/dist/crypto/envelope.js +129 -0
  50. package/dist/crypto/fieldKeys.d.ts +41 -0
  51. package/dist/crypto/fieldKeys.d.ts.map +1 -0
  52. package/dist/crypto/fieldKeys.js +56 -0
  53. package/dist/data/codecs.d.ts +23 -10
  54. package/dist/data/codecs.d.ts.map +1 -1
  55. package/dist/data/codecs.js +50 -36
  56. package/dist/error/extend.d.ts +6 -5
  57. package/dist/error/extend.d.ts.map +1 -1
  58. package/dist/error/http.d.ts +72 -0
  59. package/dist/error/http.d.ts.map +1 -1
  60. package/dist/error/payload.d.ts +144 -0
  61. package/dist/error/payload.d.ts.map +1 -1
  62. package/dist/error/payload.js +48 -0
  63. package/dist/error/pithyError.d.ts +18 -7
  64. package/dist/error/pithyError.d.ts.map +1 -1
  65. package/dist/error/pithyError.js +6 -0
  66. package/dist/http/validation.d.ts +11 -2
  67. package/dist/http/validation.d.ts.map +1 -1
  68. package/dist/http/validation.js +20 -3
  69. package/dist/index.d.ts +1 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +2 -2
  72. package/dist/logger/logger.d.ts +12 -5
  73. package/dist/logger/logger.d.ts.map +1 -1
  74. package/dist/logger/record.d.ts +42 -0
  75. package/dist/logger/record.d.ts.map +1 -1
  76. package/dist/privacy/blindIndex.d.ts +27 -0
  77. package/dist/privacy/blindIndex.d.ts.map +1 -0
  78. package/dist/privacy/blindIndex.js +57 -0
  79. package/dist/privacy/classes.d.ts +142 -0
  80. package/dist/privacy/classes.d.ts.map +1 -0
  81. package/dist/privacy/classes.js +209 -0
  82. package/dist/privacy/classify.d.ts +336 -0
  83. package/dist/privacy/classify.d.ts.map +1 -0
  84. package/dist/privacy/classify.js +1024 -0
  85. package/dist/privacy/encrypted.d.ts +210 -0
  86. package/dist/privacy/encrypted.d.ts.map +1 -0
  87. package/dist/privacy/encrypted.js +350 -0
  88. package/dist/privacy/encryptedColumns.d.ts +64 -0
  89. package/dist/privacy/encryptedColumns.d.ts.map +1 -0
  90. package/dist/privacy/encryptedColumns.js +134 -0
  91. package/dist/privacy/fieldRotation.d.ts +34 -0
  92. package/dist/privacy/fieldRotation.d.ts.map +1 -0
  93. package/dist/privacy/fieldRotation.js +186 -0
  94. package/dist/privacy/inventory.d.ts +12 -0
  95. package/dist/privacy/inventory.d.ts.map +1 -0
  96. package/dist/privacy/inventory.js +154 -0
  97. package/dist/privacy/posture.d.ts +114 -0
  98. package/dist/privacy/posture.d.ts.map +1 -0
  99. package/dist/privacy/posture.js +136 -0
  100. package/dist/privacy/redact.d.ts +7 -0
  101. package/dist/privacy/redact.d.ts.map +1 -0
  102. package/dist/privacy/redact.js +404 -0
  103. package/dist/privacy/report.d.ts +58 -0
  104. package/dist/privacy/report.d.ts.map +1 -0
  105. package/dist/privacy/report.js +15 -0
  106. package/dist/privacy/retirement.d.ts +68 -0
  107. package/dist/privacy/retirement.d.ts.map +1 -0
  108. package/dist/privacy/retirement.js +72 -0
  109. package/dist/privacy/sealed.d.ts +87 -0
  110. package/dist/privacy/sealed.d.ts.map +1 -0
  111. package/dist/privacy/sealed.js +221 -0
  112. package/dist/privacy/types.d.ts +559 -0
  113. package/dist/privacy/types.d.ts.map +1 -0
  114. package/dist/privacy/types.js +2 -0
  115. package/dist/version.generated.d.ts +1 -1
  116. package/dist/version.generated.js +1 -1
  117. package/package.json +1 -1
  118. package/src/audit/composed.ts +30 -0
  119. package/src/audit/recorder.ts +10 -2
  120. package/src/capability/capability.ts +26 -0
  121. package/src/capability/purge.ts +187 -0
  122. package/src/cli/commandCatalog.generated.ts +65 -0
  123. package/src/cli/commandFacts.ts +1 -0
  124. package/src/controlPlane/capability.ts +11 -0
  125. package/src/controlPlane/discovery/adminRoute.ts +11 -0
  126. package/src/controlPlane/error/errors.ts +4 -3
  127. package/src/controlPlane/http/guard.ts +60 -1
  128. package/src/controlPlane/http/handlers.ts +57 -4
  129. package/src/controlPlane/http/responses.ts +100 -0
  130. package/src/controlPlane/http/routes.ts +31 -1
  131. package/src/controlPlane/scope/scope.ts +14 -3
  132. package/src/controlPlane/token/base64url.ts +1 -1
  133. package/src/createBackend.ts +4 -0
  134. package/src/createEntrypoint.ts +17 -2
  135. package/src/crypto/envelope.ts +196 -0
  136. package/src/crypto/fieldKeys.ts +76 -0
  137. package/src/data/codecs.ts +69 -37
  138. package/src/error/extend.ts +6 -5
  139. package/src/error/payload.ts +110 -0
  140. package/src/error/pithyError.ts +21 -7
  141. package/src/http/validation.ts +35 -3
  142. package/src/index.ts +1 -0
  143. package/src/logger/logger.ts +12 -5
  144. package/src/privacy/blindIndex.ts +81 -0
  145. package/src/privacy/classes.ts +277 -0
  146. package/src/privacy/classify.ts +1447 -0
  147. package/src/privacy/encrypted.ts +513 -0
  148. package/src/privacy/encryptedColumns.ts +185 -0
  149. package/src/privacy/fieldRotation.ts +234 -0
  150. package/src/privacy/inventory.ts +147 -0
  151. package/src/privacy/posture.ts +205 -0
  152. package/src/privacy/redact.ts +423 -0
  153. package/src/privacy/report.ts +64 -0
  154. package/src/privacy/retirement.ts +102 -0
  155. package/src/privacy/sealed.ts +323 -0
  156. package/src/privacy/types.ts +804 -0
  157. package/src/version.generated.ts +1 -1
@@ -0,0 +1,1447 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import { isSqliteJson, SQLiteBoolean, SQLiteDate } from "../data/codecs";
6
+ import { ownPrivacyClasses, privacyClasses, rankPrivacyClasses, strongestPrivacyClass } from "./classes";
7
+
8
+ /**
9
+ * **Reading a privacy marker off a schema, and every marker off a schema tree** (#132).
10
+ *
11
+ * The marker is `meta({ privacy })` on the schema a marker was handed. {@link privacyOf} reads it through the
12
+ * wrappers that are the same field — `pii(z.string()).optional()` is `pii` — and {@link classifyPrivacy} names
13
+ * each position of a schema that `redact` masks.
14
+ *
15
+ * ## The redaction contract
16
+ *
17
+ * `redact`, `classifyPrivacy` and `Redacted<T>` all read the one record this module builds for a position, so they
18
+ * agree on every one. The contract they keep is stated on #132 and in `docs/PRIVACY.md`:
19
+ *
20
+ * - **Walked exactly**: objects (strict, loose, catchall, and `pick`, `omit`, `partial`, `extend` of one), arrays,
21
+ * tuples and their rest, records, `Map` and `Set`; the wrappers `optional`, `nullable`, `default`, `catch` with a
22
+ * constant value, `readonly` and a brand; unions, discriminated unions and intersections, where an option's marks apply where
23
+ * the value has that option's shape — a marked string option to a string, never to an array or a number beside
24
+ * it — and a mark reached through no option applies to every value; the kit's `SQLiteDate`, `SQLiteBoolean` and
25
+ * `sqliteJson(schema)`, read through to the schema they decode to; and recursion, a `z.lazy` or a getter, to the
26
+ * one limit below.
27
+ * - **Everything else is opaque**: a transform, a pipe, a preprocess, any other codec, `z.custom`, `any`, `unknown`,
28
+ * and an `.overwrite()` or a `.catch()` with a fallback function over a structure, which make a new value.
29
+ * Nothing inside one is walked. With a mark anywhere beneath it, its position is masked whole with the strongest
30
+ * class it carries; with none, it is kept, a union option beside a marked one included.
31
+ * - **A mark on a container masks it whole**, and the container carries every class marked inside it as well as its
32
+ * own: `pii(z.object({ token: secret(z.string()) }))` is listed `pii` and `secret`, and masked `[secret]`.
33
+ * - **Nesting is bounded once**, at {@link MAX_RECURSION_DEPTH} levels: a position deeper than that is masked whole as
34
+ * {@link TRUNCATED_MASK}, whatever nests it, a recursion or a schema written out level by level. It is a safety
35
+ * cutoff rather than a privacy class. A recursion that repeats a position (the same set of schemas) already read on
36
+ * its path needs no cut in the list: its classes are those of the position already read. Recursions defined once
37
+ * side by side in a union, with cycles of different lengths, can reach the limit before any position repeats. Where
38
+ * a read reaches the limit on a chain of positions not yet seen on its path, a factory or plain nesting, or such a
39
+ * union, nothing past it was read, so the list assumes every class there, each `truncated`, as past the budget, and
40
+ * `hasPrivacy` answers yes for each: the list never says less than is marked. A path that reaches the limit ends
41
+ * alone: every other path is still read, so a class beside a recursion is found whatever the field order, and a
42
+ * position masked whole for a class it carries is masked with that class, cut off below or not. The cutoff is a flag
43
+ * beside the classes, never instead of them. Every answer is a function of the schema and the value alone.
44
+ * - **One budget per call, {@link MAX_READ_SCHEMAS} schemas.** `redact`, `classifyPrivacy`, `hasPrivacy` and
45
+ * `walkPrivacy` each read at most that many in one call, and every position and every search in it draws on it: each
46
+ * schema a read meets counts one, and so does each schema a lazy or an object's shape yields, where a getter runs and
47
+ * may build one. So a call's time and memory grow linearly with the size of the schema and the value, and are bounded
48
+ * beyond that by the budget and by what the schema's own getters build. Reads go shallowest first, so a class above the point the budget runs out on is found and
49
+ * kept. Past it the call fails closed: `redact` masks each position it has not resolved whole, with the strongest
50
+ * class it found there or {@link TRUNCATED_MASK} where it found none, and the list names every class at each position
51
+ * it has not read, each `truncated`: nothing there was read, so any class may be marked there, and the list never
52
+ * says less than the schema marks. It is one of five places the list says more than `redact` masks, beside a position
53
+ * cut at the depth limit, the classes a cut assumes, a reference's own path and an intersection that narrows. A position read once is read once
54
+ * wherever it sits in the call, so a schema reused in many fields costs one, and each call starts with the whole
55
+ * budget and keeps nothing for the next, so every answer is a function of the schema and the value alone. A position
56
+ * the walk cannot read at all, nested deeper than the stack can follow, is listed with every class, each `truncated`:
57
+ * nothing here throws.
58
+ * - **`classifyPrivacy` writes out at most {@link MAX_LISTED_PATHS} paths.** A schema reused in two fields at every
59
+ * level has a path per field per level, two to the depth; past the cap a position met again is listed at its own path
60
+ * once per class marked beneath it, with `sameAs` the path it was first written out under: `redact` masks the paths
61
+ * below it, not that path itself. So the paths it writes out are bounded too, however many reach one schema, and
62
+ * every class is still listed.
63
+ * - **An intersection is not narrowed.** Where one side narrows a field so it can never hold the type the other side
64
+ * marks, `classifyPrivacy` may still list that field: the inventory can list more than `redact` masks, and never
65
+ * less. `z.intersection(z.object({ b: z.number() }), z.object({ b: z.union([pii(z.string()), z.number()]) }))`
66
+ * lists `b` as `pii`, and `redact` keeps a `b` of `3`.
67
+ *
68
+ * ## What a path looks like
69
+ *
70
+ * Object keys join with `.`, an array or set element is `[]`, a tuple position is `[i]`, a record's or a map's value
71
+ * and an object's catchall are `*`, and a record's or a map's key is `<key>`: `address.line1`, `devices[].token`,
72
+ * `pair[1]`, `notes.*`, `byEmail.<key>`. A marked root is the empty path. A position masked whole is listed once per
73
+ * class it carries, and nothing below it is.
74
+ */
75
+
76
+ /** One marked path in a schema, and its class. The per-field record the inventory is built from. */
77
+ export interface PrivacyField {
78
+ /** Where in the value the field sits: `email`, `address.line1`, `devices[].token`, or `""` for the root. */
79
+ path: string;
80
+ /** The class it was marked with. */
81
+ class: string;
82
+ /**
83
+ * Set where the class is assumed rather than found: a position the call ran out of {@link MAX_READ_SCHEMAS} before it
84
+ * read, or a read beneath a position that ran out of it, a position cut at {@link MAX_RECURSION_DEPTH} on a chain of
85
+ * schemas not yet seen on its path, or a schema the walk could not read at all, nested deeper than the stack. Each
86
+ * lists {@link unreadClasses}. Absent everywhere the schema was read to the end.
87
+ */
88
+ truncated?: true;
89
+ /**
90
+ * Set on a reference: the path this field's position was first listed under. Once the walk has written out
91
+ * {@link MAX_LISTED_PATHS} paths, a position it meets again is listed once per class marked beneath it, with the path
92
+ * it was first written out under, rather than once per path below it. The paths below `sameAs` are the paths below
93
+ * this one.
94
+ */
95
+ sameAs?: string;
96
+ /**
97
+ * Whether the field rests encrypted in its store (#390): `true` for a field marked `encrypted()`, `false` for one
98
+ * that is not. Set by the inventory, on the observed side of #132's attestation split, and **absent where the core
99
+ * cannot say** — an assumed class, a reference, or a core older than #390 — never defaulted to `false`.
100
+ */
101
+ encryptedAtRest?: boolean;
102
+ /** Set on an encrypted field whose storage is not D1: nothing can prove it re-sealed, so it blocks retirement. */
103
+ unrotatable?: true;
104
+ }
105
+
106
+ /**
107
+ * **The one recursion limit**, shared by `redact`, `classifyPrivacy` and `Redacted<T>` (`./types` reads this constant's
108
+ * type). Depth is counted in levels of nesting: every object, array, tuple, record, map and set a position sits inside.
109
+ * It applies to all nesting, not only recursion: a position deeper than this is masked whole as {@link TRUNCATED_MASK}
110
+ * and typed `unknown`, whatever nests it: a `z.lazy`, a getter that returns one schema, one that builds a new schema
111
+ * each read, or a schema written out level by level. The list assumes every class there, unless the chain repeats a
112
+ * position (the same set of schemas) already read on its path. A search for a mark beneath an opaque node or a marked
113
+ * container ends a path at the same depth, counting each lazy too, and reads every other path; so does the one
114
+ * `Redacted<T>` makes. So does a read of the shapes a choice can have, and of the schemas that reach one position, each
115
+ * counting the lazies nested above a schema: a lazy that builds a new choice each read never meets a schema twice. A
116
+ * union of many lazies, none inside another, counts one.
117
+ */
118
+ export const MAX_RECURSION_DEPTH = 256;
119
+
120
+ /**
121
+ * **The one budget**: how many schemas one call of `redact`, `classifyPrivacy`, `hasPrivacy` or `walkPrivacy` reads.
122
+ * Every position and every search in that call draws on it. Each schema a read meets counts one — a step of the read of
123
+ * a position, of the shapes a choice can have, or of a search beneath a marked container or an opaque node — and so
124
+ * does each schema a lazy or an object's shape yields: a lazy's target, and each field of a shape, where a getter runs
125
+ * and may build a new schema. A schema met again in the same read costs nothing. So a call reads at most this many
126
+ * schemas, and runs at most this many of the schema's own getters beyond the fields of the one object it was reading
127
+ * when the budget ran out. So its time and memory grow linearly with the size of the schema and the value, and are
128
+ * bounded beyond that by the budget and by what one of those getters builds. Past it the call fails closed: what it has not read, it assumes. `redact` masks each position it has
129
+ * not resolved whole, with the strongest class it found there or {@link TRUNCATED_MASK} where it found none; the list
130
+ * names every class at each position it has not read, each `truncated`. Far past any schema written out by hand, and
131
+ * past any recursion defined once: only a schema that builds new ones as it is read, or one wider than any written out,
132
+ * reaches it. Each call starts with the whole budget and keeps nothing for the next, so every answer is a function of
133
+ * the schema and the value alone, whichever call came first.
134
+ */
135
+ export const MAX_READ_SCHEMAS = 10_000;
136
+
137
+ /**
138
+ * How many paths {@link walkPrivacy} writes out before a position it met already is listed by reference. A schema
139
+ * reused in two fields at every level of thirty has thirty-one schemas and a billion paths, so a list of every path
140
+ * cannot be built; past this many, each new position is still read and written out once, and each one met again is
141
+ * listed at its own path with every class beneath it and `sameAs` the path it was written out under. So the paths it
142
+ * writes out are bounded too, however many reach one schema. Far past any table written out: a book of 22 groups of 22
143
+ * contacts of 22 addresses is written out whole.
144
+ */
145
+ export const MAX_LISTED_PATHS = 50_000;
146
+
147
+ /**
148
+ * What `redact` puts where it stops: a value nested past {@link MAX_RECURSION_DEPTH}, an opaque value over a recursion
149
+ * that reaches it, and a position the call ran out of {@link MAX_READ_SCHEMAS} before it read, where no class was
150
+ * found. A safety cutoff rather than a privacy class, so it is never a placeholder for one.
151
+ */
152
+ export const TRUNCATED_MASK = "[truncated]";
153
+
154
+ /**
155
+ * The classes {@link classifyPrivacy} lists for a position it did not read: every class registered, strongest first,
156
+ * because nothing there was read and any of them may be marked below it. So the list never says less than the schema
157
+ * marks, and `hasPrivacy` answers yes for each. A read that runs out of {@link MAX_READ_SCHEMAS} assumes them where it
158
+ * stopped, a cut at {@link MAX_RECURSION_DEPTH} on a chain of schemas not yet seen assumes them where it cut, and a
159
+ * schema the walk cannot read at all is listed with them.
160
+ */
161
+ export function unreadClasses(): string[] {
162
+ return rankPrivacyClasses(privacyClasses().map((each) => each.name));
163
+ }
164
+
165
+ /**
166
+ * One call of `redact`, `classifyPrivacy`, `hasPrivacy` or `walkPrivacy`: how much of {@link MAX_READ_SCHEMAS} it has
167
+ * left, and what it has read, so a schema met again in the call is read once. Made new for each call and kept by none.
168
+ */
169
+ export interface Call {
170
+ /** How many more schemas the call may read. */
171
+ left: number;
172
+ /** The shapes each schema's output can have, read to its end. */
173
+ readonly kinds: Map<z.ZodType, KindsFound>;
174
+ /** The fewest lazies above each schema from which a read of its shapes is known to run past the limit. */
175
+ readonly stops: Map<z.ZodType, number>;
176
+ /** What a search found beneath each schema. */
177
+ readonly searched: Map<z.ZodType, Search>;
178
+ /** Every position read, by the schemas that reach it. */
179
+ readonly positions: Map<string, Position>;
180
+ /** Every position walked into, by its structural nodes and its masks. */
181
+ readonly walked: Map<string, Position>;
182
+ }
183
+
184
+ /** A call with the whole budget, and nothing read. */
185
+ export function newCall(): Call {
186
+ return {
187
+ left: MAX_READ_SCHEMAS,
188
+ kinds: new Map(),
189
+ stops: new Map(),
190
+ searched: new Map(),
191
+ positions: new Map(),
192
+ walked: new Map(),
193
+ };
194
+ }
195
+
196
+ /** Spend one of the call's budget on a read: `false`, and nothing spent, once there is none left. */
197
+ function take(call: Call): boolean {
198
+ if (call.left <= 0) return false;
199
+ call.left -= 1;
200
+ return true;
201
+ }
202
+
203
+ /**
204
+ * An object's shape, read for the call: reading it runs the shape's getters, each of which may build a schema, so the
205
+ * call pays one for each field it holds as well as the one it took to open it. The budget may go below nothing here,
206
+ * by at most the width of that one object, and the next read stops.
207
+ */
208
+ function readShape(schema: z.ZodType, call: Call): Record<PropertyKey, z.ZodType> {
209
+ const shape = shapeOf(schema);
210
+ call.left -= Reflect.ownKeys(shape).length;
211
+ return shape;
212
+ }
213
+
214
+ /** A Zod schema's internal definition, as much of it as the walk reads. */
215
+ interface Def {
216
+ type: string;
217
+ innerType?: z.ZodType;
218
+ element?: z.ZodType;
219
+ options?: z.ZodType[];
220
+ left?: z.ZodType;
221
+ right?: z.ZodType;
222
+ items?: z.ZodType[];
223
+ rest?: z.ZodType | null;
224
+ keyType?: z.ZodType;
225
+ valueType?: z.ZodType;
226
+ catchall?: z.ZodType;
227
+ shape?: Record<PropertyKey, z.ZodType>;
228
+ out?: z.ZodType;
229
+ catchValue?: unknown;
230
+ checks?: readonly unknown[];
231
+ }
232
+
233
+ function defOf(schema: z.ZodType): Def {
234
+ return (schema as unknown as { _zod: { def: Def } })._zod.def;
235
+ }
236
+
237
+ function isSchema(value: unknown): value is z.ZodType {
238
+ return typeof value === "object" && value !== null && "_zod" in value;
239
+ }
240
+
241
+ /** The wrappers that are the same position as what they wrap. A brand is a type, and no node at all. */
242
+ const WRAPPERS: ReadonlySet<string> = new Set([
243
+ "optional",
244
+ "nullable",
245
+ "default",
246
+ "prefault",
247
+ "catch",
248
+ "readonly",
249
+ "nonoptional",
250
+ ]);
251
+
252
+ /** The kinds a value is walked into. */
253
+ const STRUCTURAL: ReadonlySet<string> = new Set(["object", "array", "tuple", "record", "map", "set"]);
254
+
255
+ /** The tag Zod puts on the function it wraps a constant `.catch()` value in. */
256
+ const CONSTANT_CATCH = "~constantCatch";
257
+
258
+ /**
259
+ * Whether a node hands back a value its own code made rather than the one it read: a `.catch()` with a fallback
260
+ * function, or an `.overwrite()`. A constant fallback is a value, and is walked as the wrapper it is.
261
+ */
262
+ function makesValue(def: Def): boolean {
263
+ if (def.type === "catch") {
264
+ const fallback = def.catchValue as { [CONSTANT_CATCH]?: unknown } | undefined;
265
+ if (typeof fallback !== "function" || fallback[CONSTANT_CATCH] !== true) return true;
266
+ }
267
+ return (def.checks ?? []).some(
268
+ (check) => (check as { _zod?: { def?: { check?: unknown } } })._zod?.def?.check === "overwrite",
269
+ );
270
+ }
271
+
272
+ /** The value a constant `.catch()` hands back, or `undefined` for any other node. */
273
+ function constantFallback(def: Def): { value: unknown } | undefined {
274
+ if (def.type !== "catch" || makesValue(def)) return undefined;
275
+ try {
276
+ return { value: (def.catchValue as () => unknown)() };
277
+ } catch {
278
+ return undefined;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * The shapes a value can have, as far as a choice is concerned: what `typeof` says, with an object told apart by what
284
+ * it is. An option's marks apply to a value of a shape the option names, and to no other.
285
+ */
286
+ export type ValueKind =
287
+ | "string"
288
+ | "number"
289
+ | "bigint"
290
+ | "boolean"
291
+ | "symbol"
292
+ | "function"
293
+ | "date"
294
+ | "object"
295
+ | "array"
296
+ | "map"
297
+ | "set";
298
+
299
+ /** The shapes a value is walked into. */
300
+ const STRUCTURE: readonly ValueKind[] = ["object", "array", "map", "set"];
301
+
302
+ /** Whether a value of one of `kinds` could be walked into. */
303
+ function holdsStructure(kinds: ReadonlySet<ValueKind>): boolean {
304
+ return STRUCTURE.some((kind) => kinds.has(kind));
305
+ }
306
+
307
+ /**
308
+ * A node's shapes, as a choice reads them: every shape for one that {@link makesValue} over a structure, as its own
309
+ * code may hand back anything. A scalar's is still its own: what it makes is the scalar a mark masks or keeps.
310
+ */
311
+ function widened(def: Def, kinds: ReadonlySet<ValueKind>): ReadonlySet<ValueKind> {
312
+ return holdsStructure(kinds) && makesValue(def) ? EVERY_KIND : kinds;
313
+ }
314
+
315
+ /** Every {@link ValueKind}, in the order a mask lists them. */
316
+ const VALUE_KINDS: readonly ValueKind[] = [
317
+ "string",
318
+ "number",
319
+ "bigint",
320
+ "boolean",
321
+ "symbol",
322
+ "function",
323
+ "date",
324
+ "object",
325
+ "array",
326
+ "map",
327
+ "set",
328
+ ];
329
+
330
+ const EVERY_KIND: ReadonlySet<ValueKind> = new Set(VALUE_KINDS);
331
+ const NO_KIND: ReadonlySet<ValueKind> = new Set();
332
+
333
+ /** The shape of a value, or `undefined` for `null` and `undefined`, which are kept wherever they are. */
334
+ export function kindOfValue(value: unknown): ValueKind | undefined {
335
+ const type = typeof value;
336
+ if (type === "undefined" || value === null) return undefined;
337
+ if (type !== "object") return type as Exclude<typeof type, "undefined" | "object">;
338
+ if (Array.isArray(value)) return "array";
339
+ if (value instanceof Map) return "map";
340
+ if (value instanceof Set) return "set";
341
+ return value instanceof Date ? "date" : "object";
342
+ }
343
+
344
+ /** The prototype a value of each kind has where it is what its schema parses: a plain object's, a `Date`'s … */
345
+ const TYPICAL: Partial<Record<ValueKind, object>> = {
346
+ object: Object.prototype,
347
+ array: Array.prototype,
348
+ map: Map.prototype,
349
+ set: Set.prototype,
350
+ date: Date.prototype,
351
+ };
352
+
353
+ /**
354
+ * Whether a value of `kind` is one its schema parses: a plain object, an `Array.prototype` array, a `Map`, a `Set` or
355
+ * a `Date` of its own prototype, never a subclass or a class instance. A scalar and a function always are.
356
+ */
357
+ function isTypical(value: object, kind: ValueKind): boolean {
358
+ const typical = TYPICAL[kind];
359
+ if (typical === undefined) return true;
360
+ const prototype = Object.getPrototypeOf(value);
361
+ return prototype === typical || (kind === "object" && prototype === null);
362
+ }
363
+
364
+ /** The one shape each kind of schema produces. A record is an object, and a tuple an array. */
365
+ const SCHEMA_KINDS: Readonly<Record<string, ValueKind>> = {
366
+ string: "string",
367
+ template_literal: "string",
368
+ number: "number",
369
+ nan: "number",
370
+ bigint: "bigint",
371
+ boolean: "boolean",
372
+ symbol: "symbol",
373
+ function: "function",
374
+ date: "date",
375
+ object: "object",
376
+ record: "object",
377
+ array: "array",
378
+ tuple: "array",
379
+ map: "map",
380
+ set: "set",
381
+ };
382
+
383
+ /**
384
+ * The kinds a scalar, a literal, an enum or a node that holds no value names, or `undefined` for an opaque node, which
385
+ * takes a value of any kind and prototype: a transform, a pipe, `z.custom`, `any`, `unknown`, or one that makes its
386
+ * own value.
387
+ */
388
+ function scalarKinds(node: z.ZodType): ReadonlySet<ValueKind> | undefined {
389
+ const def = defOf(node);
390
+ if (makesValue(def)) return undefined;
391
+ const one = SCHEMA_KINDS[def.type];
392
+ if (one !== undefined) return new Set([one]);
393
+ if (NO_VALUE.has(def.type)) return NO_KIND;
394
+ if (def.type !== "literal" && def.type !== "enum") return undefined;
395
+ const values = (node as unknown as { _zod: { values?: ReadonlySet<unknown> } })._zod.values;
396
+ return values ? new Set([...values].flatMap((value) => [kindOfValue(value)].filter(isKind))) : undefined;
397
+ }
398
+
399
+ /** The kinds of schema that hold no value a mask could apply to. */
400
+ const NO_VALUE: ReadonlySet<string> = new Set(["null", "undefined", "void", "never"]);
401
+
402
+ /** A schema's shapes, read to its end, and how many lazies deep the read went below it. */
403
+ interface KindsFound {
404
+ readonly kinds: ReadonlySet<ValueKind>;
405
+ readonly reach: number;
406
+ }
407
+
408
+ /** A schema on the path of a read of {@link kindsOf}: where on the path it sits, and which time it was put there. */
409
+ interface OnPath {
410
+ readonly index: number;
411
+ readonly stamp: number;
412
+ }
413
+
414
+ /**
415
+ * A schema read inside a cycle that comes round to a schema above it: its answer, and the schema on the path that cycle
416
+ * comes round to, as it was put there. The answer holds while that schema is still on the path, put there that time.
417
+ */
418
+ interface WithinCycle {
419
+ readonly found: KindsFound;
420
+ readonly anchor: z.ZodType;
421
+ readonly stamp: number;
422
+ }
423
+
424
+ /**
425
+ * One read of {@link kindsOf}: whether it ran past the limit or out of the call's budget, how deep it went, the schemas
426
+ * on the path, the shallowest place on the path a cycle below came round to, and the answers of the schemas it read
427
+ * inside a cycle.
428
+ */
429
+ interface KindsRead {
430
+ readonly call: Call;
431
+ stopped: boolean;
432
+ /** Whether it stopped at the recursion limit, rather than at the budget. */
433
+ past: boolean;
434
+ deepest: number;
435
+ low: number;
436
+ stamps: number;
437
+ path: Map<z.ZodType, OnPath>;
438
+ stack: z.ZodType[];
439
+ within: Map<z.ZodType, WithinCycle>;
440
+ }
441
+
442
+ /**
443
+ * The shapes a schema's output can have: read through its wrappers, the options of a union, the sides of an
444
+ * intersection, a lazy and the kit's codecs, every option to the end. Every shape for anything opaque, whose output
445
+ * nothing here reads, for a read that meets a path more than {@link MAX_RECURSION_DEPTH} lazies long, and for one that
446
+ * runs out of the call's {@link MAX_READ_SCHEMAS}: a lazy that builds a new choice each read never meets a schema
447
+ * twice, and every shape is the answer that masks the most. Depth first, and the first path that runs out ends the
448
+ * read. A schema read to its end is remembered for the call with how deep it went, so a choice reused in many places
449
+ * is read once. A schema whose cycles all come round to itself or below is remembered for every read; one inside a
450
+ * cycle that comes round above it is remembered while that schema is still on the path, so a choice reused inside a
451
+ * cycle is read once too.
452
+ */
453
+ function kindsOf(schema: z.ZodType, call: Call, lazies = 0): ReadonlySet<ValueKind> {
454
+ const read: KindsRead = {
455
+ call,
456
+ stopped: false,
457
+ past: false,
458
+ deepest: 0,
459
+ low: Number.POSITIVE_INFINITY,
460
+ stamps: 0,
461
+ path: new Map(),
462
+ stack: [],
463
+ within: new Map(),
464
+ };
465
+ const found = readKinds(schema, lazies, read);
466
+ return read.stopped ? EVERY_KIND : found;
467
+ }
468
+
469
+ /** A schema's answer met again, `depth` lazies down: it would go as deep below as it did before. */
470
+ function replay(known: KindsFound, depth: number, read: KindsRead): ReadonlySet<ValueKind> {
471
+ if (depth + known.reach > MAX_RECURSION_DEPTH) {
472
+ read.stopped = true;
473
+ read.past = true;
474
+ }
475
+ read.deepest = Math.max(read.deepest, depth + known.reach);
476
+ return read.stopped ? EVERY_KIND : known.kinds;
477
+ }
478
+
479
+ /** One step of {@link kindsOf}, `depth` lazies down. */
480
+ function readKinds(schema: z.ZodType, depth: number, read: KindsRead): ReadonlySet<ValueKind> {
481
+ if (read.stopped) return EVERY_KIND;
482
+ // Past the limit, or as deep as a read of this schema was seen to run past it: this path, and the read, end here.
483
+ const stop = read.call.stops.get(schema);
484
+ if (depth > MAX_RECURSION_DEPTH || (stop !== undefined && depth >= stop)) {
485
+ read.stopped = true;
486
+ read.past = true;
487
+ return EVERY_KIND;
488
+ }
489
+ read.deepest = Math.max(read.deepest, depth);
490
+ const known = read.call.kinds.get(schema);
491
+ if (known) return replay(known, depth, read);
492
+ // Each schema read costs one of the call's budget; past it, every shape, the answer that masks the most.
493
+ if (!take(read.call)) {
494
+ read.stopped = true;
495
+ return EVERY_KIND;
496
+ }
497
+ const def = defOf(schema);
498
+ const one = SCHEMA_KINDS[def.type];
499
+ if (one !== undefined) return widened(def, new Set([one]));
500
+ if (NO_VALUE.has(def.type)) return NO_KIND;
501
+ if (def.type === "literal" || def.type === "enum") {
502
+ const values = (schema as unknown as { _zod: { values?: ReadonlySet<unknown> } })._zod.values;
503
+ return values ? new Set([...values].flatMap((value) => [kindOfValue(value)].filter(isKind))) : EVERY_KIND;
504
+ }
505
+ // A lazy that only ever resolves to itself holds nothing parse could return: every shape, so nothing is missed.
506
+ const onPath = read.path.get(schema);
507
+ if (onPath) {
508
+ read.low = Math.min(read.low, onPath.index);
509
+ return EVERY_KIND;
510
+ }
511
+ // Read inside a cycle before, and the schema that cycle comes round to is still on the path, put there that time.
512
+ const cycled = read.within.get(schema);
513
+ const anchor = cycled && read.path.get(cycled.anchor);
514
+ if (cycled && anchor?.stamp === cycled.stamp) {
515
+ read.low = Math.min(read.low, anchor.index);
516
+ return replay(cycled.found, depth, read);
517
+ }
518
+ const { deepest, low } = read;
519
+ const index = read.stack.length;
520
+ read.stamps += 1;
521
+ read.deepest = depth;
522
+ read.low = Number.POSITIVE_INFINITY;
523
+ read.path.set(schema, { index, stamp: read.stamps });
524
+ read.stack.push(schema);
525
+ let found: ReadonlySet<ValueKind>;
526
+ try {
527
+ found = widened(def, readStep(schema, def, depth, read));
528
+ } finally {
529
+ read.path.delete(schema);
530
+ read.stack.pop();
531
+ }
532
+ const reach = read.deepest - depth;
533
+ const below = read.low;
534
+ read.deepest = Math.max(deepest, read.deepest);
535
+ read.low = Math.min(low, below);
536
+ // Run past the limit with every cycle below coming round to this schema or below it: a read of it from this many
537
+ // lazies down, or more, runs past it again, whatever reached it. So a chain of new lazies is read once, not once a
538
+ // level.
539
+ if (read.stopped && read.past && below >= index) {
540
+ const known = read.call.stops.get(schema);
541
+ read.call.stops.set(schema, known === undefined ? depth : Math.min(known, depth));
542
+ }
543
+ if (!read.stopped) {
544
+ const answer: KindsFound = { kinds: found, reach };
545
+ // Every cycle below came round to this schema or below it: the answer is the schema's, whatever reached it.
546
+ if (below >= index) read.call.kinds.set(schema, answer);
547
+ else {
548
+ // A cycle came round above it: the answer holds while the schema it came round to is on the path.
549
+ const to = read.stack[below] as z.ZodType;
550
+ read.within.set(schema, { found: answer, anchor: to, stamp: (read.path.get(to) as OnPath).stamp });
551
+ }
552
+ }
553
+ return found;
554
+ }
555
+
556
+ /** {@link readKinds} for a node that holds other schemas. */
557
+ function readStep(schema: z.ZodType, def: Def, depth: number, read: KindsRead): ReadonlySet<ValueKind> {
558
+ if (WRAPPERS.has(def.type) && def.innerType) return readKinds(def.innerType, depth, read);
559
+ if (def.type === "union" && def.options) {
560
+ const joined = new Set<ValueKind>();
561
+ for (const option of def.options) {
562
+ for (const kind of readKinds(option, depth, read)) joined.add(kind);
563
+ // Every shape already, or a path that ran out: the options left can add none.
564
+ if (joined.size === EVERY_KIND.size || read.stopped) break;
565
+ }
566
+ return joined;
567
+ }
568
+ if (def.type === "intersection" && def.left && def.right) {
569
+ const left = readKinds(def.left, depth, read);
570
+ if (left.size === 0) return NO_KIND;
571
+ const right = readKinds(def.right, depth, read);
572
+ return new Set([...left].filter((kind) => right.has(kind)));
573
+ }
574
+ if (def.type === "lazy") {
575
+ // Resolving a lazy runs its getter, which may build a schema: the call pays for it as for a read.
576
+ if (!take(read.call)) {
577
+ read.stopped = true;
578
+ return EVERY_KIND;
579
+ }
580
+ const inner = lazyInner(schema);
581
+ return inner ? readKinds(inner, depth + 1, read) : EVERY_KIND;
582
+ }
583
+ const codec = kitCodecOutput(schema);
584
+ return codec ? readKinds(codec, depth, read) : EVERY_KIND;
585
+ }
586
+
587
+ function isKind(kind: ValueKind | undefined): kind is ValueKind {
588
+ return kind !== undefined;
589
+ }
590
+
591
+ /**
592
+ * The class a field was marked with, read through its wrappers — `pii(z.string()).optional()` is `pii` — or
593
+ * `null` when nothing along the chain carries one. A field marked twice, `pii(secret(x))`, is its strongest class.
594
+ * It never throws: a schema whose metadata cannot be read, cloned by `.describe()` or `.meta()` more times than Zod
595
+ * can read it back through, may carry any class, so it answers the strongest of {@link unreadClasses}.
596
+ */
597
+ export function privacyOf(schema: z.ZodType): string | null {
598
+ try {
599
+ for (let node: z.ZodType | undefined = schema; node; ) {
600
+ const found = strongestPrivacyClass(ownPrivacyClasses(node));
601
+ if (found !== undefined) return found;
602
+ const def = defOf(node);
603
+ node = WRAPPERS.has(def.type) ? def.innerType : undefined;
604
+ }
605
+ } catch {
606
+ return strongestPrivacyClass(unreadClasses()) ?? null;
607
+ }
608
+ return null;
609
+ }
610
+
611
+ /** The schema one of the kit's three codecs decodes to, or `undefined` for any other node. */
612
+ function kitCodecOutput(schema: z.ZodType): z.ZodType | undefined {
613
+ const def = defOf(schema);
614
+ if (def.type !== "pipe") return undefined;
615
+ const kit = def === defOf(SQLiteDate) || def === defOf(SQLiteBoolean) || isSqliteJson(schema);
616
+ return kit ? def.out : undefined;
617
+ }
618
+
619
+ /** What a lazy stands for: Zod's own cached target, the schema `parse` ran. Never its getter called again. */
620
+ function lazyInner(schema: z.ZodType): z.ZodType | undefined {
621
+ const inner = (schema as unknown as { _zod: { innerType?: unknown } })._zod.innerType;
622
+ return isSchema(inner) ? inner : undefined;
623
+ }
624
+
625
+ /** An object's fields, read through the shape Zod freezes on first read, so every walk sees the schemas `parse` does. */
626
+ function shapeOf(schema: z.ZodType): Record<PropertyKey, z.ZodType> {
627
+ return defOf(schema).shape ?? {};
628
+ }
629
+
630
+ /** Every schema a node holds, whatever its kind: what a search for a mark reads through. */
631
+ function children(schema: z.ZodType): z.ZodType[] {
632
+ const def = defOf(schema);
633
+ if (def.type === "lazy") {
634
+ const inner = lazyInner(schema);
635
+ return inner ? [inner] : [];
636
+ }
637
+ const found: z.ZodType[] = def.type === "object" ? Object.values(shapeOf(schema)).filter(isSchema) : [];
638
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(def))) {
639
+ // A check is not a node, and an accessor — a default's value, a shape — is never run here: it may be the
640
+ // adopter's own function. A lazy's getter is read above, through its cache, and an object's shape through Zod's.
641
+ if (key === "checks" || key === "getter" || !("value" in descriptor)) continue;
642
+ const slot: unknown = descriptor.value;
643
+ if (isSchema(slot)) found.push(slot);
644
+ else if (Array.isArray(slot)) for (const each of slot) if (isSchema(each)) found.push(each);
645
+ }
646
+ return found;
647
+ }
648
+
649
+ /** What a search found beneath a schema. */
650
+ interface Search {
651
+ /** Every class it found marked. */
652
+ readonly classes: ReadonlySet<string>;
653
+ /** Whether a path ended at the recursion limit, or the search at the budget. */
654
+ readonly truncated: boolean;
655
+ /**
656
+ * Whether every class is assumed beneath it: the search ended at the call's {@link MAX_READ_SCHEMAS}, or a path of
657
+ * schemas it had not met reached {@link MAX_RECURSION_DEPTH}. Either way, what lies past it was never read.
658
+ */
659
+ readonly unread: boolean;
660
+ }
661
+
662
+ /**
663
+ * Every class marked anywhere beneath a schema, on every side of every node. Level by level, each level in the order
664
+ * the schema declares it, and each schema once. A path more than {@link MAX_RECURSION_DEPTH} levels long, counting
665
+ * each lazy and each structural node, ends there and marks the search `truncated` and `unread`; every other path is
666
+ * still read, so a class beside a recursion is found whatever the field order. Each schema is met once, so a recursion
667
+ * defined once comes round to a schema already read and never reaches the limit: only a chain of schemas not met
668
+ * before does, a factory or nesting deeper than the limit, and past it any class may be marked, so every one is
669
+ * assumed there. Each schema it meets costs one of the call's
670
+ * {@link MAX_READ_SCHEMAS}, so shallowest first, a class above the point the budget runs out on is found. There the
671
+ * search ends `truncated` and `unread`: what lies past it was never read, so every class is assumed there, and the
672
+ * search never says less than the schema marks. Remembered for the call, so a schema searched again costs nothing.
673
+ */
674
+ function search(schema: z.ZodType, call: Call): Search {
675
+ const known = call.searched.get(schema);
676
+ if (known) return known;
677
+ const classes = new Set<string>();
678
+ let truncated = false;
679
+ let unread = false;
680
+ let spent = false;
681
+ const seen = new Set<z.ZodType>();
682
+ let level: z.ZodType[] = [schema];
683
+ for (let depth = 0; level.length > 0 && !spent; depth += 1) {
684
+ const next: z.ZodType[] = [];
685
+ // Reversed, so the classes come out in the order the schema declares them.
686
+ const stack = [...level].reverse();
687
+ for (let node = stack.pop(); node; node = stack.pop()) {
688
+ if (seen.has(node)) continue;
689
+ // Past the budget, nothing more is read, so any class may be marked there: the search assumes every one.
690
+ if (!take(call)) {
691
+ truncated = true;
692
+ unread = true;
693
+ spent = true;
694
+ break;
695
+ }
696
+ seen.add(node);
697
+ // A mark does not end the search: a container marked whole carries every class marked inside it too.
698
+ for (const own of ownPrivacyClasses(node)) classes.add(own);
699
+ const def = defOf(node);
700
+ if (def.type !== "lazy" && !STRUCTURAL.has(def.type)) {
701
+ const inside = children(node);
702
+ for (let index = inside.length - 1; index >= 0; index -= 1) stack.push(inside[index] as z.ZodType);
703
+ continue;
704
+ }
705
+ // Past the limit, this path ends here, and only this one. Every schema on it was new, so any class may be past it.
706
+ if (depth + 1 > MAX_RECURSION_DEPTH) {
707
+ truncated = true;
708
+ unread = true;
709
+ continue;
710
+ }
711
+ // Opening a node is what may build a schema — a lazy's target, an object's shape read through its getters — so
712
+ // the call pays for it as for a read, and for each field a shape holds.
713
+ if (!take(call)) {
714
+ truncated = true;
715
+ unread = true;
716
+ spent = true;
717
+ break;
718
+ }
719
+ if (def.type === "object") readShape(node, call);
720
+ for (const child of children(node)) next.push(child);
721
+ }
722
+ level = next;
723
+ }
724
+ const found = { classes, truncated, unread };
725
+ call.searched.set(schema, found);
726
+ return found;
727
+ }
728
+
729
+ /** How a position is masked whole. */
730
+ export interface Mask {
731
+ /**
732
+ * The strongest class found at the position: its placeholder is the mask, unless `own`. Never a class only assumed.
733
+ * Empty where none was found — a recursion that reached the limit, or a read that ran out of
734
+ * {@link MAX_READ_SCHEMAS}, is all there is — and only there is the mask {@link TRUNCATED_MASK}.
735
+ */
736
+ readonly className: string;
737
+ /** Whether the class's own mask applies: one class, from a marker, nothing opaque, and nothing cut off. */
738
+ readonly own: boolean;
739
+ /**
740
+ * Every class marked at the position, in the order met, then every class a read that ran out of
741
+ * {@link MAX_READ_SCHEMAS}, or reached {@link MAX_RECURSION_DEPTH} on a chain of schemas not yet seen, assumed. Never
742
+ * the cutoff, which is not a class.
743
+ */
744
+ readonly classes: readonly string[];
745
+ /**
746
+ * The classes in `classes` that no read found, only assumed: a read here ran out of {@link MAX_READ_SCHEMAS}, or
747
+ * reached {@link MAX_RECURSION_DEPTH} on a chain of schemas not yet seen, so any class may be marked past it. Listed
748
+ * `truncated`.
749
+ */
750
+ readonly assumed: readonly string[];
751
+ /**
752
+ * Whether a recursion here reached {@link MAX_RECURSION_DEPTH}, or a read ran out of {@link MAX_READ_SCHEMAS}: a flag
753
+ * beside the classes, never instead of them. A class found here is still the mask.
754
+ */
755
+ readonly truncated: boolean;
756
+ }
757
+
758
+ /** One position: every schema that reaches it, flattened through wrappers, choices, lazies and the kit's codecs. */
759
+ export interface Position {
760
+ /**
761
+ * Set when some value here is masked whole: every class the position masks, whatever the value's shape. What
762
+ * `classifyPrivacy` lists.
763
+ */
764
+ readonly mask: Mask | undefined;
765
+ /**
766
+ * How an object's string key is masked when it is read here. A string key is a string whatever the key schema says,
767
+ * a number key's included, so every mark applies to it but one that only a symbol option reaches.
768
+ */
769
+ readonly nameMask: Mask | undefined;
770
+ /** Whether every value here is masked whole, whatever its shape. */
771
+ readonly whole: boolean;
772
+ /**
773
+ * How a value is masked here, by its shape: a mark reached through a union option applies to a value of that
774
+ * option's shape, and one reached through no option to every value. `undefined` where the value is walked or kept.
775
+ */
776
+ maskFor(value: unknown): Mask | undefined;
777
+ /**
778
+ * Whether a value's kind and prototype miss what the schemas here expect, and how it is masked where they do: a
779
+ * class instance or a plain object where none of its kind is named, a `Date`, an array, a `Map` or a `Set` of a
780
+ * subclass or with fields where an object is, an array-like instance where an array is. `undefined` where it fits:
781
+ * a schema here names its kind and it is typical of that kind, or only an opaque node here takes its kind. A misfit
782
+ * is never walked. Its mask is the strongest class beneath the schemas here of its kind, or beneath every schema here
783
+ * where none names it, and `undefined`, to keep it as it is, where nothing there is marked.
784
+ */
785
+ misfit(value: unknown): { readonly mask: Mask | undefined } | undefined;
786
+ /** The structural kinds here — `object`, `array`, `map` … — a value is walked by: none of a shape masked whole. */
787
+ readonly kinds: ReadonlySet<string>;
788
+ /** Whether anything here but a structural node may hold the value: a scalar, an opaque node. */
789
+ readonly other: boolean;
790
+ /** The position at an object key or a record's value. */
791
+ field(key: PropertyKey): Position | undefined;
792
+ /** The keys the objects here name, in order. */
793
+ readonly keys: readonly PropertyKey[];
794
+ /** The position at a key no object names: a catchall, a record's value. */
795
+ readonly catchall: Position | undefined;
796
+ /** The position a record's keys are read at. */
797
+ readonly key: Position | undefined;
798
+ /** The position at element `index` of an array or a tuple. */
799
+ item(index: number): Position | undefined;
800
+ /** How many items the longest tuple here names. */
801
+ readonly items: number;
802
+ /** The position at an element past every tuple item: an array's element, a tuple's rest. */
803
+ readonly rest: Position | undefined;
804
+ /** The positions of a map's keys and values. */
805
+ readonly mapKey: Position | undefined;
806
+ readonly mapValue: Position | undefined;
807
+ /** The position of a set's elements. */
808
+ readonly element: Position | undefined;
809
+ }
810
+
811
+ /** An id per schema, so a list of schemas has a key. */
812
+ const ids = new WeakMap<z.ZodType, number>();
813
+ let lastId = 0;
814
+ function idOf(schema: z.ZodType): number {
815
+ let id = ids.get(schema);
816
+ if (id === undefined) {
817
+ lastId += 1;
818
+ id = lastId;
819
+ ids.set(schema, id);
820
+ }
821
+ return id;
822
+ }
823
+
824
+ /** The schema each leaf of one kind holds at a slot, read by `pick`. */
825
+ function slots(leaves: readonly z.ZodType[], kinds: readonly string[], pick: (def: Def) => unknown): z.ZodType[] {
826
+ return leaves
827
+ .filter((leaf) => kinds.includes(defOf(leaf).type))
828
+ .flatMap((leaf) => [pick(defOf(leaf))].filter(isSchema));
829
+ }
830
+
831
+ /** A catchall a value's extra key is parsed by: none for a stripped object, `never` for a strict one. */
832
+ function openCatchall(def: Def): z.ZodType | undefined {
833
+ return def.catchall && defOf(def.catchall).type !== "never" ? def.catchall : undefined;
834
+ }
835
+
836
+ /** The position `schemas` reach, read once in the call for that list of schemas. */
837
+ export function positionOf(schemas: readonly z.ZodType[], call: Call): Position | undefined {
838
+ const distinct = [...new Set(schemas)];
839
+ if (distinct.length === 0) return undefined;
840
+ const key = distinct.map(idOf).join(",");
841
+ const known = call.positions.get(key);
842
+ if (known) return known;
843
+ const { position, leaves, signature } = readPosition(distinct, call);
844
+ // A position walked into is the same position whatever wrappers reached it: `Node` and `Node.optional()` are one,
845
+ // so a recursion defined once comes round to the position it started at. Its masks are part of what it is.
846
+ let found = position;
847
+ if (leaves.length > 0) {
848
+ const leafKey = `${leaves.map(idOf).join(",")}${position.other ? "+" : ""}#${signature}`;
849
+ found = call.walked.get(leafKey) ?? position;
850
+ call.walked.set(leafKey, found);
851
+ }
852
+ call.positions.set(key, found);
853
+ return found;
854
+ }
855
+
856
+ /** One mark, or one opaque node's classes, at a position: and the shapes of value it applies to. */
857
+ interface Reach {
858
+ /** The shapes of value it applies to: every shape, narrowed by each union option it was reached through. */
859
+ readonly gate: ReadonlySet<ValueKind>;
860
+ readonly classes: readonly string[];
861
+ /** Whether it came from an opaque node or a recursion that ran out, rather than a marker. */
862
+ readonly opaque: boolean;
863
+ readonly truncated: boolean;
864
+ /**
865
+ * Whether every class is assumed here: a read here or beneath it ran out of {@link MAX_READ_SCHEMAS}, or reached
866
+ * {@link MAX_RECURSION_DEPTH} on a chain of schemas it had not met.
867
+ */
868
+ readonly unread: boolean;
869
+ }
870
+
871
+ /** The kind of value a structural node holds. */
872
+ function leafKind(leaf: z.ZodType): ValueKind {
873
+ return SCHEMA_KINDS[defOf(leaf).type] as ValueKind;
874
+ }
875
+
876
+ /**
877
+ * Every class in `reaches` and beneath `leaves`, and every class assumed where a read there ran out: what a mask made
878
+ * of them carries. `undefined` where none is marked and no read there was cut off or ran out.
879
+ */
880
+ function carried(reaches: readonly Reach[], leaves: readonly z.ZodType[], call: Call): Mask | undefined {
881
+ const searches = leaves.map((leaf) => search(leaf, call));
882
+ const found = new Set([
883
+ ...reaches.flatMap((reach) => reach.classes),
884
+ ...searches.flatMap((each) => [...each.classes]),
885
+ ]);
886
+ const truncated = reaches.some((reach) => reach.truncated) || searches.some((each) => each.truncated);
887
+ // A read that ran out of the budget never saw what lies past it: every class may be marked there.
888
+ const unread = reaches.some((reach) => reach.unread) || searches.some((each) => each.unread);
889
+ if (found.size === 0 && !truncated && !unread) return undefined;
890
+ const assumed = unread ? unreadClasses().filter((name) => !found.has(name)) : [];
891
+ // The placeholder is the strongest class found, never one only assumed: none found is the cutoff.
892
+ return {
893
+ className: strongestPrivacyClass([...found]) ?? "",
894
+ own: false,
895
+ classes: [...found, ...assumed],
896
+ assumed,
897
+ truncated,
898
+ };
899
+ }
900
+
901
+ /** The mask made of `reaches` and the structural nodes masked with them, or `undefined` when none applies. */
902
+ function maskOf(reaches: readonly Reach[], leaves: readonly z.ZodType[], call: Call): Mask | undefined {
903
+ if (reaches.length === 0) return undefined;
904
+ const mask = carried(reaches, leaves, call);
905
+ if (mask === undefined) return undefined;
906
+ const own = !mask.truncated && reaches.every((reach) => !reach.opaque) && mask.classes.length === 1;
907
+ return { ...mask, own };
908
+ }
909
+
910
+ /**
911
+ * The mask for a value of a kind or a prototype the schemas here do not expect: every class in `reaches` and beneath
912
+ * `leaves`, as beneath an opaque node, or `undefined` where none is marked and no read there was cut off or ran out.
913
+ */
914
+ function misfitMaskOf(reaches: readonly Reach[], leaves: readonly z.ZodType[], call: Call): Mask | undefined {
915
+ return carried(reaches, leaves, call);
916
+ }
917
+
918
+ /** A mask, written out: two positions with the same leaves and the same masks are one. */
919
+ function maskKey(mask: Mask): string {
920
+ const read = mask.truncated ? "truncated" : "read";
921
+ return `${mask.own ? "own" : "strongest"}:${read}:${mask.classes.join("|")}:${mask.assumed.join("|")}`;
922
+ }
923
+
924
+ /** A set of shapes, written out the same way whatever order it was built in. */
925
+ function gateKey(gate: ReadonlySet<ValueKind>): string {
926
+ return VALUE_KINDS.map((kind) => (gate.has(kind) ? "1" : "0")).join("");
927
+ }
928
+
929
+ /** Flatten a position and decide how it is masked. Its children are read when first asked for. */
930
+ function readPosition(
931
+ schemas: readonly z.ZodType[],
932
+ call: Call,
933
+ ): {
934
+ position: Position;
935
+ leaves: readonly z.ZodType[];
936
+ signature: string;
937
+ } {
938
+ const reaches: Reach[] = [];
939
+ const leaves = new Set<z.ZodType>();
940
+ // Each schema met with the shapes it applies to, and whether as an option: a choice whose sides wrap one schema
941
+ // reaches it once per path, and what it adds is the same each time, so it is read once.
942
+ const met = new Set<string>();
943
+ const lazyGates = new Set<string>();
944
+ let other = false;
945
+ // The kinds a scalar here names, and the kinds an opaque node here takes, whatever their prototype.
946
+ const named = new Set<ValueKind>();
947
+ const taken = new Set<ValueKind>();
948
+ /** An opaque node: masked whole with every class beneath it, and kept where there is none. */
949
+ const opaque = (node: z.ZodType, gate: ReadonlySet<ValueKind>) => {
950
+ const own = scalarKinds(node);
951
+ for (const kind of gate) if (!own || own.has(kind)) (own ? named : taken).add(kind);
952
+ const inside = search(node, call);
953
+ // A recursion beneath it that reached the limit counts as possibly marked: the value is masked whole.
954
+ if (inside.classes.size > 0 || inside.truncated)
955
+ reaches.push({
956
+ gate,
957
+ classes: [...inside.classes],
958
+ opaque: true,
959
+ truncated: inside.truncated,
960
+ unread: inside.unread,
961
+ });
962
+ other = true;
963
+ };
964
+ /**
965
+ * A schema the call ran out of its budget before it read, or a lazy nested past the limit: cut off, and only this
966
+ * path. Nothing beneath it was read, so any class may be marked there, and every one is assumed. A lazy is met once
967
+ * per position, so only a chain of lazies not met before, each built anew, reaches the limit.
968
+ */
969
+ const cut = (gate: ReadonlySet<ValueKind>) => {
970
+ reaches.push({ gate, classes: [], opaque: true, truncated: true, unread: true });
971
+ for (const kind of gate) taken.add(kind);
972
+ other = true;
973
+ };
974
+ // Each schema with the shapes of value it applies to, every shape until a union option narrows it, and whether it
975
+ // is an option, narrowed to its own shapes when it is read. Level by level, a level for each lazy the schemas sit
976
+ // beneath at this position, each level in the order the schema declares it, so a path that runs out ends alone.
977
+ type Entry = [z.ZodType, ReadonlySet<ValueKind>, boolean];
978
+ let level: Entry[] = schemas.map((schema): Entry => [schema, EVERY_KIND, false]);
979
+ for (let lazies = 0; level.length > 0; lazies += 1) {
980
+ const next: Entry[] = [];
981
+ const stack = [...level].reverse();
982
+ for (let entry = stack.pop(); entry; entry = stack.pop()) {
983
+ const [node, reached, option] = entry;
984
+ const seen = `${idOf(node)}:${gateKey(reached)}:${option ? "option" : ""}`;
985
+ if (met.has(seen)) continue;
986
+ met.add(seen);
987
+ // Each schema read costs one of the call's budget. Past it, this schema was never read.
988
+ if (!take(call)) {
989
+ cut(reached);
990
+ continue;
991
+ }
992
+ // An option applies where the value has its shape: its marks are narrowed to that shape.
993
+ const kinds = option ? kindsOf(node, call, lazies) : EVERY_KIND;
994
+ const gate = option ? new Set([...reached].filter((kind) => kinds.has(kind))) : reached;
995
+ if (gate.size === 0) continue;
996
+ if (ownPrivacyClasses(node).length > 0) {
997
+ // A marked container is masked whole, so every class marked inside it is a class it carries, as beneath an
998
+ // opaque node: `pii(z.object({ token: secret(…) }))` is listed `pii` and `secret`, and masked `[secret]`.
999
+ const inside = search(node, call);
1000
+ reaches.push({
1001
+ gate,
1002
+ // Its own classes are known whether or not the search beneath it had the budget to read it.
1003
+ classes: [...new Set([...ownPrivacyClasses(node), ...inside.classes])],
1004
+ opaque: false,
1005
+ truncated: inside.truncated,
1006
+ unread: inside.unread,
1007
+ });
1008
+ continue;
1009
+ }
1010
+ const def = defOf(node);
1011
+ const codec = kitCodecOutput(node);
1012
+ if (makesValue(def) && holdsStructure(kindsOf(node, call, lazies))) {
1013
+ // A fallback function or an `.overwrite()` over a structure makes a new value: opaque, as a transform is.
1014
+ opaque(node, gate);
1015
+ continue;
1016
+ }
1017
+ if (def.type === "lazy") {
1018
+ const key = `${idOf(node)}:${gateKey(gate)}`;
1019
+ if (lazyGates.has(key)) continue;
1020
+ lazyGates.add(key);
1021
+ // A lazy nested in more lazies than the limit: this path is cut off, and only this one.
1022
+ if (lazies >= MAX_RECURSION_DEPTH) {
1023
+ cut(gate);
1024
+ continue;
1025
+ }
1026
+ // Resolving it runs its getter, which may build a schema: the call pays for it as for a read.
1027
+ if (!take(call)) {
1028
+ cut(gate);
1029
+ continue;
1030
+ }
1031
+ const inner = lazyInner(node);
1032
+ if (inner) next.push([inner, gate, false]);
1033
+ } else if (WRAPPERS.has(def.type) && def.innerType) {
1034
+ // A constant `.catch()` fallback is a value parse hands back here, so its kind is one the schema names.
1035
+ const fallback = constantFallback(def);
1036
+ const kind = fallback && kindOfValue(fallback.value);
1037
+ if (fallback && kind && gate.has(kind)) (isTypical(fallback.value as object, kind) ? named : taken).add(kind);
1038
+ stack.push([def.innerType, gate, false]);
1039
+ } else if (def.type === "union" && def.options) {
1040
+ for (const each of [...def.options].reverse()) stack.push([each, gate, true]);
1041
+ } else if (def.type === "intersection") {
1042
+ // A value of an intersection has both sides' shapes, so both apply wherever it does.
1043
+ for (const side of [def.right, def.left].filter(isSchema)) stack.push([side, gate, false]);
1044
+ } else if (codec) stack.push([codec, gate, false]);
1045
+ else if (STRUCTURAL.has(def.type)) {
1046
+ if (!gate.has(leafKind(node))) continue;
1047
+ // An object's shape is read here, once, and paid for by the field: its getters may build what it holds.
1048
+ if (def.type === "object") {
1049
+ if (!take(call)) {
1050
+ cut(gate);
1051
+ continue;
1052
+ }
1053
+ readShape(node, call);
1054
+ }
1055
+ leaves.add(node);
1056
+ } else opaque(node, gate);
1057
+ }
1058
+ level = next;
1059
+ }
1060
+ const allLeaves = [...leaves];
1061
+ const byKind = new Map<ValueKind, Mask>();
1062
+ for (const kind of VALUE_KINDS) {
1063
+ const applying = reaches.filter((reach) => reach.gate.has(kind));
1064
+ const mask = maskOf(
1065
+ applying,
1066
+ allLeaves.filter((leaf) => leafKind(leaf) === kind),
1067
+ call,
1068
+ );
1069
+ if (mask) byKind.set(kind, mask);
1070
+ }
1071
+ // A structural node of a shape that is masked is never walked: a value of that shape is masked whole.
1072
+ const leafList = allLeaves.filter((leaf) => !byKind.has(leafKind(leaf)));
1073
+ const mask = maskOf(
1074
+ reaches,
1075
+ allLeaves.filter((leaf) => byKind.has(leafKind(leaf))),
1076
+ call,
1077
+ );
1078
+ const nameMask = maskOf(
1079
+ reaches.filter((reach) => [...reach.gate].some((kind) => kind !== "symbol")),
1080
+ allLeaves.filter((leaf) => byKind.has(leafKind(leaf))),
1081
+ call,
1082
+ );
1083
+ const whole = byKind.size === VALUE_KINDS.length;
1084
+ for (const leaf of allLeaves) named.add(leafKind(leaf));
1085
+ const signature = `${[...byKind].map(([kind, each]) => `${kind}=${maskKey(each)}`).join(";")}/${gateKey(named)}/${gateKey(taken)}`;
1086
+ const memo = new Map<string, Position | undefined>();
1087
+ const once = (slot: string, read: () => readonly z.ZodType[]): Position | undefined => {
1088
+ if (!memo.has(slot)) memo.set(slot, positionOf(read(), call));
1089
+ return memo.get(slot);
1090
+ };
1091
+ const fields = new Map<PropertyKey, Position | undefined>();
1092
+ const objects = leafList.filter((leaf) => defOf(leaf).type === "object");
1093
+ const tuples = leafList.filter((leaf) => defOf(leaf).type === "tuple").map(defOf);
1094
+ const records = slots(leafList, ["record"], (def) => def.valueType);
1095
+ const arrays = slots(leafList, ["array"], (def) => def.element);
1096
+ const catchalls = objects.flatMap((leaf) => [openCatchall(defOf(leaf))].filter(isSchema));
1097
+ // Each object's keys are indexed once, so a key's schemas are looked up, never scanned for across every object here.
1098
+ const open = objects.flatMap((leaf, index) => {
1099
+ const schema = openCatchall(defOf(leaf));
1100
+ return schema ? [{ index, schema }] : [];
1101
+ });
1102
+ let naming: Map<PropertyKey, number[]> | undefined;
1103
+ const namingOf = (): Map<PropertyKey, number[]> => {
1104
+ if (!naming) {
1105
+ naming = new Map();
1106
+ objects.forEach((leaf, index) => {
1107
+ for (const key of Reflect.ownKeys(shapeOf(leaf))) {
1108
+ const list = naming?.get(key);
1109
+ if (list) list.push(index);
1110
+ else naming?.set(key, [index]);
1111
+ }
1112
+ });
1113
+ }
1114
+ return naming;
1115
+ };
1116
+ /** The schemas a key is read by, in the objects' order: an object's own where it names the key, else its catchall. */
1117
+ const namedBy = (name: PropertyKey, indexes: readonly number[]): z.ZodType[] => {
1118
+ const out: z.ZodType[] = [];
1119
+ let next = 0;
1120
+ for (const index of indexes) {
1121
+ for (; next < open.length && (open[next]?.index ?? index) < index; next += 1)
1122
+ out.push(open[next]?.schema as z.ZodType);
1123
+ if (open[next]?.index === index) next += 1;
1124
+ out.push(shapeOf(objects[index] as z.ZodType)[name] as z.ZodType);
1125
+ }
1126
+ for (; next < open.length; next += 1) out.push(open[next]?.schema as z.ZodType);
1127
+ return out;
1128
+ };
1129
+ // Every key no object names is read by the open catchalls alone: one position for all of them.
1130
+ let unnamed: { readonly at: Position | undefined } | undefined;
1131
+ const misfits = new Map<ValueKind, { mask: Mask | undefined }>();
1132
+ const position: Position = {
1133
+ mask,
1134
+ nameMask,
1135
+ whole,
1136
+ maskFor: (value) => {
1137
+ const kind = kindOfValue(value);
1138
+ return kind === undefined ? undefined : byKind.get(kind);
1139
+ },
1140
+ misfit: (value) => {
1141
+ const kind = kindOfValue(value);
1142
+ if (kind === undefined) return undefined;
1143
+ const expected = named.has(kind);
1144
+ if (expected ? isTypical(value as object, kind) : taken.has(kind)) return undefined;
1145
+ let misfit = misfits.get(kind);
1146
+ if (!misfit) {
1147
+ // Where a schema here names its kind, the value is one of those gone wrong; where none does, it could be any.
1148
+ misfit = {
1149
+ mask: expected
1150
+ ? misfitMaskOf(
1151
+ [],
1152
+ leafList.filter((leaf) => leafKind(leaf) === kind),
1153
+ call,
1154
+ )
1155
+ : misfitMaskOf(reaches, allLeaves, call),
1156
+ };
1157
+ misfits.set(kind, misfit);
1158
+ }
1159
+ return misfit;
1160
+ },
1161
+ kinds: new Set(leafList.map((leaf) => defOf(leaf).type)),
1162
+ other,
1163
+ get keys() {
1164
+ return [...namingOf().keys()];
1165
+ },
1166
+ field: (name) => {
1167
+ if (!fields.has(name)) {
1168
+ const indexes = namingOf().get(name);
1169
+ if (indexes) fields.set(name, positionOf([...namedBy(name, indexes), ...records], call));
1170
+ else {
1171
+ unnamed ??= { at: positionOf([...open.map((each) => each.schema), ...records], call) };
1172
+ fields.set(name, unnamed.at);
1173
+ }
1174
+ }
1175
+ return fields.get(name);
1176
+ },
1177
+ get catchall() {
1178
+ return once("catchall", () => [...catchalls, ...records]);
1179
+ },
1180
+ get key() {
1181
+ return once("key", () => slots(leafList, ["record"], (def) => def.keyType));
1182
+ },
1183
+ items: tuples.reduce((most, def) => Math.max(most, def.items?.length ?? 0), 0),
1184
+ item: (index) =>
1185
+ once(`item:${index}`, () => [
1186
+ ...arrays,
1187
+ ...tuples.flatMap((def) => [def.items?.[index] ?? def.rest].filter(isSchema)),
1188
+ ]),
1189
+ get rest() {
1190
+ return once("rest", () => [...arrays, ...tuples.flatMap((def) => [def.rest].filter(isSchema))]);
1191
+ },
1192
+ get mapKey() {
1193
+ return once("mapKey", () => slots(leafList, ["map"], (def) => def.keyType));
1194
+ },
1195
+ get mapValue() {
1196
+ return once("mapValue", () => slots(leafList, ["map"], (def) => def.valueType));
1197
+ },
1198
+ get element() {
1199
+ return once("element", () => slots(leafList, ["set"], (def) => def.valueType));
1200
+ },
1201
+ };
1202
+ return { position, leaves: leafList, signature };
1203
+ }
1204
+
1205
+ /** The position at the root of a schema, read in `call`: a new one, with the whole budget, unless one is given. */
1206
+ export function rootPosition(schema: z.ZodType, call: Call = newCall()): Position {
1207
+ return positionOf([schema], call) as Position;
1208
+ }
1209
+
1210
+ /** What a walk says beside one masked path and class. */
1211
+ export interface MarkNote {
1212
+ /**
1213
+ * Set where the class is assumed rather than found: a read ran out of its budget there, or the schema could not be
1214
+ * read at all, so any class may be marked below it.
1215
+ */
1216
+ readonly truncated?: true;
1217
+ /**
1218
+ * Set on a reference, once {@link MAX_LISTED_PATHS} paths are written out: the path this position was first listed
1219
+ * under. The class is marked somewhere beneath both, and the paths beneath this one are those beneath that one.
1220
+ */
1221
+ readonly sameAs?: string;
1222
+ }
1223
+
1224
+ /**
1225
+ * What a walk reports. `marked` is called once per masked path and class. `unmarkedKey`, when given, is called for each
1226
+ * object key whose position is not masked whole, before the walk steps into it — the hook `pithy privacy scan` reads a
1227
+ * field's name through, so the scan and the inventory walk one tree the same way.
1228
+ */
1229
+ export interface PrivacyVisitor {
1230
+ /** One masked path and class, with a {@link MarkNote} where the class is assumed or the path is a reference. */
1231
+ marked(path: string, className: string, note?: MarkNote): void;
1232
+ unmarkedKey?(path: string, key: string): void;
1233
+ }
1234
+
1235
+ /** `parent.key`, or `key` at the root; an index or a bracket joins without a dot. */
1236
+ function join(path: string, segment: string): string {
1237
+ return path === "" || segment.startsWith("[") ? `${path}${segment}` : `${path}.${segment}`;
1238
+ }
1239
+
1240
+ /** One position the walk meets, at one path. */
1241
+ interface Frame {
1242
+ readonly at: Position | undefined;
1243
+ readonly path: string;
1244
+ readonly depth: number;
1245
+ readonly key?: string;
1246
+ readonly parent?: Frame;
1247
+ /** The positions below, once this one is read. */
1248
+ below?: Frame[];
1249
+ /** Set when its schema could not be read at all: nested deeper than the stack, or a getter that throws. */
1250
+ unread?: true;
1251
+ /** Set when {@link MAX_LISTED_PATHS} paths were written out before this one and its position was walked already. */
1252
+ sameAs?: Frame;
1253
+ }
1254
+
1255
+ /** Whether a frame's position is one it sits below: a recursion defined once, come round. */
1256
+ function onLine(frame: Frame): boolean {
1257
+ for (let above = frame.parent; above; above = above.parent) if (above.at === frame.at) return true;
1258
+ return false;
1259
+ }
1260
+
1261
+ /**
1262
+ * Whether a frame is cut off at the limit with every class assumed: nested deeper than {@link MAX_RECURSION_DEPTH}, at
1263
+ * a position not met on its path. A position is a set of schemas, and it is the position that is compared: a recursion
1264
+ * defined once comes round to a position (the same set of schemas) already read, whose classes are listed where it was
1265
+ * read, so it needs no cut. A chain that keeps producing positions not yet seen, a factory, nesting deeper than the
1266
+ * limit, or once-defined cycles of different lengths side by side in a union, was never read past it: any class may be
1267
+ * marked there, though each schema on the chain may be one already read.
1268
+ */
1269
+ function cutOff(frame: Frame): boolean {
1270
+ return frame.depth > MAX_RECURSION_DEPTH && !onLine(frame);
1271
+ }
1272
+
1273
+ /**
1274
+ * A frame for the position `read` returns. A schema nested deeper than the stack can follow, or one whose getter
1275
+ * throws, cannot be read: its frame is `unread`, listed with every class, and nothing throws.
1276
+ */
1277
+ function frameAt(
1278
+ read: () => Position | undefined,
1279
+ place: { path: string; depth: number; key?: string; parent?: Frame },
1280
+ ): Frame {
1281
+ try {
1282
+ return { ...place, at: read() };
1283
+ } catch {
1284
+ return { ...place, at: undefined, unread: true };
1285
+ }
1286
+ }
1287
+
1288
+ /** The frames below a position that is read, in the order the schema names them. */
1289
+ function framesBelow(frame: Frame, at: Position): Frame[] {
1290
+ const next = frame.depth + 1;
1291
+ const below: Frame[] = [];
1292
+ const add = (child: () => Position | undefined, path: string, depth: number, key?: string) => {
1293
+ const found = frameAt(
1294
+ child,
1295
+ key === undefined ? { path, depth, parent: frame } : { path, depth, key, parent: frame },
1296
+ );
1297
+ // A slot no schema here holds is no frame: only a position, or one that could not be read, is written out.
1298
+ if (found.at !== undefined || found.unread) below.push(found);
1299
+ };
1300
+ for (const key of at.keys) if (typeof key === "string") add(() => at.field(key), join(frame.path, key), next, key);
1301
+ add(() => at.catchall, join(frame.path, "*"), next);
1302
+ // A key is read where its object is, and never nested below it.
1303
+ add(() => at.key, join(frame.path, "<key>"), frame.depth);
1304
+ for (let index = 0; index < at.items; index += 1) add(() => at.item(index), `${frame.path}[${index}]`, next);
1305
+ add(() => at.rest, `${frame.path}[]`, next);
1306
+ add(() => at.mapKey, join(frame.path, "<key>"), next);
1307
+ add(() => at.mapValue, join(frame.path, "*"), next);
1308
+ add(() => at.element, `${frame.path}[]`, next);
1309
+ return below;
1310
+ }
1311
+
1312
+ /** Every class a frame lists at its own path, once each, with whether it is only assumed. */
1313
+ function ownClasses(frame: Frame): [string, boolean][] {
1314
+ const found = new Map<string, boolean>();
1315
+ const mask = frame.at?.mask;
1316
+ if (mask) for (const name of mask.classes) found.set(name, mask.assumed.includes(name));
1317
+ if (frame.unread || cutOff(frame)) for (const name of unreadClasses()) if (!found.has(name)) found.set(name, true);
1318
+ return [...found];
1319
+ }
1320
+
1321
+ /**
1322
+ * Every class listed beneath each position the walk read, by the first frame that read it, and whether each is only
1323
+ * assumed. A class found anywhere beneath is found. A position beneath itself, a recursion defined once, is read until
1324
+ * nothing changes, last read first, so a chain of reused schemas settles in one pass.
1325
+ */
1326
+ function classesBeneath(walked: ReadonlyMap<Position, Frame>): Map<Position, Map<string, boolean>> {
1327
+ const beneath = new Map<Position, Map<string, boolean>>();
1328
+ for (const at of walked.keys()) beneath.set(at, new Map());
1329
+ const firsts = [...walked].reverse();
1330
+ for (let changed = true; changed; ) {
1331
+ changed = false;
1332
+ for (const [at, first] of firsts) {
1333
+ const into = beneath.get(at) as Map<string, boolean>;
1334
+ const merge = (name: string, assumed: boolean) => {
1335
+ const known = into.get(name);
1336
+ if (known === undefined || (known && !assumed)) {
1337
+ into.set(name, assumed);
1338
+ changed = true;
1339
+ }
1340
+ };
1341
+ for (const child of first.below ?? []) {
1342
+ // Past the limit only a cut lists anything, and nothing beneath it is read.
1343
+ if (child.depth > MAX_RECURSION_DEPTH && !cutOff(child)) continue;
1344
+ for (const [name, assumed] of ownClasses(child)) merge(name, assumed);
1345
+ if (child.depth > MAX_RECURSION_DEPTH) continue;
1346
+ const below = child.at && beneath.get(child.at);
1347
+ if (below) for (const [name, assumed] of below) merge(name, assumed);
1348
+ }
1349
+ }
1350
+ }
1351
+ return beneath;
1352
+ }
1353
+
1354
+ /**
1355
+ * Walk a schema tree the way {@link classifyPrivacy} does, from `path`, and report to `visitor`. Exported so a second
1356
+ * reader of the tree — the CLI's scan — cannot walk it differently. A position met again below itself is a recursion
1357
+ * defined once, and is not walked again: its fields are listed once. A position nested more than
1358
+ * {@link MAX_RECURSION_DEPTH} levels deep is cut off by `redact` as {@link TRUNCATED_MASK}; where it is a position not
1359
+ * met on its path, nothing past it was read, so it is listed with every class, each truncated.
1360
+ *
1361
+ * Positions are read level by level, shallowest first, and reported in the schema's own order, all in one call with one
1362
+ * {@link MAX_READ_SCHEMAS}. Each distinct position is read once, wherever it sits; every level above the one the budget
1363
+ * runs out on is read whole, and each position it did not read is listed with {@link unreadClasses}, each truncated.
1364
+ * Once {@link MAX_LISTED_PATHS} paths are written out, a position met again is a reference: its path is listed with
1365
+ * every class beneath it and the path it was first written out under, and nothing below it is written out again. So the
1366
+ * walk is bounded by the budget, however many paths reach one schema. A schema that cannot be read at all, nested
1367
+ * deeper than the stack or behind a getter that throws, is listed with every class, each truncated. It never throws.
1368
+ */
1369
+ export function walkPrivacy(schema: z.ZodType, visitor: PrivacyVisitor, path = ""): void {
1370
+ const call = newCall();
1371
+ const root = frameAt(() => rootPosition(schema, call), { path, depth: 0 });
1372
+ // Read, breadth first, so the call's budget runs out on the deepest levels it reaches, never on a shallow branch.
1373
+ const queue: Frame[] = [root];
1374
+ // Each position is read once, wherever it sits, by the first frame that met it: a schema reused in many fields
1375
+ // costs the budget once. Past the budget a new position is read as cut off, masked whole with every class assumed.
1376
+ const walked = new Map<Position, Frame>();
1377
+ let references = false;
1378
+ for (let index = 0; index < queue.length; index += 1) {
1379
+ const frame = queue[index] as Frame;
1380
+ const { at } = frame;
1381
+ if (at === undefined || frame.depth > MAX_RECURSION_DEPTH || at.whole) continue;
1382
+ if (at.kinds.size === 0 || onLine(frame)) continue;
1383
+ const first = walked.get(at);
1384
+ if (first === undefined) {
1385
+ walked.set(at, frame);
1386
+ } else if (queue.length >= MAX_LISTED_PATHS) {
1387
+ // Enough paths are written out: this one stands for the paths below the first, and adds none of its own.
1388
+ frame.sameAs = first;
1389
+ references = true;
1390
+ continue;
1391
+ }
1392
+ frame.below = framesBelow(frame, at);
1393
+ for (const child of frame.below) queue.push(child);
1394
+ }
1395
+ const beneath = references ? classesBeneath(walked) : new Map<Position, Map<string, boolean>>();
1396
+ // Report, depth first, in the order the schema names each position.
1397
+ const stack: Frame[] = [root];
1398
+ for (let frame = stack.pop(); frame; frame = stack.pop()) {
1399
+ const { at } = frame;
1400
+ if (frame.depth <= MAX_RECURSION_DEPTH && at !== undefined && at.mask === undefined && frame.key !== undefined)
1401
+ visitor.unmarkedKey?.(frame.path, frame.key);
1402
+ // Past the limit a value is cut off as `[truncated]`: on a chain of new schemas every class is assumed there, and
1403
+ // a recursion defined once, come round, lists nothing it has not listed where it was read.
1404
+ if (frame.depth <= MAX_RECURSION_DEPTH || cutOff(frame))
1405
+ for (const [className, assumed] of ownClasses(frame))
1406
+ visitor.marked(frame.path, className, assumed ? { truncated: true } : undefined);
1407
+ if (frame.depth > MAX_RECURSION_DEPTH) continue;
1408
+ const reference = frame.sameAs;
1409
+ if (reference?.at) {
1410
+ for (const [className, assumed] of beneath.get(reference.at) ?? []) {
1411
+ visitor.marked(
1412
+ frame.path,
1413
+ className,
1414
+ assumed ? { truncated: true, sameAs: reference.path } : { sameAs: reference.path },
1415
+ );
1416
+ }
1417
+ }
1418
+ if (frame.below)
1419
+ for (let index = frame.below.length - 1; index >= 0; index -= 1) stack.push(frame.below[index] as Frame);
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * Every position `redact` masks in a schema, and its class — the Art. 30 record for one object, derived rather than
1425
+ * maintained. Empty for a schema with no markers. A path reached twice is listed once per class. It never throws.
1426
+ */
1427
+ export function classifyPrivacy(schema: z.ZodType): PrivacyField[] {
1428
+ const fields: PrivacyField[] = [];
1429
+ const byKey = new Map<string, PrivacyField>();
1430
+ walkPrivacy(schema, {
1431
+ marked: (path, className, note) => {
1432
+ const key = `${path}\u0000${className}`;
1433
+ if (byKey.has(key)) return;
1434
+ const field: PrivacyField = { path, class: className };
1435
+ if (note?.truncated) field.truncated = true;
1436
+ if (note?.sameAs !== undefined) field.sameAs = note.sameAs;
1437
+ byKey.set(key, field);
1438
+ fields.push(field);
1439
+ },
1440
+ });
1441
+ return fields;
1442
+ }
1443
+
1444
+ /** Whether a schema holds a field of `className` at any depth: exactly what {@link classifyPrivacy} lists. */
1445
+ export function hasPrivacy(schema: z.ZodType, className: string): boolean {
1446
+ return classifyPrivacy(schema).some((field) => field.class === className);
1447
+ }