@pikku/inspector 0.12.50 → 0.12.54

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