@pikku/inspector 0.12.9 → 0.12.11

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.
@@ -12,7 +12,7 @@ import * as ts from 'typescript';
12
12
  * - ThrowStatement (for WorkflowCancelledException)
13
13
  * - Block (containers)
14
14
  */
15
- export function validateNoDisallowedPatterns(node) {
15
+ export function validateNoDisallowedPatterns(node, options) {
16
16
  const errors = [];
17
17
  function visitBlock(block) {
18
18
  for (const statement of block.statements) {
@@ -30,7 +30,7 @@ export function validateNoDisallowedPatterns(node) {
30
30
  // Unknown/disallowed statement type
31
31
  const nodeType = ts.SyntaxKind[statement.kind];
32
32
  errors.push({
33
- message: `Statement type '${nodeType}' is not allowed in simple workflows. Allowed: const/let, if/else, switch/case, for..of, return, throw, and workflow calls. If this should be supported, please report the node type: ${nodeType}`,
33
+ message: `Statement type '${nodeType}' is not allowed in DSL workflows. Allowed: const/let, if/else, switch/case, for..of, return, throw, and workflow calls. If this should be supported, please report the node type: ${nodeType}`,
34
34
  node: statement,
35
35
  });
36
36
  }
@@ -40,7 +40,7 @@ export function validateNoDisallowedPatterns(node) {
40
40
  // Disallow while and do-while
41
41
  if (ts.isWhileStatement(node) || ts.isDoStatement(node)) {
42
42
  errors.push({
43
- message: 'while and do-while loops are not allowed in simple workflows',
43
+ message: 'while and do-while loops are not allowed in DSL workflows',
44
44
  node,
45
45
  });
46
46
  return;
@@ -48,13 +48,13 @@ export function validateNoDisallowedPatterns(node) {
48
48
  // Disallow for and for-in loops
49
49
  if (ts.isForInStatement(node) || ts.isForStatement(node)) {
50
50
  errors.push({
51
- message: 'for and for-in loops are not allowed in simple workflows. Use for-of instead.',
51
+ message: 'for and for-in loops are not allowed in DSL workflows. Use for-of instead.',
52
52
  node,
53
53
  });
54
54
  return;
55
55
  }
56
56
  // Check for inline workflow.do
57
- if (ts.isCallExpression(node)) {
57
+ if (!options?.allowInline && ts.isCallExpression(node)) {
58
58
  if (ts.isPropertyAccessExpression(node.expression)) {
59
59
  const propAccess = node.expression;
60
60
  if (propAccess.name.text === 'do' &&
@@ -65,7 +65,7 @@ export function validateNoDisallowedPatterns(node) {
65
65
  (ts.isArrowFunction(secondArg) ||
66
66
  ts.isFunctionExpression(secondArg))) {
67
67
  errors.push({
68
- message: 'Inline workflow.do with function argument is not allowed in simple workflows. Use RPC form instead.',
68
+ message: 'Inline workflow.do with function argument is not allowed in DSL workflows. Use pikkuWorkflowComplexFunc instead.',
69
69
  node,
70
70
  });
71
71
  return;
@@ -97,11 +97,19 @@ export function validateAwaitedCalls(node) {
97
97
  if (propAccess.name.text === 'all' &&
98
98
  ts.isIdentifier(propAccess.expression) &&
99
99
  propAccess.expression.text === 'Promise') {
100
- // console.log('[DEBUG] Found Promise.all, setting insidePromiseAll=true')
101
- // Visit children with insidePromiseAll = true
102
100
  ts.forEachChild(node, (child) => visit(child, parentIsAwait, true));
103
101
  return;
104
102
  }
103
+ // .push() on an array — workflow.do() inside is collecting promises
104
+ if (propAccess.name.text === 'push') {
105
+ ts.forEachChild(node, (child) => visit(child, parentIsAwait, true));
106
+ return;
107
+ }
108
+ }
109
+ // Array literal — workflow.do() inside is collecting promises
110
+ if (ts.isArrayLiteralExpression(node)) {
111
+ ts.forEachChild(node, (child) => visit(child, parentIsAwait, true));
112
+ return;
105
113
  }
106
114
  // Now check for workflow calls
107
115
  if (ts.isCallExpression(node)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.9",
3
+ "version": "0.12.11",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
38
- "@pikku/core": "^0.12.16",
38
+ "@pikku/core": "^0.12.19",
39
39
  "path-to-regexp": "^8.3.0",
40
40
  "ts-json-schema-generator": "^2.5.0",
41
41
  "tsx": "^4.21.0",
@@ -0,0 +1,169 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { inspect } from '../inspector.js'
7
+ import { ErrorCode } from '../error-codes.js'
8
+ import type { InspectorLogger } from '../types.js'
9
+
10
+ describe('addFunctions duplicate name handling', () => {
11
+ test('logs a critical error when function name is duplicated across files', async () => {
12
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-duplicate-function-'))
13
+ const fileA = join(rootDir, 'a.ts')
14
+ const fileB = join(rootDir, 'b.ts')
15
+
16
+ await writeFile(
17
+ fileA,
18
+ [
19
+ "import { pikkuFunc } from '@pikku/core'",
20
+ 'export const createUser = pikkuFunc({',
21
+ ' func: async () => ({ ok: true })',
22
+ '})',
23
+ ].join('\n')
24
+ )
25
+
26
+ await writeFile(
27
+ fileB,
28
+ [
29
+ "import { pikkuFunc } from '@pikku/core'",
30
+ 'export const createUser = pikkuFunc({',
31
+ ' func: async () => ({ ok: true })',
32
+ '})',
33
+ ].join('\n')
34
+ )
35
+
36
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
37
+ const logger: InspectorLogger = {
38
+ debug: () => {},
39
+ info: () => {},
40
+ warn: () => {},
41
+ error: () => {},
42
+ critical: (code: ErrorCode, message: string) => {
43
+ criticals.push({ code, message })
44
+ },
45
+ hasCriticalErrors: () => criticals.length > 0,
46
+ }
47
+
48
+ try {
49
+ const state = await inspect(logger, [fileA, fileB], { rootDir })
50
+ const nameCollision = criticals.find(
51
+ (entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
52
+ )
53
+ assert.ok(nameCollision)
54
+ assert.match(nameCollision!.message, /createUser/)
55
+ assert.strictEqual(state.rpc.internalMeta['createUser'], 'createUser')
56
+ } finally {
57
+ await rm(rootDir, { recursive: true, force: true })
58
+ }
59
+ })
60
+
61
+ test('allows same base function name across files when versions differ', async () => {
62
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-versioned-function-'))
63
+ const fileA = join(rootDir, 'a.ts')
64
+ const fileB = join(rootDir, 'b.ts')
65
+
66
+ await writeFile(
67
+ fileA,
68
+ [
69
+ "import { pikkuFunc } from '@pikku/core'",
70
+ 'export const createUser = pikkuFunc({',
71
+ ' version: 1,',
72
+ ' func: async () => ({ ok: true })',
73
+ '})',
74
+ ].join('\n')
75
+ )
76
+
77
+ await writeFile(
78
+ fileB,
79
+ [
80
+ "import { pikkuFunc } from '@pikku/core'",
81
+ 'export const createUser = pikkuFunc({',
82
+ ' version: 2,',
83
+ ' func: async () => ({ ok: true })',
84
+ '})',
85
+ ].join('\n')
86
+ )
87
+
88
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
89
+ const logger: InspectorLogger = {
90
+ debug: () => {},
91
+ info: () => {},
92
+ warn: () => {},
93
+ error: () => {},
94
+ critical: (code: ErrorCode, message: string) => {
95
+ criticals.push({ code, message })
96
+ },
97
+ hasCriticalErrors: () => criticals.length > 0,
98
+ }
99
+
100
+ try {
101
+ const state = await inspect(logger, [fileA, fileB], { rootDir })
102
+ const nameCollision = criticals.find(
103
+ (entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
104
+ )
105
+ assert.equal(nameCollision, undefined)
106
+ assert.strictEqual(state.rpc.internalMeta['createUser'], 'createUser@v2')
107
+ assert.ok(state.functions.meta['createUser@v1'])
108
+ assert.ok(state.functions.meta['createUser@v2'])
109
+ } finally {
110
+ await rm(rootDir, { recursive: true, force: true })
111
+ }
112
+ })
113
+
114
+ test('logs a critical error when exposed function name is duplicated across files', async () => {
115
+ const rootDir = await mkdtemp(
116
+ join(tmpdir(), 'pikku-exposed-duplicate-function-')
117
+ )
118
+ const fileA = join(rootDir, 'a.ts')
119
+ const fileB = join(rootDir, 'b.ts')
120
+
121
+ await writeFile(
122
+ fileA,
123
+ [
124
+ "import { pikkuFunc } from '@pikku/core'",
125
+ 'export const createUser = pikkuFunc({',
126
+ ' expose: true,',
127
+ ' func: async () => ({ ok: true })',
128
+ '})',
129
+ ].join('\n')
130
+ )
131
+
132
+ await writeFile(
133
+ fileB,
134
+ [
135
+ "import { pikkuFunc } from '@pikku/core'",
136
+ 'export const createUser = pikkuFunc({',
137
+ ' expose: true,',
138
+ ' func: async () => ({ ok: true })',
139
+ '})',
140
+ ].join('\n')
141
+ )
142
+
143
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
144
+ const logger: InspectorLogger = {
145
+ debug: () => {},
146
+ info: () => {},
147
+ warn: () => {},
148
+ error: () => {},
149
+ critical: (code: ErrorCode, message: string) => {
150
+ criticals.push({ code, message })
151
+ },
152
+ hasCriticalErrors: () => criticals.length > 0,
153
+ }
154
+
155
+ try {
156
+ await inspect(logger, [fileA, fileB], { rootDir })
157
+ const nameCollision = criticals.find(
158
+ (entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
159
+ )
160
+ assert.ok(nameCollision)
161
+ assert.match(
162
+ nameCollision!.message,
163
+ /Function name 'createUser' is not unique/
164
+ )
165
+ } finally {
166
+ await rm(rootDir, { recursive: true, force: true })
167
+ }
168
+ })
169
+ })
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript'
2
- import type { AddWiring, SchemaRef } from '../types.js'
2
+ import type { AddWiring, InspectorState, SchemaRef } from '../types.js'
3
3
  import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js'
4
4
  import type { TypesMap } from '../types-map.js'
5
5
  import {
@@ -9,7 +9,7 @@ import {
9
9
  import { extractFunctionNode } from '../utils/extract-function-node.js'
10
10
  import { extractUsedWires } from '../utils/extract-services.js'
11
11
  import type { FunctionServicesMeta } from '@pikku/core'
12
- import { formatVersionedId } from '@pikku/core'
12
+ import { formatVersionedId, parseVersionedId } from '@pikku/core'
13
13
  import {
14
14
  getPropertyValue,
15
15
  getCommonWireMetaData,
@@ -287,6 +287,31 @@ function unwrapPromise(checker: ts.TypeChecker, type: ts.Type): ts.Type {
287
287
  return type
288
288
  }
289
289
 
290
+ const resolveExistingFunctionSource = (
291
+ state: InspectorState,
292
+ pikkuFuncId: string
293
+ ): string | null => {
294
+ return (
295
+ state.functions.meta[pikkuFuncId]?.sourceFile ||
296
+ state.rpc.internalFiles.get(pikkuFuncId)?.path ||
297
+ null
298
+ )
299
+ }
300
+
301
+ const areCompatibleFunctionIds = (
302
+ existingId: string,
303
+ incomingId: string
304
+ ): boolean => {
305
+ if (existingId === incomingId) {
306
+ return true
307
+ }
308
+
309
+ const existingParsed = parseVersionedId(existingId)
310
+ const incomingParsed = parseVersionedId(incomingId)
311
+
312
+ return existingParsed.baseName === incomingParsed.baseName
313
+ }
314
+
290
315
  /**
291
316
  * Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
292
317
  * then push into state.functions.meta.
@@ -339,6 +364,7 @@ export const addFunctions: AddWiring = (
339
364
  let remote: boolean | undefined
340
365
  let mcp: boolean | undefined
341
366
  let readonly_: boolean | undefined
367
+ let deploy: 'serverless' | 'server' | 'auto' | undefined
342
368
  let approvalRequired: boolean | undefined
343
369
  let approvalDescription: string | undefined
344
370
  let version: number | undefined
@@ -422,6 +448,11 @@ export const addFunctions: AddWiring = (
422
448
  remote = getPropertyValue(firstArg, 'remote') as boolean | undefined
423
449
  mcp = getPropertyValue(firstArg, 'mcp') as boolean | undefined
424
450
  readonly_ = getPropertyValue(firstArg, 'readonly') as boolean | undefined
451
+ deploy = getPropertyValue(firstArg, 'deploy') as
452
+ | 'serverless'
453
+ | 'server'
454
+ | 'auto'
455
+ | undefined
425
456
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired') as
426
457
  | boolean
427
458
  | undefined
@@ -540,6 +571,7 @@ export const addFunctions: AddWiring = (
540
571
  }
541
572
 
542
573
  const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc'
574
+ const isListFunc = expression.text === 'pikkuListFunc'
543
575
  const mcpEnabled = mcp || isMCPToolFunc
544
576
 
545
577
  // Pick the handler: use resolvedFunc when it exists and is a function, otherwise fall back to handlerNode
@@ -612,7 +644,33 @@ export const addFunctions: AddWiring = (
612
644
  inputNames = [schemaName]
613
645
  state.schemaLookup.set(schemaName, inputSchemaRef)
614
646
  state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
615
- } else if (genericTypes.length >= 1 && genericTypes[0]) {
647
+ } else if (isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
648
+ const inputAliasName = `${capitalizedName}Input`
649
+ const filterType = genericTypes[0]
650
+ const filterTypeText = checker.typeToString(
651
+ filterType,
652
+ undefined,
653
+ ts.TypeFormatFlags.NoTruncation
654
+ )
655
+ const refs = resolveTypeImports(
656
+ filterType,
657
+ state.functions.typesMap,
658
+ true,
659
+ checker
660
+ )
661
+ state.functions.typesMap.addCustomType(
662
+ inputAliasName,
663
+ `{ cursor?: string; limit?: number; sort?: Array<{ column: string; direction: 'asc' | 'desc' }>; filter?: unknown; search?: string; } & ${filterTypeText}`,
664
+ [...new Set(refs)]
665
+ )
666
+ inputNames = [inputAliasName]
667
+ const secondParam = handler.parameters[1]
668
+ if (secondParam) {
669
+ inputTypes = [checker.getTypeAtLocation(secondParam)]
670
+ } else {
671
+ inputTypes = [filterType]
672
+ }
673
+ } else if (!isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
616
674
  // Fall back to extracting from generic type arguments
617
675
  const result = getNamesAndTypes(
618
676
  checker,
@@ -648,7 +706,27 @@ export const addFunctions: AddWiring = (
648
706
  outputNames = [schemaName]
649
707
  state.schemaLookup.set(schemaName, outputSchemaRef)
650
708
  state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
651
- } else if (genericTypes.length >= 2) {
709
+ } else if (isListFunc && genericTypes.length >= 2 && genericTypes[1]) {
710
+ const outputAliasName = `${capitalizedName}Output`
711
+ const rowType = genericTypes[1]
712
+ const rowTypeText = checker.typeToString(
713
+ rowType,
714
+ undefined,
715
+ ts.TypeFormatFlags.NoTruncation
716
+ )
717
+ const refs = resolveTypeImports(
718
+ rowType,
719
+ state.functions.typesMap,
720
+ true,
721
+ checker
722
+ )
723
+ state.functions.typesMap.addCustomType(
724
+ outputAliasName,
725
+ `{ rows: Array<${rowTypeText}>; nextCursor: string | null; totalCount?: number; }`,
726
+ [...new Set(refs)]
727
+ )
728
+ outputNames = [outputAliasName]
729
+ } else if (!isListFunc && genericTypes.length >= 2) {
652
730
  outputNames = getNamesAndTypes(
653
731
  checker,
654
732
  state.functions.typesMap,
@@ -725,6 +803,56 @@ export const addFunctions: AddWiring = (
725
803
  state.typesLookup.set(pikkuFuncId, inputTypes)
726
804
  }
727
805
 
806
+ const sourceFile = node.getSourceFile().fileName
807
+ const existingFunction = state.functions.meta[pikkuFuncId]
808
+ if (
809
+ existingFunction &&
810
+ existingFunction.sourceFile &&
811
+ existingFunction.sourceFile !== sourceFile
812
+ ) {
813
+ logger.critical(
814
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
815
+ `Function name '${name}' is not unique. ` +
816
+ `'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`
817
+ )
818
+ return
819
+ }
820
+
821
+ if (exportedName || explicitName) {
822
+ const existingInternal = state.rpc.internalMeta[name]
823
+ if (
824
+ existingInternal &&
825
+ !areCompatibleFunctionIds(existingInternal, pikkuFuncId)
826
+ ) {
827
+ const existingSource =
828
+ resolveExistingFunctionSource(state, existingInternal) || 'unknown file'
829
+ logger.critical(
830
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
831
+ `Function name '${name}' is not unique. ` +
832
+ `It already points to '${existingInternal}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
833
+ )
834
+ return
835
+ }
836
+
837
+ if (expose) {
838
+ const existingExposed = state.rpc.exposedMeta[name]
839
+ if (
840
+ existingExposed &&
841
+ !areCompatibleFunctionIds(existingExposed, pikkuFuncId)
842
+ ) {
843
+ const existingSource =
844
+ resolveExistingFunctionSource(state, existingExposed) ||
845
+ 'unknown file'
846
+ logger.critical(
847
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
848
+ `Exposed function name '${name}' is not unique. ` +
849
+ `It already points to '${existingExposed}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
850
+ )
851
+ return
852
+ }
853
+ }
854
+ }
855
+
728
856
  // --- resolve middleware ---
729
857
  let middleware = objectNode
730
858
  ? resolveMiddleware(state, objectNode, tags, checker)
@@ -783,6 +911,7 @@ export const addFunctions: AddWiring = (
783
911
  remote: remote || undefined,
784
912
  mcp: mcpEnabled || undefined,
785
913
  readonly: readonly_ || undefined,
914
+ deploy: deploy || undefined,
786
915
  approvalRequired: approvalRequired || undefined,
787
916
  approvalDescription: approvalDescription || undefined,
788
917
  version,
@@ -794,7 +923,7 @@ export const addFunctions: AddWiring = (
794
923
  middleware,
795
924
  permissions,
796
925
  isDirectFunction,
797
- sourceFile: node.getSourceFile().fileName,
926
+ sourceFile,
798
927
  exportedName: exportedName || undefined,
799
928
  }
800
929
 
@@ -817,9 +946,7 @@ export const addFunctions: AddWiring = (
817
946
 
818
947
  if (mcpEnabled) {
819
948
  if (!description) {
820
- logger.warn(
821
- `MCP tool '${name}' is missing a description.`
822
- )
949
+ logger.warn(`MCP tool '${name}' is missing a description.`)
823
950
  }
824
951
  state.mcpEndpoints.files.add(node.getSourceFile().fileName)
825
952
  state.mcpEndpoints.toolsMeta[name] = {
@@ -18,6 +18,7 @@ import { resolveHTTPMiddlewareFromObject } from '../utils/middleware.js'
18
18
  import { resolveHTTPPermissionsFromObject } from '../utils/permissions.js'
19
19
  import { extractWireNames } from '../utils/post-process.js'
20
20
  import { ensureFunctionMetadata } from '../utils/ensure-function-metadata.js'
21
+ import { resolveFunctionMeta } from '../utils/resolve-function-meta.js'
21
22
  import { ErrorCode } from '../error-codes.js'
22
23
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js'
23
24
  import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js'
@@ -204,6 +205,22 @@ export function registerHTTPRoute({
204
205
  funcName = makeContextBasedId('http', method, fullRoute)
205
206
  }
206
207
 
208
+ let refAddonTarget: string | null = null
209
+ if (
210
+ ts.isCallExpression(funcInitializer) &&
211
+ ts.isIdentifier(funcInitializer.expression) &&
212
+ funcInitializer.expression.text === 'ref'
213
+ ) {
214
+ const [firstArg] = funcInitializer.arguments
215
+ if (
216
+ firstArg &&
217
+ ts.isStringLiteral(firstArg) &&
218
+ firstArg.text.includes(':')
219
+ ) {
220
+ refAddonTarget = firstArg.text
221
+ }
222
+ }
223
+
207
224
  const packageName = ts.isIdentifier(funcInitializer)
208
225
  ? resolveAddonName(
209
226
  funcInitializer,
@@ -212,6 +229,16 @@ export function registerHTTPRoute({
212
229
  )
213
230
  : null
214
231
 
232
+ if (refAddonTarget) {
233
+ const targetMeta = resolveFunctionMeta(state, refAddonTarget)
234
+ if (!targetMeta) {
235
+ logger.warn(
236
+ `Skipping route '${fullRoute}': addon function metadata for '${refAddonTarget}' is not available yet.`
237
+ )
238
+ return
239
+ }
240
+ }
241
+
215
242
  ensureFunctionMetadata(
216
243
  state,
217
244
  funcName,
@@ -222,7 +249,7 @@ export function registerHTTPRoute({
222
249
  )
223
250
 
224
251
  // Lookup existing function metadata
225
- const fnMeta = state.functions.meta[funcName]
252
+ const fnMeta = resolveFunctionMeta(state, funcName)
226
253
  if (!fnMeta) {
227
254
  logger.critical(
228
255
  ErrorCode.FUNCTION_METADATA_NOT_FOUND,
@@ -155,7 +155,7 @@ function getWorkflowInvocations(
155
155
  }
156
156
 
157
157
  /**
158
- * Inspector for pikkuWorkflow() and pikkuSimpleWorkflow() calls
158
+ * Inspector for pikkuWorkflowFunc() and pikkuWorkflowComplexFunc() calls
159
159
  * Detects workflow registration and extracts metadata
160
160
  */
161
161
  export const addWorkflow: AddWiring = (logger, node, checker, state) => {
@@ -171,11 +171,11 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
171
171
  return
172
172
  }
173
173
 
174
- let wrapperType: 'dsl' | 'regular' | null = null
174
+ let wrapperType: 'dsl' | 'complex' | null = null
175
175
  if (expression.text === 'pikkuWorkflowFunc') {
176
176
  wrapperType = 'dsl'
177
177
  } else if (expression.text === 'pikkuWorkflowComplexFunc') {
178
- wrapperType = 'regular'
178
+ wrapperType = 'complex'
179
179
  } else {
180
180
  return
181
181
  }
@@ -267,7 +267,9 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
267
267
 
268
268
  // Try DSL workflow extraction first
269
269
  // Pass the whole CallExpression node so findWorkflowFunction can find the arrow function
270
- const result = extractDSLWorkflow(node, checker)
270
+ const result = extractDSLWorkflow(node, checker, {
271
+ allowInline: wrapperType === 'complex',
272
+ })
271
273
 
272
274
  if (result.status === 'ok' && result.steps) {
273
275
  // Extraction succeeded
@@ -305,7 +307,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
305
307
  getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps)
306
308
  logger.critical(
307
309
  ErrorCode.INVALID_DSL_WORKFLOW,
308
- `Workflow '${workflowName}' uses pikkuWorkflowFunc but does not conform to DSL workflow rules:\n${result.reason || 'Unknown error'}`
310
+ `Workflow '${workflowName}' does not conform to DSL workflow rules:\n${result.reason || 'Unknown error'}`
309
311
  )
310
312
  return
311
313
  } else {
@@ -317,12 +319,10 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
317
319
  }
318
320
  }
319
321
 
320
- /**
321
- * For non-dsl workflows or pikkuWorkflowComplexFunc, run basic extraction
322
- * to ensure all RPC invocations are tracked for function registration.
323
- * This catches RPCs in Promise.all callbacks and other patterns DSL can't extract.
324
- */
325
- if (!dsl || wrapperType === 'regular') {
322
+ // For pikkuWorkflowComplexFunc, also run basic extraction so RPCs in
323
+ // patterns the DSL extractor doesn't handle (array+push, nested Promise.all
324
+ // with identifier args, etc.) are still registered as invoked functions.
325
+ if (wrapperType === 'complex') {
326
326
  getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps)
327
327
  }
328
328
 
@@ -53,6 +53,7 @@ export enum ErrorCode {
53
53
 
54
54
  // Versioning errors
55
55
  DUPLICATE_FUNCTION_VERSION = 'PKU850',
56
+ DUPLICATE_FUNCTION_NAME = 'PKU851',
56
57
 
57
58
  // Contract versioning errors
58
59
  MANIFEST_MISSING = 'PKU860',
@@ -27,6 +27,11 @@ export function extractStringLiteral(
27
27
  return result
28
28
  }
29
29
 
30
+ // Unwrap type assertions: `expr as Type` or `<Type>expr`
31
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
32
+ return extractStringLiteral(node.expression, checker)
33
+ }
34
+
30
35
  // Try to evaluate constant identifiers
31
36
  if (ts.isIdentifier(node)) {
32
37
  const symbol = checker.getSymbolAtLocation(node)
@@ -375,7 +375,7 @@ async function generateZodSchemas(
375
375
  const uniqueSourceFiles = [
376
376
  ...new Set([...schemaLookup.values()].map((ref) => ref.sourceFile)),
377
377
  ]
378
- console.log(
378
+ logger.info(
379
379
  `[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`
380
380
  )
381
381
 
@@ -384,7 +384,7 @@ async function generateZodSchemas(
384
384
  logger,
385
385
  uniqueSourceFiles
386
386
  )
387
- console.log(
387
+ logger.info(
388
388
  `[TIMING] Batch import: ${(performance.now() - importStart).toFixed(0)}ms`
389
389
  )
390
390
 
@@ -456,7 +456,7 @@ async function generateZodSchemas(
456
456
  }
457
457
  }
458
458
 
459
- console.log(
459
+ logger.info(
460
460
  `[TIMING] Process schemas: ${(performance.now() - processStart).toFixed(0)}ms (${Object.keys(schemas).length} generated)`
461
461
  )
462
462
  return schemas
@@ -1,4 +1,4 @@
1
- import * as ts from 'typescript'
1
+ import type * as ts from 'typescript'
2
2
  import { getPropertyValue } from './get-property-value.js'
3
3
  import { ErrorCode } from '../error-codes.js'
4
4
  import type { InspectorLogger, InspectorState } from '../types.js'