@pikku/inspector 0.12.66 → 0.12.68

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.
@@ -23,11 +23,22 @@ function makeLogger() {
23
23
 
24
24
  /**
25
25
  * Stands in for @pikku/core, which is not importable from a tmp dir. The shapes
26
- * are what matters: `Secret<T>` is the optional brand `.reveal()` returns, and
27
- * the service interfaces are matched by name, so these must keep the real names.
26
+ * are what matters: `Secret<T>` is the optional brand `.reveal()` returns,
27
+ * `Pii<T>` and `Private<T>` are the brands a generated row type carries, and the
28
+ * service interfaces are matched by name, so these must keep the real names.
28
29
  */
29
30
  const PRELUDE = `
30
31
  type Secret<T> = T & { readonly __classification__?: 'secret' }
32
+ type Pii<T> = T & { readonly __classification__?: 'pii' }
33
+ type Private<T> = T & { readonly __classification__?: 'private' }
34
+
35
+ /** What \`db.selectFrom('user')\` hands back once the schema is generated. */
36
+ interface UserRow {
37
+ userId: string
38
+ email: Pii<string>
39
+ internalNote: Private<string>
40
+ }
41
+ declare function getUser(): Promise<UserRow>
31
42
 
32
43
  declare class SecretValue<T = string> {
33
44
  private brand: T
@@ -88,6 +99,9 @@ async function runInspect(source: string) {
88
99
  const sinkErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
89
100
  diagnostics.filter((d) => d.code === ErrorCode.SECRET_REVEALED_INTO_SINK)
90
101
 
102
+ const piiErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
103
+ diagnostics.filter((d) => d.code === ErrorCode.PII_INTO_SINK)
104
+
91
105
  describe('PKU953 — a revealed secret reaching a sink', () => {
92
106
  test('flags a secret logged inline', async () => {
93
107
  const diagnostics = await runInspect(`
@@ -212,3 +226,122 @@ describe('PKU953 — a revealed secret reaching a sink', () => {
212
226
  assert.equal(sinkErrors(diagnostics).length, 0)
213
227
  })
214
228
  })
