@pikku/inspector 0.12.67 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.12.68
2
+
3
+ ### Patch Changes
4
+
5
+ - ee9da9e: A `.transform()` in a schema used as a function's `input` now reports PKU491, naming the schema and the path to the transform, instead of surfacing downstream as PKU724 — the bad `#pikku/*` import error.
6
+ - Updated dependencies [ee9da9e]
7
+ - Updated dependencies [7a15c9c]
8
+ - Updated dependencies [ee9da9e]
9
+ - @pikku/core@0.12.99
10
+
1
11
  ## 0.12.67
2
12
 
3
13
  ### Patch Changes
@@ -65,6 +65,7 @@ export declare enum ErrorCode {
65
65
  AGENT_TOOL_MISSING_DESCRIPTION = "PKU154",
66
66
  AGENT_SCORER_NOT_FOUND = "PKU155",
67
67
  SCHEMA_AND_WIRING_COLOCATED = "PKU490",
68
+ SCHEMA_TRANSFORM_NOT_SUPPORTED = "PKU491",
68
69
  SERVICES_NOT_DESTRUCTURED = "PKU410",
69
70
  WIRES_NOT_DESTRUCTURED = "PKU411",
70
71
  FUNCTION_DYNAMIC_IMPORT = "PKU498",
@@ -84,6 +84,8 @@ export var ErrorCode;
84
84
  ErrorCode["AGENT_SCORER_NOT_FOUND"] = "PKU155";
85
85
  // File structure errors
86
86
  ErrorCode["SCHEMA_AND_WIRING_COLOCATED"] = "PKU490";
87
+ // Schema authoring errors
88
+ ErrorCode["SCHEMA_TRANSFORM_NOT_SUPPORTED"] = "PKU491";
87
89
  // Optimization diagnostics
88
90
  ErrorCode["SERVICES_NOT_DESTRUCTURED"] = "PKU410";
89
91
  ErrorCode["WIRES_NOT_DESTRUCTURED"] = "PKU411";
@@ -0,0 +1,21 @@
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
+ * The path to the first `.transform()` found — `''` when the transform is the
18
+ * schema itself, `'.user.email'` when it is nested — or `undefined` when there
19
+ * is none.
20
+ */
21
+ export declare function findZodTransform(schema: unknown): string | undefined;
@@ -0,0 +1,93 @@
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
+ /** Max nodes visited, so a pathological or cyclic schema cannot hang the walk. */
17
+ const MAX_NODES = 10_000;
18
+ const isNode = (value) => typeof value === 'object' &&
19
+ value !== null &&
20
+ typeof value._zod?.def?.type === 'string';
21
+ /**
22
+ * The path to the first `.transform()` found — `''` when the transform is the
23
+ * schema itself, `'.user.email'` when it is nested — or `undefined` when there
24
+ * is none.
25
+ */
26
+ export function findZodTransform(schema) {
27
+ const seen = new Set();
28
+ let budget = MAX_NODES;
29
+ const walk = (node, path) => {
30
+ if (budget-- <= 0 || !isNode(node) || seen.has(node))
31
+ return undefined;
32
+ seen.add(node);
33
+ const def = node._zod.def;
34
+ if (def.type === 'transform')
35
+ return path;
36
+ const children = [];
37
+ switch (def.type) {
38
+ case 'object':
39
+ case 'interface':
40
+ for (const [key, child] of Object.entries(def.shape ?? {})) {
41
+ children.push([child, `${path}.${key}`]);
42
+ }
43
+ break;
44
+ case 'array':
45
+ case 'set':
46
+ children.push([def.element, `${path}[]`]);
47
+ break;
48
+ case 'tuple':
49
+ for (const [i, child] of (def.items ?? []).entries()) {
50
+ children.push([child, `${path}[${i}]`]);
51
+ }
52
+ if (def.rest)
53
+ children.push([def.rest, `${path}[]`]);
54
+ break;
55
+ case 'union':
56
+ for (const [i, child] of (def.options ?? []).entries()) {
57
+ children.push([child, `${path}|${i}`]);
58
+ }
59
+ break;
60
+ case 'intersection':
61
+ children.push([def.left, path], [def.right, path]);
62
+ break;
63
+ case 'record':
64
+ case 'map':
65
+ children.push([def.keyType, `${path}[key]`], [def.valueType, `${path}[]`]);
66
+ break;
67
+ case 'pipe':
68
+ // `.transform()` itself: report the pipe's own path, not `out`'s.
69
+ children.push([def.in, path], [def.out, path]);
70
+ break;
71
+ default:
72
+ // optional, nullable, default, prefault, nonoptional, readonly, catch,
73
+ // promise, lazy, success — every wrapper Zod spells `innerType`.
74
+ if (def.innerType)
75
+ children.push([def.innerType, path]);
76
+ if (def.getter) {
77
+ try {
78
+ children.push([def.getter(), path]);
79
+ }
80
+ catch {
81
+ // A lazy schema that cannot be forced tells us nothing; skip it.
82
+ }
83
+ }
84
+ }
85
+ for (const [child, childPath] of children) {
86
+ const found = walk(child, childPath);
87
+ if (found !== undefined)
88
+ return found;
89
+ }
90
+ return undefined;
91
+ };
92
+ return walk(schema, '');
93
+ }
@@ -6,6 +6,7 @@ import { createGenerator, RootlessError } from 'ts-json-schema-generator';
6
6
  import { register } from 'tsx/esm/api';
7
7
  import * as z from 'zod';
8
8
  import { zodToTs, createAuxiliaryTypeStore } from 'zod-to-ts';
9
+ import { findZodTransform } from './find-zod-transform.js';
9
10
  import { ErrorCode } from '../error-codes.js';
10
11
  import { generateCustomTypes } from './custom-types-generator.js';
11
12
  const PRIMITIVE_TYPES = new Set([
@@ -382,6 +383,16 @@ async function importWithRegister(sourceFile) {
382
383
  }
383
384
  }
384
385
  function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger) {
386
+ const transformPath = findZodTransform(zodSchema);
387
+ if (transformPath !== undefined) {
388
+ const where = transformPath === '' ? 'the schema itself' : transformPath;
389
+ throw new Error(`[${ErrorCode.SCHEMA_TRANSFORM_NOT_SUPPORTED}] '${schemaName}' contains a .transform() at ${where}.\n` +
390
+ 'A transform makes the value sent and the value received two different ' +
391
+ 'shapes, and a wire contract describes one: it cannot be expressed in ' +
392
+ 'JSON Schema or in the generated TypeScript.\n' +
393
+ 'Validate with the plain schema and apply the transform inside the ' +
394
+ 'function, or move it to the output type.');
395
+ }
385
396
  const schema = z.toJSONSchema(zodSchema, {
386
397
  unrepresentable: 'any',
387
398
  override: ({ zodSchema, jsonSchema }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.67",
3
+ "version": "0.12.68",
4
4
  "description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "BUSL-1.1",
@@ -45,10 +45,10 @@
45
45
  "zod-to-ts": "^2.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@pikku/core": "^0.12.97"
48
+ "@pikku/core": "^0.12.99"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.97",
51
+ "@pikku/core": "^0.12.99",
52
52
  "@types/node": "^24.13.3"
53
53
  },
54
54
  "engines": {
@@ -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',
@@ -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 }) => {