@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,225 @@
1
+ import * as ts from 'typescript'
2
+ import { getPropertyValue } from '../utils/get-property-value.js'
3
+ import type { AddWiring, InspectorLogger } from '../types.js'
4
+ import { ErrorCode } from '../error-codes.js'
5
+ import type { ScopeNodeMeta } from '@pikku/core/scope'
6
+
7
+ const SEPARATOR = ':'
8
+ const WILDCARD = '*'
9
+
10
+ /**
11
+ * Validates a single scope segment, reporting rather than throwing so the
12
+ * inspector can surface every problem in one pass.
13
+ *
14
+ * @returns true when the segment is usable.
15
+ */
16
+ const isValidSegment = (
17
+ segment: string,
18
+ scopeName: string,
19
+ logger: InspectorLogger
20
+ ): boolean => {
21
+ if (segment.length === 0) {
22
+ logger.critical(
23
+ ErrorCode.INVALID_VALUE,
24
+ `Scope '${scopeName}' contains an empty segment.`
25
+ )
26
+ return false
27
+ }
28
+ if (segment.includes(SEPARATOR)) {
29
+ logger.critical(
30
+ ErrorCode.INVALID_VALUE,
31
+ `Scope segment '${segment}' in '${scopeName}' contains the '${SEPARATOR}' separator. ` +
32
+ `Nest scopes with the 'scopes' property instead of embedding '${SEPARATOR}' in a name.`
33
+ )
34
+ return false
35
+ }
36
+ if (segment === WILDCARD) {
37
+ logger.critical(
38
+ ErrorCode.INVALID_VALUE,
39
+ `Scope segment '${segment}' in '${scopeName}' is the wildcard. ` +
40
+ `'${WILDCARD}' is reserved for granting a scope and its descendants, and cannot be declared.`
41
+ )
42
+ return false
43
+ }
44
+ return true
45
+ }
46
+
47
+ /**
48
+ * Recursively extracts a `scopes: { ... }` object literal into nested metadata.
49
+ */
50
+ const extractScopeNodes = (
51
+ obj: ts.ObjectLiteralExpression,
52
+ scopeName: string,
53
+ logger: InspectorLogger
54
+ ): Record<string, ScopeNodeMeta> | undefined => {
55
+ const nodes: Record<string, ScopeNodeMeta> = {}
56
+
57
+ for (const prop of obj.properties) {
58
+ if (!ts.isPropertyAssignment(prop)) {
59
+ continue
60
+ }
61
+
62
+ let segment: string | undefined
63
+ if (ts.isIdentifier(prop.name)) {
64
+ segment = prop.name.text
65
+ } else if (ts.isStringLiteral(prop.name)) {
66
+ segment = prop.name.text
67
+ }
68
+
69
+ if (segment === undefined) {
70
+ logger.critical(
71
+ ErrorCode.NON_LITERAL_WIRE_NAME,
72
+ `Scope '${scopeName}' has a nested scope whose key is not a literal.`
73
+ )
74
+ continue
75
+ }
76
+
77
+ if (!isValidSegment(segment, scopeName, logger)) {
78
+ continue
79
+ }
80
+
81
+ if (!ts.isObjectLiteralExpression(prop.initializer)) {
82
+ logger.critical(
83
+ ErrorCode.INVALID_VALUE,
84
+ `Nested scope '${segment}' in '${scopeName}' must be an object literal.`
85
+ )
86
+ continue
87
+ }
88
+
89
+ const node: ScopeNodeMeta = {}
90
+ const displayName = getPropertyValue(prop.initializer, 'displayName') as
91
+ | string
92
+ | null
93
+ if (displayName) {
94
+ node.displayName = displayName
95
+ }
96
+ const description = getPropertyValue(prop.initializer, 'description') as
97
+ | string
98
+ | null
99
+ if (description) {
100
+ node.description = description
101
+ }
102
+
103
+ const nestedProp = prop.initializer.properties.find(
104
+ (p) =>
105
+ ts.isPropertyAssignment(p) &&
106
+ ts.isIdentifier(p.name) &&
107
+ p.name.text === 'scopes'
108
+ )
109
+ if (
110
+ nestedProp &&
111
+ ts.isPropertyAssignment(nestedProp) &&
112
+ ts.isObjectLiteralExpression(nestedProp.initializer)
113
+ ) {
114
+ const nested = extractScopeNodes(
115
+ nestedProp.initializer,
116
+ scopeName,
117
+ logger
118
+ )
119
+ if (nested) {
120
+ node.scopes = nested
121
+ }
122
+ }
123
+
124
+ nodes[segment] = node
125
+ }
126
+
127
+ return Object.keys(nodes).length > 0 ? nodes : undefined
128
+ }
129
+
130
+ /**
131
+ * Unwraps `x as const` / `x as any` so a cast declaration is still extracted
132
+ * rather than silently skipped.
133
+ */
134
+ const unwrapAs = (node: ts.Expression): ts.Expression =>
135
+ ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
136
+ ? unwrapAs(node.expression)
137
+ : node
138
+
139
+ export const addScope: AddWiring = (logger, node, checker, state, _options) => {
140
+ if (!ts.isCallExpression(node)) {
141
+ return
142
+ }
143
+
144
+ const expression = node.expression
145
+ if (!ts.isIdentifier(expression) || expression.text !== 'wireScope') {
146
+ return
147
+ }
148
+
149
+ const firstArg = node.arguments[0]
150
+ if (!firstArg) {
151
+ return
152
+ }
153
+
154
+ const unwrapped = unwrapAs(firstArg)
155
+ if (!ts.isObjectLiteralExpression(unwrapped)) {
156
+ return
157
+ }
158
+
159
+ const sourceFile = node.getSourceFile().fileName
160
+
161
+ // Roots are keyed exactly like the nodes beneath them, so each property of
162
+ // the call's single argument is one tree.
163
+ for (const prop of unwrapped.properties) {
164
+ if (!ts.isPropertyAssignment(prop)) {
165
+ continue
166
+ }
167
+
168
+ let name: string | undefined
169
+ if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
170
+ name = prop.name.text
171
+ }
172
+
173
+ if (name === undefined) {
174
+ logger.critical(
175
+ ErrorCode.NON_LITERAL_WIRE_NAME,
176
+ 'A scope is declared with a key that is not a literal.'
177
+ )
178
+ continue
179
+ }
180
+
181
+ if (!isValidSegment(name, name, logger)) {
182
+ continue
183
+ }
184
+
185
+ if (!ts.isObjectLiteralExpression(prop.initializer)) {
186
+ logger.critical(
187
+ ErrorCode.INVALID_VALUE,
188
+ `Scope '${name}' must be an object literal.`
189
+ )
190
+ continue
191
+ }
192
+
193
+ const root = prop.initializer
194
+ const displayNameValue = getPropertyValue(root, 'displayName') as
195
+ | string
196
+ | null
197
+ const descriptionValue = getPropertyValue(root, 'description') as
198
+ | string
199
+ | null
200
+
201
+ let scopes: Record<string, ScopeNodeMeta> | undefined
202
+ const scopesProp = root.properties.find(
203
+ (p) =>
204
+ ts.isPropertyAssignment(p) &&
205
+ ts.isIdentifier(p.name) &&
206
+ p.name.text === 'scopes'
207
+ )
208
+ if (
209
+ scopesProp &&
210
+ ts.isPropertyAssignment(scopesProp) &&
211
+ ts.isObjectLiteralExpression(scopesProp.initializer)
212
+ ) {
213
+ scopes = extractScopeNodes(scopesProp.initializer, name, logger)
214
+ }
215
+
216
+ state.scopes.files.add(sourceFile)
217
+ state.scopes.definitions.push({
218
+ name,
219
+ displayName: displayNameValue || undefined,
220
+ description: descriptionValue || undefined,
221
+ scopes,
222
+ sourceFile,
223
+ })
224
+ }
225
+ }
@@ -9,6 +9,7 @@ import {
9
9
  makeContextBasedId,
10
10
  } from '../utils/extract-function-name.js'
