@pikku/inspector 0.12.70 → 0.12.73
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 +56 -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 +22 -3
- package/dist/source-file-cache.d.ts +11 -0
- package/dist/source-file-cache.js +54 -0
- package/dist/types.d.ts +38 -0
- package/dist/utils/filter-inspector-state.js +30 -0
- 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 +22 -2
- package/src/source-file-cache.ts +73 -0
- package/src/types.ts +39 -0
- package/src/utils/filter-inspector-state.test.ts +142 -0
- package/src/utils/filter-inspector-state.ts +32 -0
- 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
- package/dist/utils/check-secret-sinks.d.ts +0 -31
- package/dist/utils/check-secret-sinks.js +0 -118
|
@@ -24,6 +24,8 @@ export function serializeInspectorState(state) {
|
|
|
24
24
|
wireServicesFactories: Array.from(state.wireServicesFactories.entries()),
|
|
25
25
|
wireServicesMeta: Array.from(state.wireServicesMeta.entries()),
|
|
26
26
|
addonRequiredParentServices: state.addonRequiredParentServices,
|
|
27
|
+
addonCreatedServices: state.addonCreatedServices,
|
|
28
|
+
addonServicesFactorySeen: state.addonServicesFactorySeen,
|
|
27
29
|
addonServerlessIncompatible: Array.from(state.addonServerlessIncompatible.entries()),
|
|
28
30
|
configFactories: Array.from(state.configFactories.entries()),
|
|
29
31
|
serverLifecycleFactories: Array.from(state.serverLifecycleFactories.entries()),
|
|
@@ -94,6 +96,7 @@ export function serializeInspectorState(state) {
|
|
|
94
96
|
agents: {
|
|
95
97
|
agentsMeta: state.agents?.agentsMeta ?? {},
|
|
96
98
|
files: Array.from(state.agents?.files?.entries() ?? []),
|
|
99
|
+
invokedAgentsByFile: Array.from((state.agents?.invokedAgentsByFile ?? new Map()).entries()).map(([file, agents]) => [file, Array.from(agents)]),
|
|
97
100
|
},
|
|
98
101
|
scorers: {
|
|
99
102
|
scorersMeta: state.scorers?.scorersMeta ?? {},
|
|
@@ -198,6 +201,8 @@ export function deserializeInspectorState(data) {
|
|
|
198
201
|
wireServicesFactories: new Map(data.wireServicesFactories),
|
|
199
202
|
wireServicesMeta: new Map(data.wireServicesMeta),
|
|
200
203
|
addonRequiredParentServices: data.addonRequiredParentServices || [],
|
|
204
|
+
addonCreatedServices: data.addonCreatedServices || [],
|
|
205
|
+
addonServicesFactorySeen: data.addonServicesFactorySeen ?? false,
|
|
201
206
|
addonServerlessIncompatible: new Map(data.addonServerlessIncompatible || []),
|
|
202
207
|
configFactories: new Map(data.configFactories),
|
|
203
208
|
serverLifecycleFactories: new Map(data.serverLifecycleFactories),
|
|
@@ -272,6 +277,7 @@ export function deserializeInspectorState(data) {
|
|
|
272
277
|
agents: {
|
|
273
278
|
agentsMeta: data.agents?.agentsMeta || {},
|
|
274
279
|
files: new Map(data.agents?.files || []),
|
|
280
|
+
invokedAgentsByFile: new Map((data.agents?.invokedAgentsByFile || []).map(([file, agents]) => [file, new Set(agents)])),
|
|
275
281
|
},
|
|
276
282
|
scorers: {
|
|
277
283
|
scorersMeta: data.scorers?.scorersMeta || {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.73",
|
|
4
4
|
"description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"zod-to-ts": "^2.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@pikku/core": "^0.12.
|
|
48
|
+
"@pikku/core": "^0.12.102"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.102",
|
|
52
52
|
"@types/node": "^24.13.3"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
@@ -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
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
} from './types.js'
|
|
11
11
|
import { getFilesAndMethods } from './utils/get-files-and-methods.js'
|
|
12
12
|
import { findCommonAncestor } from './utils/find-root-dir.js'
|
|
13
|
+
import { createSourceFileCacheHost } from './source-file-cache.js'
|
|
13
14
|
import { createNestedProjectFilter } from './utils/nested-project-filter.js'
|
|
14
15
|
import {
|
|
15
16
|
aggregateRequiredServices,
|
|
@@ -83,6 +84,8 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
83
84
|
wireServicesFactories: new Map(),
|
|
84
85
|
wireServicesMeta: new Map(),
|
|
85
86
|
addonRequiredParentServices: [],
|
|
87
|
+
addonCreatedServices: [],
|
|
88
|
+
addonServicesFactorySeen: false,
|
|
86
89
|
addonServerlessIncompatible: new Map(),
|
|
87
90
|
configFactories: new Map(),
|
|
88
91
|
serverLifecycleFactories: new Map(),
|
|
@@ -163,6 +166,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
163
166
|
agents: {
|
|
164
167
|
agentsMeta: {},
|
|
165
168
|
files: new Map(),
|
|
169
|
+
invokedAgentsByFile: new Map(),
|
|
166
170
|
},
|
|
167
171
|
scorers: {
|
|
168
172
|
scorersMeta: {},
|
|
@@ -332,15 +336,19 @@ export const inspect = async (
|
|
|
332
336
|
allowJs: false,
|
|
333
337
|
checkJs: false,
|
|
334
338
|
}
|
|
339
|
+
const cpuStart = process.cpuUsage()
|
|
335
340
|
const startProgram = performance.now()
|
|
341
|
+
const host = createSourceFileCacheHost(compilerOptions)
|
|
336
342
|
const program = ts.createProgram(
|
|
337
343
|
normalizedRouteFiles,
|
|
338
344
|
compilerOptions,
|
|
339
|
-
|
|
345
|
+
host,
|
|
340
346
|
options.oldProgram
|
|
341
347
|
)
|
|
348
|
+
host.prune(program)
|
|
349
|
+
const filesReused = host.reused()
|
|
342
350
|
logger.debug(
|
|
343
|
-
`Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files${
|
|
351
|
+
`Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files, ${filesReused} source files reused)`
|
|
344
352
|
)
|
|
345
353
|
|
|
346
354
|
const startChecker = performance.now()
|
|
@@ -549,6 +557,18 @@ export const inspect = async (
|
|
|
549
557
|
logger.endValidationPass?.()
|
|
550
558
|
}
|
|
551
559
|
|
|
560
|
+
const cpu = process.cpuUsage(cpuStart)
|
|
561
|
+
state.stats = {
|
|
562
|
+
files: program.getSourceFiles().length,
|
|
563
|
+
projectFiles: sourceFiles.length,
|
|
564
|
+
filesReused,
|
|
565
|
+
types: program.getTypeCount(),
|
|
566
|
+
instantiations: program.getInstantiationCount(),
|
|
567
|
+
symbols: program.getSymbolCount(),
|
|
568
|
+
cpuMs: Math.round((cpu.user + cpu.system) / 1000),
|
|
569
|
+
wallMs: Math.round(performance.now() - startProgram),
|
|
570
|
+
heapUsedMb: Math.round(process.memoryUsage().heapUsed / 1048576),
|
|
571
|
+
}
|
|
552
572
|
state.program = program
|
|
553
573
|
|
|
554
574
|
return state
|