@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.
@@ -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.70",
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.100"
48
+ "@pikku/core": "^0.12.102"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.100",
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
- wrapperFunctionName === 'pikkuAddonServices' &&
82
- functionNode &&
83
- functionNode.parameters.length >= 2
84
- ) {
85
- const secondParam = functionNode.parameters[1]
86
- if (secondParam && ts.isObjectBindingPattern(secondParam.name)) {
87
- for (const elem of secondParam.name.elements) {
88
- const name =
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 })",
@@ -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
- undefined, // host
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${options.oldProgram ? ', incremental' : ''})`
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