11
11
  import { getPropertyAssignmentInitializer } from '../utils/type-utils.js'
12
+ import { ensureInlineWiringFunction } from '../utils/ensure-function-metadata.js'
12
13
  import { resolveMiddleware } from '../utils/middleware.js'
13
14
  import { extractWireNames } from '../utils/post-process.js'
14
15
  import { resolveAddonName } from '../utils/resolve-addon-package.js'
@@ -83,6 +84,16 @@ const addWireTrigger: (
83
84
  return
84
85
  }
85
86
 
87
+ // Register metadata for a func inlined into the wiring (see helper).
88
+ ensureInlineWiringFunction(
89
+ state,
90
+ pikkuFuncId,
91
+ nameValue,
92
+ funcInitializer,
93
+ checker,
94
+ extracted.isHelper
95
+ )
96
+
86
97
  // --- resolve middleware ---
87
98
  const middleware = resolveMiddleware(state, obj, tags, checker)
88
99
 
@@ -0,0 +1,77 @@
1
+ import * as ts from 'typescript'
2
+ import type { InspectorState, InspectorLogger } from '../types.js'
3
+
4
+ /**
5
+ * Detect wireRemoteAddon({ name: '...', package: '...' }) call expressions and
6
+ * record the namespace as a **remote** addon declaration. Only name + package
7
+ * are needed for codegen (serverUrl/auth are runtime closures resolved by the
8
+ * RPC runner); the `remote` marker tells the consumer's RPC-map generator to
9
+ * import the addon's `.remote.gen` map instead of `.internal.gen`.
10
+ */
11
+ export function addWireRemoteAddon(
12
+ node: ts.Node,
13
+ state: InspectorState,
14
+ logger: InspectorLogger
15
+ ) {
16
+ if (!ts.isCallExpression(node)) return
17
+
18
+ const { expression, arguments: args } = node
19
+ if (!ts.isIdentifier(expression) || expression.text !== 'wireRemoteAddon')
20
+ return
21
+
22
+ const [firstArg] = args
23
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) return
24
+
25
+ let name: string | undefined
26
+ let pkg: string | undefined
27
+ // The auth binding is a runtime closure; we only extract the statically
28
+ // knowable slot names so `pikku verify` can check they exist in the
29
+ // consumer's own wirings. `hasAuth` distinguishes "public" (omitted) from
30
+ // "bound via a custom resolve()" (present but no static id).
31
+ let hasAuth = false
32
+ let authCredentialId: string | undefined
33
+ let authSecretId: string | undefined
34
+
35
+ for (const prop of firstArg.properties) {
36
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
37
+ const key = prop.name.text
38
+ if (key === 'name' && ts.isStringLiteral(prop.initializer)) {
39
+ name = prop.initializer.text
40
+ } else if (key === 'package' && ts.isStringLiteral(prop.initializer)) {
41
+ pkg = prop.initializer.text
42
+ } else if (
43
+ key === 'auth' &&
44
+ ts.isObjectLiteralExpression(prop.initializer)
45
+ ) {
46
+ hasAuth = true
47
+ for (const authProp of prop.initializer.properties) {
48
+ if (!ts.isPropertyAssignment(authProp) || !ts.isIdentifier(authProp.name))
49
+ continue
50
+ if (
51
+ authProp.name.text === 'credentialId' &&
52
+ ts.isStringLiteral(authProp.initializer)
53
+ ) {
54
+ authCredentialId = authProp.initializer.text
55
+ } else if (
56
+ authProp.name.text === 'secretId' &&
57
+ ts.isStringLiteral(authProp.initializer)
58
+ ) {
59
+ authSecretId = authProp.initializer.text
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ if (!name || !pkg) return
66
+
67
+ logger.debug(`• Found wireRemoteAddon: ${name} → ${pkg} (remote)`)
68
+ state.rpc.wireAddonDeclarations.set(name, {
69
+ package: pkg,
70
+ remote: true,
71
+ hasAuth,
72
+ authCredentialId,
73
+ authSecretId,
74
+ })
75
+ state.rpc.usedAddons.add(name)
76
+ state.rpc.wireAddonFiles.add(node.getSourceFile().fileName)
77
+ }
@@ -0,0 +1,201 @@
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
+ 'export const notify = pikkuSessionlessFunc({',
19
+ " func: async () => ({ id: 'n' }),",
20
+ '})',
21
+ 'export const audit = pikkuSessionlessFunc({',
22
+ " func: async () => ({ id: 'a' }),",
23
+ '})',
24
+ ].join('\n')
25
+
26
+ async function run(body: string): Promise<{
27
+ nodes: Record<string, any>
28
+ diags: string[]
29
+ code: string
30
+ }> {
31
+ const diags: string[] = []
32
+ const logger = {
33
+ debug: () => {},
34
+ info: () => {},
35
+ warn: () => {},
36
+ error: () => {},
37
+ diagnostic: (...a: any[]) => diags.push(a.map(String).join(' ')),
38
+ critical: (...a: any[]) => diags.push(a.map(String).join(' ')),
39
+ hasCriticalErrors: () => false,
40
+ } as unknown as InspectorLogger
41
+
42
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-edge-'))
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
+ try {
56
+ const state: any = await inspect(logger, [stepFile, wfFile], { rootDir })
57
+ const gm = state.workflows.graphMeta
58
+ const graph = (
59
+ gm instanceof Map ? [...gm.values()] : Object.values(gm)
60
+ )[0] as any
61
+ return {
62
+ nodes: graph?.nodes ?? {},
63
+ diags,
64
+ code: graph ? deserializeDslWorkflow(graph) : '',
65
+ }
66
+ } finally {
67
+ await rm(rootDir, { recursive: true, force: true })
68
+ }
69
+ }
70
+
71
+ describe('onError — edge cases', () => {
72
+ test('the handler is not also emitted as a step that always runs', async () => {
73
+ const { code } = await run(
74
+ [
75
+ " await workflow.do('Charge', 'chargeCard', {}, { onError: 'refundOrder' })",
76
+ ' return { ok: true }',
77
+ ].join('\n')
78
+ )
79
+
80
+ const refundCalls = code.match(/'refundOrder'/g) ?? []
81
+ assert.equal(
82
+ refundCalls.length,
83
+ 1,
84
+ `the handler must appear only as onError, never as its own sequential step, got:\n${code}`
85
+ )
86
+ })
87
+
88
+ test('a non-literal onError is reported, not silently ignored', async () => {
89
+ const { diags } = await run(
90
+ [
91
+ ' const target = data.handler',
92
+ " await workflow.do('Charge', 'chargeCard', {}, { onError: target })",
93
+ ' return { ok: true }',
94
+ ].join('\n')
95
+ )
96
+
97
+ assert.ok(
98
+ diags.some((d) => d.includes('onError')),
99
+ `a non-literal onError must be diagnosed, got: ${JSON.stringify(diags)}`
100
+ )
101
+ })
102
+
103
+ test('onError inside a branch still resolves', async () => {
104
+ const { nodes } = await run(
105
+ [
106
+ ' if (data.paid) {',
107
+ " await workflow.do('Charge', 'chargeCard', {}, { onError: 'refundOrder' })",
108
+ ' }',
109
+ ' return { ok: true }',
110
+ ].join('\n')
111
+ )
112
+
113
+ const charge = Object.values(nodes).find(
114
+ (n: any) => n.rpcName === 'chargeCard'
115
+ ) as any
116
+ assert.ok(
117
+ charge?.onError,
118
+ 'a branch step should still carry its error route'
119
+ )
120
+ assert.equal((nodes[charge.onError] as any)?.rpcName, 'refundOrder')
121
+ })
122
+ })
123
+
124
+ describe('switch — fall-through to default', () => {
125
+ test('a trailing empty case routes to the default body', async () => {
126
+ const { nodes } = await run(
127
+ [
128
+ ' switch (data.kind) {',
129
+ " case 'a':",
130
+ " await workflow.do('Notify', 'notify', {})",
131
+ ' break',
132
+ " case 'b':",
133
+ ' default:',
134
+ " await workflow.do('Audit', 'audit', {})",
135
+ ' }',
136
+ ' return { ok: true }',
137
+ ].join('\n')
138
+ )
139
+
140
+ const sw = Object.values(nodes).find((n: any) => n.flow === 'switch') as any
141
+ assert.ok(sw, 'a switch node should exist')
142
+ const caseB = (sw.cases ?? []).find((c: any) => c.value === 'b')
143
+ assert.ok(
144
+ caseB,
145
+ `a case falling through to default must not be dropped, got: ${JSON.stringify(sw.cases)}`
146
+ )
147
+ assert.equal(
148
+ caseB.entry,
149
+ sw.defaultEntry,
150
+ 'it should route to the default body it falls into'
151
+ )
152
+ })
153
+ })
154
+
155
+ describe('options — numeric values keep their type', () => {
156
+ test('a numeric retryDelay is not quoted', async () => {
157
+ const { code } = await run(
158
+ [
159
+ " await workflow.do('Charge', 'chargeCard', {}, { retries: 2, retryDelay: 500 })",
160
+ ' return { ok: true }',
161
+ ].join('\n')
162
+ )
163
+
164
+ assert.ok(
165
+ code.includes('retryDelay: 500'),
166
+ `a numeric retryDelay must stay numeric, got:\n${code}`
167
+ )
168
+ })
169
+ })
170
+
171
+ describe('sequential fanout — for..of bodies', () => {
172
+ test('a multi-step for..of body keeps every step', async () => {
173
+ const { nodes } = await run(
174
+ [
175
+ ' for (const u of data.users) {',
176
+ " const c = await workflow.do('Charge', 'chargeCard', { id: u.id })",
177
+ " await workflow.do('Notify', 'notify', { id: c.id })",
178
+ ' }',
179
+ ' return { ok: true }',
180
+ ].join('\n')
181
+ )
182
+
183
+ const fanout = Object.values(nodes).find(
184
+ (n: any) => n.flow === 'fanout'
185
+ ) as any
186
+ assert.ok(fanout, 'a for..of should produce a fanout node')
187
+ assert.notEqual(
188
+ fanout.nodeId,
189
+ fanout.childEntry,
190
+ 'the loop and its body entry must not share an id'
191
+ )
192
+
193
+ const rpcs = Object.values(nodes)
194
+ .filter((n: any) => n.rpcName)
195
+ .map((n: any) => n.rpcName)
196
+ assert.ok(
197
+ rpcs.includes('chargeCard') && rpcs.includes('notify'),
198
+ `both body steps must survive, got: ${JSON.stringify(rpcs)}`
199
+ )
200
+ })
201
+ })
@@ -103,4 +103,64 @@ describe('addWorkflow — Promise.all fanout RPC detection', () => {
103
103
  await rm(rootDir, { recursive: true, force: true })
104
104
  }
105
105
  })
106
+
107
+ test('registers every RPC in a multi-step map body, including a `const`-captured step feeding the next', async () => {
108
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-fanout-multi-'))
109
+ const wfFile = join(rootDir, 'digest.workflow.ts')
110
+ const stepFile = join(rootDir, 'digest.steps.ts')
111
+
112
+ await writeFile(
113
+ stepFile,
114
+ [
115
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
116
+ 'export const getDigestData = pikkuSessionlessFunc({',
117
+ ' func: async ({ logger }) => ({ dealsByStage: {}, totalDealValue: 0 }),',
118
+ '})',
119
+ 'export const sendDigestEmail = pikkuSessionlessFunc({',
120
+ ' func: async ({ logger }) => ({ sent: true }),',
121
+ '})',
122
+ ].join('\n')
123
+ )
124
+ await writeFile(
125
+ wfFile,
126
+ [
127
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
128
+ 'export const dailyDigestWorkflow = pikkuWorkflowFunc(async (_, data, { workflow }) => {',
129
+ ' const users = [{ id: "1", email: "a@b.c" }]',
130
+ ' await Promise.all(',
131
+ ' users.map(async (u) => {',
132
+ ' const digestData = await workflow.do(',
133
+ ' `Get pipeline for ${u.email}`,',
134
+ " 'getDigestData',",
135
+ ' { userId: u.id },',
136
+ ' )',
137
+ " await workflow.do(`Send digest to ${u.email}`, 'sendDigestEmail', {",
138
+ ' email: u.email,',
139
+ ' dealsByStage: digestData.dealsByStage,',
140
+ ' totalDealValue: digestData.totalDealValue,',
141
+ ' })',
142
+ ' }),',
143
+ ' )',
144
+ ' return { sent: users.length }',
145
+ '})',
146
+ ].join('\n')
147
+ )
148
+
149
+ const criticals: Array<{ code: string; message: string }> = []
150
+ try {
151
+ const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
152
+ rootDir,
153
+ })
154
+ assert.ok(
155
+ state.rpc.invokedFunctions.has('sendDigestEmail'),
156
+ 'sendDigestEmail should be registered'
157
+ )
158
+ assert.ok(
159
+ state.rpc.invokedFunctions.has('getDigestData'),
160
+ 'getDigestData should be registered — a `const`-captured workflow.do inside the map body must not be dropped'
161
+ )
162
+ } finally {
163
+ await rm(rootDir, { recursive: true, force: true })
164
+ }
165
+ })
106
166
  })
