@pikku/inspector 0.12.47 → 0.12.48

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 (50) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/README.md +28 -2
  3. package/dist/add/add-feature.d.ts +19 -0
  4. package/dist/add/add-feature.js +200 -0
  5. package/dist/add/add-functions.js +24 -9
  6. package/dist/add/add-workflow.js +22 -29
  7. package/dist/error-codes.d.ts +3 -0
  8. package/dist/error-codes.js +3 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/inspector.js +39 -2
  11. package/dist/types.d.ts +43 -0
  12. package/dist/utils/extract-function-name.js +27 -0
  13. package/dist/utils/post-process.d.ts +26 -0
  14. package/dist/utils/post-process.js +85 -0
  15. package/dist/utils/serialize-inspector-state.d.ts +2 -1
  16. package/dist/utils/serialize-inspector-state.js +2 -0
  17. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +203 -19
  18. package/dist/utils/workflow/dsl/patterns.d.ts +21 -0
  19. package/dist/utils/workflow/dsl/patterns.js +30 -0
  20. package/dist/utils/workflow/dsl/validation.d.ts +5 -0
  21. package/dist/utils/workflow/dsl/validation.js +9 -0
  22. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +36 -0
  23. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -1
  24. package/dist/visit.js +2 -0
  25. package/package.json +4 -3
  26. package/src/add/add-feature.test.ts +306 -0
  27. package/src/add/add-feature.ts +253 -0
  28. package/src/add/add-functions.ts +29 -9
  29. package/src/add/add-scenario-step.test.ts +311 -0
  30. package/src/add/add-workflow-silent-drops.test.ts +98 -0
  31. package/src/add/add-workflow.ts +25 -37
  32. package/src/add/scenario-not-extractable.test.ts +159 -0
  33. package/src/add/scenario-services.test.ts +103 -0
  34. package/src/add/scenario-skip.test.ts +83 -0
  35. package/src/error-codes.ts +3 -0
  36. package/src/index.ts +1 -0
  37. package/src/inspector.ts +50 -1
  38. package/src/tsconfig-paths.test.ts +90 -0
  39. package/src/types.ts +45 -0
  40. package/src/utils/extract-function-name.ts +36 -0
  41. package/src/utils/post-process.ts +97 -0
  42. package/src/utils/serialize-inspector-state.ts +8 -1
  43. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
  44. package/src/utils/workflow/dsl/patterns.ts +38 -0
  45. package/src/utils/workflow/dsl/validation.ts +12 -0
  46. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
  47. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
  48. package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
  49. package/src/visit.ts +2 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,311 @@
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 type { ScenarioStepMeta, WorkflowStepMeta } from '@pikku/core/workflow'
9
+
10
+ function makeLogger(
11
+ criticals: Array<{ code: string; message: string }>
12
+ ): InspectorLogger {
13
+ return {
14
+ debug: () => {},
15
+ info: () => {},
16
+ warn: () => {},
17
+ error: () => {},
18
+ diagnostic: ({ code, message }: any) => {
19
+ criticals.push({ code, message })
20
+ },
21
+ critical: (code: any, message: string) => {
22
+ criticals.push({ code, message })
23
+ },
24
+ hasCriticalErrors: () => criticals.length > 0,
25
+ } as InspectorLogger
26
+ }
27
+
28
+ const STEPS = [
29
+ "import { pikkuScenarioStep } from '@pikku/core'",
30
+ 'export const buysAnApple = pikkuScenarioStep({',
31
+ " name: 'buysAnApple',",
32
+ " description: 'buys an apple',",
33
+ ' browser: true,',
34
+ ' func: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
35
+ '})',
36
+ 'export const seesAReceipt = pikkuScenarioStep({',
37
+ " name: 'seesAReceipt',",
38
+ " description: 'sees a receipt',",
39
+ ' func: async ({ logger }) => ({ ok: true }),',
40
+ '})',
41
+ ].join('\n')
42
+
43
+ const scenarioSource = (body: string[], preamble: string[] = []) =>
44
+ [
45
+ "import { pikkuScenario } from '@pikku/core/workflow'",
46
+ 'declare const actors: Record<string, any>',
47
+ ...preamble,
48
+ 'export const shopFlow = pikkuScenario(async (_services, _input, { scenario }: any) => {',
49
+ ...body.map((line) => ` ${line}`),
50
+ ' return { ok: true }',
51
+ '})',
52
+ ].join('\n')
53
+
54
+ async function run(body: string[], preamble: string[] = []) {
55
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-step-'))
56
+ const stepsFile = join(rootDir, 'shop.steps.ts')
57
+ const scenarioFile = join(rootDir, 'shop.scenario.ts')
58
+ await writeFile(stepsFile, STEPS)
59
+ await writeFile(scenarioFile, scenarioSource(body, preamble))
60
+ const criticals: Array<{ code: string; message: string }> = []
61
+ const state = await inspect(
62
+ makeLogger(criticals),
63
+ [stepsFile, scenarioFile],
64
+ {
65
+ rootDir,
66
+ }
67
+ )
68
+ return {
69
+ state,
70
+ criticals,
71
+ cleanup: () => rm(rootDir, { recursive: true, force: true }),
72
+ }
73
+ }
74
+
75
+ const flatten = (steps: WorkflowStepMeta[]): WorkflowStepMeta[] =>
76
+ steps.flatMap((step) => [
77
+ step,
78
+ ...('body' in step && step.body
79
+ ? flatten(step.body as WorkflowStepMeta[])
80
+ : []),
81
+ ])
82
+
83
+ const scenarioSteps = (state: any): ScenarioStepMeta[] =>
84
+ flatten(state.workflows.meta.shopFlow?.steps ?? []).filter(
85
+ (step): step is ScenarioStepMeta => step.type === 'scenarioStep'
86
+ )
87
+
88
+ describe('pikkuScenarioStep', () => {
89
+ test('a step is registered under its declared name, not its export name', async () => {
90
+ const { state, cleanup } = await run([
91
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
92
+ ])
93
+ try {
94
+ assert.ok(
95
+ state.functions.meta['buysAnApple'],
96
+ `expected a function registered as 'buysAnApple', got: ${Object.keys(state.functions.meta).join(', ')}`
97
+ )
98
+ } finally {
99
+ await cleanup()
100
+ }
101
+ })
102
+
103
+ test('a step is never registered as an RPC — a browser step must not be network-callable', async () => {
104
+ const { state, cleanup } = await run([
105
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
106
+ ])
107
+ try {
108
+ assert.equal(
109
+ state.rpc.internalMeta['buysAnApple'],
110
+ undefined,
111
+ 'scenario steps must not be RPC-callable'
112
+ )
113
+ assert.equal(
114
+ state.rpc.exposedMeta['buysAnApple'],
115
+ undefined,
116
+ 'scenario steps must not be exposed over RPC'
117
+ )
118
+ } finally {
119
+ await cleanup()
120
+ }
121
+ })
122
+
123
+ test('given/when/then record phase, target and actor', async () => {
124
+ const { state, cleanup } = await run([
125
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
126
+ "await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
127
+ ])
128
+ try {
129
+ const steps = scenarioSteps(state)
130
+ assert.equal(
131
+ steps.length,
132
+ 2,
133
+ `expected 2 scenario steps, got ${steps.length}`
134
+ )
135
+ assert.equal(steps[0]!.phase, 'given')
136
+ assert.equal(steps[0]!.stepFunc, 'buysAnApple')
137
+ assert.equal(steps[0]!.actor, 'shopper')
138
+ assert.equal(steps[1]!.phase, 'then')
139
+ assert.equal(steps[1]!.stepFunc, 'seesAReceipt')
140
+ } finally {
141
+ await cleanup()
142
+ }
143
+ })
144
+
145
+ test('the step target is bundled, so the step function is wired', async () => {
146
+ const { state, cleanup } = await run([
147
+ "await scenario.step('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
148
+ ])
149
+ try {
150
+ assert.ok(
151
+ state.rpc.invokedFunctions.has('buysAnApple'),
152
+ `expected 'buysAnApple' to be bundled, got: ${[...state.rpc.invokedFunctions].join(', ')}`
153
+ )
154
+ } finally {
155
+ await cleanup()
156
+ }
157
+ })
158
+
159
+ test('a step inside a for..of loop is extracted, not silently dropped', async () => {
160
+ const { state, cleanup } = await run([
161
+ 'const quantities = [1, 2]',
162
+ 'for (const qty of quantities) {',
163
+ " await scenario.when('buys an apple', 'buysAnApple', { qty }, { actor: actors.shopper })",
164
+ '}',
165
+ ])
166
+ try {
167
+ const steps = scenarioSteps(state)
168
+ assert.equal(
169
+ steps.length,
170
+ 1,
171
+ `expected the looped step to appear in the fanout body, got ${steps.length} scenario steps`
172
+ )
173
+ assert.equal(steps[0]!.stepFunc, 'buysAnApple')
174
+ assert.ok(
175
+ state.rpc.invokedFunctions.has('buysAnApple'),
176
+ 'a looped step must still be bundled'
177
+ )
178
+ } finally {
179
+ await cleanup()
180
+ }
181
+ })
182
+
183
+ test('a non-literal step target is a PKU678 critical', async () => {
184
+ const { criticals, cleanup } = await run([
185
+ 'const target = Math.random() > 0.5 ? "buysAnApple" : "seesAReceipt"',
186
+ "await scenario.step('buys something', target as any, {}, { actor: actors.shopper })",
187
+ ])
188
+ try {
189
+ assert.ok(
190
+ criticals.find((c) => c.code === 'PKU678'),
191
+ `expected a PKU678 critical for a dynamic step target, got: ${JSON.stringify(criticals)}`
192
+ )
193
+ } finally {
194
+ await cleanup()
195
+ }
196
+ })
197
+
198
+ test('a browser step called without an actor is a PKU677 critical', async () => {
199
+ const { criticals, cleanup } = await run([
200
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 })",
201
+ ])
202
+ try {
203
+ assert.ok(
204
+ criticals.find((c) => c.code === 'PKU677'),
205
+ `expected a PKU677 critical for a browser step without an actor, got: ${JSON.stringify(criticals)}`
206
+ )
207
+ } finally {
208
+ await cleanup()
209
+ }
210
+ })
211
+
212
+ test('a non-browser step without an actor is allowed', async () => {
213
+ const { criticals, cleanup } = await run([
214
+ "await scenario.given('sees a receipt', 'seesAReceipt', {})",
215
+ ])
216
+ try {
217
+ assert.equal(
218
+ criticals.filter((c) => c.code === 'PKU677').length,
219
+ 0,
220
+ `setup steps need no actor, got: ${JSON.stringify(criticals)}`
221
+ )
222
+ } finally {
223
+ await cleanup()
224
+ }
225
+ })
226
+
227
+ test('a step is marked as a step RPC in its function meta', async () => {
228
+ const { state, cleanup } = await run([
229
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
230
+ ])
231
+ try {
232
+ // The fourth RPC kind, alongside public/private/remote: a name that is
233
+ // dispatched inside a run and refused everywhere else.
234
+ assert.equal(state.functions.meta['buysAnApple']?.scenarioStep, true)
235
+ assert.equal(state.functions.meta['seesAReceipt']?.scenarioStep, true)
236
+ } finally {
237
+ await cleanup()
238
+ }
239
+ })
240
+
241
+ test('an ordinary function is not a step RPC', async () => {
242
+ const { state, cleanup } = await run([
243
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
244
+ ])
245
+ try {
246
+ assert.equal(state.functions.meta['shopFlow']?.scenarioStep, undefined)
247
+ } finally {
248
+ await cleanup()
249
+ }
250
+ })
251
+
252
+ test('a closure in the scenario body does not have its statements whitelisted', async () => {
253
+ const { state, cleanup } = await run([
254
+ 'const safely = async (fn: any) => { try { return await fn() } catch { return null } }',
255
+ 'void safely',
256
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
257
+ ])
258
+ try {
259
+ const steps = scenarioSteps(state)
260
+ assert.equal(
261
+ steps.length,
262
+ 1,
263
+ `a try/catch inside a closure must not drop the scenario's steps, got ${steps.length}`
264
+ )
265
+ assert.equal(steps[0]!.stepFunc, 'buysAnApple')
266
+ } finally {
267
+ await cleanup()
268
+ }
269
+ })
270
+
271
+ test('destructuring a step result is reported, not silently dropped', async () => {
272
+ const { state, criticals, cleanup } = await run([
273
+ "const { ok } = await scenario.given('sees a receipt', 'seesAReceipt', {})",
274
+ 'void ok',
275
+ ])
276
+ try {
277
+ const steps = scenarioSteps(state)
278
+ assert.equal(
279
+ steps.length,
280
+ 0,
281
+ 'the DSL does not model a destructured step result'
282
+ )
283
+ assert.ok(
284
+ criticals.find((c) => c.code === 'PKU679'),
285
+ `a step the DSL cannot model must be loud, got: ${JSON.stringify(criticals)}`
286
+ )
287
+ } finally {
288
+ await cleanup()
289
+ }
290
+ })
291
+
292
+ test('a module-level constant used as step input is inlined, not read off the trigger', async () => {
293
+ const { state, cleanup } = await run(
294
+ [
295
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: QTY }, { actor: actors.shopper })",
296
+ ],
297
+ ['const QTY = 3']
298
+ )
299
+ try {
300
+ const steps = scenarioSteps(state)
301
+ assert.equal(steps.length, 1)
302
+ assert.deepEqual(
303
+ steps[0]!.inputs?.qty,
304
+ { from: 'literal', value: 3 },
305
+ `a constant declared outside the scenario is not a trigger field, got: ${JSON.stringify(steps[0]!.inputs)}`
306
+ )
307
+ } finally {
308
+ await cleanup()
309
+ }
310
+ })
311
+ })
@@ -146,6 +146,104 @@ describe('DSL extraction — constructs that must not be silently dropped', () =
146
146
  )
