@pikku/inspector 0.12.50 → 0.12.52

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 (99) hide show
  1. package/CHANGELOG.md +441 -0
  2. package/OPTIMIZATION-PLAN.md +9 -3
  3. package/dist/add/add-auth.d.ts +1 -1
  4. package/dist/add/add-auth.js +1 -1
  5. package/dist/add/add-cli.js +7 -0
  6. package/dist/add/add-credential.js +1 -1
  7. package/dist/add/add-functions.js +156 -7
  8. package/dist/add/add-http-route.js +16 -6
  9. package/dist/add/add-middleware.js +46 -3
  10. package/dist/add/add-personas.d.ts +13 -0
  11. package/dist/add/add-personas.js +210 -0
  12. package/dist/add/add-scope.js +1 -1
  13. package/dist/add/add-secret.js +1 -1
  14. package/dist/add/add-system-role.d.ts +2 -0
  15. package/dist/add/add-system-role.js +102 -0
  16. package/dist/add/add-variable.js +1 -1
  17. package/dist/add/add-wire-addon.js +31 -0
  18. package/dist/error-codes.d.ts +10 -1
  19. package/dist/error-codes.js +11 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/inspector.js +33 -1
  22. package/dist/types.d.ts +46 -2
  23. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  24. package/dist/utils/annotate-http-route-auth.js +48 -0
  25. package/dist/utils/extract-function-name.js +13 -4
  26. package/dist/utils/extract-function-node.js +18 -1
  27. package/dist/utils/extract-secret-usage.d.ts +9 -0
  28. package/dist/utils/extract-secret-usage.js +61 -0
  29. package/dist/utils/literal-properties.d.ts +22 -0
  30. package/dist/utils/literal-properties.js +69 -0
  31. package/dist/utils/load-addon-functions-meta.js +26 -1
  32. package/dist/utils/permissions.js +8 -0
  33. package/dist/utils/post-process.d.ts +68 -3
  34. package/dist/utils/post-process.js +243 -13
  35. package/dist/utils/secret-alias-services.d.ts +8 -0
  36. package/dist/utils/secret-alias-services.js +45 -0
  37. package/dist/utils/secret-brokers.d.ts +2 -0
  38. package/dist/utils/secret-brokers.js +9 -0
  39. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  40. package/dist/utils/serialize-inspector-state.js +18 -0
  41. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  42. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  43. package/dist/utils/validate-exposed-functions-gated.js +85 -0
  44. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  45. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  46. package/dist/visit.js +4 -0
  47. package/package.json +3 -3
  48. package/src/add/add-auth.test.ts +5 -3
  49. package/src/add/add-auth.ts +1 -1
  50. package/src/add/add-cli.ts +8 -0
  51. package/src/add/add-credential.ts +1 -1
  52. package/src/add/add-functions.test.ts +263 -1
  53. package/src/add/add-functions.ts +219 -9
  54. package/src/add/add-http-route.ts +20 -7
  55. package/src/add/add-middleware.test.ts +196 -0
  56. package/src/add/add-middleware.ts +64 -4
  57. package/src/add/add-personas.test.ts +268 -0
  58. package/src/add/add-personas.ts +276 -0
  59. package/src/add/add-scenario-step.test.ts +49 -3
  60. package/src/add/add-scope.test.ts +30 -30
  61. package/src/add/add-scope.ts +1 -1
  62. package/src/add/add-secret.ts +1 -1
  63. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  64. package/src/add/add-system-role.test.ts +214 -0
  65. package/src/add/add-system-role.ts +164 -0
  66. package/src/add/add-variable.ts +1 -1
  67. package/src/add/add-wire-addon.test.ts +111 -0
  68. package/src/add/add-wire-addon.ts +29 -0
  69. package/src/add/addon-bans.test.ts +4 -4
  70. package/src/add/config-value-metadata.test.ts +11 -11
  71. package/src/add/wire-name-literal.test.ts +2 -2
  72. package/src/error-codes.ts +13 -0
  73. package/src/index.ts +4 -1
  74. package/src/inspector.ts +39 -0
  75. package/src/types.ts +47 -2
  76. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  77. package/src/utils/annotate-http-route-auth.ts +52 -0
  78. package/src/utils/extract-function-name.ts +14 -4
  79. package/src/utils/extract-function-node.ts +18 -6
  80. package/src/utils/extract-secret-usage.test.ts +81 -0
  81. package/src/utils/extract-secret-usage.ts +73 -0
  82. package/src/utils/literal-properties.ts +97 -0
  83. package/src/utils/load-addon-functions-meta.ts +32 -2
  84. package/src/utils/permissions-shorthand.test.ts +79 -0
  85. package/src/utils/permissions.ts +7 -0
  86. package/src/utils/post-process.test.ts +133 -0
  87. package/src/utils/post-process.ts +310 -12
  88. package/src/utils/secret-alias-services.test.ts +146 -0
  89. package/src/utils/secret-alias-services.ts +58 -0
  90. package/src/utils/secret-brokers.test.ts +53 -0
  91. package/src/utils/secret-brokers.ts +11 -0
  92. package/src/utils/serialize-inspector-state.ts +28 -0
  93. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  94. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  95. package/src/utils/validate-exposed-functions-gated.ts +94 -0
  96. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  97. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  98. package/src/visit.ts +4 -0
  99. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,268 @@
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 { ErrorCode } from '../error-codes.js'
8
+ import type { InspectorLogger } from '../types.js'
9
+
10
+ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
11
+ ({
12
+ debug: () => {},
13
+ info: () => {},
14
+ warn: () => {},
15
+ error: () => {},
16
+ diagnostic: ({ code, message }) => {
17
+ criticals.push({ code, message })
18
+ },
19
+ critical: (code: ErrorCode, message: string) => {
20
+ criticals.push({ code, message })
21
+ },
22
+ hasCriticalErrors: () => criticals.length > 0,
23
+ }) satisfies InspectorLogger
24
+
25
+ const inspectSource = async (source: string) => {
26
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-personas-'))
27
+ const file = join(rootDir, 'personas.ts')
28
+ await writeFile(file, source)
29
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
30
+ try {
31
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
32
+ return { state, criticals }
33
+ } finally {
34
+ await rm(rootDir, { recursive: true, force: true })
35
+ }
36
+ }
37
+
38
+ /** The declared world every persona below is checked against. */
39
+ const withRoles = (...lines: string[]) =>
40
+ [
41
+ "import { defineScope } from '@pikku/core/scope'",
42
+ "import { defineSystemRole } from '@pikku/core/role'",
43
+ "import { definePersonas } from '@pikku/core/persona'",
44
+ 'defineScope({ catalogue: { scopes: { read: {} } }, admin: {} })',
45
+ 'defineSystemRole({',
46
+ " buyer: { scopes: ['catalogue:read'] },",
47
+ " admin: { scopes: ['admin'] },",
48
+ '})',
49
+ ...lines,
50
+ ].join('\n')
51
+
52
+ describe('addPersonas inspector', () => {
53
+ test('extracts a whole person', async () => {
54
+ const { state, criticals } = await inspectSource(
55
+ withRoles(
56
+ 'definePersonas({',
57
+ ' susan: {',
58
+ " name: 'Susan',",
59
+ " jobTitle: 'Buys for a small café',",
60
+ " roles: ['buyer'],",
61
+ " personality: 'Hunts cheap deals.',",
62
+ " goals: ['Get the weekly order in under five minutes'],",
63
+ " tags: ['commerce'],",
64
+ " disposition: 'careless',",
65
+ ' tuning: { repeatRate: 0.3 },',
66
+ " fixtures: ['seedCatalogue'],",
67
+ ' account: {},',
68
+ ' },',
69
+ '})'
70
+ )
71
+ )
72
+
73
+ assert.deepEqual(criticals, [])
74
+ assert.equal(state.personas.definitions.length, 1)
75
+ const susan = state.personas.definitions[0]!
76
+ assert.equal(susan.id, 'susan')
77
+ assert.equal(susan.name, 'Susan')
78
+ assert.equal(susan.jobTitle, 'Buys for a small café')
79
+ assert.deepEqual(susan.roles, ['buyer'])
80
+ assert.equal(susan.personality, 'Hunts cheap deals.')
81
+ assert.deepEqual(susan.goals, [
82
+ 'Get the weekly order in under five minutes',
83
+ ])
84
+ assert.deepEqual(susan.tags, ['commerce'])
85
+ assert.equal(susan.disposition, 'careless')
86
+ assert.deepEqual(susan.tuning, { repeatRate: 0.3 })
87
+ assert.deepEqual(susan.fixtures, ['seedCatalogue'])
88
+ assert.deepEqual(susan.account, {})
89
+ assert.equal(susan.runnable, true)
90
+ })
91
+
92
+ test('extracts the environments a persona declares', async () => {
93
+ const { state } = await inspectSource(
94
+ withRoles(
95
+ 'definePersonas({',
96
+ ' robin: {',
97
+ " name: 'Robin',",
98
+ " disposition: 'accountable',",
99
+ " environments: ['staging', 'prod'],",
100
+ ' },',
101
+ '})'
102
+ )
103
+ )
104
+ assert.deepEqual(state.personas.definitions[0]!.environments, [
105
+ 'staging',
106
+ 'prod',
107
+ ])
108
+ })
109
+
110
+ // "Everywhere but production" is a fact about the config, and the meta file
111
+ // outlives whichever environments were configured the day it was written —
112
+ // so the absence is preserved rather than resolved into a list here.
113
+ test('leaves environments absent when none are declared', async () => {
114
+ const { state } = await inspectSource(
115
+ withRoles('definePersonas({', " susan: { name: 'Susan' },", '})')
116
+ )
117
+ assert.equal(state.personas.definitions[0]!.environments, undefined)
118
+ })
119
+
120
+ test('the declaration key is the id', async () => {
121
+ const { state } = await inspectSource(
122
+ withRoles(
123
+ 'definePersonas({',
124
+ " susan: { name: 'Susan' },",
125
+ " mallory: { name: 'Mallory' },",
126
+ '})'
127
+ )
128
+ )
129
+ assert.deepEqual(
130
+ state.personas.definitions.map((p) => p.id),
131
+ ['susan', 'mallory']
132
+ )
133
+ })
134
+
135
+ test('a persona without a name is refused', async () => {
136
+ const { state, criticals } = await inspectSource(
137
+ withRoles('definePersonas({', " susan: { roles: ['buyer'] },", '})')
138
+ )
139
+ assert.equal(state.personas.definitions.length, 0)
140
+ assert.ok(criticals.some((c) => /needs a literal 'name'/.test(c.message)))
141
+ })
142
+
143
+ test('an unknown disposition is refused rather than defaulted', async () => {
144
+ const { state, criticals } = await inspectSource(
145
+ withRoles(
146
+ 'definePersonas({',
147
+ " susan: { name: 'Susan', disposition: 'grumpy' },",
148
+ '})'
149
+ )
150
+ )
151
+ assert.equal(state.personas.definitions.length, 0)
152
+ assert.ok(
153
+ criticals.some((c) => /unknown disposition 'grumpy'/.test(c.message))
154
+ )
155
+ })
156
+
157
+ // `repeatRate: 18` meaning 18% would silently double every call, and the run
158
+ // would read as a product bug.
159
+ test('a tuning dial outside its range is refused', async () => {
160
+ const { state, criticals } = await inspectSource(
161
+ withRoles(
162
+ 'definePersonas({',
163
+ " susan: { name: 'Susan', tuning: { repeatRate: 18 } },",
164
+ '})'
165
+ )
166
+ )
167
+ assert.equal(state.personas.definitions.length, 0)
168
+ assert.ok(
169
+ criticals.some((c) =>
170
+ /repeatRate must be between 0 and 1/.test(c.message)
171
+ )
172
+ )
173
+ })
174
+
175
+ test('a linked account is extracted alongside the primary one', async () => {
176
+ const { state, criticals } = await inspectSource(
177
+ withRoles(
178
+ 'definePersonas({',
179
+ ' yasser: {',
180
+ " name: 'Yasser',",
181
+ ' account: {},',
182
+ " linkedAccounts: { google: { provider: 'google' } },",
183
+ ' },',
184
+ '})'
185
+ )
186
+ )
187
+ assert.deepEqual(criticals, [])
188
+ const yasser = state.personas.definitions[0]!
189
+ assert.deepEqual(yasser.account, {})
190
+ assert.deepEqual(yasser.linkedAccounts, { google: { provider: 'google' } })
191
+ // Their primary login is email+password, so they can still be driven.
192
+ assert.equal(yasser.runnable, true)
193
+ })
194
+
195
+ // Driving a consent screen needs a human — refused at the CLI rather than
196
+ // failing somewhere inside a browser.
197
+ test('a persona whose own login is a provider is not runnable', async () => {
198
+ const { state } = await inspectSource(
199
+ withRoles(
200
+ 'definePersonas({',
201
+ " yasser: { name: 'Yasser', account: { provider: 'google' } },",
202
+ '})'
203
+ )
204
+ )
205
+ assert.equal(state.personas.definitions[0]!.runnable, false)
206
+ })
207
+
208
+ test('a person who is only ever acted upon says so', async () => {
209
+ const { state } = await inspectSource(
210
+ withRoles(
211
+ 'definePersonas({',
212
+ " target: { name: 'Target', account: {}, runnable: false },",
213
+ '})'
214
+ )
215
+ )
216
+ assert.equal(state.personas.definitions[0]!.runnable, false)
217
+ })
218
+
219
+ // A half-read roles array typechecks, runs, and grants less than the source
220
+ // says — so an unreadable one reads as absent.
221
+ test('a computed roles array is not half-extracted', async () => {
222
+ const { state } = await inspectSource(
223
+ withRoles(
224
+ 'const r = "buyer"',
225
+ 'definePersonas({',
226
+ " susan: { name: 'Susan', roles: ['buyer', r] },",
227
+ '})'
228
+ )
229
+ )
230
+ assert.deepEqual(state.personas.definitions[0]!.roles, [])
231
+ })
232
+ })
233
+
234
+ describe('validatePersonaRoles', () => {
235
+ test('a declared system role is accepted', async () => {
236
+ const { criticals } = await inspectSource(
237
+ withRoles(
238
+ 'definePersonas({',
239
+ " susan: { name: 'Susan', roles: ['buyer', 'admin'] },",
240
+ '})'
241
+ )
242
+ )
243
+ assert.deepEqual(criticals, [])
244
+ })
245
+
246
+ // The failure this prevents: an admin deletes 'invoicing-clerk' from the
247
+ // console, and Susan goes on claiming to test it while 403ing everywhere.
248
+ test('a role that is not declared fails the build', async () => {
249
+ const { criticals } = await inspectSource(
250
+ withRoles(
251
+ 'definePersonas({',
252
+ " susan: { name: 'Susan', roles: ['invoicing-clerk'] },",
253
+ '})'
254
+ )
255
+ )
256
+ const critical = criticals.find((c) => /invoicing-clerk/.test(c.message))
257
+ assert.ok(critical, `expected a critical, got ${JSON.stringify(criticals)}`)
258
+ assert.match(critical!.message, /may only name a system role/)
259
+ assert.match(critical!.message, /admin, buyer/)
260
+ })
261
+
262
+ test('a persona with no roles at all is fine', async () => {
263
+ const { criticals } = await inspectSource(
264
+ withRoles('definePersonas({', " susan: { name: 'Susan' },", '})')
265
+ )
266
+ assert.deepEqual(criticals, [])
267
+ })
268
+ })
@@ -0,0 +1,276 @@
1
+ import * as ts from 'typescript'
2
+ import { DISPOSITIONS } from '@pikku/core/virtual-user'
3
+ import { isRunnablePersona } from '@pikku/core/persona'
4
+ import type {
5
+ PersonaAccountMeta,
6
+ PersonaMeta,
7
+ } from '@pikku/core/persona'
8
+ import { ErrorCode } from '../error-codes.js'
9
+ import type { AddWiring, InspectorLogger } from '../types.js'
10
+ import {
11
+ booleanProperty,
12
+ getProperty,
13
+ numberProperty,
14
+ stringArrayProperty,
15
+ stringProperty,
16
+ unwrapAs,
17
+ } from '../utils/literal-properties.js'
18
+
19
+ type Tuning = NonNullable<PersonaMeta['tuning']>
20
+
21
+ /** A dial and the range outside which it is a mistake rather than a choice. */
22
+ const RATES: [keyof Tuning, number, number][] = [
23
+ ['temperature', 0, 2],
24
+ ['repeatRate', 0, 1],
25
+ ['reReadRate', 0, 1],
26
+ ]
27
+
28
+ /**
29
+ * `{ moves, temperature, repeatRate, … }` — a disposition's dials, overridden.
30
+ *
31
+ * The ranges are checked here rather than at run time because these are the
32
+ * kind of typo that does not throw: `repeatRate: 18` meaning 18% would silently
33
+ * double every call the persona makes, and you would read the run as a product
34
+ * bug.
35
+ */
36
+ const tuningProperty = (
37
+ config: ts.ObjectLiteralExpression,
38
+ onError: (message: string) => void
39
+ ): Tuning | undefined => {
40
+ const value = getProperty(config, 'tuning')
41
+ if (!value || !ts.isObjectLiteralExpression(value)) {
42
+ return undefined
43
+ }
44
+
45
+ const tuning: Tuning = {}
46
+
47
+ const movesValue = getProperty(value, 'moves')
48
+ if (movesValue && ts.isObjectLiteralExpression(movesValue)) {
49
+ const moves: NonNullable<Tuning['moves']> = {}
50
+ for (const move of ['continue', 'suspend', 'resume', 'abandon'] as const) {
51
+ const weight = numberProperty(movesValue, move)
52
+ if (weight === undefined) {
53
+ continue
54
+ }
55
+ if (weight < 0) {
56
+ onError(`tuning.moves.${move} must not be negative (got ${weight}).`)
57
+ continue
58
+ }
59
+ moves[move] = weight
60
+ }
61
+ if (Object.keys(moves).length) {
62
+ tuning.moves = moves
63
+ }
64
+ }
65
+
66
+ for (const [name, min, max] of RATES) {
67
+ const rate = numberProperty(value, name)
68
+ if (rate === undefined) {
69
+ continue
70
+ }
71
+ if (rate < min || rate > max) {
72
+ onError(`tuning.${name} must be between ${min} and ${max} (got ${rate}).`)
73
+ continue
74
+ }
75
+ Object.assign(tuning, { [name]: rate })
76
+ }
77
+
78
+ for (const flag of ['emptyMemory', 'readOnly', 'invertedOracle'] as const) {
79
+ const declared = booleanProperty(value, flag)
80
+ if (declared !== undefined) {
81
+ tuning[flag] = declared
82
+ }
83
+ }
84
+
85
+ const instructions = stringProperty(value, 'instructions')
86
+ if (instructions !== undefined) {
87
+ tuning.instructions = instructions
88
+ }
89
+
90
+ return Object.keys(tuning).length ? tuning : undefined
91
+ }
92
+
93
+ const accountProperty = (
94
+ value: ts.Expression | undefined
95
+ ): PersonaAccountMeta | undefined => {
96
+ if (!value || !ts.isObjectLiteralExpression(value)) {
97
+ return undefined
98
+ }
99
+ const provider = stringProperty(value, 'provider')
100
+ return provider === undefined ? {} : { provider }
101
+ }
102
+
103
+ const linkedAccountsProperty = (
104
+ config: ts.ObjectLiteralExpression,
105
+ personaId: string,
106
+ logger: InspectorLogger
107
+ ): Record<string, PersonaAccountMeta> | undefined => {
108
+ const value = getProperty(config, 'linkedAccounts')
109
+ if (!value || !ts.isObjectLiteralExpression(value)) {
110
+ return undefined
111
+ }
112
+
113
+ const accounts: Record<string, PersonaAccountMeta> = {}
114
+ for (const prop of value.properties) {
115
+ if (!ts.isPropertyAssignment(prop)) {
116
+ continue
117
+ }
118
+ if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
119
+ logger.critical(
120
+ ErrorCode.NON_LITERAL_WIRE_NAME,
121
+ `Persona '${personaId}' has a linked account whose key is not a literal.`
122
+ )
123
+ continue
124
+ }
125
+ const account = accountProperty(prop.initializer)
126
+ if (!account) {
127
+ logger.critical(
128
+ ErrorCode.INVALID_VALUE,
129
+ `Persona '${personaId}' linked account '${prop.name.text}' must be an object literal.`
130
+ )
131
+ continue
132
+ }
133
+ accounts[prop.name.text] = account
134
+ }
135
+
136
+ return Object.keys(accounts).length ? accounts : undefined
137
+ }
138
+
139
+ /**
140
+ * Inspector for `definePersonas()` calls.
141
+ *
142
+ * A persona is entirely declaration — a person, their roles, what they want —
143
+ * so there is no code body and nothing to resolve at runtime. What is read here
144
+ * is the whole thing, and it is generated straight to `personas.gen.json` for
145
+ * the CLI and the console to read without loading the app.
146
+ *
147
+ * `name` is the one field that cannot be defaulted. A persona without one is
148
+ * not a person, and the console has nothing to show.
149
+ */
150
+ export const addPersonas: AddWiring = (logger, node, _checker, state) => {
151
+ if (!ts.isCallExpression(node)) {
152
+ return
153
+ }
154
+
155
+ const expression = node.expression
156
+ if (!ts.isIdentifier(expression) || expression.text !== 'definePersonas') {
157
+ return
158
+ }
159
+
160
+ const firstArg = node.arguments[0]
161
+ if (!firstArg) {
162
+ return
163
+ }
164
+
165
+ const unwrapped = unwrapAs(firstArg)
166
+ if (!ts.isObjectLiteralExpression(unwrapped)) {
167
+ logger.critical(
168
+ ErrorCode.INVALID_VALUE,
169
+ 'definePersonas() needs an inline object — personas are read statically, never evaluated.'
170
+ )
171
+ return
172
+ }
173
+
174
+ const sourceFile = node.getSourceFile().fileName
175
+ const known = Object.keys(DISPOSITIONS)
176
+
177
+ for (const prop of unwrapped.properties) {
178
+ if (!ts.isPropertyAssignment(prop)) {
179
+ continue
180
+ }
181
+
182
+ if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
183
+ logger.critical(
184
+ ErrorCode.NON_LITERAL_WIRE_NAME,
185
+ 'A persona is declared with a key that is not a literal.'
186
+ )
187
+ continue
188
+ }
189
+ const id = prop.name.text
190
+
191
+ if (!ts.isObjectLiteralExpression(prop.initializer)) {
192
+ logger.critical(
193
+ ErrorCode.INVALID_VALUE,
194
+ `Persona '${id}' must be an object literal.`
195
+ )
196
+ continue
197
+ }
198
+ const config = prop.initializer
199
+
200
+ const name = stringProperty(config, 'name')
201
+ if (!name) {
202
+ logger.critical(
203
+ ErrorCode.INVALID_VALUE,
204
+ `Persona '${id}' needs a literal 'name' — a persona is a person, and the console has nothing to show without one.`
205
+ )
206
+ continue
207
+ }
208
+
209
+ // The generated `definePersonas` types this as a union, so a bad value only
210
+ // reaches here in source that does not compile. Caught anyway: a
211
+ // disposition is the entire behaviour, and defaulting a typo to `realistic`
212
+ // would run a different person than the one that was written.
213
+ const disposition = stringProperty(config, 'disposition')
214
+ if (disposition !== undefined && !known.includes(disposition)) {
215
+ logger.critical(
216
+ ErrorCode.INVALID_VALUE,
217
+ `Persona '${id}' has an unknown disposition '${disposition}'. One of: ${known.join(', ')}`
218
+ )
219
+ continue
220
+ }
221
+
222
+ let tuningFailed = false
223
+ const tuning = tuningProperty(config, (message) => {
224
+ tuningFailed = true
225
+ logger.critical(
226
+ ErrorCode.INVALID_VALUE,
227
+ `Persona '${id}': ${message}`
228
+ )
229
+ })
230
+ if (tuningFailed) {
231
+ continue
232
+ }
233
+
234
+ const account = accountProperty(getProperty(config, 'account'))
235
+ const declaredRunnable = booleanProperty(config, 'runnable')
236
+
237
+ const persona: PersonaMeta = {
238
+ id,
239
+ name,
240
+ roles: stringArrayProperty(config, 'roles') ?? [],
241
+ goals: stringArrayProperty(config, 'goals') ?? [],
242
+ tags: stringArrayProperty(config, 'tags') ?? [],
243
+ runnable: isRunnablePersona({
244
+ ...(declaredRunnable !== undefined
245
+ ? { runnable: declaredRunnable }
246
+ : {}),
247
+ ...(account ? { account } : {}),
248
+ }),
249
+ sourceFile,
250
+ }
251
+
252
+ const jobTitle = stringProperty(config, 'jobTitle')
253
+ if (jobTitle !== undefined) persona.jobTitle = jobTitle
254
+ const description = stringProperty(config, 'description')
255
+ if (description !== undefined) persona.description = description
256
+ const personality = stringProperty(config, 'personality')
257
+ if (personality !== undefined) persona.personality = personality
258
+ if (disposition !== undefined) {
259
+ persona.disposition = disposition as PersonaMeta['disposition']
260
+ }
261
+ if (tuning !== undefined) persona.tuning = tuning
262
+ const fixtures = stringArrayProperty(config, 'fixtures')
263
+ if (fixtures !== undefined) persona.fixtures = fixtures
264
+ // Left absent rather than resolved here: "everywhere but production" is a
265
+ // fact about the config, and the inspector has no business baking today's
266
+ // environment list into a meta file that outlives it.
267
+ const environments = stringArrayProperty(config, 'environments')
268
+ if (environments !== undefined) persona.environments = environments
269
+ if (account !== undefined) persona.account = account
270
+ const linkedAccounts = linkedAccountsProperty(config, id, logger)
271
+ if (linkedAccounts !== undefined) persona.linkedAccounts = linkedAccounts
272
+
273
+ state.personas.files.add(sourceFile)
274
+ state.personas.definitions.push(persona)
275
+ }
276
+ }
@@ -30,13 +30,13 @@ const STEPS = [
30
30
  'export const buysAnApple = pikkuScenarioStep({',
31
31
  " name: 'buysAnApple',",
32
32
  " description: 'buys an apple',",
33
- ' browser: true,',
34
- ' func: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
33
+ ' browser: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
34
+ ' default: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
35
35
  '})',
36
36
  'export const seesAReceipt = pikkuScenarioStep({',
37
37
  " name: 'seesAReceipt',",
38
38
  " description: 'sees a receipt',",
39
- ' func: async ({ logger }) => ({ ok: true }),',
39
+ ' default: async ({ logger }) => ({ ok: true }),',
40
40
  '})',
41
41
  ].join('\n')
