@pikku/inspector 0.12.34 → 0.12.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/add/add-functions.js +2 -2
  3. package/dist/add/add-http-route.js +4 -1
  4. package/dist/add/add-rpc-invocations.js +7 -0
  5. package/dist/add/add-workflow.d.ts +1 -1
  6. package/dist/add/add-workflow.js +39 -11
  7. package/dist/error-codes.d.ts +2 -1
  8. package/dist/error-codes.js +2 -1
  9. package/dist/inspector.js +1 -0
  10. package/dist/types.d.ts +1 -0
  11. package/dist/utils/filter-inspector-state.js +43 -0
  12. package/dist/utils/load-addon-functions-meta.js +20 -14
  13. package/dist/utils/post-process.js +38 -13
  14. package/dist/utils/serialize-inspector-state.d.ts +1 -0
  15. package/dist/utils/serialize-inspector-state.js +2 -0
  16. package/dist/utils/workflow/dsl/patterns.d.ts +1 -1
  17. package/dist/utils/workflow/dsl/patterns.js +1 -1
  18. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +3 -3
  19. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -5
  20. package/package.json +2 -2
  21. package/run-tests.sh +4 -4
  22. package/src/add/add-functions.ts +11 -5
  23. package/src/add/add-http-route.ts +4 -1
  24. package/src/add/add-rpc-invocations.test.ts +113 -0
  25. package/src/add/add-rpc-invocations.ts +8 -0
  26. package/src/add/add-workflow.ts +53 -14
  27. package/src/add/expect-eventually-scenario-only.test.ts +108 -0
  28. package/src/error-codes.ts +2 -1
  29. package/src/inspector.ts +1 -0
  30. package/src/types.ts +1 -0
  31. package/src/utils/filter-inspector-state.test.ts +231 -0
  32. package/src/utils/filter-inspector-state.ts +56 -0
  33. package/src/utils/load-addon-functions-meta.ts +26 -16
  34. package/src/utils/post-process.test.ts +254 -0
  35. package/src/utils/post-process.ts +41 -14
  36. package/src/utils/serialize-inspector-state.ts +11 -0
  37. package/src/utils/workflow/dsl/patterns.ts +1 -1
  38. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +3 -3
  39. package/src/utils/workflow/graph/workflow-graph.types.ts +5 -5
  40. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,113 @@
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 { InspectorLogger, InspectorState } from '../types.js'
8
+
9
+ function makeLogger(): InspectorLogger {
10
+ return {
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ diagnostic: () => {},
16
+ critical: () => {},
17
+ hasCriticalErrors: () => false,
18
+ }
19
+ }
20
+
21
+ async function inspectFiles(
22
+ files: Record<string, string>
23
+ ): Promise<{ state: InspectorState; dir: string }> {
24
+ const dir = await mkdtemp(join(tmpdir(), 'pikku-rpc-invocations-test-'))
25
+ const paths: string[] = []
26
+ for (const [name, source] of Object.entries(files)) {
27
+ const path = join(dir, name)
28
+ await writeFile(path, source)
29
+ paths.push(path)
30
+ }
31
+ const state = await inspect(makeLogger(), paths, { rootDir: dir })
32
+ return { state, dir }
33
+ }
34
+
35
+ describe('add-rpc-invocations — invokedFunctionsByFile', () => {
36
+ test('body-level rpc.invoke() is attributed to its source file', async () => {
37
+ const { state, dir } = await inspectFiles({
38
+ 'caller.ts': `
39
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> }
40
+ export async function doWork() {
41
+ return rpc.invoke('console:getSchema')
42
+ }
43
+ `,
44
+ 'other.ts': `
45
+ export const unrelated = () => 'no invocations here'
46
+ `,
47
+ })
48
+ try {
49
+ assert.ok(state.rpc.invokedFunctions.has('console:getSchema'))
50
+ const byFile = state.rpc.invokedFunctionsByFile
51
+ assert.strictEqual(byFile.size, 1)
52
+ const [file, invoked] = [...byFile.entries()][0]!
53
+ assert.ok(file.endsWith('caller.ts'))
54
+ assert.deepStrictEqual([...invoked], ['console:getSchema'])
55
+ } finally {
56
+ await rm(dir, { recursive: true, force: true })
57
+ }
58
+ })
59
+
60
+ test('namespaced body invoke joins serviceAggregation.usedFunctions', async () => {
61
+ const { state, dir } = await inspectFiles({
62
+ 'caller.ts': `
63
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> }
64
+ export async function doWork() {
65
+ await rpc.invoke('ext:goodbye')
66
+ return rpc.invoke('localHelper')
67
+ }
68
+ `,
69
+ })
70
+ try {
71
+ assert.ok(state.serviceAggregation.usedFunctions.has('ext:goodbye'))
72
+ assert.ok(!state.serviceAggregation.usedFunctions.has('localHelper'))
73
+ } finally {
74
+ await rm(dir, { recursive: true, force: true })
75
+ }
76
+ })
77
+
78
+ test('multiple invocations in one file accumulate under that file', async () => {
79
+ const { state, dir } = await inspectFiles({
80
+ 'caller.ts': `
81
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> }
82
+ export async function doWork() {
83
+ await rpc.invoke('console:getSchema')
84
+ return rpc.invoke('listTasks')
85
+ }
86
+ `,
87
+ })
88
+ try {
89
+ const invoked = [...state.rpc.invokedFunctionsByFile.values()][0]!
90
+ assert.deepStrictEqual([...invoked].sort(), [
91
+ 'console:getSchema',
92
+ 'listTasks',
93
+ ])
94
+ } finally {
95
+ await rm(dir, { recursive: true, force: true })
96
+ }
97
+ })
98
+
99
+ test('wiring-level ref() lands in invokedFunctions but NOT in the by-file map', async () => {
100
+ const { state, dir } = await inspectFiles({
101
+ 'wiring.ts': `
102
+ declare const ref: (name: string) => unknown
103
+ export const routes = { stream: { func: ref('console:streamWorkflowRun') } }
104
+ `,
105
+ })
106
+ try {
107
+ assert.ok(state.rpc.invokedFunctions.has('console:streamWorkflowRun'))
108
+ assert.strictEqual(state.rpc.invokedFunctionsByFile.size, 0)
109
+ } finally {
110
+ await rm(dir, { recursive: true, force: true })
111
+ }
112
+ })
113
+ })
@@ -115,10 +115,18 @@ export function addRPCInvocations(
115
115
  logger.debug(`• Found RPC invocation: ${functionRef}`)
116
116
  state.rpc.invokedFunctions.add(functionRef)
117
117
 
118
+ let byFile = state.rpc.invokedFunctionsByFile.get(sourceFileName)
119
+ if (!byFile) {
120
+ byFile = new Set()
121
+ state.rpc.invokedFunctionsByFile.set(sourceFileName, byFile)
122
+ }
123
+ byFile.add(functionRef)
124
+
118
125
  const namespace = extractNamespace(functionRef)
119
126
  if (namespace) {
120
127
  logger.debug(` → Addon detected: ${namespace}`)
121
128
  state.rpc.usedAddons.add(namespace)
129
+ state.serviceAggregation.usedFunctions.add(functionRef)
122
130
  }
123
131
  }
