@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.
- package/CHANGELOG.md +743 -0
- package/OPTIMIZATION-PLAN.md +9 -3
- package/dist/add/add-auth.d.ts +1 -1
- package/dist/add/add-auth.js +1 -1
- package/dist/add/add-cli.js +7 -0
- package/dist/add/add-credential.js +1 -1
- package/dist/add/add-functions.js +199 -8
- package/dist/add/add-http-route.js +16 -6
- package/dist/add/add-middleware.js +46 -3
- package/dist/add/add-personas.d.ts +13 -0
- package/dist/add/add-personas.js +216 -0
- package/dist/add/add-scope.js +1 -1
- package/dist/add/add-secret.js +1 -1
- package/dist/add/add-system-role.d.ts +2 -0
- package/dist/add/add-system-role.js +102 -0
- package/dist/add/add-variable.js +1 -1
- package/dist/add/add-wire-addon.js +31 -0
- package/dist/add/add-workflow.js +12 -1
- package/dist/error-codes.d.ts +17 -1
- package/dist/error-codes.js +23 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +55 -2
- package/dist/utils/annotate-http-route-auth.d.ts +22 -0
- package/dist/utils/annotate-http-route-auth.js +48 -0
- package/dist/utils/check-secret-sinks.d.ts +31 -0
- package/dist/utils/check-secret-sinks.js +117 -0
- package/dist/utils/extract-function-name.js +13 -4
- package/dist/utils/extract-function-node.js +18 -1
- package/dist/utils/extract-secret-usage.d.ts +9 -0
- package/dist/utils/extract-secret-usage.js +61 -0
- package/dist/utils/literal-properties.d.ts +22 -0
- package/dist/utils/literal-properties.js +69 -0
- package/dist/utils/load-addon-functions-meta.js +26 -1
- package/dist/utils/permissions.js +8 -0
- package/dist/utils/post-process.d.ts +68 -3
- package/dist/utils/post-process.js +246 -13
- package/dist/utils/secret-alias-services.d.ts +8 -0
- package/dist/utils/secret-alias-services.js +45 -0
- package/dist/utils/secret-brokers.d.ts +2 -0
- package/dist/utils/secret-brokers.js +9 -0
- package/dist/utils/serialize-inspector-state.d.ts +10 -0
- package/dist/utils/serialize-inspector-state.js +18 -0
- package/dist/utils/serialize-middleware-groups-meta.js +3 -0
- package/dist/utils/single-declaration.d.ts +32 -0
- package/dist/utils/single-declaration.js +43 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +93 -0
- package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
- package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
- package/dist/utils/workflow/dsl/patterns.js +26 -4
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +5 -3
- package/src/add/add-auth.ts +1 -1
- package/src/add/add-cli.ts +8 -0
- package/src/add/add-credential.ts +1 -1
- package/src/add/add-functions.test.ts +263 -1
- package/src/add/add-functions.ts +273 -11
- package/src/add/add-http-route.ts +20 -7
- package/src/add/add-middleware.test.ts +196 -0
- package/src/add/add-middleware.ts +64 -4
- package/src/add/add-personas.test.ts +382 -0
- package/src/add/add-personas.ts +285 -0
- package/src/add/add-scenario-step.test.ts +87 -5
- package/src/add/add-scope.test.ts +103 -30
- package/src/add/add-scope.ts +1 -1
- package/src/add/add-secret.ts +1 -1
- package/src/add/add-subject-scenario-steps.test.ts +161 -0
- package/src/add/add-system-role.test.ts +297 -0
- package/src/add/add-system-role.ts +164 -0
- package/src/add/add-variable.ts +1 -1
- package/src/add/add-wire-addon.test.ts +111 -0
- package/src/add/add-wire-addon.ts +29 -0
- package/src/add/add-workflow.ts +21 -1
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/allow-escape-hatches.test.ts +144 -0
- package/src/add/audit-meta.test.ts +75 -0
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/secret-sink-check.test.ts +211 -0
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +26 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +56 -2
- package/src/utils/annotate-http-route-auth.test.ts +163 -0
- package/src/utils/annotate-http-route-auth.ts +52 -0
- package/src/utils/check-secret-sinks.ts +145 -0
- package/src/utils/extract-function-name.ts +14 -4
- package/src/utils/extract-function-node.ts +18 -6
- package/src/utils/extract-secret-usage.test.ts +81 -0
- package/src/utils/extract-secret-usage.ts +73 -0
- package/src/utils/literal-properties.ts +97 -0
- package/src/utils/load-addon-functions-meta.ts +32 -2
- package/src/utils/permissions-shorthand.test.ts +79 -0
- package/src/utils/permissions.ts +7 -0
- package/src/utils/post-process.test.ts +133 -0
- package/src/utils/post-process.ts +313 -12
- package/src/utils/secret-alias-services.test.ts +146 -0
- package/src/utils/secret-alias-services.ts +58 -0
- package/src/utils/secret-brokers.test.ts +53 -0
- package/src/utils/secret-brokers.ts +11 -0
- package/src/utils/serialize-inspector-state.ts +28 -0
- package/src/utils/serialize-middleware-groups-meta.ts +3 -0
- package/src/utils/single-declaration.ts +57 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +103 -0
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
- package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
- package/src/utils/workflow/dsl/patterns.ts +33 -4
- package/src/visit.ts +4 -0
- 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
|
+
}
|