@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.
- package/CHANGELOG.md +56 -0
- package/dist/add/add-functions.js +2 -2
- package/dist/add/add-http-route.js +4 -1
- package/dist/add/add-rpc-invocations.js +7 -0
- package/dist/add/add-workflow.d.ts +1 -1
- package/dist/add/add-workflow.js +39 -11
- package/dist/error-codes.d.ts +2 -1
- package/dist/error-codes.js +2 -1
- package/dist/inspector.js +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/utils/filter-inspector-state.js +43 -0
- package/dist/utils/load-addon-functions-meta.js +20 -14
- package/dist/utils/post-process.js +38 -13
- package/dist/utils/serialize-inspector-state.d.ts +1 -0
- package/dist/utils/serialize-inspector-state.js +2 -0
- package/dist/utils/workflow/dsl/patterns.d.ts +1 -1
- package/dist/utils/workflow/dsl/patterns.js +1 -1
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +3 -3
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -5
- package/package.json +2 -2
- package/run-tests.sh +4 -4
- package/src/add/add-functions.ts +11 -5
- package/src/add/add-http-route.ts +4 -1
- package/src/add/add-rpc-invocations.test.ts +113 -0
- package/src/add/add-rpc-invocations.ts +8 -0
- package/src/add/add-workflow.ts +53 -14
- package/src/add/expect-eventually-scenario-only.test.ts +108 -0
- package/src/error-codes.ts +2 -1
- package/src/inspector.ts +1 -0
- package/src/types.ts +1 -0
- package/src/utils/filter-inspector-state.test.ts +231 -0
- package/src/utils/filter-inspector-state.ts +56 -0
- package/src/utils/load-addon-functions-meta.ts +26 -16
- package/src/utils/post-process.test.ts +254 -0
- package/src/utils/post-process.ts +41 -14
- package/src/utils/serialize-inspector-state.ts +11 -0
- package/src/utils/workflow/dsl/patterns.ts +1 -1
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +3 -3
- package/src/utils/workflow/graph/workflow-graph.types.ts +5 -5
- 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}`
|
package/src/add/add-workflow.ts
CHANGED
|
@@ -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(
|
|
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
|
-
*
|
|
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' | '
|
|
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 === '
|
|
218
|
-
// A
|
|
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 = '
|
|
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 (
|
|
372
|
-
// personas view and the
|
|
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
|
-
|
|
432
|
+
scenario: wrapperType === 'scenario' ? true : undefined,
|
|
394
433
|
actors: actorNames.length > 0 ? actorNames : undefined,
|
|
395
434
|
}
|
|
396
435
|
|
|
397
|
-
//
|
|
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 === '
|
|
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.
|
|
409
|
-
`
|
|
410
|
-
`
|
|
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
|
+
})
|
package/src/error-codes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|