124
132
  // Handle template literals like `function-${name}`
@@ -34,6 +34,28 @@ function extractStepName(node: ts.Node, checker: ts.TypeChecker): string {
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * Walk a function body for any `X.expectEventually(...)` call. Used to enforce
39
+ * that the durable-polling assertion is a scenario-only primitive — regular
40
+ * workflows must not carry test/assertion semantics.
41
+ */
42
+ function containsExpectEventuallyCall(node: ts.Node): boolean {
43
+ if (
44
+ ts.isCallExpression(node) &&
45
+ ts.isPropertyAccessExpression(node.expression) &&
46
+ node.expression.name.text === 'expectEventually'
47
+ ) {
48
+ return true
49
+ }
50
+ let found = false
51
+ ts.forEachChild(node, (child) => {
52
+ if (!found && !ts.isFunctionDeclaration(child)) {
53
+ found = containsExpectEventuallyCall(child)
54
+ }
55
+ })
56
+ return found
57
+ }
58
+
37
59
  /**
38
60
  * Recursively check if any step has inline type (non-serializable)
39
61
  */
@@ -155,7 +177,9 @@ function getWorkflowInvocations(
155
177
  stepName,
156
178
  rpcName,
157
179
  expectEventually: true,
158
- actor: extractActorFromOptions(args.length >= 5 ? args[4] : undefined),
180
+ actor: extractActorFromOptions(
181
+ args.length >= 5 ? args[4] : undefined
182
+ ),
159
183
  })