229
+
230
+ /**
231
+ * PII is not a secret, so the policy is not "never write it down" — it is "not
232
+ * where it leaves your control". A log ships to an aggregator and a webhook
233
+ * posts to somebody else's server; an email is addressed to the data subject,
234
+ * and a queue and an audit stay on the operator's own infrastructure.
235
+ */
236
+ describe('PKU954 — personal data reaching a sink', () => {
237
+ test('flags PII logged inline and through a local binding', async () => {
238
+ const diagnostics = await runInspect(`
239
+ export const leak = pikkuFunc<void, void>({
240
+ func: async ({ logger }) => {
241
+ const user = await getUser()
242
+ logger.info(user.email)
243
+ const email = user.email
244
+ logger.error('send failed', { email })
245
+ },
246
+ })
247
+ `)
248
+ const errors = piiErrors(diagnostics)
249
+ assert.equal(errors.length, 2)
250
+ assert.match(errors[0]!.message, /logger\.info/)
251
+ assert.match(errors[1]!.message, /logger\.error/)
252
+ assert.equal(sinkErrors(diagnostics).length, 0)
253
+ })
254
+
255
+ test('flags a whole row logged, since the PII rides along inside it', async () => {
256
+ const diagnostics = await runInspect(`
257
+ export const leak = pikkuFunc<void, void>({
258
+ func: async ({ logger }) => {
259
+ const user = await getUser()
260
+ logger.info('loaded', user)
261
+ },
262
+ })
263
+ `)
264
+ assert.equal(piiErrors(diagnostics).length, 1)
265
+ })
266
+
267
+ test('flags PII on console and in a webhook', async () => {
268
+ const diagnostics = await runInspect(`
269
+ export const leak = pikkuFunc<void, void>({
270
+ func: async ({ webhooks }) => {
271
+ const user = await getUser()
272
+ console.log(user.email)
273
+ await webhooks.send({ url: 'https://x', data: { email: user.email } })
274
+ },
275
+ })
276
+ `)
277
+ const errors = piiErrors(diagnostics)
278
+ assert.equal(errors.length, 2)
279
+ assert.match(errors[0]!.message, /console\.log/)
280
+ assert.match(errors[1]!.message, /webhooks\.send/)
281
+ })
282
+
283
+ test('stays quiet for an email to the data subject, an audit and a queue', async () => {
284
+ const diagnostics = await runInspect(`
285
+ export const fine = pikkuFunc<void, void>({
286
+ func: async ({ email, audit, auditLog, queueService }) => {
287
+ const user = await getUser()
288
+ await email.send({ to: user.email, text: 'Hi ' + user.email })
289
+ await audit?.audit({ type: 'viewed', input: { email: user.email } })
290
+ await auditLog?.write({ type: 'viewed', input: user.email })
291
+ await queueService.add('welcome', { email: user.email })
292
+ },
293
+ })
294
+ `)
295
+ assert.equal(piiErrors(diagnostics).length, 0)
296
+ assert.equal(sinkErrors(diagnostics).length, 0)
297
+ })
298
+
299
+ test('stays quiet for private, which is about who may read the row', async () => {
300
+ const diagnostics = await runInspect(`
301
+ export const fine = pikkuFunc<void, void>({
302
+ func: async ({ logger }) => {
303
+ const user = await getUser()
304
+ logger.info('note', { note: user.internalNote, id: user.userId })
305
+ },
306
+ })
307
+ `)
308
+ assert.equal(piiErrors(diagnostics).length, 0)
309
+ })
310
+
311
+ test('stays quiet when an explicit annotation erases the brand', async () => {
312
+ const diagnostics = await runInspect(`
313
+ export const documentedLimit = pikkuFunc<void, void>({
314
+ func: async ({ logger }) => {
315
+ const user = await getUser()
316
+ const email: string = user.email
317
+ logger.info(email)
318
+ },
319
+ })
320
+ `)
321
+ assert.equal(piiErrors(diagnostics).length, 0)
322
+ })
323
+
324
+ test('does not run without the classification flag', async () => {
325
+ const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-pii-sink-off-'))
326
+ const file = join(tmpDir, 'funcs.ts')
327
+ await writeFile(
328
+ file,
329
+ PRELUDE +
330
+ `
331
+ export const leak = pikkuFunc<void, void>({
332
+ func: async ({ logger }) => {
333
+ const user = await getUser()
334
+ logger.info(user.email)
335
+ },
336
+ })
337
+ `
338
+ )
339
+ const { logger, diagnostics } = makeLogger()
340
+ try {
341
+ await inspect(logger, [file], { rootDir: tmpDir })
342
+ } finally {
343
+ await rm(tmpDir, { recursive: true, force: true })
344
+ }
345
+ assert.equal(piiErrors(diagnostics).length, 0)
346
+ })
347
+ })
@@ -98,6 +98,9 @@ export enum ErrorCode {
98
98
  // File structure errors
99
99
  SCHEMA_AND_WIRING_COLOCATED = 'PKU490',
100
100
 
101
+ // Schema authoring errors
102
+ SCHEMA_TRANSFORM_NOT_SUPPORTED = 'PKU491',
103
+
101
104
  // Optimization diagnostics
102
105
  SERVICES_NOT_DESTRUCTURED = 'PKU410',
103
106
  WIRES_NOT_DESTRUCTURED = 'PKU411',
@@ -123,6 +126,7 @@ export enum ErrorCode {
123
126
  SECRET_NOT_DECLARED = 'PKU951',
124
127
  SECRET_KEY_NOT_STATIC = 'PKU952',
125
128
  SECRET_REVEALED_INTO_SINK = 'PKU953',
129
+ PII_INTO_SINK = 'PKU954',
126
130
  }
127
131
 
128
132
  /**
@@ -0,0 +1,215 @@
1
+ import * as ts from 'typescript'
2
+ import { findPiiPaths } from './check-pii-output.js'
3
+ import { doesTypeExtendsCore } from './does-type-extend-core-type.js'
4
+
5
+ export type SinkClassification = 'secret' | 'pii'
6
+
7
+ export type SinkViolation = {
8
+ /** How the value reaches the outside world, e.g. `logger.info`. */
9
+ sink: string
10
+ /** Dotted path to the secret inside the argument, or `<argument>` when bare. */
11
+ path: string
12
+ /** Position of the offending argument, for the diagnostic message. */
13
+ line: number
14
+ /** Which brand was found, so the caller can pick the diagnostic. */
15
+ classification: SinkClassification
16
+ }
17
+
18
+ /**
19
+ * A method that writes its arguments somewhere a classified value must never
20
+ * land, keyed by the core interface that owns it. `'*'` means every method on
21
+ * the type is a sink, which is true of a logger.
22
+ *
23
+ * `rejects` differs per sink because the two brands leak differently. A vault
24
+ * secret is wrong everywhere: no sink has a legitimate reason to receive one.
25
+ * PII is wrong only where it leaves the operator's control, or lands somewhere
26
+ * never designed to hold it:
27
+ *
28
+ * - a log is read far more widely than the record it came from, is usually
29
+ * shipped to a third-party aggregator, and is retained long past the
30
+ * lifetime of any consent — so PII does not belong in one;
31
+ * - a webhook posts to somebody else's server, which is the same disclosure
32
+ * with a different transport;
33
+ * - an email is addressed to the data subject, and their own name in it is
34
+ * the point;
35
+ * - an audit is a record in the operator's own database, and naming who did
36
+ * what is usually the legal requirement rather than a breach of it;
37
+ * - a queue payload stays on the operator's own infrastructure and is
38
+ * consumed by their own worker.
39
+ *
40
+ * `private` is deliberately absent everywhere: it marks a value that is not
41
+ * public, which is a statement about who may read the row — not a claim that
42
+ * writing it down is a disclosure.
43
+ */
44
+ const SINKS: Array<{
45
+ coreType: string
46
+ methods: '*' | string[]
47
+ rejects: SinkClassification[]
48
+ }> = [
49
+ { coreType: 'Logger', methods: '*', rejects: ['secret', 'pii'] },
50
+ { coreType: 'WebhookService', methods: ['send'], rejects: ['secret', 'pii'] },
51
+ { coreType: 'QueueService', methods: ['add'], rejects: ['secret'] },
52
+ { coreType: 'EmailService', methods: ['send'], rejects: ['secret'] },
53
+ {
54
+ coreType: 'AuditService',
55
+ methods: ['audit', 'write'],
56
+ rejects: ['secret'],
57
+ },
58
+ { coreType: 'AuditLog', methods: ['write'], rejects: ['secret'] },
59
+ ]
60
+
61
+ /** `console` has no Pikku type, and is read as widely as any logger. */
62
+ const CONSOLE_REJECTS: SinkClassification[] = ['secret', 'pii']
63
+
64
+ /**
65
+ * Finds revealed vault secrets — and, for the sinks that reject them, PII
66
+ * columns — that flow into a sink.
67
+ *
68
+ * `SecretValue` is nominally typed, so the type system alone stops it reaching
69
+ * any of these. `.reveal()` is the deliberate escape hatch, and it hands back
70
+ * `Secret<T>` — the erasable classification brand. That brand is what this scan
71
+ * follows, which means it catches the revealed value both inline
72
+ * (`logger.info(s.reveal())`) and through a local binding, because the local's
73
+ * inferred type carries the brand too.
74
+ *
75
+ * PII arrives by the same route without a `.reveal()`: `Pii<T>` is the brand the
76
+ * generated row types carry, so a column read straight out of the database keeps
77
+ * it through a local binding and into the call.
78
+ *
79
+ * The brand is optional by design, so an explicit annotation (`const t: string =
80
+ * s.reveal()`, or `const email: string = user.email`) erases it and this scan
81
+ * goes quiet. That is the documented limit
82
+ * of a structural marker: it catches the mistake, not someone determined to
83
+ * silence it.
84
+ *
85
+ * Sinks are matched on the receiver's resolved core interface, which means the
86
+ * scan needs the services parameter to be typed. Real projects get that from
87
+ * the generated `#pikku` factory; a hand-rolled file that calls the raw core
88
+ * factory without annotating its services leaves the parameter `any`, and an
89
+ * `any` receiver matches nothing.
90
+ */
91
+ export function findClassifiedSinks(
92
+ checker: ts.TypeChecker,
93
+ handler: ts.Node
94
+ ): SinkViolation[] {
95
+ const violations: SinkViolation[] = []
96
+
97
+ const visit = (node: ts.Node): void => {
98
+ if (
99
+ ts.isCallExpression(node) &&
100
+ ts.isPropertyAccessExpression(node.expression)
101
+ ) {
102
+ const sink = describeSink(checker, node.expression)
103
+ if (sink) {
104
+ for (const arg of node.arguments) {
105
+ const found = findClassifiedExpression(checker, arg, sink.rejects)
106
+ if (found) {
107
+ violations.push({
108
+ sink: sink.name,
109
+ path: found.node.getText(),
110
+ line:
111
+ found.node
112
+ .getSourceFile()
113
+ .getLineAndCharacterOfPosition(found.node.getStart()).line +
114
+ 1,
115
+ classification: found.classification,
116
+ })
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ts.forEachChild(node, visit)
122
+ }
123
+ visit(handler)
124
+
125
+ return violations
126
+ }
127
+
128
+ /**
129
+ * Finds the first expression inside a sink argument whose own type carries one
130
+ * of the brands this sink rejects, and returns it so the diagnostic can quote
131
+ * it.
132
+ *
133
+ * This walks the argument rather than typing it whole, because an object
134
+ * literal in an argument position is contextually typed by the parameter — for
135
+ * a guarded sink that parameter is `Safe<M>`, and resolving the literal against
136
+ * it drops the optional brand before it can be read. Each inner expression
137
+ * (`token.reveal()`, or a reference to a local holding it) carries its own type
138
+ * regardless of the surrounding context, so reading those is what actually
139
+ * sees the secret.
140
+ */
141
+ function findClassifiedExpression(
142
+ checker: ts.TypeChecker,
143
+ node: ts.Node,
144
+ rejects: SinkClassification[],
145
+ /**
146
+ * The node is only being read to reach a property of it, so its own type says
147
+ * nothing about what the call receives. Without this, `user.userId` reports
148
+ * the row's `email` column: the walk types the `user` receiver on its way
149
+ * down, and a row type is branded wherever any of its columns are.
150
+ */
151
+ isReceiver = false
152
+ ): { node: ts.Expression; classification: SinkClassification } | undefined {
153
+ // Descend first, so an object literal wrapping the secret reports the
154
+ // expression that actually holds it (`token.reveal()`) rather than the whole
155
+ // literal. Nothing inside a `SecretValue` receiver is itself branded, so a
156
+ // bare `token.reveal()` still falls through to the self-check below.
157
+ for (const child of node.getChildren()) {
158
+ // A property name is not a value, and typing it re-resolves the property.
159
+ if (ts.isPropertyAssignment(node) && child === node.name) continue
160
+ const childIsReceiver =
161
+ (ts.isPropertyAccessExpression(node) ||
162
+ ts.isElementAccessExpression(node)) &&
163
+ child === node.expression
164
+ const found = findClassifiedExpression(
165
+ checker,
166
+ child,
167
+ rejects,
168
+ childIsReceiver
169
+ )
170
+ if (found) return found
171
+ }
172
+
173
+ if (!isReceiver && ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
174
+ const type = checker.getTypeAtLocation(node)
175
+ /* `secret` outranks `pii` when a value somehow carries both, so the caller
176
+ reports the more serious of the two rather than whichever came first. */
177
+ for (const classification of ['secret', 'pii'] as const) {
178
+ if (!rejects.includes(classification)) continue
179
+ const hit = findPiiPaths(checker, type).some(
180
+ (f) => f.classification === classification
181
+ )
182
+ if (hit) return { node, classification }
183
+ }
184
+ }
185
+
186
+ return undefined
187
+ }
188
+
189
+ /**
190
+ * Names the sink a call writes to, or undefined when the call is not one.
191
+ * `console` is matched by identifier because it is a global with no Pikku type;
192
+ * everything else is matched on its resolved core interface so a renamed
193
+ * service or a decorator still counts.
194
+ */
195
+ function describeSink(
196
+ checker: ts.TypeChecker,
197
+ callee: ts.PropertyAccessExpression
198
+ ): { name: string; rejects: SinkClassification[] } | undefined {
199
+ const method = callee.name.text
200
+ const receiver = callee.expression
201
+
202
+ if (ts.isIdentifier(receiver) && receiver.text === 'console') {
203
+ return { name: `console.${method}`, rejects: CONSOLE_REJECTS }
204
+ }
205
+
206
+ const receiverType = checker.getTypeAtLocation(receiver)
207
+ for (const { coreType, methods, rejects } of SINKS) {
208
+ if (methods !== '*' && !methods.includes(method)) continue
209
+ if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
210
+ return { name: `${receiver.getText()}.${method}`, rejects }
211
+ }
212
+ }
213
+
214
+ return undefined
215
+ }
@@ -0,0 +1,70 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import * as z from 'zod'
4
+ import { findZodTransform } from './find-zod-transform.js'
5
+
6
+ test('a schema with no transform is reported as clean', () => {
7
+ assert.equal(findZodTransform(z.object({ id: z.string() })), undefined)
8
+ assert.equal(findZodTransform(z.string().min(1).trim()), undefined)
9
+ // a refinement narrows the value, it does not change its shape
10
+ assert.equal(
11
+ findZodTransform(z.object({ id: z.string() }).refine(() => true)),
12
+ undefined
13
+ )
14
+ })
15
+
16
+ test('a transform on the schema itself is found at the root', () => {
17
+ assert.equal(
18
+ findZodTransform(z.object({ id: z.string() }).transform((v) => v)),
19
+ ''
20
+ )
21
+ })
22
+
23
+ test('a transform on a field is found by path', () => {
24
+ assert.equal(
25
+ findZodTransform(
26
+ z.object({
27
+ id: z.string(),
28
+ when: z.string().transform((s) => new Date(s)),
29
+ })
30
+ ),
31
+ '.when'
32
+ )
33
+ })
34
+
35
+ test('a transform is found through wrappers, arrays and unions', () => {
36
+ assert.equal(
37
+ findZodTransform(
38
+ z.object({ tags: z.array(z.string().transform((s) => s.trim())) })
39
+ ),
40
+ '.tags[]'
41
+ )
42
+ assert.equal(
43
+ findZodTransform(z.object({ n: z.string().transform(Number).optional() })),
44
+ '.n'
45
+ )
46
+ assert.equal(
47
+ findZodTransform(
48
+ z.object({ v: z.union([z.string(), z.number().transform(String)]) })
49
+ ),
50
+ '.v|1'
51
+ )
52
+ assert.equal(
53
+ findZodTransform(
54
+ z.object({
55
+ m: z.record(
56
+ z.string(),
57
+ z.string().transform((s) => s)
58
+ ),
59
+ })
60
+ ),
61
+ '.m[]'
62
+ )
63
+ })
64
+
65
+ test('a self-referencing schema terminates instead of recursing forever', () => {
66
+ const node: z.ZodType = z.lazy(() =>
67
+ z.object({ name: z.string(), children: z.array(node) })
68
+ )
69
+ assert.equal(findZodTransform(node), undefined)
70
+ })
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Locate a `.transform()` anywhere inside a Zod schema.
3
+ *
4
+ * A transform is why a schema cannot be a function's `input`: the value the
5
+ * caller sends and the value the function receives are different, and a wire
6
+ * contract has to describe one shape. Zod says so twice, in two different
7
+ * places, and neither says it usefully on its own — `toJSONSchema` with
8
+ * `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
9
+ * everything, and `zodToTs` throws about TypeScript rather than about the
10
+ * wiring. Finding it up front lets the diagnostic name the schema, the path,
11
+ * and the call.
12
+ *
13
+ * Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
14
+ * the walk looks for that node rather than for the method.
15
+ */
16
+
17
+ /** Max nodes visited, so a pathological or cyclic schema cannot hang the walk. */
18
+ const MAX_NODES = 10_000
19
+
20
+ interface ZodNode {
21
+ _zod?: { def?: Record<string, any> }
22
+ }
23
+
24
+ const isNode = (value: unknown): value is ZodNode =>
25
+ typeof value === 'object' &&
26
+ value !== null &&
27
+ typeof (value as ZodNode)._zod?.def?.type === 'string'
28
+
29
+ /**
30
+ * The path to the first `.transform()` found — `''` when the transform is the
31
+ * schema itself, `'.user.email'` when it is nested — or `undefined` when there
32
+ * is none.
33
+ */
34
+ export function findZodTransform(schema: unknown): string | undefined {
35
+ const seen = new Set<unknown>()
36
+ let budget = MAX_NODES
37
+
38
+ const walk = (node: unknown, path: string): string | undefined => {
39
+ if (budget-- <= 0 || !isNode(node) || seen.has(node)) return undefined
40
+ seen.add(node)
41
+
42
+ const def = node._zod!.def!
43
+ if (def.type === 'transform') return path
44
+
45
+ const children: Array<[unknown, string]> = []
46
+ switch (def.type) {
47
+ case 'object':
48
+ case 'interface':
49
+ for (const [key, child] of Object.entries(def.shape ?? {})) {
50
+ children.push([child, `${path}.${key}`])
51
+ }
52
+ break
53
+ case 'array':
54
+ case 'set':
55
+ children.push([def.element, `${path}[]`])
56
+ break
57
+ case 'tuple':
58
+ for (const [i, child] of (def.items ?? []).entries()) {
59
+ children.push([child, `${path}[${i}]`])
60
+ }
61
+ if (def.rest) children.push([def.rest, `${path}[]`])
62
+ break
63
+ case 'union':
64
+ for (const [i, child] of (def.options ?? []).entries()) {
65
+ children.push([child, `${path}|${i}`])
66
+ }
67
+ break
68
+ case 'intersection':
69
+ children.push([def.left, path], [def.right, path])
70
+ break
71
+ case 'record':
72
+ case 'map':
73
+ children.push(
74
+ [def.keyType, `${path}[key]`],
75
+ [def.valueType, `${path}[]`]
76
+ )
77
+ break
78
+ case 'pipe':
79
+ // `.transform()` itself: report the pipe's own path, not `out`'s.
80
+ children.push([def.in, path], [def.out, path])
81
+ break
82
+ default:
83
+ // optional, nullable, default, prefault, nonoptional, readonly, catch,
84
+ // promise, lazy, success — every wrapper Zod spells `innerType`.
85
+ if (def.innerType) children.push([def.innerType, path])
86
+ if (def.getter) {
87
+ try {
88
+ children.push([def.getter(), path])
89
+ } catch {
90
+ // A lazy schema that cannot be forced tells us nothing; skip it.
91
+ }
92
+ }
93
+ }
94
+
95
+ for (const [child, childPath] of children) {
96
+ const found = walk(child, childPath)
97
+ if (found !== undefined) return found
98
+ }
99
+ return undefined
100
+ }
101
+
102
+ return walk(schema, '')
103
+ }
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
3
3
  import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
4
4
  import { tmpdir } from 'os'
5
5
  import { join } from 'path'
6
+ import { fileURLToPath } from 'url'
6
7
 
7
8
  import { getInitialInspectorState } from '../inspector.js'
8
9
  import { generateAllSchemas, schemaRuntimeFile } from './schema-generator.js'
@@ -223,3 +224,49 @@ describe('schemaRuntimeFile', () => {
223
224
  assert.equal(schemaRuntimeFile(orphan), orphan)
224
225
  })
225
226
  })
227
+
228
+ describe('a Zod schema that cannot be a wire contract', () => {
229
+ test('names .transform() and its own error code, not a downstream one', async () => {
230
+ // A transform used to surface as PKU724 — the *bad `#pikku/*` import* error
231
+ // — because the schema failed to convert, its generated leaf never got
232
+ // written, and the type that leaf feeds resolved to an error type. The
233
+ // message sent you to an import block that was fine.
234
+ // Inside the package, so the fixture's `import 'zod'` resolves the same
235
+ // way a project's would; a tmpdir has no node_modules above it.
236
+ const dir = mkdtempSync(
237
+ join(fileURLToPath(new URL('../../', import.meta.url)), '.zod-transform-')
238
+ )
239
+ try {
240
+ const file = join(dir, 'schema.ts')
241
+ writeFileSync(
242
+ file,
243
+ `import * as z from 'zod'\n` +
244
+ `export const CreateAssessment = z.object({ patientId: z.string(), when: z.string().transform((s) => new Date(s)) })\n`
245
+ )
246
+ const state = getInitialInspectorState(dir) as any
247
+ state.schemaLookup.set('CreateAssessmentInput', {
248
+ variableName: 'CreateAssessment',
249
+ sourceFile: file,
250
+ vendor: 'zod',
251
+ })
252
+
253
+ const errors: string[] = []
254
+ await assert.rejects(() =>
255
+ generateAllSchemas(
256
+ { ...logger, error: (m: string) => errors.push(m) },
257
+ { tsconfig: join(projectDir, 'tsconfig.json') },
258
+ state
259
+ )
260
+ )
261
+
262
+ const reported = errors.join('\n')
263
+ assert.match(reported, /PKU491/)
264
+ assert.match(reported, /CreateAssessmentInput/)
265
+ assert.match(reported, /\.transform\(\)/)
266
+ assert.match(reported, /\.when/)
267
+ assert.doesNotMatch(reported, /PKU724/)
268
+ } finally {
269
+ rmSync(dir, { recursive: true, force: true })
270
+ }
271
+ })
272
+ })
@@ -12,6 +12,7 @@ import { createGenerator, RootlessError } from 'ts-json-schema-generator'
12
12
  import { register } from 'tsx/esm/api'
