@pikku/inspector 0.12.52 → 0.12.55

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 (40) hide show
  1. package/CHANGELOG.md +341 -0
  2. package/dist/add/add-functions.js +46 -4
  3. package/dist/add/add-keyed-wiring.js +3 -1
  4. package/dist/add/add-personas.js +7 -1
  5. package/dist/add/add-workflow.js +12 -1
  6. package/dist/error-codes.d.ts +8 -1
  7. package/dist/error-codes.js +12 -0
  8. package/dist/types.d.ts +9 -0
  9. package/dist/utils/check-secret-sinks.d.ts +31 -0
  10. package/dist/utils/check-secret-sinks.js +117 -0
  11. package/dist/utils/post-process.js +4 -1
  12. package/dist/utils/single-declaration.d.ts +32 -0
  13. package/dist/utils/single-declaration.js +43 -0
  14. package/dist/utils/validate-exposed-functions-gated.js +15 -7
  15. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
  16. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
  17. package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
  18. package/dist/utils/workflow/dsl/patterns.js +26 -4
  19. package/package.json +3 -3
  20. package/src/add/add-functions.ts +57 -5
  21. package/src/add/add-keyed-wiring.ts +3 -0
  22. package/src/add/add-personas.test.ts +115 -1
  23. package/src/add/add-personas.ts +18 -9
  24. package/src/add/add-scenario-step.test.ts +38 -2
  25. package/src/add/add-scope.test.ts +73 -0
  26. package/src/add/add-system-role.test.ts +83 -0
  27. package/src/add/add-workflow.ts +21 -1
  28. package/src/add/allow-escape-hatches.test.ts +144 -0
  29. package/src/add/audit-meta.test.ts +75 -0
  30. package/src/add/secret-sink-check.test.ts +211 -0
  31. package/src/error-codes.ts +13 -0
  32. package/src/types.ts +9 -0
  33. package/src/utils/check-secret-sinks.ts +145 -0
  34. package/src/utils/post-process.ts +4 -1
  35. package/src/utils/single-declaration.ts +57 -0
  36. package/src/utils/validate-exposed-functions-gated.test.ts +2 -2
  37. package/src/utils/validate-exposed-functions-gated.ts +16 -7
  38. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
  39. package/src/utils/workflow/dsl/patterns.ts +33 -4
  40. package/tsconfig.tsbuildinfo +1 -1
@@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { inspect } from '../inspector.js'
7
- import type { ErrorCode } from '../error-codes.js'
7
+ import { ErrorCode } from '../error-codes.js'
8
8
  import type { InspectorLogger } from '../types.js'
9
9
 
10
10
  const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
@@ -35,6 +35,25 @@ const inspectSource = async (source: string) => {
35
35
  }
36
36
  }
37
37
 
38
+ /** Inspects several files at once, keyed by basename. */
39
+ const inspectSources = async (sources: Record<string, string>) => {
40
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-personas-multi-'))
41
+ const files: Record<string, string> = {}
42
+ for (const [name, source] of Object.entries(sources)) {
43
+ files[name] = join(rootDir, name)
44
+ await writeFile(files[name]!, source)
45
+ }
46
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
47
+ try {
48
+ const state = await inspect(makeLogger(criticals), Object.values(files), {
49
+ rootDir,
50
+ })
51
+ return { state, criticals, files }
52
+ } finally {
53
+ await rm(rootDir, { recursive: true, force: true })
54
+ }
55
+ }
56
+
38
57
  /** The declared world every persona below is checked against. */
39
58
  const withRoles = (...lines: string[]) =>