160
184
  state.rpc.invokedFunctions.add(rpcName)
161
185
  }
@@ -194,7 +218,7 @@ function getWorkflowInvocations(
194
218
 
195
219
  /**
196
220
  * Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
197
- * pikkuUserFlow() calls. Detects workflow registration and extracts metadata.
221
+ * pikkuScenario() calls. Detects workflow registration and extracts metadata.
198
222
  */
199
223
  export const addWorkflow: AddWiring = (logger, node, checker, state) => {
200
224
  if (!ts.isCallExpression(node)) {
@@ -209,15 +233,15 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
209
233
  return
210
234
  }
211
235
 
212
- let wrapperType: 'dsl' | 'complex' | 'user-flow' | null = null
236
+ let wrapperType: 'dsl' | 'complex' | 'scenario' | null = null
213
237
  if (expression.text === 'pikkuWorkflowFunc') {
214
238
  wrapperType = 'dsl'
215
239
  } else if (expression.text === 'pikkuWorkflowComplexFunc') {
216
240
  wrapperType = 'complex'
217
- } else if (expression.text === 'pikkuUserFlow') {
218
- // A user flow is a complex workflow whose steps run as actors over the
241
+ } else if (expression.text === 'pikkuScenario') {
242
+ // A scenario is a complex workflow whose steps run as actors over the
219
243
  // real transport — same extraction rules as complex, distinct meta.
220
- wrapperType = 'user-flow'
244
+ wrapperType = 'scenario'
221
245
  } else {
222
246
  return
223
247
  }
@@ -292,6 +316,21 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
292
316
  return
293
317
  }
294
318
 
319
+ // expectEventually is a scenario-only assertion primitive — regular
320
+ // workflows must stay free of test/eval semantics.
321
+ if (
322
+ wrapperType !== 'scenario' &&
323
+ containsExpectEventuallyCall(resolvedFunc)
324
+ ) {
325
+ logger.critical(
326
+ ErrorCode.EXPECT_EVENTUALLY_SCENARIO_ONLY,
327
+ `Workflow '${workflowName}' calls workflow.expectEventually(), which is only ` +
328
+ `available in scenarios (pikkuScenario). Move it into a scenario, or drive ` +
329
+ `the assertion outside the workflow.`
330
+ )
331
+ return
332
+ }
333
+
295
334
  // Track workflow file for wiring generation
296
335
  if (exportedName) {
297
336
  state.workflows.files.set(pikkuFuncId, {
@@ -368,8 +407,8 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
368
407
  getWorkflowInvocations(resolvedFunc, checker, state, workflowName, sink)
369
408
  }
370
409
 
371
- // Actor names the flow's steps run as (user flows) — powers the console
372
- // personas view and the User Flow graph badges.
410
+ // Actor names the flow's steps run as (scenarios) — powers the console
411
+ // personas view and the Scenario graph badges.
373
412
  const actorNames = [
374
413
  ...new Set(
375
414
  steps
@@ -390,24 +429,24 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
390
429
  errors,
391
430
  tags,
392
431
  expose,
393
- userFlow: wrapperType === 'user-flow' ? true : undefined,
432
+ scenario: wrapperType === 'scenario' ? true : undefined,
394
433
  actors: actorNames.length > 0 ? actorNames : undefined,
395
434
  }
396
435
 
397
- // User flows are pure stories of remote RPCs (same rule as client-side CLI
436
+ // Scenarios are pure stories of remote RPCs (same rule as client-side CLI
398
437
  // renderers): the func may only destructure logger/config — everything else
399
438
  // must go through actor steps so the flow runs against the TARGET
400
439
  // environment, never local services.
401
440
  const funcMeta = state.functions.meta[pikkuFuncId]
402
- if (wrapperType === 'user-flow' && funcMeta?.services) {
441
+ if (wrapperType === 'scenario' && funcMeta?.services) {
403
442
  const disallowed = funcMeta.services.services.filter(
404
443
  (svc) => svc !== 'logger' && svc !== 'config'
405
444
  )
406
445
  if (disallowed.length > 0) {
407
446
  logger.critical(
408
- ErrorCode.USER_FLOW_HAS_SERVICES,
409
- `User flow '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
410
- `User flows may only use 'logger'/'config' — drive everything else through ` +
447
+ ErrorCode.SCENARIO_HAS_SERVICES,
448
+ `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
449
+ `Scenarios may only use 'logger'/'config' — drive everything else through ` +
411
450
  `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
412
451
  `runs against the target environment.`
413
452
  )
@@ -0,0 +1,108 @@
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 { InspectorLogger } from '../types.js'
8
+
9
+ function makeLogger(
10
+ criticals: Array<{ code: string; message: string }>
11
+ ): InspectorLogger {
12
+ return {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ diagnostic: ({ code, message }) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ critical: (code: any, message: string) => {
21
+ criticals.push({ code, message })
22
+ },
23
+ hasCriticalErrors: () => criticals.length > 0,
24
+ }
25
+ }
26
+
27
+ const stepFile = (dir: string) => join(dir, 'my.steps.ts')
28
+ const stepSource = [
29
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
30
+ 'export const getTodos = pikkuSessionlessFunc({',
31
+ ' func: async ({ logger }) => [{ done: true }],',
32
+ '})',
33
+ ].join('\n')
34
+
35
+ describe('expectEventually is scenario-only', () => {
36
+ test('pikkuWorkflowFunc calling expectEventually is a critical error pointing at pikkuScenario', async () => {
37
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-ee-wf-'))
38
+ const wfFile = join(rootDir, 'my.workflow.ts')
39
+ await writeFile(stepFile(rootDir), stepSource)
40
+ await writeFile(
41
+ wfFile,
42
+ [
43
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
44
+ 'export const badWorkflow = pikkuWorkflowFunc(async (_, _input, { workflow }) => {',
45
+ " await workflow.expectEventually('Wait', 'getTodos', {}, (todos: any) => todos.length > 0)",
46
+ ' return { ok: true }',
47
+ '})',
48
+ ].join('\n')
49
+ )
50
+
51
+ const criticals: Array<{ code: string; message: string }> = []
52
+ try {
53
+ await inspect(makeLogger(criticals), [stepFile(rootDir), wfFile], {
54
+ rootDir,
55
+ })
56
+ const pku675 = criticals.find((c) => c.code === 'PKU675')
57
+ assert.ok(
58
+ pku675,
59
+ `expected a PKU675 critical, got: ${JSON.stringify(criticals)}`
60
+ )
61
+ assert.ok(
62
+ pku675.message.includes('pikkuScenario'),
63
+ `PKU675 should point users at pikkuScenario, got: ${pku675.message}`
64
+ )
65
+ } finally {
66
+ await rm(rootDir, { recursive: true, force: true })
67
+ }
68
+ })
69
+
70
+ test('pikkuScenario calling expectEventually is allowed and flagged as a scenario', async () => {
71
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-ee-sc-'))
72
+ const wfFile = join(rootDir, 'my.scenario.ts')
73
+ await writeFile(stepFile(rootDir), stepSource)
74
+ await writeFile(
75
+ wfFile,
76
+ [
77
+ "import { pikkuScenario } from '@pikku/core/workflow'",
78
+ 'declare const actors: Record<string, any>',
79
+ 'export const goodFlow = pikkuScenario(async (_, _input, { workflow }) => {',
80
+ " await workflow.expectEventually('Wait', 'getTodos', {}, (todos: any) => todos.length > 0, { actor: actors.pm })",
81
+ ' return { ok: true }',
82
+ '})',
83
+ ].join('\n')
84
+ )
85
+
86
+ const criticals: Array<{ code: string; message: string }> = []
87
+ try {
88
+ const state = await inspect(
89
+ makeLogger(criticals),
90
+ [stepFile(rootDir), wfFile],
91
+ { rootDir }
92
+ )
93
+ assert.ok(
94
+ !criticals.some((c) => c.code === 'PKU675'),
95
+ `scenarios may use expectEventually; got: ${JSON.stringify(criticals)}`
96
+ )
97
+ const meta = (state.workflows.meta as any).goodFlow
98
+ assert.ok(meta, 'pikkuScenario export should register a workflow')
99
+ assert.equal(
100
+ meta.scenario,
101
+ true,
102
+ `scenario meta flag should be true, got: ${JSON.stringify(meta)}`
103
+ )
104
+ } finally {
105
+ await rm(rootDir, { recursive: true, force: true })
106
+ }
107
+ })
108
+ })
@@ -21,7 +21,8 @@ export enum ErrorCode {
21
21
  MISSING_QUEUE_NAME = 'PKU384',
22
22
  MISSING_CHANNEL_NAME = 'PKU400',
23
23
  CLI_CLIENTSIDE_RENDERER_HAS_SERVICES = 'PKU672',
24
- USER_FLOW_HAS_SERVICES = 'PKU673',
24
+ SCENARIO_HAS_SERVICES = 'PKU673',
25
+ EXPECT_EVENTUALLY_SCENARIO_ONLY = 'PKU675',
25
26
  DYNAMIC_STEP_NAME = 'PKU529',
26
27
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
27
28
  INVALID_DSL_WORKFLOW = 'PKU641',
package/src/inspector.ts CHANGED
@@ -124,6 +124,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
124
124
  exposedMeta: {},
125
125
  exposedFiles: new Map(),
126
126
  invokedFunctions: new Set(),
127
+ invokedFunctionsByFile: new Map(),
127
128
  usedAddons: new Set(),
128
129
  wireAddonDeclarations: new Map(),
129
130
  wireAddonFiles: new Set(),
package/src/types.ts CHANGED
@@ -482,6 +482,7 @@ export interface InspectorState {
482
482
  exposedMeta: Record<string, string>
483
483
  exposedFiles: Map<string, { path: string; exportedName: string }>
484
484
  invokedFunctions: Set<string>
485
+ invokedFunctionsByFile: Map<string, Set<string>>
485
486
  usedAddons: Set<string>
486
487
  wireAddonDeclarations: Map<
487
488
  string,
@@ -234,6 +234,7 @@ function createMockInspectorState(): Omit<InspectorState, 'typesLookup'> {
234
234
  exposedMeta: {},
235
235
  exposedFiles: new Map(),
236
236
  invokedFunctions: new Set(),
237
+ invokedFunctionsByFile: new Map(),
237
238
  },
238
239
  mcpEndpoints: {
239
240
  toolsMeta: {
@@ -1657,3 +1658,233 @@ describe('addonServerlessIncompatible serialization roundtrip', () => {
1657
1658
  assert.strictEqual(restored.addonServerlessIncompatible.size, 0)
1658
1659
  })
1659
1660
  })
1661
+
1662
+ describe('addon bootstrap tree-shake', () => {
1663
+ const withAddon = (
1664
+ mutate?: (state: Omit<InspectorState, 'typesLookup'>) => void
1665
+ ) => {
1666
+ const state = createMockInspectorState()
1667
+ state.rpc.wireAddonDeclarations = new Map([
1668
+ ['console', { package: '@pikku/addon-console' }],
1669
+ ])
1670
+ state.rpc.usedAddons = new Set(['console'])
1671
+ state.rpc.invokedFunctions = new Set(['console:streamFunctionTests'])
1672
+ mutate?.(state)
1673
+ return state
1674
+ }
1675
+
1676
+ test('drops an addon nothing kept references', () => {
1677
+ const state = withAddon()
1678
+ const filtered = filterInspectorState(
1679
+ state,
1680
+ { names: ['getUsers'] },
1681
+ mockLogger
1682
+ )
1683
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 0)
1684
+ assert.strictEqual(filtered.rpc.usedAddons.size, 0)
1685
+ })
1686
+
1687
+ test('keeps an addon when a kept wiring targets one of its functions', () => {
1688
+ const state = withAddon((s) => {
1689
+ s.http.meta.get['/console/stream'] = {
1690
+ pikkuFuncId: 'console:streamFunctionTests',
1691
+ route: '/console/stream',
1692
+ method: 'GET',
1693
+ tags: [],
1694
+ middleware: [],
1695
+ permissions: [],
1696
+ } as any
1697
+ })
1698
+ const filtered = filterInspectorState(
1699
+ state,
1700
+ { names: ['console:streamFunctionTests'] },
1701
+ mockLogger
1702
+ )
1703
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1704
+ assert.ok(filtered.rpc.wireAddonDeclarations.has('console'))
1705
+ })
1706
+
1707
+ test('keeps an addon when a kept route ref()-targets one of its functions', () => {
1708
+ const state = withAddon((s) => {
1709
+ s.http.meta.get['/function-tests/stream'] = {
1710
+ pikkuFuncId: 'http:get:/function-tests/stream',
1711
+ refTarget: 'console:streamFunctionTests',
1712
+ route: '/function-tests/stream',
1713
+ method: 'GET',
1714
+ tags: [],
1715
+ middleware: [],
1716
+ permissions: [],
1717
+ } as any
1718
+ s.functions.meta['http:get:/function-tests/stream'] = {
1719
+ services: { optimized: false, services: [] },
1720
+ } as any
1721
+ })
1722
+ const filtered = filterInspectorState(
1723
+ state,
1724
+ { names: ['http:get:/function-tests/stream'] },
1725
+ mockLogger
1726
+ )
1727
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1728
+ assert.ok(filtered.rpc.wireAddonDeclarations.has('console'))
1729
+ assert.ok(
1730
+ filtered.serviceAggregation.usedFunctions.has(
1731
+ 'console:streamFunctionTests'
1732
+ )
1733
+ )
1734
+ })
1735
+
1736
+ test('drops the addon when the ref()-wired route is filtered out', () => {
1737
+ const state = withAddon((s) => {
1738
+ s.http.meta.get['/function-tests/stream'] = {
1739
+ pikkuFuncId: 'http:get:/function-tests/stream',
1740
+ refTarget: 'console:streamFunctionTests',
1741
+ route: '/function-tests/stream',
1742
+ method: 'GET',
1743
+ tags: [],
1744
+ middleware: [],
1745
+ permissions: [],
1746
+ } as any
1747
+ s.functions.meta['http:get:/function-tests/stream'] = {
1748
+ services: { optimized: false, services: [] },
1749
+ } as any
1750
+ })
1751
+ const filtered = filterInspectorState(
1752
+ state,
1753
+ { names: ['getUsers'] },
1754
+ mockLogger
1755
+ )
1756
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 0)
1757
+ assert.ok(
1758
+ !filtered.serviceAggregation.usedFunctions.has(
1759
+ 'console:streamFunctionTests'
1760
+ )
1761
+ )
1762
+ })
1763
+
1764
+ test('keeps an addon when a kept MCP tool targets one of its functions', () => {
1765
+ const state = withAddon((s) => {
1766
+ s.mcpEndpoints.toolsMeta['console:getSchema'] = {
1767
+ name: 'console:getSchema',
1768
+ description: 'addon tool',
1769
+ pikkuFuncId: 'console:getSchema',
1770
+ tags: [],
1771
+ middleware: [],
1772
+ permissions: [],
1773
+ } as any
1774
+ })
1775
+ const filtered = filterInspectorState(
1776
+ state,
1777
+ { names: ['console:getSchema'] },
1778
+ mockLogger
1779
+ )
1780
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1781
+ })
1782
+
1783
+ test('keeps an addon when a kept agent lists one of its functions as a tool', () => {
1784
+ const state = withAddon((s) => {
1785
+ s.agents = s.agents ?? ({ agentsMeta: {} } as any)
1786
+ s.agents.agentsMeta['support-agent'] = {
1787
+ name: 'support-agent',
1788
+ pikkuFuncId: 'supportAgent',
1789
+ tools: ['console:getSchema'],
1790
+ tags: [],
1791
+ } as any
1792
+ })
1793
+ const filtered = filterInspectorState(
1794
+ state,
1795
+ { names: ['support-agent'] },
1796
+ mockLogger
1797
+ )
1798
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1799
+ })
1800
+
1801
+ test('keeps an addon when a kept function body-invokes it', () => {
1802
+ const state = withAddon((s) => {
1803
+ s.rpc.invokedFunctionsByFile = new Map([
1804
+ ['/test/project/src/api/users.ts', new Set(['console:getSchema'])],
1805
+ ])
1806
+ })
1807
+ const filtered = filterInspectorState(
1808
+ state,
1809
+ { names: ['getUsers'] },
1810
+ mockLogger
1811
+ )
1812
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1813
+ })
1814
+
1815
+ test('a kept-file body invoke joins the filtered usedFunctions', () => {
1816
+ const state = withAddon((s) => {
1817
+ s.rpc.invokedFunctionsByFile = new Map([
1818
+ ['/test/project/src/api/users.ts', new Set(['console:getSchema'])],
1819
+ ])
1820
+ })
1821
+ const filtered = filterInspectorState(
1822
+ state,
1823
+ { names: ['getUsers'] },
1824
+ mockLogger
1825
+ )
1826
+ assert.ok(
1827
+ filtered.serviceAggregation.usedFunctions.has('console:getSchema')
1828
+ )
1829
+ })
1830
+
1831
+ test('drops an addon body-invoked only from filtered-out files', () => {
1832
+ const state = withAddon((s) => {
1833
+ s.rpc.invokedFunctionsByFile = new Map([
1834
+ ['/test/project/src/admin/settings.ts', new Set(['console:getSchema'])],
1835
+ ])
1836
+ })
1837
+ const filtered = filterInspectorState(
1838
+ state,
1839
+ { names: ['getUsers'] },
1840
+ mockLogger
1841
+ )
1842
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 0)
1843
+ })
1844
+
1845
+ test('unfiltered state keeps all addons', () => {
1846
+ const state = withAddon()
1847
+ const filtered = filterInspectorState(state, {}, mockLogger)
1848
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1849
+ })
1850
+ })
1851
+
1852
+ describe('invokedFunctionsByFile serialization', () => {
1853
+ test('round-trips through serialize/deserialize', () => {
1854
+ const state = getInitialInspectorState('/test/project')
1855
+ state.rpc.invokedFunctionsByFile = new Map([
1856
+ ['/test/project/src/api/users.ts', new Set(['console:getSchema'])],
1857
+ [
1858
+ '/test/project/src/api/tasks.ts',
1859
+ new Set(['listTasks', 'console:runAgent']),
1860
+ ],
1861
+ ])
1862
+ const restored = deserializeInspectorState(
1863
+ JSON.parse(JSON.stringify(serializeInspectorState(state as any)))
1864
+ )
1865
+ assert.ok(restored.rpc.invokedFunctionsByFile instanceof Map)
1866
+ assert.strictEqual(restored.rpc.invokedFunctionsByFile.size, 2)
1867
+ assert.deepStrictEqual(
1868
+ [
1869
+ ...restored.rpc.invokedFunctionsByFile.get(
1870
+ '/test/project/src/api/tasks.ts'
1871
+ )!,
1872
+ ].sort(),
1873
+ ['console:runAgent', 'listTasks']
1874
+ )
1875
+ })
1876
+
1877
+ test('deserializing legacy state without the field yields an empty Map', () => {
1878
+ const serialized = JSON.parse(
1879
+ JSON.stringify(
1880
+ serializeInspectorState(
1881
+ getInitialInspectorState('/test/project') as any
1882
+ )
1883
+ )
1884
+ )
1885
+ delete serialized.rpc.invokedFunctionsByFile
1886
+ const restored = deserializeInspectorState(serialized)
1887
+ assert.ok(restored.rpc.invokedFunctionsByFile instanceof Map)
1888
+ assert.strictEqual(restored.rpc.invokedFunctionsByFile.size, 0)
1889
+ })
1890
+ })