42
42
 
@@ -209,6 +209,52 @@ describe('pikkuScenarioStep', () => {
209
209
  }
210
210
  })
211
211
 
212
+ test('a scenario that never asserts is a PKU680 critical', async () => {
213
+ const { criticals, cleanup } = await run([
214
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
215
+ "await scenario.when('buys an apple', 'buysAnApple', { qty: 2 }, { actor: actors.shopper })",
216
+ ])
217
+ try {
218
+ assert.ok(
219
+ criticals.find((c) => c.code === 'PKU680'),
220
+ `a flow with no 'then' proves only that nothing threw, and contributes 0/0 to witness coverage, got: ${JSON.stringify(criticals)}`
221
+ )
222
+ } finally {
223
+ await cleanup()
224
+ }
225
+ })
226
+
227
+ test('a scenario with an assertion is not flagged', async () => {
228
+ const { criticals, cleanup } = await run([
229
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
230
+ "await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
231
+ ])
232
+ try {
233
+ assert.equal(
234
+ criticals.filter((c) => c.code === 'PKU680').length,
235
+ 0,
236
+ `got: ${JSON.stringify(criticals)}`
237
+ )
238
+ } finally {
239
+ await cleanup()
240
+ }
241
+ })
242
+
243
+ test('a scenario driving only rpc steps is not flagged — it has no step ladder to assert on', async () => {
244
+ const { criticals, cleanup } = await run([
245
+ "await scenario.do('reads the order', 'orderGet', { orderId: 'o1' })",
246
+ ])
247
+ try {
248
+ assert.equal(
249
+ criticals.filter((c) => c.code === 'PKU680').length,
250
+ 0,
251
+ `the rule is about scenarios written as given/when/then, got: ${JSON.stringify(criticals)}`
252
+ )
253
+ } finally {
254
+ await cleanup()
255
+ }
256
+ })
257
+
212
258
  test('a non-browser step without an actor is allowed', async () => {
213
259
  const { criticals, cleanup } = await run([
214
260
  "await scenario.given('sees a receipt', 'seesAReceipt', {})",