40
59
  [
@@ -89,6 +108,30 @@ describe('addPersonas inspector', () => {
89
108
  assert.equal(susan.runnable, true)
90
109
  })
91
110
 
111
+ test('extracts a declared avatar', async () => {
112
+ const { state } = await inspectSource(
113
+ withRoles(
114
+ 'definePersonas({',
115
+ ' susan: {',
116
+ " name: 'Susan',",
117
+ " avatarUrl: '/avatars/susan.png',",
118
+ ' },',
119
+ '})'
120
+ )
121
+ )
122
+ assert.equal(state.personas.definitions[0]!.avatarUrl, '/avatars/susan.png')
123
+ })
124
+
125
+ // The console draws a colour-and-icon avatar from the persona's id when none
126
+ // is declared, so an absent field has to stay absent rather than become an
127
+ // empty string the renderer would treat as a broken image.
128
+ test('leaves the avatar absent when none is declared', async () => {
129
+ const { state } = await inspectSource(
130
+ withRoles('definePersonas({', " susan: { name: 'Susan' },", '})')
131
+ )
132
+ assert.equal(state.personas.definitions[0]!.avatarUrl, undefined)
133
+ })
134
+
92
135
  test('extracts the environments a persona declares', async () => {
93
136
  const { state } = await inspectSource(
94
137
  withRoles(
@@ -216,6 +259,77 @@ describe('addPersonas inspector', () => {
216
259
  assert.equal(state.personas.definitions[0]!.runnable, false)
217
260
  })
218
261
 
262
+ // One call site per codebase, so there is one place to read the cast from
263
+ // and one place to add to. The keyed form above is how you declare more.
264
+ test('a second definePersonas in the same file is refused', async () => {
265
+ const { state, criticals } = await inspectSource(
266
+ withRoles(
267
+ "definePersonas({ susan: { name: 'Susan' } })",
268
+ "definePersonas({ dave: { name: 'Dave' } })"
269
+ )
270
+ )
271
+
272
+ const hit = criticals.find(
273
+ (c) => c.code === ErrorCode.DUPLICATE_PERSONAS_DEFINITION
274
+ )
275
+ assert.ok(
276
+ hit,
277
+ `expected DUPLICATE_PERSONAS_DEFINITION, got ${JSON.stringify(criticals)}`
278
+ )
279
+ assert.deepEqual(
280
+ state.personas.definitions.map((p) => p.id),
281
+ ['susan']
282
+ )
283
+ })
284
+
285
+ test('a second definePersonas in another file is refused', async () => {
286
+ const { criticals, files } = await inspectSources({
287
+ 'personas.ts': withRoles("definePersonas({ susan: { name: 'Susan' } })"),
288
+ 'more-personas.ts': [
289
+ "import { definePersonas } from '@pikku/core/persona'",
290
+ "definePersonas({ dave: { name: 'Dave' } })",
291
+ ].join('\n'),
292
+ })
293
+
294
+ const hit = criticals.find(
295
+ (c) => c.code === ErrorCode.DUPLICATE_PERSONAS_DEFINITION
296
+ )
297
+ assert.ok(
298
+ hit,
299
+ `expected DUPLICATE_PERSONAS_DEFINITION, got ${JSON.stringify(criticals)}`
300
+ )
301
+ assert.ok(
302
+ hit!.message.includes(files['personas.ts']!) &&
303
+ hit!.message.includes(files['more-personas.ts']!),
304
+ `expected both files named, got ${hit!.message}`
305
+ )
306
+ })
307
+
308
+ // The CLI scaffolds personas of its own, and nobody adds a persona to a file
309
+ // the next codegen run overwrites — so a generated declaration neither takes
310
+ // the single slot nor collides with the app's own.
311
+ test('a generated declaration neither claims the slot nor conflicts', async () => {
312
+ const { state, criticals } = await inspectSources({
313
+ 'pikku-personas.gen.ts': [
314
+ "import { definePersonas } from '@pikku/core/persona'",
315
+ "definePersonas({ dave: { name: 'Dave' } })",
316
+ ].join('\n'),
317
+ 'personas.ts': withRoles("definePersonas({ susan: { name: 'Susan' } })"),
318
+ })
319
+
320
+ assert.deepEqual(
321
+ criticals.filter(
322
+ (c) => c.code === ErrorCode.DUPLICATE_PERSONAS_DEFINITION
323
+ ),
324
+ [],
325
+ `expected no duplicate critical, got ${JSON.stringify(criticals)}`
326
+ )
327
+ assert.deepEqual(state.personas.definitions.map((d) => d.id).sort(), [
328
+ 'dave',
329
+ 'susan',
330
+ ])
331
+ })
332
+
219
333
  // A half-read roles array typechecks, runs, and grants less than the source
220
334
  // says — so an unreadable one reads as absent.
221
335
  test('a computed roles array is not half-extracted', async () => {
@@ -1,11 +1,9 @@
1
1
  import * as ts from 'typescript'
2
2
  import { DISPOSITIONS } from '@pikku/core/virtual-user'
3
3
  import { isRunnablePersona } from '@pikku/core/persona'
4
- import type {
5
- PersonaAccountMeta,
6
- PersonaMeta,
7
- } from '@pikku/core/persona'
4
+ import type { PersonaAccountMeta, PersonaMeta } from '@pikku/core/persona'
8
5
  import { ErrorCode } from '../error-codes.js'
6
+ import { claimSingleDeclaration } from '../utils/single-declaration.js'
9
7
  import type { AddWiring, InspectorLogger } from '../types.js'
10
8
  import {
11
9
  booleanProperty,
@@ -172,6 +170,19 @@ export const addPersonas: AddWiring = (logger, node, _checker, state) => {
172
170
  }
173
171
 
174
172
  const sourceFile = node.getSourceFile().fileName
173
+
174
+ if (
175
+ !claimSingleDeclaration(
176
+ logger,
177
+ state.personas.files,
178
+ ErrorCode.DUPLICATE_PERSONAS_DEFINITION,
179
+ 'definePersonas',
180
+ sourceFile
181
+ )
182
+ ) {
183
+ return
184
+ }
185
+
175
186
  const known = Object.keys(DISPOSITIONS)
176
187
 
177
188
  for (const prop of unwrapped.properties) {
@@ -222,10 +233,7 @@ export const addPersonas: AddWiring = (logger, node, _checker, state) => {
222
233
  let tuningFailed = false
223
234
  const tuning = tuningProperty(config, (message) => {
224
235
  tuningFailed = true
225
- logger.critical(
226
- ErrorCode.INVALID_VALUE,
227
- `Persona '${id}': ${message}`
228
- )
236
+ logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}': ${message}`)
229
237
  })
230
238
  if (tuningFailed) {
231
239
  continue
@@ -253,6 +261,8 @@ export const addPersonas: AddWiring = (logger, node, _checker, state) => {
253
261
  if (jobTitle !== undefined) persona.jobTitle = jobTitle
254
262
  const description = stringProperty(config, 'description')
255
263
  if (description !== undefined) persona.description = description
264
+ const avatarUrl = stringProperty(config, 'avatarUrl')
265
+ if (avatarUrl !== undefined) persona.avatarUrl = avatarUrl
256
266
  const personality = stringProperty(config, 'personality')
257
267
  if (personality !== undefined) persona.personality = personality
258
268
  if (disposition !== undefined) {
@@ -270,7 +280,6 @@ export const addPersonas: AddWiring = (logger, node, _checker, state) => {
270
280
  const linkedAccounts = linkedAccountsProperty(config, id, logger)
271
281
  if (linkedAccounts !== undefined) persona.linkedAccounts = linkedAccounts
272
282
 
273
- state.personas.files.add(sourceFile)
274
283
  state.personas.definitions.push(persona)
275
284
  }
276
285
  }
@@ -144,7 +144,7 @@ describe('pikkuScenarioStep', () => {
144
144
 
145
145
  test('the step target is bundled, so the step function is wired', async () => {
146
146
  const { state, cleanup } = await run([
147
- "await scenario.step('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
147
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
148
148
  ])
149
149
  try {
150
150
  assert.ok(
@@ -183,7 +183,7 @@ describe('pikkuScenarioStep', () => {
183
183
  test('a non-literal step target is a PKU678 critical', async () => {
184
184
  const { criticals, cleanup } = await run([
185
185
  'const target = Math.random() > 0.5 ? "buysAnApple" : "seesAReceipt"',
186
- "await scenario.step('buys something', target as any, {}, { actor: actors.shopper })",
186
+ "await scenario.when('buys something', target as any, {}, { actor: actors.shopper })",
187
187
  ])
188
188
  try {
189
189
  assert.ok(
@@ -224,6 +224,42 @@ describe('pikkuScenarioStep', () => {
224
224
  }
225
225
  })
226
226
 
227
+ test('a scenario whose witness is an expectation helper is not flagged', async () => {
228
+ // `expectService` is an inline step and carries no phase, so it reaches
229
+ // PKU680 as nothing at all — but a recorded service call is a witness, and
230
+ // a flow that has one is not the assertion-free flow the rule is for.
231
+ const { criticals, cleanup } = await run([
232
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
233
+ "await scenario.expectService('the receipt was emailed', 'emailService.send', { actor: actors.shopper })",
234
+ ])
235
+ try {
236
+ assert.equal(
237
+ criticals.filter((c) => c.code === 'PKU680').length,
238
+ 0,
239
+ `got: ${JSON.stringify(criticals)}`
240
+ )
241
+ } finally {
242
+ await cleanup()
243
+ }
244
+ })
245
+
246
+ test('an expectation parked in an uncalled helper is still no assertion', async () => {
247
+ const { criticals, cleanup } = await run([
248
+ 'const neverCalled = async () => {',
249
+ " await scenario.expectService('the receipt was emailed', 'emailService.send')",
250
+ '}',
251
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
252
+ ])
253
+ try {
254
+ assert.ok(
255
+ criticals.find((c) => c.code === 'PKU680'),
256
+ `expected PKU680 — the helper never runs, so nothing is asserted; got: ${JSON.stringify(criticals)}`
257
+ )
258
+ } finally {
259
+ await cleanup()
260
+ }
261
+ })
262
+
227
263
  test('a scenario with an assertion is not flagged', async () => {
228
264
  const { criticals, cleanup } = await run([
229
265
  "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
@@ -35,6 +35,25 @@ const inspectSource = async (source: string) => {
35
35
  }
36
36
  }
37
37
 
38
+ /** Inspects several files at once, keyed by basename. */
39
+ const inspectSources = async (sources: Record<string, string>) => {
40
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-scope-multi-'))
41
+ const files: Record<string, string> = {}
42
+ for (const [name, source] of Object.entries(sources)) {
43
+ files[name] = join(rootDir, name)
44
+ await writeFile(files[name]!, source)
45
+ }
46
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
47
+ try {
48
+ const state = await inspect(makeLogger(criticals), Object.values(files), {
49
+ rootDir,
50
+ })
51
+ return { state, criticals, files }
52
+ } finally {
53
+ await rm(rootDir, { recursive: true, force: true })
54
+ }
55
+ }
56
+
38
57
  describe('addScope inspector', () => {
39
58
  test('extracts a flat scope', async () => {
40
59
  const { state, criticals } = await inspectSource(
@@ -162,6 +181,60 @@ describe('addScope inspector', () => {
162
181
  )
163
182
  })
164
183
 
184
+ // The CLI generates a `defineScope` of its own — `user-admin.gen.ts` ships
185
+ // the whole `admin` tree, and `@pikku/addon-console` spells it out again — so
186
+ // a project that scaffolds user-admin declares scopes in more than one file
187
+ // and every one of them has to survive.
188
+ test('declarations in separate files all survive', async () => {
189
+ const { state, criticals } = await inspectSources({
190
+ 'user-admin.gen.ts': [
191
+ "import { defineScope } from '@pikku/core/scope'",
192
+ 'defineScope({ admin: {} })',
193
+ ].join('\n'),
194
+ 'scopes.ts': [
195
+ "import { defineScope } from '@pikku/core/scope'",
196
+ 'defineScope({ billing: {} })',
197
+ ].join('\n'),
198
+ })
199
+
200
+ assert.deepEqual(
201
+ criticals.filter((c) => /Only one defineScope/.test(c.message)),
202
+ [],
203
+ `expected no duplicate critical, got ${JSON.stringify(criticals)}`
204
+ )
205
+ assert.deepEqual(
206
+ state.scopes.definitions.map((d) => d.name).sort(),
207
+ ['admin', 'billing']
208
+ )
209
+ })
210
+
211
+ // The CLI scaffolds a scope tree of its own — `user-admin.gen.ts` declares the
212
+ // whole `admin` tree — so a generated declaration cannot be made to compete
213
+ // with the app's own for the single slot.
214
+ test('a generated declaration neither claims the slot nor conflicts', async () => {
215
+ const { state, criticals } = await inspectSources({
216
+ 'user-admin.gen.ts': [
217
+ "import { defineScope } from '@pikku/core/scope'",
218
+ 'defineScope({ admin: {} })',
219
+ ].join('\n'),
220
+ 'scopes.ts': [
221
+ "import { defineScope } from '@pikku/core/scope'",
222
+ 'defineScope({ billing: {} })',
223
+ ].join('\n'),
224
+ })
225
+
226
+ assert.deepEqual(
227
+ criticals.filter((c) => c.code === ErrorCode.DUPLICATE_SCOPE_DEFINITION),
228
+ [],
229
+ `expected no duplicate critical, got ${JSON.stringify(criticals)}`
230
+ )
231
+ assert.deepEqual(
232
+ state.scopes.definitions.map((d) => d.name).sort(),
233
+ ['admin', 'billing'],
234
+ "both the generated tree and the app's own must survive"
235
+ )
236
+ })
237
+
165
238
  test('is critical when a root embeds the separator', async () => {
166
239
  const { criticals } = await inspectSource(
167
240
  [
@@ -35,6 +35,25 @@ const inspectSource = async (source: string) => {
35
35
  }
36
36
  }
37
37
 
38
+ /** Inspects several files at once, keyed by basename. */
39
+ const inspectSources = async (sources: Record<string, string>) => {
40
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-role-multi-'))
41
+ const files: Record<string, string> = {}
42
+ for (const [name, source] of Object.entries(sources)) {
43
+ files[name] = join(rootDir, name)
44
+ await writeFile(files[name]!, source)
45
+ }
46
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
47
+ try {
48
+ const state = await inspect(makeLogger(criticals), Object.values(files), {
49
+ rootDir,
50
+ })
51
+ return { state, criticals, files }
52
+ } finally {
53
+ await rm(rootDir, { recursive: true, force: true })
54
+ }
55
+ }
56
+
38
57
  const IMPORTS = [
39
58
  "import { defineScope } from '@pikku/core/scope'",
40
59
  "import { defineSystemRole } from '@pikku/core/role'",
@@ -91,6 +110,70 @@ describe('addSystemRole inspector', () => {
91
110
  )
92
111
  })
93
112
 
113
+ test('extracts roles from several calls in one file', async () => {
114
+ const { state } = await inspectSource(
115
+ withScopes(
116
+ "defineSystemRole({ buyer: { scopes: ['catalogue:read'] } })",
117
+ "defineSystemRole({ admin: { scopes: ['admin'] } })"
118
+ )
119
+ )
120
+
121
+ assert.deepEqual(
122
+ state.systemRoles.definitions.map((r) => r.name).sort(),
123
+ ['admin', 'buyer']
124
+ )
125
+ })
126
+
127
+ // Roles follow scopes: a project scaffolding user-admin has a generated
128
+ // declaration beside its own, so every file's roles have to survive.
129
+ test('declarations in separate files all survive', async () => {
130
+ const { state, criticals } = await inspectSources({
131
+ 'roles.ts': withScopes(
132
+ "defineSystemRole({ buyer: { scopes: ['catalogue:read'] } })"
133
+ ),
134
+ 'more-roles.ts': [
135
+ "import { defineSystemRole } from '@pikku/core/role'",
136
+ "defineSystemRole({ admin: { scopes: ['admin'] } })",
137
+ ].join('\n'),
138
+ })
139
+
140
+ assert.deepEqual(
141
+ criticals.filter((c) => /Only one defineSystemRole/.test(c.message)),
142
+ [],
143
+ `expected no duplicate critical, got ${JSON.stringify(criticals)}`
144
+ )
145
+ assert.deepEqual(
146
+ state.systemRoles.definitions.map((r) => r.name).sort(),
147
+ ['admin', 'buyer']
148
+ )
149
+ })
150
+
151
+ // A generated file is not the one place a person adds a role to, so it neither
152
+ // takes the single slot nor collides with the app's own declaration.
153
+ test('a generated declaration neither claims the slot nor conflicts', async () => {
154
+ const { state, criticals } = await inspectSources({
155
+ 'roles.gen.ts': [
156
+ "import { defineSystemRole } from '@pikku/core/role'",
157
+ 'defineSystemRole({ admin: { scopes: [] } })',
158
+ ].join('\n'),
159
+ 'roles.ts': withScopes(
160
+ "defineSystemRole({ buyer: { scopes: ['catalogue:read'] } })"
161
+ ),
162
+ })
163
+
164
+ assert.deepEqual(
165
+ criticals.filter(
166
+ (c) => c.code === ErrorCode.DUPLICATE_SYSTEM_ROLE_DEFINITION
167
+ ),
168
+ [],
169
+ `expected no duplicate critical, got ${JSON.stringify(criticals)}`
170
+ )
171
+ assert.deepEqual(state.systemRoles.definitions.map((r) => r.name).sort(), [
172
+ 'admin',
173
+ 'buyer',
174
+ ])
175
+ })
176
+
94
177
  test('a role granting nothing is legitimate, said explicitly', async () => {
95
178
  const { state, criticals } = await inspectSource(
96
179
  withScopes('defineSystemRole({', ' guest: { scopes: [] },', '})')
@@ -229,7 +229,13 @@ function getWorkflowInvocations(
229
229
  * Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
230
230
  * pikkuScenario() calls. Detects workflow registration and extracts metadata.
231
231
  */
232
- export const addWorkflow: AddWiring = (logger, node, checker, state) => {
232
+ export const addWorkflow: AddWiring = (
233
+ logger,
234
+ node,
235
+ checker,
236
+ state,
237
+ options
238
+ ) => {
233
239
  if (!ts.isCallExpression(node)) {
234
240
  return
235
241
  }
@@ -246,6 +252,17 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
246
252
  if (expression.text === 'pikkuWorkflowFunc') {
247
253
  wrapperType = 'dsl'
248
254
  } else if (expression.text === 'pikkuWorkflowComplexFunc') {
255
+ if (!options.allow?.complexWorkflows) {
256
+ logger.critical(
257
+ ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED,
258
+ `'${expression.text}' is used here, but this project has not enabled complex workflows. ` +
259
+ `Their inline steps cannot be serialized, so they are absent from the workflow graph and ` +
260
+ `cannot be replayed or migrated across a definition change. Express the workflow with ` +
261
+ `pikkuWorkflowFunc and the step DSL, or set \`allow: { complexWorkflows: true }\` in ` +
262
+ `pikku.config.json if the project accepts that trade.`
263
+ )
264
+ return
265
+ }
249
266
  wrapperType = 'complex'
250
267
  } else if (expression.text === 'pikkuScenario') {
251
268
  // A scenario is a complex workflow whose steps run as actors over the
@@ -351,6 +368,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
351
368
  let steps: WorkflowStepMeta[] = []
352
369
  let context: WorkflowContext | undefined = undefined
353
370
  let dsl: boolean | undefined = undefined
371
+ let asserts: boolean | undefined = undefined
354
372
 
355
373
  // Try DSL workflow extraction first
356
374
  // Pass the whole CallExpression node so findWorkflowFunction can find the arrow function
@@ -362,6 +380,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
362
380
  // Extraction succeeded
363
381
  steps = result.steps
364
382
  context = result.context
383
+ asserts = result.asserts
365
384
 
366
385
  // Check if workflow contains inline steps (non-serializable)
367
386
  if (hasInlineSteps(steps)) {
@@ -449,6 +468,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
449
468
  expose,
450
469
  skip,
451
470
  scenario: wrapperType === 'scenario' ? true : undefined,
471
+ asserts,
452
472
  actors: actorNames.length > 0 ? actorNames : undefined,
453
473
  }
454
474
 
@@ -0,0 +1,144 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { inspect } from '../inspector.js'
7
+ import { ErrorCode } from '../error-codes.js'
8
+ import type { InspectorLogger, InspectorOptions } from '../types.js'
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) => criticals.push({ code, message }),
19
+ critical: (code: any, message: string) => criticals.push({ code, message }),
20
+ hasCriticalErrors: () => criticals.length > 0,
21
+ } as InspectorLogger
22
+ }
23
+
24
+ async function run(source: string, allow: InspectorOptions['allow'] = {}) {
25
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-allow-'))
26
+ const file = join(rootDir, 'subject.ts')
27
+ await writeFile(file, source)
28
+ const criticals: Array<{ code: string; message: string }> = []
29
+ const state = await inspect(makeLogger(criticals), [file], {
30
+ rootDir,
31
+ allow,
32
+ })
33
+ await rm(rootDir, { recursive: true, force: true })
34
+ return { state, criticals }
35
+ }
36
+
37
+ const has = (criticals: Array<{ code: string }>, code: ErrorCode) =>
38
+ criticals.some((c) => c.code === code)
39
+
40
+ const PERMISSIONS_IN_BODY = [
41
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
42
+ 'export const stripeWebhook = pikkuSessionlessFunc({',
43
+ ' expose: true,',
44
+ ' permissionsInBody: true,',
45
+ ' func: async () => ({ ok: true }),',
46
+ '})',
47
+ ].join('\n')
48
+
49
+ const COMPLEX_WORKFLOW = [
50
+ "import { pikkuWorkflowComplexFunc } from '@pikku/core/workflow'",
51
+ 'export const cleanup = pikkuWorkflowComplexFunc(',
52
+ ' async (_services, _input, { workflow }: any) => {',
53
+ ' for (const id of [1, 2, 3]) {',
54
+ " await workflow.step('touch', { id })",
55
+ ' }',
56
+ ' return { ok: true }',
57
+ ' }',
58
+ ')',
59
+ ].join('\n')
60
+
61
+ describe('allow: escape hatches are refused unless opted into', () => {
62
+ test('permissionsInBody is a critical when not allowed', async () => {
63
+ const { criticals } = await run(PERMISSIONS_IN_BODY)
64
+ assert.ok(
65
+ has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
66
+ `expected PKU576, got ${JSON.stringify(criticals)}`
67
+ )
68
+ })
69
+
70
+ test('the message names the flag that would permit it', async () => {
71
+ const { criticals } = await run(PERMISSIONS_IN_BODY)
72
+ const found = criticals.find(
73
+ (c) => c.code === ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED
74
+ )
75
+ assert.match(found!.message, /allow: \{ permissionsInBody: true \}/)
76
+ assert.match(found!.message, /stripeWebhook/)
77
+ })
78
+
79
+ test('permissionsInBody is accepted once allowed', async () => {
80
+ const { criticals, state } = await run(PERMISSIONS_IN_BODY, {
81
+ permissionsInBody: true,
82
+ })
83
+ assert.equal(
84
+ has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
85
+ false
86
+ )
87
+ const meta = Object.values(state.functions.meta) as any[]
88
+ assert.equal(
89
+ meta.find((m) => m.name === 'stripeWebhook')?.permissionsInBody,
90
+ true
91
+ )
92
+ })
93
+
94
+ test('a refused function is not registered at all', async () => {
95
+ const { state } = await run(PERMISSIONS_IN_BODY)
96
+ const meta = Object.values(state.functions.meta) as any[]
97
+ assert.equal(
98
+ meta.some((m) => m.name === 'stripeWebhook'),
99
+ false,
100
+ 'a refused function must not reach the meta, or the build would emit it anyway'
101
+ )
102
+ })
103
+
104
+ test('complexWorkflows is a critical when not allowed', async () => {
105
+ const { criticals } = await run(COMPLEX_WORKFLOW)
106
+ assert.ok(
107
+ has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED),
108
+ `expected PKU643, got ${JSON.stringify(criticals)}`
109
+ )
110
+ })
111
+
112
+ test('the complex-workflow message points at the DSL', async () => {
113
+ const { criticals } = await run(COMPLEX_WORKFLOW)
114
+ const found = criticals.find(
115
+ (c) => c.code === ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED
116
+ )
117
+ assert.match(found!.message, /pikkuWorkflowFunc/)
118
+ assert.match(found!.message, /allow: \{ complexWorkflows: true \}/)
119
+ })
120
+
121
+ test('complexWorkflows is accepted once allowed', async () => {
122
+ const { criticals } = await run(COMPLEX_WORKFLOW, {
123
+ complexWorkflows: true,
124
+ })
125
+ assert.equal(has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED), false)
126
+ })
127
+
128
+ test('an ordinary function and DSL workflow are unaffected', async () => {
129
+ const { criticals } = await run(
130
+ [
131
+ "import { pikkuFunc } from '@pikku/core'",
132
+ 'export const ordinary = pikkuFunc({',
133
+ ' expose: true,',
134
+ ' func: async () => ({ ok: true }),',
135
+ '})',
136
+ ].join('\n')
137
+ )
138
+ assert.equal(
139
+ has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
140
+ false
141
+ )
142
+ assert.equal(has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED), false)
143
+ })
144
+ })