@pikku/inspector 0.12.42 → 0.12.44

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 (126) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/dist/add/add-addon-bans.js +1 -0
  3. package/dist/add/add-ai-agent.d.ts +3 -1
  4. package/dist/add/add-ai-agent.js +82 -0
  5. package/dist/add/add-credential.js +5 -1
  6. package/dist/add/add-functions.js +10 -9
  7. package/dist/add/add-gateway.js +3 -0
  8. package/dist/add/add-http-route.js +2 -2
  9. package/dist/add/add-keyed-wiring.js +2 -0
  10. package/dist/add/add-mcp-prompt.js +0 -1
  11. package/dist/add/add-mcp-resource.js +0 -1
  12. package/dist/add/add-permission.d.ts +1 -1
  13. package/dist/add/add-permission.js +3 -177
  14. package/dist/add/add-queue-worker.js +3 -0
  15. package/dist/add/add-schedule.js +3 -0
  16. package/dist/add/add-scope.d.ts +2 -0
  17. package/dist/add/add-scope.js +146 -0
  18. package/dist/add/add-trigger.js +3 -0
  19. package/dist/add/add-wire-remote-addon.d.ts +10 -0
  20. package/dist/add/add-wire-remote-addon.js +66 -0
  21. package/dist/add/add-workflow-graph.js +32 -3
  22. package/dist/add/add-workflow.js +9 -4
  23. package/dist/error-codes.d.ts +3 -0
  24. package/dist/error-codes.js +4 -0
  25. package/dist/inspector.js +11 -5
  26. package/dist/types.d.ts +19 -13
  27. package/dist/utils/custom-types-generator.js +2 -1
  28. package/dist/utils/ensure-function-metadata.d.ts +15 -0
  29. package/dist/utils/ensure-function-metadata.js +24 -0
  30. package/dist/utils/filter-inspector-state.js +9 -0
  31. package/dist/utils/get-property-value.d.ts +7 -2
  32. package/dist/utils/get-property-value.js +48 -1
  33. package/dist/utils/load-addon-functions-meta.js +81 -6
  34. package/dist/utils/permissions.d.ts +1 -21
  35. package/dist/utils/permissions.js +17 -108
  36. package/dist/utils/post-process.d.ts +30 -1
  37. package/dist/utils/post-process.js +150 -72
  38. package/dist/utils/schema-generator.js +19 -4
  39. package/dist/utils/serialize-inspector-state.d.ts +13 -8
  40. package/dist/utils/serialize-inspector-state.js +12 -6
  41. package/dist/utils/serialize-permissions-groups-meta.d.ts +0 -2
  42. package/dist/utils/serialize-permissions-groups-meta.js +0 -26
  43. package/dist/utils/workflow/derive-workflow-plan.js +9 -3
  44. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
  45. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +274 -100
  46. package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
  47. package/dist/utils/workflow/dsl/patterns.js +11 -0
  48. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +76 -5
  49. package/dist/utils/workflow/graph/finalize-workflows.d.ts +7 -0
  50. package/dist/utils/workflow/graph/finalize-workflows.js +11 -2
  51. package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +2 -0
  52. package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -0
  53. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +8 -4
  54. package/dist/visit.js +4 -0
  55. package/package.json +3 -3
  56. package/run-tests.sh +1 -1
  57. package/src/add/add-addon-bans.ts +1 -0
  58. package/src/add/add-ai-agent.test.ts +87 -0
  59. package/src/add/add-ai-agent.ts +122 -0
  60. package/src/add/add-credential.ts +8 -0
  61. package/src/add/add-functions.ts +10 -12
  62. package/src/add/add-gateway.ts +11 -0
  63. package/src/add/add-http-route.ts +2 -8
  64. package/src/add/add-keyed-wiring.ts +2 -0
  65. package/src/add/add-mcp-prompt.ts +0 -1
  66. package/src/add/add-mcp-resource.ts +0 -1
  67. package/src/add/add-permission.ts +4 -242
  68. package/src/add/add-queue-worker.ts +11 -0
  69. package/src/add/add-schedule.ts +11 -0
  70. package/src/add/add-scope.test.ts +346 -0
  71. package/src/add/add-scope.ts +225 -0
  72. package/src/add/add-trigger.ts +11 -0
  73. package/src/add/add-wire-remote-addon.ts +77 -0
  74. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  75. package/src/add/add-workflow-fanout.test.ts +60 -0
  76. package/src/add/add-workflow-graph-input.test.ts +94 -0
  77. package/src/add/add-workflow-graph-notes.test.ts +74 -0
  78. package/src/add/add-workflow-graph.ts +35 -3
  79. package/src/add/add-workflow-on-error.test.ts +118 -0
  80. package/src/add/add-workflow-return-switch.test.ts +121 -0
  81. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  82. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  83. package/src/add/add-workflow.ts +9 -4
  84. package/src/add/config-value-metadata.test.ts +169 -0
  85. package/src/add/inline-wiring-function.test.ts +104 -0
  86. package/src/add/validate-workflow-graph-addons.test.ts +102 -0
  87. package/src/error-codes.ts +5 -0
  88. package/src/inspector.ts +14 -4
  89. package/src/types.ts +16 -17
  90. package/src/utils/custom-types-generator.test.ts +26 -1
  91. package/src/utils/custom-types-generator.ts +2 -1
  92. package/src/utils/ensure-function-metadata.ts +42 -0
  93. package/src/utils/filter-inspector-state.test.ts +0 -2
  94. package/src/utils/filter-inspector-state.ts +9 -0
  95. package/src/utils/get-property-value.test.ts +141 -0
  96. package/src/utils/get-property-value.ts +62 -1
  97. package/src/utils/load-addon-functions-meta.test.ts +157 -0
  98. package/src/utils/load-addon-functions-meta.ts +94 -6
  99. package/src/utils/load-addon-scopes.test.ts +138 -0
  100. package/src/utils/permissions.test.ts +7 -232
  101. package/src/utils/permissions.ts +20 -160
  102. package/src/utils/post-process.test.ts +269 -4
  103. package/src/utils/post-process.ts +216 -95
  104. package/src/utils/schema-generator.test.ts +164 -0
  105. package/src/utils/schema-generator.ts +26 -3
  106. package/src/utils/serialize-inspector-state.ts +22 -25
  107. package/src/utils/serialize-permissions-groups-meta.ts +0 -28
  108. package/src/utils/workflow/derive-workflow-plan.test.ts +41 -3
  109. package/src/utils/workflow/derive-workflow-plan.ts +11 -4
  110. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  111. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  112. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  113. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  114. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  115. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  116. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  117. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +311 -105
  118. package/src/utils/workflow/dsl/patterns.ts +18 -0
  119. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +82 -10
  120. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  121. package/src/utils/workflow/graph/finalize-workflows.test.ts +50 -0
  122. package/src/utils/workflow/graph/finalize-workflows.ts +13 -2
  123. package/src/utils/workflow/graph/serialize-workflow-graph.ts +6 -0
  124. package/src/utils/workflow/graph/workflow-graph.types.ts +11 -2
  125. package/src/visit.ts +4 -0
  126. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,74 @@
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 type { InspectorLogger } from '../types.js'
8
+
9
+ function makeLogger(): InspectorLogger {
10
+ return {
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ diagnostic: () => {},
16
+ critical: () => {},
17
+ hasCriticalErrors: () => false,
18
+ }
19
+ }
20
+
21
+ describe('addWorkflowGraph — notes extraction', () => {
22
+ test('node-level and graph-level notes are carried into the serialized graph', async () => {
23
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-graph-notes-'))
24
+ const stepFile = join(rootDir, 'my.steps.ts')
25
+ const graphFile = join(rootDir, 'my.graph.ts')
26
+
27
+ await writeFile(
28
+ stepFile,
29
+ [
30
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
31
+ 'export const assess = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
32
+ 'export const notify = pikkuSessionlessFunc({ func: async () => ({ sent: true }) })',
33
+ ].join('\n')
34
+ )
35
+
36
+ await writeFile(
37
+ graphFile,
38
+ [
39
+ "import { pikkuWorkflowGraph } from '@pikku/core/workflow'",
40
+ 'export const myGraph = pikkuWorkflowGraph({',
41
+ " name: 'my-graph',",
42
+ " notes: ['imported sticky note'],",
43
+ ' nodes: {',
44
+ " assess: 'assess',",
45
+ " notify: 'notify',",
46
+ ' },',
47
+ ' config: {',
48
+ " assess: { next: 'notify', notes: 'STUB — generated from node \"Assess\"' },",
49
+ ' notify: {},',
50
+ ' },',
51
+ '})',
52
+ ].join('\n')
53
+ )
54
+
55
+ try {
56
+ const state = await inspect(makeLogger(), [stepFile, graphFile], {
57
+ rootDir,
58
+ })
59
+ const graph = state.workflows.graphMeta['my-graph']
60
+ assert.ok(graph, 'graph meta should be registered')
61
+ assert.deepEqual(graph.notes, ['imported sticky note'])
62
+ assert.equal(
63
+ (graph.nodes['assess'] as { notes?: string }).notes,
64
+ 'STUB — generated from node "Assess"'
65
+ )
66
+ assert.equal(
67
+ (graph.nodes['notify'] as { notes?: string }).notes,
68
+ undefined
69
+ )
70
+ } finally {
71
+ await rm(rootDir, { recursive: true, force: true })
72
+ }
73
+ })
74
+ })
@@ -12,6 +12,11 @@ function extractAstValue(
12
12
  refParamName: string,
13
13
  templateParamName: string | undefined
14
14
  ): unknown {
15
+ // `'GET' as const` / parenthesised values wrap the real initializer — see
16
+ // through them or the whole property is silently dropped from the meta.
17
+ while (ts.isAsExpression(expr) || ts.isParenthesizedExpression(expr)) {
18
+ expr = expr.expression
19
+ }
15
20
  if (ts.isStringLiteral(expr)) {
16
21
  return expr.text
17
22
  }
@@ -146,10 +151,14 @@ function extractInputMapping(
146
151
  ? node.parameters[0].name.text
147
152
  : 'ref'
148
153
 
154
+ // The canonical form imports `template` from '@pikku/core/workflow' and calls
155
+ // it inside a single-param `(ref) => (...)` arrow; the older form passes it as
156
+ // a second arrow parameter. Support both — default to the imported name so a
157
+ // `template(...)` value is never dropped just because it isn't a 2nd param.
149
158
  const templateParamName =
150
159
  node.parameters.length > 1 && ts.isIdentifier(node.parameters[1].name)
151
160
  ? node.parameters[1].name.text
152
- : undefined
161
+ : 'template'
153
162
 
154
163
  const input: Record<string, unknown | DataRef> = {}
155
164
 
@@ -184,6 +193,13 @@ function extractNextConfig(
184
193
  node: ts.Node,
185
194
  _checker: ts.TypeChecker
186
195
  ): string | string[] | Record<string, string | string[]> | undefined {
196
+ // Key-based branch `next` (`{ key: [targets] }`) is authored with an `as any`
197
+ // cast because the graph's NextConfig can't narrow record targets to node-id
198
+ // literals; see through that (and parens) so the routing survives into the meta.
199
+ while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
200
+ node = node.expression
201
+ }
202
+
187
203
  if (ts.isStringLiteral(node)) {
188
204
  return node.text
189
205
  }
@@ -254,6 +270,7 @@ interface PikkuWorkflowGraphExtract {
254
270
  name?: string
255
271
  description?: string
256
272
  tags?: string[]
273
+ notes?: string[]
257
274
  disabled?: true
258
275
  nodesNode?: ts.ObjectLiteralExpression
259
276
  configNode?: ts.ObjectLiteralExpression
@@ -270,6 +287,7 @@ function extractWorkflowGraphConfig(
270
287
  let name: string | undefined
271
288
  let description: string | undefined
272
289
  let tags: string[] | undefined
290
+ let notes: string[] | undefined
273
291
  let disabled: true | undefined
274
292
  let nodesNode: ts.ObjectLiteralExpression | undefined
275
293
  let configNode: ts.ObjectLiteralExpression | undefined
@@ -293,6 +311,13 @@ function extractWorkflowGraphConfig(
293
311
  tags = prop.initializer.elements
294
312
  .filter(ts.isStringLiteral)
295
313
  .map((el) => (el as ts.StringLiteral).text)
314
+ } else if (
315
+ propName === 'notes' &&
316
+ ts.isArrayLiteralExpression(prop.initializer)
317
+ ) {
318
+ notes = prop.initializer.elements
319
+ .filter(ts.isStringLiteral)
320
+ .map((el) => (el as ts.StringLiteral).text)
296
321
  } else if (
297
322
  propName === 'nodes' &&
298
323
  ts.isObjectLiteralExpression(prop.initializer)
@@ -306,7 +331,7 @@ function extractWorkflowGraphConfig(
306
331
  }
307
332
  }
308
333
 
309
- return { name, description, tags, disabled, nodesNode, configNode }
334
+ return { name, description, tags, notes, disabled, nodesNode, configNode }
310
335
  }
311
336
 
312
337
  /**
@@ -359,6 +384,7 @@ function extractGraphFromNewFormat(
359
384
  onError: undefined,
360
385
  retries: undefined,
361
386
  retryDelay: undefined,
387
+ notes: undefined,
362
388
  }
363
389
  }
364
390
 
@@ -386,6 +412,7 @@ function extractGraphFromNewFormat(
386
412
  nodes[nodeId].input = nodeConfig.input
387
413
  nodes[nodeId].retries = nodeConfig.retries
388
414
  nodes[nodeId].retryDelay = nodeConfig.retryDelay
415
+ nodes[nodeId].notes = nodeConfig.notes
389
416
  }
390
417
  }
391
418
  }
@@ -406,12 +433,14 @@ function extractNodeConfigFromObject(
406
433
  input: Record<string, any>
407
434
  retries: number | undefined
408
435
  retryDelay: string | number | undefined
436
+ notes: string | undefined
409
437
  } {
410
438
  let next: any = undefined
411
439
  let onError: any = undefined
412
440
  let input: Record<string, any> = {}
413
441
  let retries: number | undefined = undefined
414
442
  let retryDelay: string | number | undefined = undefined
443
+ let notes: string | undefined = undefined
415
444
 
416
445
  for (const prop of obj.properties) {
417
446
  if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
@@ -434,10 +463,12 @@ function extractNodeConfigFromObject(
434
463
  } else if (ts.isStringLiteral(prop.initializer)) {
435
464
  retryDelay = prop.initializer.text
436
465
  }
466
+ } else if (propName === 'notes') {
467
+ notes = extractStringLiteral(prop.initializer, checker)
437
468
  }
438
469
  }
439
470
 
440
- return { next, onError, input, retries, retryDelay }
471
+ return { next, onError, input, retries, retryDelay, notes }
441
472
  }
442
473
 
443
474
  /**
@@ -526,6 +557,7 @@ export const addWorkflowGraph: AddWiring = (logger, node, checker, state) => {
526
557
  source: 'graph',
527
558
  description: graphConfig.description,
528
559
  tags: graphConfig.tags,
560
+ notes: graphConfig.notes,
529
561
  nodes: graphNodes,
530
562
  entryNodeIds,
531
563
  }
@@ -0,0 +1,118 @@
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 type { InspectorLogger } from '../types.js'
8
+ import { deserializeDslWorkflow } from '../utils/workflow/dsl/deserialize-dsl-workflow.js'
9
+
10
+ const STEP_FILE = [
11
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
12
+ 'export const chargeCard = pikkuSessionlessFunc({',
13
+ " func: async () => ({ id: 'c' }),",
14
+ '})',
15
+ 'export const refundOrder = pikkuSessionlessFunc({',
16
+ " func: async () => ({ id: 'r' }),",
17
+ '})',
18
+ ].join('\n')
19
+
20
+ const WORKFLOW_BODY = [
21
+ " await workflow.do('Charge', 'chargeCard', { id: data.id }, {",
22
+ ' retries: 3,',
23
+ " onError: 'refundOrder',",
24
+ ' })',
25
+ ' return { ok: true }',
26
+ ].join('\n')
27
+
28
+ async function inspectWorkflow(body: string): Promise<{
29
+ nodes: Record<string, any>
30
+ invoked: string[]
31
+ code: string
32
+ }> {
33
+ const logger = {
34
+ debug: () => {},
35
+ info: () => {},
36
+ warn: () => {},
37
+ error: () => {},
38
+ diagnostic: () => {},
39
+ critical: () => {},
40
+ hasCriticalErrors: () => false,
41
+ } as unknown as InspectorLogger
42
+
43
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-oe-'))
44
+ const stepFile = join(rootDir, 'w.steps.ts')
45
+ const wfFile = join(rootDir, 'w.workflow.ts')
46
+ await writeFile(stepFile, STEP_FILE)
47
+ await writeFile(
48
+ wfFile,
49
+ [
50
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
51
+ 'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
52
+ body,
53
+ '})',
54
+ ].join('\n')
55
+ )
56
+ try {
57
+ const state: any = await inspect(logger, [stepFile, wfFile], { rootDir })
58
+ const gm = state.workflows.graphMeta
59
+ const graph = (
60
+ gm instanceof Map ? [...gm.values()] : Object.values(gm)
61
+ )[0] as any
62
+ assert.ok(graph, 'a graph should have been produced')
63
+ return {
64
+ nodes: graph.nodes,
65
+ invoked: [...(state.rpc?.invokedFunctions ?? [])],
66
+ code: deserializeDslWorkflow(graph),
67
+ }
68
+ } finally {
69
+ await rm(rootDir, { recursive: true, force: true })
70
+ }
71
+ }
72
+
73
+ describe('DSL onError — compensation is part of the graph', () => {
74
+ test('the handler becomes a node the failing step points at', async () => {
75
+ const { nodes } = await inspectWorkflow(WORKFLOW_BODY)
76
+
77
+ const charge = Object.values(nodes).find(
78
+ (n: any) => n.rpcName === 'chargeCard'
79
+ ) as any
80
+ assert.ok(charge, 'the charging step should exist')
81
+ assert.ok(
82
+ charge.onError,
83
+ `the step must record its error route so the graph can render it, got: ${JSON.stringify(charge)}`
84
+ )
85
+
86
+ const handlerId = Array.isArray(charge.onError)
87
+ ? charge.onError[0]
88
+ : charge.onError
89
+ const handler = nodes[handlerId]
90
+ assert.ok(
91
+ handler,
92
+ `onError must point at a real node so an edge can be drawn, got id '${handlerId}' in ${JSON.stringify(Object.keys(nodes))}`
93
+ )
94
+ assert.equal(handler.rpcName, 'refundOrder')
95
+ })
96
+
97
+ test('the handler RPC is registered as invoked so it gets wired', async () => {
98
+ const { invoked } = await inspectWorkflow(WORKFLOW_BODY)
99
+
100
+ assert.ok(
101
+ invoked.includes('refundOrder'),
102
+ `the compensation RPC must be wired, got: ${JSON.stringify(invoked)}`
103
+ )
104
+ })
105
+
106
+ test('onError survives regeneration back to code', async () => {
107
+ const { code } = await inspectWorkflow(WORKFLOW_BODY)
108
+
109
+ assert.ok(
110
+ code.includes("onError: 'refundOrder'"),
111
+ `onError must round-trip as an rpc name, got:\n${code}`
112
+ )
113
+ assert.ok(
114
+ code.includes('retries: 3'),
115
+ 'other options must still round-trip'
116
+ )
117
+ })
118
+ })
@@ -0,0 +1,121 @@
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 type { InspectorLogger } from '../types.js'
8
+
9
+ const STEP_FILE = [
10
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
11
+ 'export const ship = pikkuSessionlessFunc({',
12
+ " func: async () => ({ id: 's', ok: true }),",
13
+ '})',
14
+ ].join('\n')
15
+
16
+ async function inspectWorkflow(body: string): Promise<{
17
+ nodes: Record<string, any>
18
+ diags: string[]
19
+ }> {
20
+ const diags: string[] = []
21
+ const logger = {
22
+ debug: () => {},
23
+ info: () => {},
24
+ warn: () => {},
25
+ error: () => {},
26
+ diagnostic: (d: any) => diags.push(d?.message ?? String(d)),
27
+ critical: (d: any) => diags.push(d?.message ?? String(d)),
28
+ hasCriticalErrors: () => false,
29
+ } as unknown as InspectorLogger
30
+
31
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-rs-'))
32
+ const stepFile = join(rootDir, 'w.steps.ts')
33
+ const wfFile = join(rootDir, 'w.workflow.ts')
34
+ await writeFile(stepFile, STEP_FILE)
35
+ await writeFile(
36
+ wfFile,
37
+ [
38
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
39
+ 'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
40
+ body,
41
+ '})',
42
+ ].join('\n')
43
+ )
44
+ try {
45
+ const state: any = await inspect(logger, [stepFile, wfFile], { rootDir })
46
+ const gm = state.workflows.graphMeta
47
+ const graph = (
48
+ gm instanceof Map ? [...gm.values()] : Object.values(gm)
49
+ )[0] as any
50
+ return { nodes: graph?.nodes ?? {}, diags }
51
+ } finally {
52
+ await rm(rootDir, { recursive: true, force: true })
53
+ }
54
+ }
55
+
56
+ describe('switch extraction — a fall-through case is a real case', () => {
57
+ test("case 'a': case 'b': records both values", async () => {
58
+ const { nodes } = await inspectWorkflow(
59
+ [
60
+ ' switch (data.kind) {',
61
+ " case 'a':",
62
+ " case 'b':",
63
+ " await workflow.do('Ship', 'ship', {})",
64
+ ' break',
65
+ ' }',
66
+ ' return { ok: true }',
67
+ ].join('\n')
68
+ )
69
+
70
+ const switchNode = Object.values(nodes).find(
71
+ (n: any) => n.flow === 'switch'
72
+ ) as any
73
+ assert.ok(switchNode, 'a switch node should exist')
74
+ const values = (switchNode.cases ?? []).map((c: any) => c.value)
75
+ assert.deepEqual(
76
+ values.sort(),
77
+ ['a', 'b'],
78
+ `a fall-through case must not be dropped, got: ${JSON.stringify(switchNode.cases)}`
79
+ )
80
+ const entries = new Set((switchNode.cases ?? []).map((c: any) => c.entry))
81
+ assert.equal(entries.size, 1, 'both cases should share the same entry')
82
+ })
83
+ })
84
+
85
+ describe('return extraction — a spread output is not silently dropped', () => {
86
+ test('return { ...r, extra } keeps the spread', async () => {
87
+ const { nodes } = await inspectWorkflow(
88
+ [
89
+ " const r = await workflow.do('Ship', 'ship', {})",
90
+ ' return { ...r, extra: 1 }',
91
+ ].join('\n')
92
+ )
93
+
94
+ const returnNode = Object.values(nodes).find(
95
+ (n: any) => n.flow === 'return'
96
+ ) as any
97
+ assert.ok(returnNode, 'a return node should exist')
98
+ assert.deepEqual(
99
+ returnNode.spread,
100
+ ['r'],
101
+ `the spread source must be recorded, got: ${JSON.stringify(returnNode)}`
102
+ )
103
+ })
104
+
105
+ test('return r produces a return node', async () => {
106
+ const { nodes } = await inspectWorkflow(
107
+ [" const r = await workflow.do('Ship', 'ship', {})", ' return r'].join(
108
+ '\n'
109
+ )
110
+ )
111
+
112
+ const returnNode = Object.values(nodes).find(
113
+ (n: any) => n.flow === 'return'
114
+ ) as any
115
+ assert.ok(
116
+ returnNode,
117
+ `returning a variable must still produce a return node, got: ${JSON.stringify(nodes)}`
118
+ )
119
+ assert.deepEqual(returnNode.spread, ['r'])
120
+ })
121
+ })
@@ -0,0 +1,163 @@
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 type { InspectorLogger } from '../types.js'
8
+
9
+ function makeLogger(diags: string[]): InspectorLogger {
10
+ return {
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ diagnostic: ({ code, message }: any) => {
16
+ diags.push(`${code}: ${message}`)
17
+ },
18
+ critical: (code: any, message: string) => {
19
+ diags.push(`${code}: ${message}`)
20
+ },
21
+ hasCriticalErrors: () => false,
22
+ }
23
+ }
24
+
25
+ const STEP_FILE = [
26
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
27
+ 'export const listUsers = pikkuSessionlessFunc({',
28
+ " func: async () => ({ users: [{ id: '1', email: 'a@b.c' }] }),",
29
+ '})',
30
+ 'export const rpcA = pikkuSessionlessFunc({',
31
+ " func: async () => ({ id: 'a' }),",
32
+ '})',
33
+ 'export const rpcB = pikkuSessionlessFunc({',
34
+ " func: async () => ({ id: 'b' }),",
35
+ '})',
36
+ 'export const rpcC = pikkuSessionlessFunc({',
37
+ ' func: async () => ({ ok: true }),',
38
+ '})',
39
+ ].join('\n')
40
+
41
+ async function inspectWorkflow(body: string) {
42
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-drops-'))
43
+ const stepFile = join(rootDir, 'w.steps.ts')
44
+ const wfFile = join(rootDir, 'w.workflow.ts')
45
+ await writeFile(stepFile, STEP_FILE)
46
+ await writeFile(
47
+ wfFile,
48
+ [
49
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
50
+ 'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
51
+ body,
52
+ '})',
53
+ ].join('\n')
54
+ )
55
+ const diags: string[] = []
56
+ try {
57
+ const state: any = await inspect(makeLogger(diags), [stepFile, wfFile], {
58
+ rootDir,
59
+ })
60
+ const metaMap = state.workflows.meta
61
+ const meta = (
62
+ metaMap instanceof Map ? [...metaMap.values()] : Object.values(metaMap)
63
+ )[0] as any
64
+ return {
65
+ invoked: [...state.rpc.invokedFunctions] as string[],
66
+ steps: (meta?.steps ?? []) as any[],
67
+ diags,
68
+ }
69
+ } finally {
70
+ await rm(rootDir, { recursive: true, force: true })
71
+ }
72
+ }
73
+
74
+ describe('DSL extraction — constructs that must not be silently dropped', () => {
75
+ test('a property access on the loop item (u.id) survives as an item input', async () => {
76
+ const { steps } = await inspectWorkflow(
77
+ [
78
+ " const audience = await workflow.do('collect', 'listUsers', {})",
79
+ ' await Promise.all(',
80
+ ' audience.users.map(async (u) => {',
81
+ " await workflow.do('send', 'rpcC', { userId: u.id, tag: 'x' })",
82
+ ' })',
83
+ ' )',
84
+ ' return { ok: true }',
85
+ ].join('\n')
86
+ )
87
+
88
+ const fanout = steps.find((s) => s.type === 'fanout')
89
+ assert.ok(fanout, 'fanout step should be extracted')
90
+ const send = fanout.body?.[0]
91
+ assert.ok(send, 'the fanout body step should be extracted')
92
+ assert.deepEqual(
93
+ send.inputs?.userId,
94
+ { from: 'item', path: 'id' },
95
+ `u.id must map to the loop item, got: ${JSON.stringify(send.inputs)}`
96
+ )
97
+ assert.deepEqual(
98
+ send.inputs?.tag,
99
+ { from: 'literal', value: 'x' },
100
+ 'sibling literal inputs must still survive'
101
+ )
102
+ })
103
+
104
+ test('array destructuring of a Promise.all group keeps both steps and binds each result', async () => {
105
+ const { invoked, steps } = await inspectWorkflow(
106
+ [
107
+ ' const [org, user] = await Promise.all([',
108
+ " workflow.do('create org', 'rpcA', {}),",
109
+ " workflow.do('create user', 'rpcB', {}),",
110
+ ' ])',
111
+ ' return { orgId: org.id, userId: user.id }',
112
+ ].join('\n')
113
+ )
114
+
115
+ assert.ok(invoked.includes('rpcA'), 'rpcA must not be dropped')
116
+ assert.ok(invoked.includes('rpcB'), 'rpcB must not be dropped')
117
+
118
+ const parallel = steps.find((s) => s.type === 'parallel')
119
+ assert.ok(parallel, 'a parallel step should be extracted')
120
+ assert.deepEqual(
121
+ parallel.children?.map((c: any) => c.outputVar),
122
+ ['org', 'user'],
123
+ 'each destructured name should bind to its matching child step'
124
+ )
125
+
126
+ const ret = steps.find((s) => s.type === 'return')
127
+ assert.deepEqual(
128
+ ret?.outputs?.orgId,
129
+ { from: 'outputVar', name: 'org', path: 'id' },
130
+ 'org.id must resolve to the bound step output, not degrade to a trigger input'
131
+ )
132
+ })
133
+
134
+ test('object destructuring of a step result reports a diagnostic instead of silently dropping', async () => {
135
+ const { invoked, diags } = await inspectWorkflow(
136
+ [
137
+ " const { id } = await workflow.do('s', 'rpcA', {})",
138
+ ' return { id }',
139
+ ].join('\n')
140
+ )
141
+
142
+ const dropped = !invoked.includes('rpcA')
143
+ assert.ok(
144
+ !dropped || diags.length > 0,
145
+ 'if the step is dropped the user must be told — silence is the bug'
146
+ )
147
+ })
148
+
149
+ test('a brace-less for-of body is still extracted as a sequential fanout', async () => {
150
+ const { invoked, steps } = await inspectWorkflow(
151
+ [
152
+ ' for (const email of data.emails)',
153
+ " await workflow.do('invite', 'rpcA', { email })",
154
+ ' return { ok: true }',
155
+ ].join('\n')
156
+ )
157
+
158
+ assert.ok(invoked.includes('rpcA'), 'rpcA must not be dropped')
159
+ const fanout = steps.find((s) => s.type === 'fanout')
160
+ assert.ok(fanout, 'a brace-less for-of should still produce a fanout step')
161
+ assert.equal(fanout.mode, 'sequential')
162
+ })
163
+ })