@pikku/inspector 0.12.14 → 0.12.17

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 (46) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/add/add-ai-agent.js +1 -1
  3. package/dist/add/add-channel.js +25 -7
  4. package/dist/add/add-functions.js +31 -13
  5. package/dist/add/add-gateway.js +1 -1
  6. package/dist/add/add-http-route.js +23 -1
  7. package/dist/add/add-mcp-prompt.js +1 -1
  8. package/dist/add/add-mcp-resource.js +1 -1
  9. package/dist/add/add-queue-worker.js +1 -1
  10. package/dist/add/add-schedule.js +1 -1
  11. package/dist/add/add-trigger.js +1 -1
  12. package/dist/add/add-workflow.js +1 -1
  13. package/dist/utils/check-pii-output.d.ts +9 -4
  14. package/dist/utils/check-pii-output.js +17 -7
  15. package/dist/utils/custom-types-generator.js +55 -4
  16. package/dist/utils/ensure-function-metadata.js +1 -1
  17. package/dist/utils/extract-node-value.d.ts +1 -1
  18. package/dist/utils/extract-node-value.js +10 -1
  19. package/dist/utils/get-property-value.d.ts +1 -1
  20. package/dist/utils/get-property-value.js +35 -9
  21. package/dist/utils/schema-generator.js +4 -2
  22. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +43 -9
  23. package/package.json +2 -2
  24. package/src/add/add-ai-agent.ts +1 -1
  25. package/src/add/add-channel.ts +37 -7
  26. package/src/add/add-functions.ts +47 -13
  27. package/src/add/add-gateway.ts +1 -1
  28. package/src/add/add-http-route.ts +26 -1
  29. package/src/add/add-mcp-prompt.ts +1 -1
  30. package/src/add/add-mcp-resource.ts +1 -1
  31. package/src/add/add-queue-worker.ts +1 -1
  32. package/src/add/add-schedule.ts +1 -1
  33. package/src/add/add-trigger.ts +1 -1
  34. package/src/add/add-workflow.test.ts +152 -0
  35. package/src/add/add-workflow.ts +2 -1
  36. package/src/add/pii-check.test.ts +70 -28
  37. package/src/utils/check-pii-output.ts +27 -11
  38. package/src/utils/custom-types-generator.test.ts +99 -0
  39. package/src/utils/custom-types-generator.ts +64 -4
  40. package/src/utils/ensure-function-metadata.ts +3 -1
  41. package/src/utils/extract-node-value.test.ts +12 -10
  42. package/src/utils/extract-node-value.ts +15 -1
  43. package/src/utils/get-property-value.ts +33 -13
  44. package/src/utils/schema-generator.ts +4 -2
  45. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +50 -11
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -212,7 +212,9 @@ async function batchImportWithRegister(logger, sourceFiles) {
212
212
  return null;
213
213
  }
214
214
  finally {
215
- await unregister?.();
215
+ void Promise.resolve(unregister?.()).catch((e) => {
216
+ logger.debug(`tsx unregister() failed: ${e.message}`);
217
+ });
216
218
  }
217
219
  }
218
220
  async function importWithRegister(sourceFile) {
@@ -221,7 +223,7 @@ async function importWithRegister(sourceFile) {
221
223
  return await import(sourceFile);
222
224
  }
223
225
  finally {
224
- await unregister();
226
+ void Promise.resolve(unregister()).catch(() => { });
225
227
  }
226
228
  }