147
147
  })
148
148
 
149
+ test('a counting for-of (Array(n).keys()) hard-errors instead of silently dropping its step', async () => {
150
+ const { invoked, steps, diags } = await inspectWorkflow(
151
+ [
152
+ ' for (const i of [...Array(3).keys()]) {',
153
+ " await workflow.do(`poll #${i}`, 'rpcA', {})",
154
+ ' }',
155
+ ' return { ok: true }',
156
+ ].join('\n')
157
+ )
158
+
159
+ // The DSL extractor can't model a computed/counting iterable, so it must
160
+ // FAIL LOUDLY (INVALID_DSL_WORKFLOW), not swallow the loop and every
161
+ // workflow.do inside it — the silent drop is exactly what stranded
162
+ // checkSandboxHostCapacity out of invokedFunctions and bricked prod.
163
+ assert.ok(
164
+ diags.length > 0,
165
+ 'a counting for-of must raise a diagnostic, not vanish silently'
166
+ )
167
+ assert.ok(
168
+ diags.some((d) => /for-of/i.test(d)),
169
+ `the diagnostic must name the offending for-of, got: ${JSON.stringify(diags)}`
170
+ )
171
+ // And it must NOT have quietly produced a fanout with zero body steps.
172
+ assert.ok(
173
+ !invoked.includes('rpcA') || diags.length > 0,
174
+ 'if rpcA was dropped the user must be told'
175
+ )
176
+ const fanout = steps.find((s) => s.type === 'fanout')
177
+ assert.ok(
178
+ !fanout,
179
+ 'a counting loop must not serialize as a fanout — it is not a data-array fanout'
180
+ )
181
+ })
182
+
183
+ /**
184
+ * The diagnostic used to blame the iterable in every case, including this one
185
+ * where the iterable is a perfectly good data array. That sent readers looking
186
+ * at `data.tagsToRemove` when the real obstacle was the `if` in the body, so
187
+ * the message has to distinguish the two causes.
188
+ */
189
+ test('a branch inside a fanout body hard-errors, and the diagnostic blames the branch not the iterable', async () => {
190
+ const { steps, diags } = await inspectWorkflow(
191
+ [
192
+ ' for (const tag of data.tags) {',
193
+ ' if (tag.length > 2) {',
194
+ " await workflow.do('add', 'rpcA', { tag })",
195
+ ' }',
196
+ ' }',
197
+ ' return { ok: true }',
198
+ ].join('\n')
199
+ )
200
+
201
+ assert.ok(
202
+ diags.length > 0,
203
+ 'a branch inside a fanout body must raise a diagnostic, not vanish silently'
204
+ )
205
+ assert.ok(
206
+ diags.some((d) => /if\/switch/.test(d)),
207
+ `the diagnostic must name the branch as the cause, got: ${JSON.stringify(diags)}`
208
+ )
209
+ assert.ok(
210
+ !diags.some((d) => /iterable must be a data array/.test(d)),
211
+ `data.tags IS a data array — the diagnostic must not blame it, got: ${JSON.stringify(diags)}`
212
+ )
213
+ const fanout = steps.find((s) => s.type === 'fanout')
214
+ assert.ok(!fanout, 'a fanout whose body was dropped must not serialize')
215
+ })
216
+
217
+ /**
218
+ * The hazard the hard error exists for is a *dropped step*. A loop that only
219
+ * massages locals has no step to drop, so failing the build on it is a false
220
+ * positive — and it fired on real workflows that were doing nothing wrong.
221
+ */
222
+ test('a for-of that calls no workflow step is not an error — there is nothing to drop', async () => {
223
+ const { steps, diags } = await inspectWorkflow(
224
+ [
225
+ " const first = await workflow.do('get', 'rpcA', {})",
226
+ ' const merged: Record<string, string> = {}',
227
+ ' for (const dup of data.duplicates) {',
228
+ ' if (dup.phone && !first.phone) {',
229
+ ' merged.phone = dup.phone',
230
+ ' }',
231
+ ' }',
232
+ ' return { ok: true }',
233
+ ].join('\n')
234
+ )
235
+
236
+ assert.deepEqual(
237
+ diags,
238
+ [],
239
+ `a pure-computation loop must not fail the build, got: ${JSON.stringify(diags)}`
240
+ )
241
+ assert.ok(
242
+ steps.some((s) => s.type === 'rpc'),
243
+ 'the surrounding steps must still be extracted'
244
+ )
245
+ })
246
+
149
247
  test('a brace-less for-of body is still extracted as a sequential fanout', async () => {
150
248
  const { invoked, steps } = await inspectWorkflow(
151
249
  [
@@ -97,6 +97,10 @@ export function collectInvokedRPCs(
97
97
  if (step.options?.onError) {
98
98
  rpcs.add(step.options.onError)
99
99
  }
100
+ } else if (step.type === 'scenarioStep' && step.stepFunc) {
101
+ // Steps are bundled like RPCs — the runner calls them through
102
+ // runPikkuFunc — but they are deliberately never RPC-registered.
103
+ rpcs.add(step.stepFunc)
100
104
  } else if (step.type === 'branch') {
101
105
  for (const branch of step.branches) {
102
106
  collectInvokedRPCs(branch.steps, rpcs)
@@ -108,9 +112,9 @@ export function collectInvokedRPCs(
108
112
  }
109
113
  if (step.defaultSteps) collectInvokedRPCs(step.defaultSteps, rpcs)
110
114
  } else if (step.type === 'fanout' && step.body) {
111
- collectInvokedRPCs(step.body, rpcs)
115
+ collectInvokedRPCs(step.body as WorkflowStepMeta[], rpcs)
112
116
  } else if (step.type === 'parallel' && step.children) {
113
- collectInvokedRPCs(step.children, rpcs)
117
+ collectInvokedRPCs(step.children as WorkflowStepMeta[], rpcs)
114
118
  }
115
119
  }
116
120
  }
@@ -282,6 +286,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
282
286
  let description: string | undefined
283
287
  let errors: string[] | undefined
284
288
  let expose: boolean | undefined
289
+ let skip: string | undefined
285
290
 
286
291
  if (ts.isObjectLiteralExpression(firstArg)) {
287
292
  const metadata = getCommonWireMetaData(
@@ -299,6 +304,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
299
304
  errors = metadata.errors
300
305
 
301
306
  expose = getPropertyValue(firstArg, 'expose') as boolean | undefined
307
+ skip = (getPropertyValue(firstArg, 'skip') as string | null) ?? undefined
302
308
  }
303
309
 
304
310
  // Validate that we got a valid function
@@ -391,6 +397,15 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
391
397
  `Workflow '${workflowName}' does not conform to DSL workflow rules:\n${result.reason || 'Unknown error'}`
392
398
  )
393
399
  return
400
+ } else if (wrapperType === 'scenario') {
401
+ // The fallback below understands do/sleep but not step/given/when/then,
402
+ // so a scenario that lands here registers with zero steps. Silence would
403
+ // leave the whole scenario passing vacuously.
404
+ logger.critical(
405
+ ErrorCode.SCENARIO_NOT_EXTRACTABLE,
406
+ `Scenario '${workflowName}' could not be extracted, so none of its steps were recorded and it would run as an empty scenario:\n${result.reason || 'Unknown error'}`
407
+ )
408
+ return
394
409
  } else {
395
410
  // For pikkuWorkflowComplexFunc, fall back to basic extraction
396
411
  logger.debug(
@@ -432,43 +447,16 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
432
447
  errors,
433
448
  tags,
434
449
  expose,
450
+ skip,
435
451
  scenario: wrapperType === 'scenario' ? true : undefined,
436
452
  actors: actorNames.length > 0 ? actorNames : undefined,
437
453
  }
438
454
 
439
- // Scenarios are pure stories of remote RPCs (same rule as client-side CLI
440
- // renderers): the func may only destructure logger/config — everything else
441
- // must go through actor steps so the flow runs against the TARGET
442
- // environment, never local services.
443
- const funcMeta = state.functions.meta[pikkuFuncId]
444
- if (wrapperType === 'scenario' && funcMeta?.services) {
445
- const disallowed = funcMeta.services.services.filter(
446
- (svc) => svc !== 'logger' && svc !== 'config'
447
- )
448
- if (disallowed.length > 0) {
449
- logger.critical(
450
- ErrorCode.SCENARIO_HAS_SERVICES,
451
- `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
452
- `Scenarios may only use 'logger'/'config' — drive everything else through ` +
453
- `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
454
- `runs against the target environment.`
455
- )
456
- return
457
- }
458
- }
459
-
460
- // Workflow functions require platform services that aren't visible
461
- // through parameter destructuring (they're accessed via workflow.do/sleep)
462
- if (funcMeta?.services) {
463
- for (const svc of [
464
- 'workflowService',
465
- 'workflowRunService',
466
- 'schedulerService',
467
- 'queueService',
468
- ]) {
469
- if (!funcMeta.services.services.includes(svc)) {
470
- funcMeta.services.services.push(svc)
471
- }
472
- }
473
- }
455
+ // The scenario "logger/config only" rule (PKU673) and the implicit
456
+ // workflow platform-service injection both used to live here, reading
457
+ // state.functions.meta[pikkuFuncId]. addWorkflow runs in the visitSetup
458
+ // pass, which completes before addFunctions populates that meta, so both
459
+ // were dead. The rule now lives in validateScenarioServices (post-process),
460
+ // and aggregateRequiredServices already adds the platform services globally
461
+ // whenever any workflow exists.
474
462
  }
@@ -0,0 +1,159 @@
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
+ diagnostic: ({ code, message }: any) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ critical: (code: any, message: string) => {
21
+ criticals.push({ code, message })
22
+ },
23
+ hasCriticalErrors: () => criticals.length > 0,
24
+ } as InspectorLogger
25
+ }
26
+
27
+ const STEPS = [
28
+ "import { pikkuScenarioStep } from '@pikku/core'",
29
+ 'export const buysAnApple = pikkuScenarioStep({',
30
+ " name: 'buysAnApple',",
31
+ " description: 'buys an apple',",
32
+ ' func: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
33
+ '})',
34
+ ].join('\n')
35
+
36
+ const source = (wrapper: string, body: string[]) =>
37
+ [
38
+ `import { ${wrapper} } from '@pikku/core/workflow'`,
39
+ 'declare const actors: Record<string, any>',
40
+ `export const shopFlow = ${wrapper}(async (_services, _input, { scenario, workflow }: any) => {`,
41
+ ...body.map((line) => ` ${line}`),
42
+ ' return { ok: true }',
43
+ '})',
44
+ ].join('\n')
45
+
46
+ async function run(wrapper: string, body: string[]) {
47
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-extract-'))
48
+ const stepsFile = join(rootDir, 'shop.steps.ts')
49
+ const scenarioFile = join(rootDir, 'shop.scenario.ts')
50
+ await writeFile(stepsFile, STEPS)
51
+ await writeFile(scenarioFile, source(wrapper, body))
52
+ const criticals: Array<{ code: string; message: string }> = []
53
+ const state = await inspect(
54
+ makeLogger(criticals),
55
+ [stepsFile, scenarioFile],
56
+ {
57
+ rootDir,
58
+ }
59
+ )
60
+ return {
61
+ state,
62
+ criticals,
63
+ cleanup: () => rm(rootDir, { recursive: true, force: true }),
64
+ }
65
+ }
66
+
67
+ /**
68
+ * A scenario that fails DSL extraction silently registers with zero steps,
69
+ * because the fallback path (`getWorkflowInvocations`) understands `do`/`sleep`
70
+ * but not `step`/`given`/`when`/`then`. That is the PKU678 silent-drop class,
71
+ * and for scenarios it must be loud.
72
+ */
73
+ describe('a scenario that cannot be extracted', () => {
74
+ test('a top-level try/catch is a PKU679 critical, not a silent zero-step scenario', async () => {
75
+ const { state, criticals, cleanup } = await run('pikkuScenario', [
76
+ 'try {',
77
+ " await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
78
+ '} catch {}',
79
+ ])
80
+ try {
81
+ assert.equal(
82
+ state.workflows.meta.shopFlow,
83
+ undefined,
84
+ 'an unextractable scenario must not register at all, rather than register empty'
85
+ )
86
+ assert.ok(
87
+ criticals.find((c) => c.code === 'PKU679'),
88
+ `expected a PKU679 critical for an unextractable scenario, got: ${JSON.stringify(criticals)}`
89
+ )
90
+ } finally {
91
+ await cleanup()
92
+ }
93
+ })
94
+
95
+ test('an extractable scenario raises no PKU679', async () => {
96
+ const { criticals, cleanup } = await run('pikkuScenario', [
97
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
98
+ ])
99
+ try {
100
+ assert.equal(
101
+ criticals.filter((c) => c.code === 'PKU679').length,
102
+ 0,
103
+ `a valid scenario must not raise PKU679, got: ${JSON.stringify(criticals)}`
104
+ )
105
+ } finally {
106
+ await cleanup()
107
+ }
108
+ })
109
+
110
+ test('a scenario that declares no input parameter is still extractable', async () => {
111
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-noinput-'))
112
+ const scenarioFile = join(rootDir, 'fails.scenario.ts')
113
+ await writeFile(
114
+ scenarioFile,
115
+ [
116
+ "import { pikkuScenario } from '@pikku/core/workflow'",
117
+ 'export const alwaysFails = pikkuScenario({',
118
+ " title: 'Always fails (test fixture)',",
119
+ " tags: ['scenario'],",
120
+ " func: async () => { throw new Error('always fails') },",
121
+ '})',
122
+ ].join('\n')
123
+ )
124
+ const criticals: Array<{ code: string; message: string }> = []
125
+ const state = await inspect(makeLogger(criticals), [scenarioFile], {
126
+ rootDir,
127
+ })
128
+ try {
129
+ assert.ok(
130
+ state.workflows.meta.alwaysFails,
131
+ `a scenario that ignores its input must still register, got criticals: ${JSON.stringify(criticals)}`
132
+ )
133
+ assert.equal(
134
+ criticals.filter((c) => c.code === 'PKU679').length,
135
+ 0,
136
+ `declaring no input parameter is legitimate, got: ${JSON.stringify(criticals)}`
137
+ )
138
+ } finally {
139
+ await rm(rootDir, { recursive: true, force: true })
140
+ }
141
+ })
142
+
143
+ test('a complex workflow still falls back quietly — only scenarios are strict', async () => {
144
+ const { criticals, cleanup } = await run('pikkuWorkflowComplexFunc', [
145
+ 'try {',
146
+ " await workflow.do('buy', 'buysAnApple', { qty: 1 })",
147
+ '} catch {}',
148
+ ])
149
+ try {
150
+ assert.equal(
151
+ criticals.filter((c) => c.code === 'PKU679').length,
152
+ 0,
153
+ `pikkuWorkflowComplexFunc legitimately falls back to basic extraction, got: ${JSON.stringify(criticals)}`
154
+ )
155
+ } finally {
156
+ await cleanup()
157
+ }
158
+ })
159
+ })