@pikku/inspector 0.12.69 → 0.12.72
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 +57 -0
- package/dist/add/add-file-with-factory.js +96 -14
- package/dist/add/add-rpc-invocations.js +48 -0
- package/dist/error-codes.d.ts +1 -0
- package/dist/error-codes.js +1 -0
- package/dist/inspector.js +3 -0
- package/dist/types.d.ts +12 -0
- package/dist/utils/filter-inspector-state.js +38 -4
- package/dist/utils/post-process.d.ts +10 -0
- package/dist/utils/post-process.js +87 -0
- package/dist/utils/serialize-inspector-state.d.ts +3 -0
- package/dist/utils/serialize-inspector-state.js +6 -0
- package/package.json +3 -3
- package/src/add/add-file-with-factory.test.ts +134 -0
- package/src/add/add-file-with-factory.ts +109 -17
- package/src/add/add-rpc-invocations.test.ts +124 -0
- package/src/add/add-rpc-invocations.ts +52 -0
- package/src/add/add-scenario-step.test.ts +61 -0
- package/src/error-codes.ts +1 -0
- package/src/inspector.ts +3 -0
- package/src/types.ts +12 -0
- package/src/utils/filter-inspector-state.test.ts +132 -0
- package/src/utils/filter-inspector-state.ts +45 -4
- package/src/utils/post-process.test.ts +42 -0
- package/src/utils/post-process.ts +110 -0
- package/src/utils/serialize-inspector-state.ts +17 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
import {
|
|
10
|
+
serializeInspectorState,
|
|
11
|
+
deserializeInspectorState,
|
|
12
|
+
} from '../utils/serialize-inspector-state.js'
|
|
13
|
+
|
|
14
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
15
|
+
({
|
|
16
|
+
debug: () => {},
|
|
17
|
+
info: () => {},
|
|
18
|
+
warn: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
diagnostic: ({ severity, code, message }) => {
|
|
21
|
+
if (severity !== 'warn') {
|
|
22
|
+
criticals.push({ code, message })
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
critical: (code: ErrorCode, message: string) => {
|
|
26
|
+
criticals.push({ code, message })
|
|
27
|
+
},
|
|
28
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
29
|
+
}) satisfies InspectorLogger
|
|
30
|
+
|
|
31
|
+
const inspectSource = async (prefix: string, source: string) => {
|
|
32
|
+
const rootDir = await mkdtemp(join(tmpdir(), prefix))
|
|
33
|
+
const file = join(rootDir, 'services.ts')
|
|
34
|
+
await writeFile(file, source)
|
|
35
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
36
|
+
try {
|
|
37
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
38
|
+
return { state, criticals }
|
|
39
|
+
} finally {
|
|
40
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('pikkuAddonServices — what an addon takes from its parent', () => {
|
|
45
|
+
test('a name destructured in the body, not the parameter list, is still forwarded', async () => {
|
|
46
|
+
const { state } = await inspectSource(
|
|
47
|
+
'pikku-addon-body-destructure-',
|
|
48
|
+
[
|
|
49
|
+
"import { pikkuAddonServices } from '#pikku/addon/setup'",
|
|
50
|
+
'export const createSingletonServices = pikkuAddonServices(',
|
|
51
|
+
' async (_config, existingServices) => {',
|
|
52
|
+
' const { kysely, secrets } = existingServices',
|
|
53
|
+
' return { ...existingServices, stripeApi: makeStripe(secrets) }',
|
|
54
|
+
' }',
|
|
55
|
+
')',
|
|
56
|
+
].join('\n')
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert.ok(
|
|
60
|
+
state.addonRequiredParentServices.includes('kysely'),
|
|
61
|
+
'kysely is read off the parent bag and must be declared as required'
|
|
62
|
+
)
|
|
63
|
+
assert.deepEqual(state.addonCreatedServices, ['stripeApi'])
|
|
64
|
+
assert.equal(state.addonServicesFactorySeen, true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('an object rest is a binding name, not a service', async () => {
|
|
68
|
+
const { state } = await inspectSource(
|
|
69
|
+
'pikku-addon-object-rest-',
|
|
70
|
+
[
|
|
71
|
+
"import { pikkuAddonServices } from '#pikku/addon/setup'",
|
|
72
|
+
'export const createSingletonServices = pikkuAddonServices(',
|
|
73
|
+
' async (_config, { kysely, ...parentServices }) => ({',
|
|
74
|
+
' ...parentServices,',
|
|
75
|
+
' kysely,',
|
|
76
|
+
' stripeApi: makeStripe(),',
|
|
77
|
+
' })',
|
|
78
|
+
')',
|
|
79
|
+
].join('\n')
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(state.addonRequiredParentServices, ['kysely'])
|
|
83
|
+
assert.ok(
|
|
84
|
+
!state.addonRequiredParentServices.includes('parentServices'),
|
|
85
|
+
'a rest binding names the leftover bag, not a service the parent provides'
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('a parenthesised return still yields its created services', async () => {
|
|
90
|
+
const { state } = await inspectSource(
|
|
91
|
+
'pikku-addon-parenthesised-return-',
|
|
92
|
+
[
|
|
93
|
+
"import { pikkuAddonServices } from '#pikku/addon/setup'",
|
|
94
|
+
'export const createSingletonServices = pikkuAddonServices(',
|
|
95
|
+
' async (_config, existingServices) => {',
|
|
96
|
+
' return ({ ...existingServices, stripeApi: makeStripe() })',
|
|
97
|
+
' }',
|
|
98
|
+
')',
|
|
99
|
+
].join('\n')
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert.deepEqual(state.addonCreatedServices, ['stripeApi'])
|
|
103
|
+
assert.ok(
|
|
104
|
+
!state.addonRequiredParentServices.includes('stripeApi'),
|
|
105
|
+
'a service the factory builds is not one the parent has to supply'
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('the addon contract survives a serialize/deserialize round trip', async () => {
|
|
110
|
+
const { state } = await inspectSource(
|
|
111
|
+
'pikku-addon-round-trip-',
|
|
112
|
+
[
|
|
113
|
+
"import { pikkuAddonServices } from '#pikku/addon/setup'",
|
|
114
|
+
'export const createSingletonServices = pikkuAddonServices(',
|
|
115
|
+
' async (_config, existingServices) => {',
|
|
116
|
+
' const { kysely } = existingServices',
|
|
117
|
+
' return { ...existingServices, stripeApi: makeStripe(kysely) }',
|
|
118
|
+
' }',
|
|
119
|
+
')',
|
|
120
|
+
].join('\n')
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const restored = deserializeInspectorState(
|
|
124
|
+
JSON.parse(JSON.stringify(serializeInspectorState(state)))
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
restored.addonRequiredParentServices,
|
|
129
|
+
state.addonRequiredParentServices
|
|
130
|
+
)
|
|
131
|
+
assert.deepEqual(restored.addonCreatedServices, ['stripeApi'])
|
|
132
|
+
assert.equal(restored.addonServicesFactorySeen, true)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -13,6 +13,106 @@ const wrapperFunctionMap: Record<string, string> = {
|
|
|
13
13
|
pikkuServerLifecycle: 'ServerLifecycle',
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* What an addon's `pikkuAddonServices` factory takes from the parent: the names
|
|
18
|
+
* destructured off its second parameter, either in the parameter list or from a
|
|
19
|
+
* `const { … } = existingServices` in the body.
|
|
20
|
+
*/
|
|
21
|
+
const extractForwardedServices = (
|
|
22
|
+
functionNode: ts.ArrowFunction | ts.FunctionExpression
|
|
23
|
+
): string[] => {
|
|
24
|
+
const forwarded: string[] = []
|
|
25
|
+
const secondParam = functionNode.parameters[1]
|
|
26
|
+
if (!secondParam) return forwarded
|
|
27
|
+
|
|
28
|
+
const collectBinding = (pattern: ts.ObjectBindingPattern) => {
|
|
29
|
+
for (const elem of pattern.elements) {
|
|
30
|
+
if (elem.dotDotDotToken) continue
|
|
31
|
+
const name =
|
|
32
|
+
elem.propertyName && ts.isIdentifier(elem.propertyName)
|
|
33
|
+
? elem.propertyName.text
|
|
34
|
+
: ts.isIdentifier(elem.name)
|
|
35
|
+
? elem.name.text
|
|
36
|
+
: undefined
|
|
37
|
+
if (name) forwarded.push(name)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (ts.isObjectBindingPattern(secondParam.name)) {
|
|
42
|
+
collectBinding(secondParam.name)
|
|
43
|
+
return forwarded
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!ts.isIdentifier(secondParam.name)) return forwarded
|
|
47
|
+
const paramName = secondParam.name.text
|
|
48
|
+
const body = functionNode.body
|
|
49
|
+
if (!ts.isBlock(body)) return forwarded
|
|
50
|
+
|
|
51
|
+
const visit = (node: ts.Node) => {
|
|
52
|
+
if (
|
|
53
|
+
ts.isVariableDeclaration(node) &&
|
|
54
|
+
ts.isObjectBindingPattern(node.name) &&
|
|
55
|
+
node.initializer &&
|
|
56
|
+
ts.isIdentifier(node.initializer) &&
|
|
57
|
+
node.initializer.text === paramName
|
|
58
|
+
) {
|
|
59
|
+
collectBinding(node.name)
|
|
60
|
+
}
|
|
61
|
+
ts.forEachChild(node, visit)
|
|
62
|
+
}
|
|
63
|
+
ts.forEachChild(body, visit)
|
|
64
|
+
|
|
65
|
+
return forwarded
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The service names the factory's returned object literals name. A spread is
|
|
70
|
+
* skipped — it carries the parent's bag through rather than naming anything —
|
|
71
|
+
* and a name the factory took off the parent is a forward, not a creation.
|
|
72
|
+
*/
|
|
73
|
+
const extractReturnedServices = (
|
|
74
|
+
functionNode: ts.ArrowFunction | ts.FunctionExpression
|
|
75
|
+
): string[] => {
|
|
76
|
+
const returned: string[] = []
|
|
77
|
+
|
|
78
|
+
const collect = (expression: ts.Expression) => {
|
|
79
|
+
while (ts.isParenthesizedExpression(expression)) {
|
|
80
|
+
expression = expression.expression
|
|
81
|
+
}
|
|
82
|
+
if (!ts.isObjectLiteralExpression(expression)) return
|
|
83
|
+
for (const property of expression.properties) {
|
|
84
|
+
if (ts.isSpreadAssignment(property)) continue
|
|
85
|
+
const name = property.name
|
|
86
|
+
if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name))) {
|
|
87
|
+
returned.push(name.text)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const body = functionNode.body
|
|
93
|
+
if (!ts.isBlock(body)) {
|
|
94
|
+
collect(body)
|
|
95
|
+
return returned
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const visit = (node: ts.Node) => {
|
|
99
|
+
if (
|
|
100
|
+
ts.isFunctionDeclaration(node) ||
|
|
101
|
+
ts.isFunctionExpression(node) ||
|
|
102
|
+
ts.isArrowFunction(node)
|
|
103
|
+
) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
107
|
+
collect(node.expression)
|
|
108
|
+
}
|
|
109
|
+
ts.forEachChild(node, visit)
|
|
110
|
+
}
|
|
111
|
+
ts.forEachChild(body, visit)
|
|
112
|
+
|
|
113
|
+
return returned
|
|
114
|
+
}
|
|
115
|
+
|
|
16
116
|
export const addFileWithFactory = (
|
|
17
117
|
node: ts.Node,
|
|
18
118
|
checker: ts.TypeChecker,
|
|
@@ -77,23 +177,15 @@ export const addFileWithFactory = (
|
|
|
77
177
|
|
|
78
178
|
// Extract existing services an addon needs from the parent
|
|
79
179
|
// (second parameter of pikkuAddonServices callback)
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
functionNode
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
elem.propertyName && ts.isIdentifier(elem.propertyName)
|
|
90
|
-
? elem.propertyName.text
|
|
91
|
-
: ts.isIdentifier(elem.name)
|
|
92
|
-
? elem.name.text
|
|
93
|
-
: undefined
|
|
94
|
-
if (name) {
|
|
95
|
-
state.addonRequiredParentServices.push(name)
|
|
96
|
-
}
|
|
180
|
+
if (wrapperFunctionName === 'pikkuAddonServices' && functionNode) {
|
|
181
|
+
state.addonServicesFactorySeen = true
|
|
182
|
+
const forwarded = new Set(extractForwardedServices(functionNode))
|
|
183
|
+
for (const name of forwarded) {
|
|
184
|
+
state.addonRequiredParentServices.push(name)
|
|
185
|
+
}
|
|
186
|
+
for (const name of extractReturnedServices(functionNode)) {
|
|
187
|
+
if (!forwarded.has(name)) {
|
|
188
|
+
state.addonCreatedServices.push(name)
|
|
97
189
|
}
|
|
98
190
|
}
|
|
99
191
|
}
|
|
@@ -161,3 +161,127 @@ export async function doWork() {
|
|
|
161
161
|
}
|
|
162
162
|
})
|
|
163
163
|
})
|
|
164
|
+
|
|
165
|
+
describe('add-rpc-invocations — invokedAgentsByFile', () => {
|
|
166
|
+
test('a body-level runAgent() is attributed to its source file', async () => {
|
|
167
|
+
const { state, dir } = await inspectFiles({
|
|
168
|
+
'caller.ts': `
|
|
169
|
+
declare const runAgent: (name: string, input: unknown) => Promise<unknown>
|
|
170
|
+
export async function askTheHouse() {
|
|
171
|
+
return runAgent('houseAssistant', { message: 'hi' })
|
|
172
|
+
}
|
|
173
|
+
`,
|
|
174
|
+
'other.ts': `
|
|
175
|
+
export const unrelated = () => 'no agents here'
|
|
176
|
+
`,
|
|
177
|
+
})
|
|
178
|
+
try {
|
|
179
|
+
const byFile = state.agents.invokedAgentsByFile
|
|
180
|
+
assert.strictEqual(byFile.size, 1)
|
|
181
|
+
const [file, agents] = [...byFile.entries()][0]!
|
|
182
|
+
assert.ok(file.endsWith('caller.ts'))
|
|
183
|
+
assert.deepStrictEqual([...agents], ['houseAssistant'])
|
|
184
|
+
} finally {
|
|
185
|
+
await rm(dir, { recursive: true, force: true })
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('rpc.agent.run() is an agent invocation too', async () => {
|
|
190
|
+
const { state, dir } = await inspectFiles({
|
|
191
|
+
'caller.ts': `
|
|
192
|
+
declare const rpc: { agent: { run: (name: string, input: unknown) => Promise<unknown> } }
|
|
193
|
+
export async function askTheHouse() {
|
|
194
|
+
return rpc.agent.run('houseAssistant', { message: 'hi' })
|
|
195
|
+
}
|
|
196
|
+
`,
|
|
197
|
+
})
|
|
198
|
+
try {
|
|
199
|
+
const agents = [...state.agents.invokedAgentsByFile.values()][0]!
|
|
200
|
+
assert.deepStrictEqual([...agents], ['houseAssistant'])
|
|
201
|
+
} finally {
|
|
202
|
+
await rm(dir, { recursive: true, force: true })
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('a non-null assertion on the rpc receiver is still an invocation', async () => {
|
|
207
|
+
const { state, dir } = await inspectFiles({
|
|
208
|
+
'caller.ts': `
|
|
209
|
+
declare const rpc: { agent: { stream: (name: string, input: unknown) => Promise<unknown> } } | undefined
|
|
210
|
+
export async function askTheHouse() {
|
|
211
|
+
return rpc!.agent.stream('houseAssistant', { message: 'hi' })
|
|
212
|
+
}
|
|
213
|
+
`,
|
|
214
|
+
})
|
|
215
|
+
try {
|
|
216
|
+
const agents = [...state.agents.invokedAgentsByFile.values()][0]!
|
|
217
|
+
assert.deepStrictEqual([...agents], ['houseAssistant'])
|
|
218
|
+
} finally {
|
|
219
|
+
await rm(dir, { recursive: true, force: true })
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('multiple agent calls in one file accumulate under that file', async () => {
|
|
224
|
+
const { state, dir } = await inspectFiles({
|
|
225
|
+
'caller.ts': `
|
|
226
|
+
declare const runAgent: (name: string, input: unknown) => Promise<unknown>
|
|
227
|
+
declare const rpc: { agent: { run: (name: string, input: unknown) => Promise<unknown> } }
|
|
228
|
+
export async function askEveryone() {
|
|
229
|
+
await runAgent('houseAssistant', {})
|
|
230
|
+
return rpc.agent.run('gardenAssistant', {})
|
|
231
|
+
}
|
|
232
|
+
`,
|
|
233
|
+
})
|
|
234
|
+
try {
|
|
235
|
+
const agents = [...state.agents.invokedAgentsByFile.values()][0]!
|
|
236
|
+
assert.deepStrictEqual([...agents].sort(), [
|
|
237
|
+
'gardenAssistant',
|
|
238
|
+
'houseAssistant',
|
|
239
|
+
])
|
|
240
|
+
} finally {
|
|
241
|
+
await rm(dir, { recursive: true, force: true })
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('a dynamic agent name is warned about, not recorded', async () => {
|
|
246
|
+
const warnings: string[] = []
|
|
247
|
+
const dir = await mkdtemp(join(tmpdir(), 'pikku-agent-invocations-test-'))
|
|
248
|
+
const path = join(dir, 'caller.ts')
|
|
249
|
+
await writeFile(
|
|
250
|
+
path,
|
|
251
|
+
`
|
|
252
|
+
declare const runAgent: (name: string, input: unknown) => Promise<unknown>
|
|
253
|
+
declare const suffix: string
|
|
254
|
+
export async function askTheHouse() {
|
|
255
|
+
return runAgent(\`house-\${suffix}\`, {})
|
|
256
|
+
}
|
|
257
|
+
`
|
|
258
|
+
)
|
|
259
|
+
const logger = makeLogger()
|
|
260
|
+
logger.warn = (message: string) => {
|
|
261
|
+
warnings.push(message)
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const state = await inspect(logger, [path], { rootDir: dir })
|
|
265
|
+
assert.strictEqual(state.agents.invokedAgentsByFile.size, 0)
|
|
266
|
+
assert.ok(warnings.some((w) => w.includes('dynamic agent invocation')))
|
|
267
|
+
} finally {
|
|
268
|
+
await rm(dir, { recursive: true, force: true })
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('run() on some other receiver is left alone', async () => {
|
|
273
|
+
const { state, dir } = await inspectFiles({
|
|
274
|
+
'caller.ts': `
|
|
275
|
+
declare const client: { agent: { run: (name: string, input: unknown) => Promise<unknown> } }
|
|
276
|
+
export async function doWork() {
|
|
277
|
+
return client.agent.run('notAnAgentCall', {})
|
|
278
|
+
}
|
|
279
|
+
`,
|
|
280
|
+
})
|
|
281
|
+
try {
|
|
282
|
+
assert.strictEqual(state.agents.invokedAgentsByFile.size, 0)
|
|
283
|
+
} finally {
|
|
284
|
+
await rm(dir, { recursive: true, force: true })
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
})
|
|
@@ -51,6 +51,33 @@ function extractNamespace(functionRef: string): string | null {
|
|
|
51
51
|
return null
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function agentInvocationName(
|
|
55
|
+
expression: ts.Expression
|
|
56
|
+
): 'runAgent' | 'streamAgent' | 'rpc.agent.run' | 'rpc.agent.stream' | null {
|
|
57
|
+
if (ts.isIdentifier(expression)) {
|
|
58
|
+
if (expression.text === 'runAgent') return 'runAgent'
|
|
59
|
+
if (expression.text === 'streamAgent') return 'streamAgent'
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
if (!ts.isPropertyAccessExpression(expression)) return null
|
|
63
|
+
const method = expression.name.text
|
|
64
|
+
if (method !== 'run' && method !== 'stream') return null
|
|
65
|
+
const agentReceiver = invocationReceiver(expression.expression)
|
|
66
|
+
if (
|
|
67
|
+
!ts.isPropertyAccessExpression(agentReceiver) ||
|
|
68
|
+
agentReceiver.name.text !== 'agent'
|
|
69
|
+
) {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
const rpcReceiver = invocationReceiver(agentReceiver.expression)
|
|
73
|
+
const isRpc = ts.isIdentifier(rpcReceiver)
|
|
74
|
+
? rpcReceiver.text === 'rpc'
|
|
75
|
+
: ts.isPropertyAccessExpression(rpcReceiver) &&
|
|
76
|
+
rpcReceiver.name.text === 'rpc'
|
|
77
|
+
if (!isRpc) return null
|
|
78
|
+
return method === 'run' ? 'rpc.agent.run' : 'rpc.agent.stream'
|
|
79
|
+
}
|
|
80
|
+
|
|
54
81
|
/**
|
|
55
82
|
* Scan for rpc.invoke() calls to track which functions are actually being invoked
|
|
56
83
|
* Also detects addon usage via:
|
|
@@ -99,6 +126,31 @@ export function addRPCInvocations(
|
|
|
99
126
|
}
|
|
100
127
|
}
|
|
101
128
|
|
|
129
|
+
const agentCall = agentInvocationName(expression)
|
|
130
|
+
if (agentCall) {
|
|
131
|
+
const [firstArg] = args
|
|
132
|
+
if (firstArg && ts.isStringLiteral(firstArg)) {
|
|
133
|
+
const agentName = firstArg.text
|
|
134
|
+
logger.debug(`• Found ${agentCall}() call: ${agentName}`)
|
|
135
|
+
const sourceFileName = node.getSourceFile().fileName
|
|
136
|
+
let byFile = state.agents.invokedAgentsByFile.get(sourceFileName)
|
|
137
|
+
if (!byFile) {
|
|
138
|
+
byFile = new Set()
|
|
139
|
+
state.agents.invokedAgentsByFile.set(sourceFileName, byFile)
|
|
140
|
+
}
|
|
141
|
+
byFile.add(agentName)
|
|
142
|
+
} else if (
|
|
143
|
+
firstArg &&
|
|
144
|
+
(ts.isTemplateExpression(firstArg) ||
|
|
145
|
+
ts.isNoSubstitutionTemplateLiteral(firstArg))
|
|
146
|
+
) {
|
|
147
|
+
logger.warn(`• Found dynamic agent invocation: ${firstArg.getText()}`)
|
|
148
|
+
logger.warn(
|
|
149
|
+
`\tYou can only use string literals for agent names, with ' or " and not \``
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
102
154
|
// Check for rpc.invoke('...') calls
|
|
103
155
|
const invokeReceiver = ts.isPropertyAccessExpression(expression)
|
|
104
156
|
? invocationReceiver(expression.expression)
|
|
@@ -245,6 +245,67 @@ describe('pikkuScenarioStep', () => {
|
|
|
245
245
|
}
|
|
246
246
|
})
|
|
247
247
|
|
|
248
|
+
test('a step whose prose opens with its own actor is a PKU681 critical', async () => {
|
|
249
|
+
const { criticals, cleanup } = await run([
|
|
250
|
+
"await scenario.given(`'shopper' buys an apple`, 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
251
|
+
"await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
252
|
+
])
|
|
253
|
+
try {
|
|
254
|
+
assert.ok(
|
|
255
|
+
criticals.find((c) => c.code === 'PKU681'),
|
|
256
|
+
`the reporter already renders the actor as the subject, so this reads "Given shopper 'shopper' buys an apple", got: ${JSON.stringify(criticals)}`
|
|
257
|
+
)
|
|
258
|
+
} finally {
|
|
259
|
+
await cleanup()
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('an unquoted, capitalised actor opening the prose is caught too', async () => {
|
|
264
|
+
const { criticals, cleanup } = await run([
|
|
265
|
+
"await scenario.given('Shopper buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
266
|
+
"await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
267
|
+
])
|
|
268
|
+
try {
|
|
269
|
+
assert.ok(
|
|
270
|
+
criticals.find((c) => c.code === 'PKU681'),
|
|
271
|
+
`quotes and case are decoration — the subject is still doubled, got: ${JSON.stringify(criticals)}`
|
|
272
|
+
)
|
|
273
|
+
} finally {
|
|
274
|
+
await cleanup()
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('a call-site description naming the actor is caught as well as the step name', async () => {
|
|
279
|
+
const { criticals, cleanup } = await run([
|
|
280
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper, description: `shopper buys an apple` })",
|
|
281
|
+
"await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
282
|
+
])
|
|
283
|
+
try {
|
|
284
|
+
assert.ok(
|
|
285
|
+
criticals.find((c) => c.code === 'PKU681'),
|
|
286
|
+
`the description is what actually renders when it is given, got: ${JSON.stringify(criticals)}`
|
|
287
|
+
)
|
|
288
|
+
} finally {
|
|
289
|
+
await cleanup()
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('an actor named mid-sentence is left alone', async () => {
|
|
294
|
+
const { criticals, cleanup } = await run([
|
|
295
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
296
|
+
"await scenario.then('sends shopper a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
297
|
+
])
|
|
298
|
+
try {
|
|
299
|
+
assert.equal(
|
|
300
|
+
criticals.filter((c) => c.code === 'PKU681').length,
|
|
301
|
+
0,
|
|
302
|
+
`only the subject position doubles up; naming someone mid-sentence is ordinary prose, got: ${JSON.stringify(criticals)}`
|
|
303
|
+
)
|
|
304
|
+
} finally {
|
|
305
|
+
await cleanup()
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
|
|
248
309
|
test('a scenario that never asserts is a PKU680 critical', async () => {
|
|
249
310
|
const { criticals, cleanup } = await run([
|
|
250
311
|
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
package/src/error-codes.ts
CHANGED
|
@@ -26,6 +26,7 @@ export enum ErrorCode {
|
|
|
26
26
|
SCENARIO_STEP_TARGET_NOT_STATIC = 'PKU678',
|
|
27
27
|
SCENARIO_NOT_EXTRACTABLE = 'PKU679',
|
|
28
28
|
SCENARIO_HAS_NO_ASSERTION = 'PKU680',
|
|
29
|
+
SCENARIO_STEP_PROSE_NAMES_ACTOR = 'PKU681',
|
|
29
30
|
WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
|
|
30
31
|
INVALID_DSL_WORKFLOW = 'PKU641',
|
|
31
32
|
WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
|
package/src/inspector.ts
CHANGED
|
@@ -83,6 +83,8 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
83
83
|
wireServicesFactories: new Map(),
|
|
84
84
|
wireServicesMeta: new Map(),
|
|
85
85
|
addonRequiredParentServices: [],
|
|
86
|
+
addonCreatedServices: [],
|
|
87
|
+
addonServicesFactorySeen: false,
|
|
86
88
|
addonServerlessIncompatible: new Map(),
|
|
87
89
|
configFactories: new Map(),
|
|
88
90
|
serverLifecycleFactories: new Map(),
|
|
@@ -163,6 +165,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
163
165
|
agents: {
|
|
164
166
|
agentsMeta: {},
|
|
165
167
|
files: new Map(),
|
|
168
|
+
invokedAgentsByFile: new Map(),
|
|
166
169
|
},
|
|
167
170
|
scorers: {
|
|
168
171
|
scorersMeta: {},
|
package/src/types.ts
CHANGED
|
@@ -526,6 +526,8 @@ export interface InspectorState {
|
|
|
526
526
|
wireServicesFactories: PathToNameAndType
|
|
527
527
|
wireServicesMeta: Map<string, string[]> // variable name -> singleton services consumed
|
|
528
528
|
addonRequiredParentServices: string[] // services an addon needs from the parent (extracted from pikkuAddonServices 2nd param)
|
|
529
|
+
addonCreatedServices: string[] // services an addon's own pikkuAddonServices factory builds itself
|
|
530
|
+
addonServicesFactorySeen: boolean // this project declares a pikkuAddonServices factory, i.e. it is an addon
|
|
529
531
|
addonServerlessIncompatible: Map<string, string[]> // namespace → service names that are serverless-incompatible (scoped per addon)
|
|
530
532
|
configFactories: PathToNameAndType
|
|
531
533
|
serverLifecycleFactories: PathToNameAndType
|
|
@@ -634,6 +636,16 @@ export interface InspectorState {
|
|
|
634
636
|
agents: {
|
|
635
637
|
agentsMeta: AgentsMeta
|
|
636
638
|
files: Map<string, { path: string; exportedName: string }>
|
|
639
|
+
/**
|
|
640
|
+
* Agent registry keys named by a `runAgent`/`rpc.agent.run` call in a
|
|
641
|
+
* function body, keyed by the file the call was written in.
|
|
642
|
+
*
|
|
643
|
+
* The lookup those calls perform is in-process, so the agent has to be
|
|
644
|
+
* registered in the same deployment unit as its caller. Without this the
|
|
645
|
+
* deploy analyzer only ever saw an agent as its own unit, and a caller in
|
|
646
|
+
* another unit failed at run time with "AI agent not found".
|
|
647
|
+
*/
|
|
648
|
+
invokedAgentsByFile: Map<string, Set<string>>
|
|
637
649
|
}
|
|
638
650
|
scorers: {
|
|
639
651
|
scorersMeta: ScorerMeta
|