227
229
  function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger) {
@@ -293,21 +293,32 @@ function extractExpressionStatement(statement, context) {
293
293
  if (ts.isIdentifier(expr.left)) {
294
294
  outputVar = expr.left.text;
295
295
  // Check if this is an assignment to a context variable (set step)
296
+ // But if the RHS is a workflow.do() call, fall through to RPC extraction —
297
+ // reassigning a pre-declared variable with a workflow step is valid and common.
296
298
  if (context.contextVars.has(outputVar)) {
297
- const literalValue = extractLiteralValue(expr.right);
298
- if (literalValue !== undefined) {
299
+ const rhs = expr.right;
300
+ const rhsCall = ts.isAwaitExpression(rhs) && ts.isCallExpression(rhs.expression)
301
+ ? rhs.expression
302
+ : null;
303
+ const isWorkflowCall = rhsCall
304
+ ? isWorkflowDoCall(rhsCall, context.checker)
305
+ : false;
306
+ if (!isWorkflowCall) {
307
+ const literalValue = extractLiteralValue(expr.right);
308
+ if (literalValue !== undefined) {
309
+ return {
310
+ type: 'set',
311
+ variable: outputVar,
312
+ value: literalValue,
313
+ };
314
+ }
315
+ // Non-literal assignment to context var - use expression as string
299
316
  return {
300
317
  type: 'set',
301
318
  variable: outputVar,
302
- value: literalValue,
319
+ value: getSourceText(expr.right),
303
320
  };
304
321
  }
305
- // Non-literal assignment to context var - use expression as string
306
- return {
307
- type: 'set',
308
- variable: outputVar,
309
- value: getSourceText(expr.right),
310
- };
311
322
  }
312
323
  }
313
324
  // Use right side as the expression to extract from
@@ -1029,6 +1040,29 @@ function extractReturn(statement, context) {
1029
1040
  if (!statement.expression) {
1030
1041
  return null;
1031
1042
  }
1043
+ if (ts.isAwaitExpression(statement.expression) &&
1044
+ ts.isCallExpression(statement.expression.expression)) {
1045
+ const call = statement.expression.expression;
1046
+ if (isWorkflowDoCall(call, context.checker)) {
1047
+ return isInlineDoCall(call)
1048
+ ? extractInlineStep(call, context)
1049
+ : extractRpcStep(call, context);
1050
+ }
1051
+ if (isWorkflowSleepCall(call, context.checker)) {
1052
+ return extractSleepStep(call, context);
1053
+ }
1054
+ }
1055
+ if (ts.isCallExpression(statement.expression)) {
1056
+ const call = statement.expression;
1057
+ if (isWorkflowDoCall(call, context.checker)) {
1058
+ return isInlineDoCall(call)
1059
+ ? extractInlineStep(call, context)
1060
+ : extractRpcStep(call, context);
1061
+ }
1062
+ if (isWorkflowSleepCall(call, context.checker)) {
1063
+ return extractSleepStep(call, context);
1064
+ }
1065
+ }
1032
1066
  if (!ts.isObjectLiteralExpression(statement.expression)) {
1033
1067
  return null;
1034
1068
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.14",
3
+ "version": "0.12.17",
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.27",
38
+ "@pikku/core": "^0.12.28",
39
39
  "path-to-regexp": "^8.3.0",
40
40
  "ts-json-schema-generator": "^2.5.0",
41
41
  "tsx": "^4.21.0",
@@ -252,7 +252,7 @@ export const addAIAgent: AddWiring = (
252
252
 
253
253
  const nameValue = getPropertyValue(obj, 'name') as string | null
254
254
  const { disabled, tags, summary, description, errors } =
255
- getCommonWireMetaData(obj, 'AI agent', nameValue, logger)
255
+ getCommonWireMetaData(obj, 'AI agent', nameValue, logger, checker)
256
256
 
257
257
  if (disabled) return
258
258
 
@@ -238,7 +238,8 @@ export function addMessagesRoutes(
238
238
  init,
239
239
  'Channel message',
240
240
  routeKey,
241
- logger
241
+ logger,
242
+ checker
242
243
  ).tags
243
244
  : undefined
244
245
  const routeMiddleware = ts.isObjectLiteralExpression(init)
@@ -270,7 +271,8 @@ export function addMessagesRoutes(
270
271
  init,
271
272
  'Channel message',
272
273
  routeKey,
273
- logger
274
+ logger,
275
+ checker
274
276
  ).tags
275
277
  : undefined
276
278
  const routeMiddleware = ts.isObjectLiteralExpression(init)
@@ -319,7 +321,8 @@ export function addMessagesRoutes(
319
321
  init,
320
322
  'Channel message',
321
323
  routeKey,
322
- logger
324
+ logger,
325
+ checker
323
326
  ).tags
324
327
  : undefined
325
328
  const routeMiddleware = ts.isObjectLiteralExpression(
@@ -350,7 +353,8 @@ export function addMessagesRoutes(
350
353
  init,
351
354
  'Channel message',
352
355
  routeKey,
353
- logger
356
+ logger,
357
+ checker
354
358
  ).tags
355
359
  : undefined
356
360
  const routeMiddleware = ts.isObjectLiteralExpression(
@@ -438,7 +442,8 @@ export function addMessagesRoutes(
438
442
  init,
439
443
  'Channel message',
440
444
  routeKey,
441
- logger
445
+ logger,
446
+ checker
442
447
  ).tags
443
448
  : undefined
444
449
  const routeMiddleware = ts.isObjectLiteralExpression(init)
@@ -481,7 +486,13 @@ export function addMessagesRoutes(
481
486
  // Resolve middleware and permissions for this route
482
487
  // Check if the route config is an object literal with middleware/permissions
483
488
  const routeTags = ts.isObjectLiteralExpression(init)
484
- ? getCommonWireMetaData(init, 'Channel message', routeKey, logger).tags
489
+ ? getCommonWireMetaData(
490
+ init,
491
+ 'Channel message',
492
+ routeKey,
493
+ logger,
494
+ checker
495
+ ).tags
485
496
  : undefined
486
497
  const routeMiddleware = ts.isObjectLiteralExpression(init)
487
498
  ? resolveMiddleware(state, init, routeTags, checker)
@@ -540,7 +551,7 @@ export const addChannel: AddWiring = (
540
551
  : []
541
552
 
542
553
  const { disabled, tags, summary, description, errors } =
543
- getCommonWireMetaData(obj, 'Channel', name, logger)
554
+ getCommonWireMetaData(obj, 'Channel', name, logger, checker)
544
555
 
545
556
  if (disabled) return
546
557
 
@@ -630,6 +641,25 @@ export const addChannel: AddWiring = (
630
641
  state.serviceAggregation.usedFunctions.add(disconnectFuncId)
631
642
  }
632
643
 
644
+ // Synthesize function meta for connect/disconnect handlers that are defined
645
+ // with pikkuChannelConnectionFunc/pikkuChannelDisconnectionFunc (not pikkuFunc/
646
+ // pikkuSessionlessFunc), so the runtime has correct sessionless info without
647
+ // needing to inject it at runtime.
648
+ {
649
+ const routeAuth = getPropertyValue(obj, 'auth')
650
+ const sessionless = routeAuth === false ? true : undefined
651
+ for (const funcId of [connectFuncId, disconnectFuncId]) {
652
+ if (funcId && !state.functions.meta[funcId]) {
653
+ state.functions.meta[funcId] = {
654
+ pikkuFuncId: funcId,
655
+ sessionless,
656
+ inputSchemaName: null,
657
+ outputSchemaName: null,
658
+ }
659
+ }
660
+ }
661
+ }
662
+
633
663
  if (message) {
634
664
  state.serviceAggregation.usedFunctions.add(message.pikkuFuncId)
635
665
  }
@@ -392,6 +392,7 @@ export const addFunctions: AddWiring = (
392
392
  let deploy: 'serverless' | 'server' | 'auto' | undefined
393
393
  let approvalRequired: boolean | undefined
394
394
  let approvalDescription: string | undefined
395
+ let inline: boolean | undefined
395
396
  let version: number | undefined
396
397
  let objectNode: ts.ObjectLiteralExpression | undefined
397
398
  let nodeDisplayName: string | null = null
@@ -462,7 +463,13 @@ export const addFunctions: AddWiring = (
462
463
  // Extract config properties if using object form
463
464
  if (ts.isObjectLiteralExpression(firstArg)) {
464
465
  objectNode = firstArg
465
- const metadata = getCommonWireMetaData(firstArg, 'Function', name, logger)
466
+ const metadata = getCommonWireMetaData(
467
+ firstArg,
468
+ 'Function',
469
+ name,
470
+ logger,
471
+ checker
472
+ )
466
473
  if (metadata.disabled) return
467
474
  title = metadata.title
468
475
  tags = metadata.tags
@@ -481,6 +488,7 @@ export const addFunctions: AddWiring = (
481
488
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired') as
482
489
  | boolean
483
490
  | undefined
491
+ inline = getPropertyValue(firstArg, 'inline') as boolean | undefined
484
492
 
485
493
  // Extract approvalDescription identifier reference
486
494
  for (const prop of firstArg.properties) {
@@ -883,24 +891,50 @@ export const addFunctions: AddWiring = (
883
891
  }
884
892
  }
885
893
 
886
- // ── PII brand check ───────────────────────────────────────────────────────
887
- // Walk the function body's ACTUAL inferred return type looking for Private<T>
888
- // / Pii<T> / Secret<T> brands (__classification__ property). This runs for every function,
889
- // including those with a Zod output schema, because the TS return type
890
- // reflects what the body actually returns before any Zod coercion.
894
+ const sessionless = expression.text !== 'pikkuFunc'
895
+
896
+ // ── Classification brand check ─────────────────────────────────────────────
897
+ // Walk the function body's ACTUAL inferred return type looking for classification
898
+ // brands (__classification__ property on Private<T>, Pii<T>, Secret<T>).
899
+ //
900
+ // Semantics:
901
+ // secret → never returned by any exposed function (sessioned or not)
902
+ // private → only visible to authenticated (sessioned) users; ok for pikkuFunc
903
+ // public → safe for sessionless functions
891
904
  {
892
905
  const sig = checker.getSignatureFromDeclaration(handler)
893
906
  if (sig) {
894
907
  const rawRet = checker.getReturnTypeOfSignature(sig)
895
908
  const unwrapped = unwrapPromise(checker, rawRet)
896
- const piiPaths = findPiiPaths(checker, unwrapped)
897
- if (piiPaths.length > 0) {
909
+ const classifiedFields = findPiiPaths(checker, unwrapped)
910
+
911
+ const secretPaths = classifiedFields
912
+ .filter((f) => f.classification === 'secret')
913
+ .map((f) => f.path)
914
+
915
+ const privatePaths = classifiedFields
916
+ .filter(
917
+ (f) => f.classification === 'private' || f.classification === 'pii'
918
+ )
919
+ .map((f) => f.path)
920
+
921
+ if (secretPaths.length > 0) {
922
+ logger.critical(
923
+ ErrorCode.PII_IN_OUTPUT,
924
+ `Function '${name}' exposes secret-classified field(s) in its return type: ` +
925
+ secretPaths.map((p) => `'${p}'`).join(', ') +
926
+ `.\n Secret fields must never appear in function output. ` +
927
+ `Strip these fields before returning or change the column classification.`
928
+ )
929
+ }
930
+
931
+ if (sessionless && privatePaths.length > 0) {
898
932
  logger.critical(
899
933
  ErrorCode.PII_IN_OUTPUT,
900
- `Function '${name}' exposes PII-classified field(s) in its return type: ` +
901
- piiPaths.map((p) => `'${p}'`).join(', ') +
902
- `.\n Either strip these fields before returning or mark the column ` +
903
- `@public in the migration if it is safe to expose.`
934
+ `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
935
+ privatePaths.map((p) => `'${p}'`).join(', ') +
936
+ `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
937
+ `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`
904
938
  )
905
939
  }
906
940
  }
@@ -946,7 +980,6 @@ export const addFunctions: AddWiring = (
946
980
  }
947
981
  }
948
982
 
949
- const sessionless = expression.text !== 'pikkuFunc'
950
983
  const implementationHash = computeImplementationHash({
951
984
  wrapper: expression.text,
952
985
  handler,
@@ -973,6 +1006,7 @@ export const addFunctions: AddWiring = (
973
1006
  deploy: deploy || undefined,
974
1007
  approvalRequired: approvalRequired || undefined,
975
1008
  approvalDescription: approvalDescription || undefined,
1009
+ inline: inline === false ? false : undefined,
976
1010
  implementationHash,
977
1011
  version,
978
1012
  title,
@@ -45,7 +45,7 @@ export const addGateway: AddWiring = (
45
45
  const typeValue = getPropertyValue(obj, 'type') as GatewayTransportType | null
46
46
  const routeValue = getPropertyValue(obj, 'route') as string | undefined
47
47
  const { disabled, tags, summary, description, errors } =
48
- getCommonWireMetaData(obj, 'Gateway', nameValue, logger)
48
+ getCommonWireMetaData(obj, 'Gateway', nameValue, logger, checker)
49
49
 
50
50
  if (disabled) return
51
51
 
@@ -175,7 +175,7 @@ export function registerHTTPRoute({
175
175
  summary,
176
176
  description,
177
177
  errors,
178
- } = getCommonWireMetaData(obj, 'HTTP route', fullRoute, logger)
178
+ } = getCommonWireMetaData(obj, 'HTTP route', fullRoute, logger, checker)
179
179
 
180
180
  if (disabled) return
181
181
 
@@ -248,6 +248,31 @@ export function registerHTTPRoute({
248
248
  extracted.isHelper
249
249
  )
250
250
 
251
+ // Propagate sessionless from the addon target so the runtime can correctly
252
+ // determine whether a session is required for ref()-based routes.
253
+ if (refAddonTarget) {
254
+ const targetMeta = resolveFunctionMeta(state, refAddonTarget)
255
+ const inlineMeta = state.functions.meta[funcName]
256
+ if (targetMeta && inlineMeta && targetMeta.sessionless !== undefined) {
257
+ inlineMeta.sessionless = targetMeta.sessionless
258
+ }
259
+ }
260
+
261
+ // For inline functions (e.g. oauth2 handlers), propagate sessionless: true
262
+ // when auth is explicitly disabled at the route or group level — the
263
+ // developer opted out of auth so no session is required.
264
+ {
265
+ const inlineMeta = state.functions.meta[funcName]
266
+ if (inlineMeta && inlineMeta.sessionless === undefined) {
267
+ const routeAuth = getPropertyValue(obj, 'auth')
268
+ const resolvedAuth =
269
+ routeAuth === true || routeAuth === false ? routeAuth : inheritedAuth
270
+ if (resolvedAuth === false) {
271
+ inlineMeta.sessionless = true
272
+ }
273
+ }
274
+ }
275
+
251
276
  // Lookup existing function metadata
252
277
  const fnMeta = resolveFunctionMeta(state, funcName)
253
278
  if (!fnMeta) {
@@ -45,7 +45,7 @@ export const addMCPPrompt: AddWiring = (
45
45
 
46
46
  const nameValue = getPropertyValue(obj, 'name') as string | null
47
47
  const { disabled, tags, summary, description, errors } =
48
- getCommonWireMetaData(obj, 'MCP prompt', nameValue, logger)
48
+ getCommonWireMetaData(obj, 'MCP prompt', nameValue, logger, checker)
49
49
 
50
50
  if (disabled) return
51
51
 
@@ -46,7 +46,7 @@ export const addMCPResource: AddWiring = (
46
46
  const uriValue = getPropertyValue(obj, 'uri') as string | null
47
47
  const titleValue = getPropertyValue(obj, 'title') as string | null
48
48
  const { disabled, tags, summary, description, errors } =
49
- getCommonWireMetaData(obj, 'MCP resource', uriValue, logger)
49
+ getCommonWireMetaData(obj, 'MCP resource', uriValue, logger, checker)
50
50
 
51
51
  if (disabled) return
52
52
 
@@ -37,7 +37,7 @@ export const addQueueWorker: AddWiring = (logger, node, checker, state) => {
37
37
 
38
38
  const name = getPropertyValue(obj, 'name') as string | null
39
39
  const { disabled, tags, summary, description, errors } =
40
- getCommonWireMetaData(obj, 'Queue worker', name, logger)
40
+ getCommonWireMetaData(obj, 'Queue worker', name, logger, checker)
41
41
 
42
42
  if (disabled) return
43
43
 
@@ -44,7 +44,7 @@ export const addSchedule: AddWiring = (
44
44
  const nameValue = getPropertyValue(obj, 'name') as string | null
45
45
  const scheduleValue = getPropertyValue(obj, 'schedule') as string | null
46
46
  const { disabled, tags, summary, description, errors } =
47
- getCommonWireMetaData(obj, 'Scheduler', nameValue, logger)
47
+ getCommonWireMetaData(obj, 'Scheduler', nameValue, logger, checker)
48
48
 
49
49
  if (disabled) return
50
50
 
@@ -55,7 +55,7 @@ const addWireTrigger: (
55
55
 
56
56
  const nameValue = getPropertyValue(obj, 'name') as string | null
57
57
  const { disabled, tags, summary, description, errors } =
58
- getCommonWireMetaData(obj, 'Trigger', nameValue, logger)
58
+ getCommonWireMetaData(obj, 'Trigger', nameValue, logger, checker)
59
59
 
60
60
  if (disabled) return
61
61
 
@@ -0,0 +1,152 @@
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(
10
+ criticals: Array<{ code: string; message: string }>
11
+ ): InspectorLogger {
12
+ return {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ critical: (code: any, message: string) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ hasCriticalErrors: () => criticals.length > 0,
21
+ }
22
+ }
23
+
24
+ describe('addWorkflow — workflow.do RPC detection', () => {
25
+ test('detects RPC step when result is assigned to a const', async () => {
26
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-workflow-'))
27
+ const wfFile = join(rootDir, 'my.workflow.ts')
28
+ const stepFile = join(rootDir, 'my.steps.ts')
29
+
30
+ await writeFile(
31
+ stepFile,
32
+ [
33
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
34
+ 'export const doThing = pikkuSessionlessFunc({',
35
+ ' func: async ({ logger }) => ({ ok: true }),',
36
+ '})',
37
+ ].join('\n')
38
+ )
39
+
40
+ await writeFile(
41
+ wfFile,
42
+ [
43
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
44
+ 'export const myWorkflow = pikkuWorkflowFunc(async (_, _input, { workflow }) => {',
45
+ " const result = await workflow.do('Do thing', 'doThing', {})",
46
+ ' return { id: result.ok }',
47
+ '})',
48
+ ].join('\n')
49
+ )
50
+
51
+ const criticals: Array<{ code: string; message: string }> = []
52
+ try {
53
+ const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
54
+ rootDir,
55
+ })
56
+ assert.ok(
57
+ state.rpc.invokedFunctions.has('doThing'),
58
+ 'doThing should be in invokedFunctions'
59
+ )
60
+ assert.ok(
61
+ state.rpc.internalFiles.has('doThing'),
62
+ 'doThing should be in internalFiles'
63
+ )
64
+ } finally {
65
+ await rm(rootDir, { recursive: true, force: true })
66
+ }
67
+ })
68
+
69
+ test('detects RPC step when result is reassigned to a pre-declared null variable', async () => {
70
+ // Regression: `let x = null; x = await workflow.do(...)` was treated as a
71
+ // set-step instead of an RPC step, so the referenced function was never registered.
72
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-workflow-reassign-'))
73
+ const wfFile = join(rootDir, 'project.workflow.ts')
74
+ const stepFile = join(rootDir, 'project.steps.ts')
75
+
76
+ await writeFile(
77
+ stepFile,
78
+ [
79
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
80
+ 'export const launchSandbox = pikkuSessionlessFunc({',
81
+ ' func: async ({ logger }) => ({ sandboxId: "abc" }),',
82
+ '})',
83
+ ].join('\n')
84
+ )
85
+
86
+ await writeFile(
87
+ wfFile,
88
+ [
89
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
90
+ 'export const createProjectWorkflow = pikkuWorkflowFunc(async (_, input, { workflow }) => {',
91
+ ' let launched: { sandboxId: string } | null = null',
92
+ ' if (input.createSandbox) {',
93
+ " launched = await workflow.do('Launch sandbox', 'launchSandbox', { projectId: input.projectId })",
94
+ ' }',
95
+ ' return { sandboxId: launched?.sandboxId ?? null }',
96
+ '})',
97
+ ].join('\n')
98
+ )
99
+
100
+ const criticals: Array<{ code: string; message: string }> = []
101
+ try {
102
+ const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
103
+ rootDir,
104
+ })
105
+ assert.ok(
106
+ state.rpc.invokedFunctions.has('launchSandbox'),
107
+ 'launchSandbox should be in invokedFunctions even when assigned to a pre-declared null variable'
108
+ )
109
+ assert.ok(
110
+ state.rpc.internalFiles.has('launchSandbox'),
111
+ 'launchSandbox should be in internalFiles so it gets registered in pikku-functions.gen.ts'
112
+ )
113
+ } finally {
114
+ await rm(rootDir, { recursive: true, force: true })
115
+ }
116
+ })
117
+
118
+ test('still treats plain reassignment to context var as a set step', async () => {
119
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-workflow-set-'))
120
+ const wfFile = join(rootDir, 'set.workflow.ts')
121
+
122
+ await writeFile(
123
+ wfFile,
124
+ [
125
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
126
+ 'export const setWorkflow = pikkuWorkflowFunc(async (_, input, { workflow }) => {',
127
+ ' let status = "pending"',
128
+ ' status = "done"',
129
+ " await workflow.do('No-op', 'noopStep', {})",
130
+ ' return { status }',
131
+ '})',
132
+ ].join('\n')
133
+ )
134
+
135
+ const criticals: Array<{ code: string; message: string }> = []
136
+ try {
137
+ const state = await inspect(makeLogger(criticals), [wfFile], { rootDir })
138
+ const meta = state.workflows.meta['setWorkflow']
139
+ assert.ok(meta, 'workflow should be registered')
140
+ const steps = meta.steps ?? []
141
+ const setStep = steps.find(
142
+ (s: any) => s.type === 'set' && s.variable === 'status'
143
+ )
144
+ assert.ok(
145
+ setStep,
146
+ 'plain string reassignment should still produce a set step'
147
+ )
148
+ } finally {
149
+ await rm(rootDir, { recursive: true, force: true })
150
+ }
151
+ })
152
+ })
@@ -217,7 +217,8 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
217
217
  firstArg,
218
218
  'Workflow',
219
219
  workflowName,
220
- logger
220
+ logger,
221
+ checker
221
222
  )
222
223
  if (metadata.disabled) return
223
224
  tags = metadata.tags