@@ -0,0 +1,94 @@
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 — node input extraction', () => {
22
+ test('`as const` and imported template() values survive into the serialized input', async () => {
23
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-graph-input-'))
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 edit = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
32
+ ].join('\n')
33
+ )
34
+
35
+ await writeFile(
36
+ graphFile,
37
+ [
38
+ "import { pikkuWorkflowGraph } from '@pikku/core/workflow'",
39
+ "import { template } from '@pikku/core/workflow'",
40
+ 'export const myGraph = pikkuWorkflowGraph({',
41
+ " name: 'input-graph',",
42
+ " nodes: { edit: 'edit' },",
43
+ ' config: {',
44
+ ' edit: {',
45
+ ' input: (ref) => ({',
46
+ " method: 'GET' as const,",
47
+ ' operations: [',
48
+ " { field: 'product', operation: 'set' as const, value: 'widget' },",
49
+ " { field: 'email', operation: 'set' as const, value: ref('trigger', 'body.email') },",
50
+ " { field: 'greeting', operation: 'set' as const, value: template('Hi $0', [ref('trigger', 'body.name')]) },",
51
+ ' ],',
52
+ ' }),',
53
+ ' },',
54
+ ' },',
55
+ '})',
56
+ ].join('\n')
57
+ )
58
+
59
+ try {
60
+ const state = await inspect(makeLogger(), [stepFile, graphFile], {
61
+ rootDir,
62
+ })
63
+ const graph = state.workflows.graphMeta['input-graph']
64
+ assert.ok(graph, 'graph meta should be registered')
65
+ const input = (graph.nodes['edit'] as { input?: any }).input
66
+ assert.ok(input, 'node input should be extracted')
67
+
68
+ // Bug 1 — top-level `X as const` must not be dropped.
69
+ assert.equal(input.method, 'GET', 'top-level `as const` value preserved')
70
+
71
+ // Bug 1 — nested `as const` inside an array element must not be dropped.
72
+ assert.equal(input.operations[0].operation, 'set')
73
+ assert.equal(input.operations[0].value, 'widget')
74
+ assert.equal(input.operations[1].operation, 'set')
75
+
76
+ // ref() still works alongside the fix.
77
+ assert.deepEqual(input.operations[1].value, {
78
+ $ref: 'trigger',
79
+ path: 'body.email',
80
+ })
81
+
82
+ // Bug 2 — imported template() (arrow has only `ref`, no 2nd param) preserved.
83
+ assert.equal(input.operations[2].operation, 'set')
84
+ assert.deepEqual(input.operations[2].value, {
85
+ $template: {
86
+ parts: ['Hi ', ''],
87
+ expressions: [{ $ref: 'trigger', path: 'body.name' }],
88
+ },
89
+ })
90
+ } finally {
91
+ await rm(rootDir, { recursive: true, force: true })
92
+ }
93
+ })
94
+ })