13
13
  import * as z from 'zod'
14
14
  import { zodToTs, createAuxiliaryTypeStore } from 'zod-to-ts'
15
+ import { findZodTransform } from './find-zod-transform.js'
15
16
  import type { FunctionsMeta } from '@pikku/core/services'
16
17
  import type { JSONValue } from '@pikku/core/utils'
17
18
  import type { HTTPWiringsMeta } from '@pikku/core/http'
@@ -508,6 +509,19 @@ function processZodSchema(
508
509
  fakeSourceFile: ts.SourceFile,
509
510
  logger: InspectorLogger
510
511
  ): void {
512
+ const transformPath = findZodTransform(zodSchema)
513
+ if (transformPath !== undefined) {
514
+ const where = transformPath === '' ? 'the schema itself' : transformPath
515
+ throw new Error(
516
+ `[${ErrorCode.SCHEMA_TRANSFORM_NOT_SUPPORTED}] '${schemaName}' contains a .transform() at ${where}.\n` +
517
+ 'A transform makes the value sent and the value received two different ' +
518
+ 'shapes, and a wire contract describes one: it cannot be expressed in ' +
519
+ 'JSON Schema or in the generated TypeScript.\n' +
520
+ 'Validate with the plain schema and apply the transform inside the ' +
521
+ 'function, or move it to the output type.'
522
+ )
523
+ }
524
+
511
525
  const schema = z.toJSONSchema(zodSchema, {
512
526
  unrepresentable: 'any',
513
527
  override: ({ zodSchema, jsonSchema }) => {