@pikku/inspector 0.12.8 → 0.12.10

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 (32) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/add/add-functions.js +5 -3
  3. package/dist/add/add-mcp-prompt.js +4 -0
  4. package/dist/add/add-mcp-resource.js +4 -0
  5. package/dist/add/add-workflow.d.ts +1 -1
  6. package/dist/add/add-workflow.js +11 -11
  7. package/dist/utils/extract-function-name.d.ts +1 -0
  8. package/dist/utils/extract-function-name.js +27 -32
  9. package/dist/utils/extract-node-value.js +10 -1
  10. package/dist/utils/filter-inspector-state.js +7 -3
  11. package/dist/utils/resolve-versions.js +30 -0
  12. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +5 -4
  13. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +47 -28
  14. package/dist/utils/workflow/dsl/patterns.d.ts +5 -1
  15. package/dist/utils/workflow/dsl/patterns.js +2 -2
  16. package/dist/utils/workflow/dsl/validation.d.ts +4 -2
  17. package/dist/utils/workflow/dsl/validation.js +16 -8
  18. package/package.json +2 -2
  19. package/src/add/add-functions.ts +9 -6
  20. package/src/add/add-mcp-prompt.ts +5 -0
  21. package/src/add/add-mcp-resource.ts +5 -0
  22. package/src/add/add-workflow.ts +12 -12
  23. package/src/utils/extract-function-name.ts +36 -37
  24. package/src/utils/extract-node-value.test.ts +67 -0
  25. package/src/utils/extract-node-value.ts +10 -1
  26. package/src/utils/filter-inspector-state.ts +7 -3
  27. package/src/utils/resolve-versions.test.ts +141 -0
  28. package/src/utils/resolve-versions.ts +37 -0
  29. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +58 -32
  30. package/src/utils/workflow/dsl/patterns.ts +2 -2
  31. package/src/utils/workflow/dsl/validation.ts +21 -9
  32. package/tsconfig.tsbuildinfo +1 -1
@@ -12,7 +12,7 @@ import * as ts from 'typescript';
12
12
  * - ThrowStatement (for WorkflowCancelledException)
13
13
  * - Block (containers)
14
14
  */
15
- export function validateNoDisallowedPatterns(node) {
15
+ export function validateNoDisallowedPatterns(node, options) {
16
16
  const errors = [];
17
17
  function visitBlock(block) {
18
18
  for (const statement of block.statements) {
@@ -30,7 +30,7 @@ export function validateNoDisallowedPatterns(node) {
30
30
  // Unknown/disallowed statement type
31
31
  const nodeType = ts.SyntaxKind[statement.kind];
32
32
  errors.push({
33
- message: `Statement type '${nodeType}' is not allowed in simple workflows. Allowed: const/let, if/else, switch/case, for..of, return, throw, and workflow calls. If this should be supported, please report the node type: ${nodeType}`,
33
+ message: `Statement type '${nodeType}' is not allowed in DSL workflows. Allowed: const/let, if/else, switch/case, for..of, return, throw, and workflow calls. If this should be supported, please report the node type: ${nodeType}`,
34
34
  node: statement,
35
35
  });
36
36
  }
@@ -40,7 +40,7 @@ export function validateNoDisallowedPatterns(node) {
40
40
  // Disallow while and do-while
41
41
  if (ts.isWhileStatement(node) || ts.isDoStatement(node)) {
42
42
  errors.push({
43
- message: 'while and do-while loops are not allowed in simple workflows',
43
+ message: 'while and do-while loops are not allowed in DSL workflows',
44
44
  node,
45
45
  });
46
46
  return;
@@ -48,13 +48,13 @@ export function validateNoDisallowedPatterns(node) {
48
48
  // Disallow for and for-in loops
49
49
  if (ts.isForInStatement(node) || ts.isForStatement(node)) {
50
50
  errors.push({
51
- message: 'for and for-in loops are not allowed in simple workflows. Use for-of instead.',
51
+ message: 'for and for-in loops are not allowed in DSL workflows. Use for-of instead.',
52
52
  node,
53
53
  });
54
54
  return;
55
55
  }
56
56
  // Check for inline workflow.do
57
- if (ts.isCallExpression(node)) {
57
+ if (!options?.allowInline && ts.isCallExpression(node)) {
58
58
  if (ts.isPropertyAccessExpression(node.expression)) {
59
59
  const propAccess = node.expression;
60
60
  if (propAccess.name.text === 'do' &&
@@ -65,7 +65,7 @@ export function validateNoDisallowedPatterns(node) {
65
65
  (ts.isArrowFunction(secondArg) ||
66
66
  ts.isFunctionExpression(secondArg))) {
67
67
  errors.push({
68
- message: 'Inline workflow.do with function argument is not allowed in simple workflows. Use RPC form instead.',
68
+ message: 'Inline workflow.do with function argument is not allowed in DSL workflows. Use pikkuWorkflowComplexFunc instead.',
69
69
  node,
70
70
  });
71
71
  return;
@@ -97,11 +97,19 @@ export function validateAwaitedCalls(node) {
97
97
  if (propAccess.name.text === 'all' &&
98
98
  ts.isIdentifier(propAccess.expression) &&
99
99
  propAccess.expression.text === 'Promise') {
100
- // console.log('[DEBUG] Found Promise.all, setting insidePromiseAll=true')
101
- // Visit children with insidePromiseAll = true
102
100
  ts.forEachChild(node, (child) => visit(child, parentIsAwait, true));
103
101
  return;
104
102
  }
103
+ // .push() on an array — workflow.do() inside is collecting promises
104
+ if (propAccess.name.text === 'push') {
105
+ ts.forEachChild(node, (child) => visit(child, parentIsAwait, true));
106
+ return;
107
+ }
108
+ }
109
+ // Array literal — workflow.do() inside is collecting promises
110
+ if (ts.isArrayLiteralExpression(node)) {
111
+ ts.forEachChild(node, (child) => visit(child, parentIsAwait, true));
112
+ return;
105
113
  }
106
114
  // Now check for workflow calls
107
115
  if (ts.isCallExpression(node)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
38
- "@pikku/core": "^0.12.15",
38
+ "@pikku/core": "^0.12.18",
39
39
  "path-to-regexp": "^8.3.0",
40
40
  "ts-json-schema-generator": "^2.5.0",
41
41
  "tsx": "^4.21.0",
@@ -339,6 +339,7 @@ export const addFunctions: AddWiring = (
339
339
  let remote: boolean | undefined
340
340
  let mcp: boolean | undefined
341
341
  let readonly_: boolean | undefined
342
+ let deploy: 'serverless' | 'server' | 'auto' | undefined
342
343
  let approvalRequired: boolean | undefined
343
344
  let approvalDescription: string | undefined
344
345
  let version: number | undefined
@@ -422,6 +423,11 @@ export const addFunctions: AddWiring = (
422
423
  remote = getPropertyValue(firstArg, 'remote') as boolean | undefined
423
424
  mcp = getPropertyValue(firstArg, 'mcp') as boolean | undefined
424
425
  readonly_ = getPropertyValue(firstArg, 'readonly') as boolean | undefined
426
+ deploy = getPropertyValue(firstArg, 'deploy') as
427
+ | 'serverless'
428
+ | 'server'
429
+ | 'auto'
430
+ | undefined
425
431
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired') as
426
432
  | boolean
427
433
  | undefined
@@ -783,6 +789,7 @@ export const addFunctions: AddWiring = (
783
789
  remote: remote || undefined,
784
790
  mcp: mcpEnabled || undefined,
785
791
  readonly: readonly_ || undefined,
792
+ deploy: deploy || undefined,
786
793
  approvalRequired: approvalRequired || undefined,
787
794
  approvalDescription: approvalDescription || undefined,
788
795
  version,
@@ -817,18 +824,14 @@ export const addFunctions: AddWiring = (
817
824
 
818
825
  if (mcpEnabled) {
819
826
  if (!description) {
820
- logger.critical(
821
- ErrorCode.MISSING_DESCRIPTION,
822
- `MCP tool '${name}' is missing a description.`
823
- )
824
- return
827
+ logger.warn(`MCP tool '${name}' is missing a description.`)
825
828
  }
826
829
  state.mcpEndpoints.files.add(node.getSourceFile().fileName)
827
830
  state.mcpEndpoints.toolsMeta[name] = {
828
831
  pikkuFuncId,
829
832
  name,
830
833
  title: title || undefined,
831
- description,
834
+ description: description || undefined,
832
835
  summary,
833
836
  errors,
834
837
  tags,
@@ -114,6 +114,11 @@ export const addMCPPrompt: AddWiring = (
114
114
  const inputSchema = fnMeta.inputs?.[0] || null
115
115
  const outputSchema = fnMeta.outputs?.[0] || null
116
116
 
117
+ if (!fnMeta.outputSchemaName) {
118
+ fnMeta.outputSchemaName = 'MCPPromptResponse'
119
+ fnMeta.outputs = ['MCPPromptResponse']
120
+ }
121
+
117
122
  // --- resolve middleware ---
118
123
  const middleware = resolveMiddleware(state, obj, tags, checker)
119
124
 
@@ -131,6 +131,11 @@ export const addMCPResource: AddWiring = (
131
131
  const inputSchema = fnMeta.inputs?.[0] || null
132
132
  const outputSchema = fnMeta.outputs?.[0] || null
133
133
 
134
+ if (!fnMeta.outputSchemaName) {
135
+ fnMeta.outputSchemaName = 'MCPResourceResponse'
136
+ fnMeta.outputs = ['MCPResourceResponse']
137
+ }
138
+
134
139
  // --- resolve middleware ---
135
140
  const middleware = resolveMiddleware(state, obj, tags, checker)
136
141
 
@@ -97,7 +97,7 @@ function getWorkflowInvocations(
97
97
  const stepNameArg = args[0]
98
98
  const secondArg = args[1]
99
99
  const optionsArg =
100
- args.length >= 3 ? args[args.length - 1] : undefined
100
+ args.length >= 4 ? args[args.length - 1] : undefined
101
101
 
102
102
  const stepName = extractStringLiteral(stepNameArg, checker)
103
103
  const description =
@@ -155,7 +155,7 @@ function getWorkflowInvocations(
155
155
  }
156
156
 
157
157
  /**
158
- * Inspector for pikkuWorkflow() and pikkuSimpleWorkflow() calls
158
+ * Inspector for pikkuWorkflowFunc() and pikkuWorkflowComplexFunc() calls
159
159
  * Detects workflow registration and extracts metadata
160
160
  */
161
161
  export const addWorkflow: AddWiring = (logger, node, checker, state) => {
@@ -171,11 +171,11 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
171
171
  return
172
172
  }
173
173
 
174
- let wrapperType: 'dsl' | 'regular' | null = null
174
+ let wrapperType: 'dsl' | 'complex' | null = null
175
175
  if (expression.text === 'pikkuWorkflowFunc') {
176
176
  wrapperType = 'dsl'
177
177
  } else if (expression.text === 'pikkuWorkflowComplexFunc') {
178
- wrapperType = 'regular'
178
+ wrapperType = 'complex'
179
179
  } else {
180
180
  return
181
181
  }
@@ -267,7 +267,9 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
267
267
 
268
268
  // Try DSL workflow extraction first
269
269
  // Pass the whole CallExpression node so findWorkflowFunction can find the arrow function
270
- const result = extractDSLWorkflow(node, checker)
270
+ const result = extractDSLWorkflow(node, checker, {
271
+ allowInline: wrapperType === 'complex',
272
+ })
271
273
 
272
274
  if (result.status === 'ok' && result.steps) {
273
275
  // Extraction succeeded
@@ -305,7 +307,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
305
307
  getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps)
306
308
  logger.critical(
307
309
  ErrorCode.INVALID_DSL_WORKFLOW,
308
- `Workflow '${workflowName}' uses pikkuWorkflowFunc but does not conform to DSL workflow rules:\n${result.reason || 'Unknown error'}`
310
+ `Workflow '${workflowName}' does not conform to DSL workflow rules:\n${result.reason || 'Unknown error'}`
309
311
  )
310
312
  return
311
313
  } else {
@@ -317,12 +319,10 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
317
319
  }
318
320
  }
319
321
 
320
- /**
321
- * For non-dsl workflows or pikkuWorkflowComplexFunc, run basic extraction
322
- * to ensure all RPC invocations are tracked for function registration.
323
- * This catches RPCs in Promise.all callbacks and other patterns DSL can't extract.
324
- */
325
- if (!dsl || wrapperType === 'regular') {
322
+ // For pikkuWorkflowComplexFunc, also run basic extraction so RPCs in
323
+ // patterns the DSL extractor doesn't handle (array+push, nested Promise.all
324
+ // with identifier args, etc.) are still registered as invoked functions.
325
+ if (wrapperType === 'complex') {
326
326
  getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps)
327
327
  }
328
328
 
@@ -1,5 +1,6 @@
1
1
  import * as ts from 'typescript'
2
2
  import { randomUUID } from 'crypto'
3
+ import { formatVersionedId } from '@pikku/core'
3
4
 
4
5
  export type ExtractedFunctionName = {
5
6
  pikkuFuncId: string
@@ -8,6 +9,7 @@ export type ExtractedFunctionName = {
8
9
  exportedName: string | null
9
10
  propertyName: string | null
10
11
  isHelper: boolean
12
+ version: number | null
11
13
  }
12
14
 
13
15
  export function makeContextBasedId(
@@ -40,6 +42,7 @@ export function extractFunctionName(
40
42
  propertyName: null,
41
43
  explicitName: null,
42
44
  isHelper: false,
45
+ version: null,
43
46
  }
44
47
 
45
48
  const workflowHelpers = new Set([
@@ -143,18 +146,7 @@ export function extractFunctionName(
143
146
  // Check for object with 'name' property in first argument
144
147
  const firstArg = args[0]
145
148
  if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
146
- for (const prop of firstArg.properties) {
147
- if (
148
- ts.isPropertyAssignment(prop) &&
149
- ts.isIdentifier(prop.name) &&
150
- prop.name.text === 'override' &&
151
- ts.isStringLiteral(prop.initializer)
152
- ) {
153
- // Priority 1: Object with override property
154
- result.explicitName = prop.initializer.text
155
- break
156
- }
157
- }
149
+ extractOverrideAndVersion(firstArg, result)
158
150
  }
159
151
 
160
152
  // Special handling for pikkuSessionlessFunc pattern - use the arrow function directly
@@ -367,21 +359,9 @@ export function extractFunctionName(
367
359
  ts.isIdentifier(decl.initializer.expression) &&
368
360
  decl.initializer.expression.text.startsWith('pikku')
369
361
  ) {
370
- // Check for object with 'override' property in first argument
371
362
  const firstArg = decl.initializer.arguments[0]
372
363
  if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
373
- for (const prop of firstArg.properties) {
374
- if (
375
- ts.isPropertyAssignment(prop) &&
376
- ts.isIdentifier(prop.name) &&
377
- prop.name.text === 'override' &&
378
- ts.isStringLiteral(prop.initializer)
379
- ) {
380
- // Priority 1: Object with override property
381
- result.explicitName = prop.initializer.text
382
- break
383
- }
384
- }
364
+ extractOverrideAndVersion(firstArg, result)
385
365
  }
386
366
 
387
367
  if (decl.initializer.expression.text.startsWith('pikku')) {
@@ -448,18 +428,7 @@ export function extractFunctionName(
448
428
  else if (ts.isCallExpression(callExpr)) {
449
429
  const firstArg = callExpr.arguments[0]
450
430
  if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
451
- for (const prop of firstArg.properties) {
452
- if (
453
- ts.isPropertyAssignment(prop) &&
454
- ts.isIdentifier(prop.name) &&
455
- prop.name.text === 'override' &&
456
- ts.isStringLiteral(prop.initializer) &&
457
- !result.explicitName // Only set if not already set
458
- ) {
459
- result.explicitName = prop.initializer.text
460
- break
461
- }
462
- }
431
+ extractOverrideAndVersion(firstArg, result)
463
432
  }
464
433
  }
465
434
 
@@ -474,6 +443,10 @@ export function extractFunctionName(
474
443
  result.pikkuFuncId = `__temp_${randomUUID()}`
475
444
  }
476
445
 
446
+ if (result.version !== null) {
447
+ result.pikkuFuncId = formatVersionedId(result.pikkuFuncId, result.version)
448
+ }
449
+
477
450
  return result
478
451
  }
479
452
 
@@ -539,3 +512,29 @@ export function isNamedExport(
539
512
 
540
513
  return false
541
514
  }
515
+
516
+ function extractOverrideAndVersion(
517
+ objLiteral: ts.ObjectLiteralExpression,
518
+ result: ExtractedFunctionName
519
+ ): void {
520
+ for (const prop of objLiteral.properties) {
521
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
522
+ if (
523
+ prop.name.text === 'override' &&
524
+ ts.isStringLiteral(prop.initializer) &&
525
+ !result.explicitName
526
+ ) {
527
+ result.explicitName = prop.initializer.text
528
+ } else if (
529
+ prop.name.text === 'version' &&
530
+ ts.isNumericLiteral(prop.initializer) &&
531
+ result.version === null
532
+ ) {
533
+ const parsed = Number(prop.initializer.text)
534
+ if (Number.isInteger(parsed) && parsed >= 1) {
535
+ result.version = parsed
536
+ }
537
+ }
538
+ }
539
+ }
540
+ }
@@ -0,0 +1,67 @@
1
+ import { test, describe } from 'node:test'
2
+ import { strict as assert } from 'node:assert'
3
+ import * as ts from 'typescript'
4
+ import { extractDescription } from './extract-node-value'
5
+
6
+ const createChecker = (source: string) => {
7
+ const sourceFile = ts.createSourceFile(
8
+ 'test.ts',
9
+ source,
10
+ ts.ScriptTarget.Latest,
11
+ true,
12
+ ts.ScriptKind.TS
13
+ )
14
+ const host = ts.createCompilerHost({})
15
+ const originalGetSourceFile = host.getSourceFile
16
+ host.getSourceFile = (fileName, target) => {
17
+ if (fileName === 'test.ts') return sourceFile
18
+ return originalGetSourceFile.call(host, fileName, target)
19
+ }
20
+ const program = ts.createProgram(['test.ts'], {}, host)
21
+ return { checker: program.getTypeChecker(), sourceFile }
22
+ }
23
+
24
+ const findObjectLiteral = (
25
+ node: ts.Node
26
+ ): ts.ObjectLiteralExpression | undefined => {
27
+ if (ts.isObjectLiteralExpression(node)) return node
28
+ let result: ts.ObjectLiteralExpression | undefined
29
+ ts.forEachChild(node, (child) => {
30
+ if (!result) result = findObjectLiteral(child)
31
+ })
32
+ return result
33
+ }
34
+
35
+ describe('extractDescription', () => {
36
+ test('returns null when node is undefined', () => {
37
+ const { checker } = createChecker('')
38
+ assert.equal(extractDescription(undefined, checker), null)
39
+ })
40
+
41
+ test('extracts string literal description', () => {
42
+ const { checker, sourceFile } = createChecker(
43
+ `const opts = { description: 'my step' }`
44
+ )
45
+ const obj = findObjectLiteral(sourceFile)!
46
+ assert.equal(extractDescription(obj, checker), 'my step')
47
+ })
48
+
49
+ test('returns null for non-literal description value without crashing', () => {
50
+ const { checker, sourceFile } = createChecker(
51
+ `const name = 'test'; const data = { description: name + ' addon' }`
52
+ )
53
+ const objs: ts.ObjectLiteralExpression[] = []
54
+ const visit = (node: ts.Node) => {
55
+ if (ts.isObjectLiteralExpression(node)) objs.push(node)
56
+ ts.forEachChild(node, visit)
57
+ }
58
+ ts.forEachChild(sourceFile, visit)
59
+ const dataObj = objs[objs.length - 1]!
60
+ assert.equal(extractDescription(dataObj, checker), null)
61
+ })
62
+
63
+ test('returns null for non-object node', () => {
64
+ const { checker, sourceFile } = createChecker(`const x = 42`)
65
+ assert.equal(extractDescription(sourceFile, checker), null)
66
+ })
67
+ })
@@ -27,6 +27,11 @@ export function extractStringLiteral(
27
27
  return result
28
28
  }
29
29
 
30
+ // Unwrap type assertions: `expr as Type` or `<Type>expr`
31
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
32
+ return extractStringLiteral(node.expression, checker)
33
+ }
34
+
30
35
  // Try to evaluate constant identifiers
31
36
  if (ts.isIdentifier(node)) {
32
37
  const symbol = checker.getSymbolAtLocation(node)
@@ -110,7 +115,11 @@ export function extractDescription(
110
115
  if (!optionsNode || !ts.isObjectLiteralExpression(optionsNode)) {
111
116
  return null
112
117
  }
113
- return extractPropertyString(optionsNode, 'description', checker)
118
+ try {
119
+ return extractPropertyString(optionsNode, 'description', checker)
120
+ } catch {
121
+ return null
122
+ }
114
123
  }
115
124
 
116
125
  /**
@@ -730,11 +730,15 @@ export function filterInspectorState(
730
730
  }
731
731
  }
732
732
 
733
- // Post-filter version expansion: include all versions of matched functions
733
+ // Post-filter version expansion: when an unversioned base name is matched,
734
+ // include all its versions. Specific version matches (e.g. analyzeData@v1)
735
+ // do NOT expand to include other versions.
734
736
  const includedBaseNames = new Set<string>()
735
737
  for (const funcId of filteredState.serviceAggregation.usedFunctions) {
736
- const { baseName } = parseVersionedId(funcId)
737
- includedBaseNames.add(baseName)
738
+ const { baseName, version } = parseVersionedId(funcId)
739
+ if (version === null) {
740
+ includedBaseNames.add(baseName)
741
+ }
738
742
  }
739
743
  if (includedBaseNames.size > 0) {
740
744
  for (const funcId of Object.keys(state.functions.meta)) {
@@ -215,6 +215,147 @@ describe('resolveLatestVersions', () => {
215
215
  assert.strictEqual(errors.length, 0)
216
216
  })
217
217
 
218
+ test('exposedMeta propagates all versions when base name is exposed', () => {
219
+ const state = makeState({
220
+ 'createUser@v1': {
221
+ pikkuFuncId: 'createUser@v1',
222
+ inputSchemaName: null,
223
+ outputSchemaName: null,
224
+ version: 1,
225
+ },
226
+ createUser: {
227
+ pikkuFuncId: 'createUser',
228
+ inputSchemaName: null,
229
+ outputSchemaName: null,
230
+ },
231
+ })
232
+ state.rpc.exposedMeta['createUser'] = 'createUser'
233
+ state.rpc.internalFiles.set('createUser@v1', {
234
+ path: '/src/user.ts',
235
+ exportedName: 'createUserV1',
236
+ })
237
+ const { logger } = makeLogger()
238
+
239
+ resolveLatestVersions(state, logger)
240
+
241
+ assert.strictEqual(state.rpc.exposedMeta['createUser'], 'createUser@v2')
242
+ assert.strictEqual(state.rpc.exposedMeta['createUser@v1'], 'createUser@v1')
243
+ assert.strictEqual(state.rpc.exposedMeta['createUser@v2'], 'createUser@v2')
244
+ })
245
+
246
+ test('exposedFiles propagated for versioned exposed functions', () => {
247
+ const state = makeState({
248
+ 'createUser@v1': {
249
+ pikkuFuncId: 'createUser@v1',
250
+ inputSchemaName: null,
251
+ outputSchemaName: null,
252
+ version: 1,
253
+ },
254
+ createUser: {
255
+ pikkuFuncId: 'createUser',
256
+ inputSchemaName: null,
257
+ outputSchemaName: null,
258
+ },
259
+ })
260
+ state.rpc.exposedMeta['createUser'] = 'createUser'
261
+ state.rpc.internalFiles.set('createUser@v1', {
262
+ path: '/src/user-v1.ts',
263
+ exportedName: 'createUserV1',
264
+ })
265
+ state.rpc.internalFiles.set('createUser', {
266
+ path: '/src/user.ts',
267
+ exportedName: 'createUser',
268
+ })
269
+ const { logger } = makeLogger()
270
+
271
+ resolveLatestVersions(state, logger)
272
+
273
+ assert.ok(state.rpc.exposedFiles.has('createUser@v1'))
274
+ assert.strictEqual(
275
+ state.rpc.exposedFiles.get('createUser@v1')!.exportedName,
276
+ 'createUserV1'
277
+ )
278
+ assert.ok(state.rpc.exposedFiles.has('createUser@v2'))
279
+ })
280
+
281
+ test('exposedMeta unchanged when base name is not exposed', () => {
282
+ const state = makeState({
283
+ 'createUser@v1': {
284
+ pikkuFuncId: 'createUser@v1',
285
+ inputSchemaName: null,
286
+ outputSchemaName: null,
287
+ version: 1,
288
+ },
289
+ createUser: {
290
+ pikkuFuncId: 'createUser',
291
+ inputSchemaName: null,
292
+ outputSchemaName: null,
293
+ },
294
+ })
295
+ const { logger } = makeLogger()
296
+
297
+ resolveLatestVersions(state, logger)
298
+
299
+ assert.strictEqual(state.rpc.exposedMeta['createUser@v1'], undefined)
300
+ assert.strictEqual(state.rpc.exposedMeta['createUser@v2'], undefined)
301
+ })
302
+
303
+ test('exposedMeta propagates when only explicit versions exist', () => {
304
+ const state = makeState({
305
+ 'createUser@v1': {
306
+ pikkuFuncId: 'createUser@v1',
307
+ inputSchemaName: null,
308
+ outputSchemaName: null,
309
+ version: 1,
310
+ },
311
+ 'createUser@v2': {
312
+ pikkuFuncId: 'createUser@v2',
313
+ inputSchemaName: null,
314
+ outputSchemaName: null,
315
+ version: 2,
316
+ },
317
+ })
318
+ state.rpc.exposedMeta['createUser'] = 'createUser@v1'
319
+ const { logger } = makeLogger()
320
+
321
+ resolveLatestVersions(state, logger)
322
+
323
+ assert.strictEqual(state.rpc.exposedMeta['createUser'], 'createUser@v2')
324
+ assert.strictEqual(state.rpc.exposedMeta['createUser@v1'], 'createUser@v1')
325
+ assert.strictEqual(state.rpc.exposedMeta['createUser@v2'], 'createUser@v2')
326
+ })
327
+
328
+ test('updates HTTP meta pikkuFuncId when renaming unversioned', () => {
329
+ const state = makeState({
330
+ 'createUser@v1': {
331
+ pikkuFuncId: 'createUser@v1',
332
+ inputSchemaName: null,
333
+ outputSchemaName: null,
334
+ version: 1,
335
+ },
336
+ createUser: {
337
+ pikkuFuncId: 'createUser',
338
+ inputSchemaName: null,
339
+ outputSchemaName: null,
340
+ },
341
+ })
342
+ ;(state as any).http = {
343
+ meta: {
344
+ post: {
345
+ '/api/users': { pikkuFuncId: 'createUser', route: '/api/users' },
346
+ },
347
+ },
348
+ }
349
+ const { logger } = makeLogger()
350
+
351
+ resolveLatestVersions(state, logger)
352
+
353
+ assert.strictEqual(
354
+ (state as any).http.meta.post['/api/users'].pikkuFuncId,
355
+ 'createUser@v2'
356
+ )
357
+ })
358
+
218
359
  test('renames files entries when renaming unversioned to versioned', () => {
219
360
  const state = makeState({
220
361
  'createUser@v1': {
@@ -91,6 +91,8 @@ export function resolveLatestVersions(
91
91
  if (state.rpc.exposedMeta[baseName] === oldId) {
92
92
  state.rpc.exposedMeta[baseName] = newId
93
93
  }
94
+
95
+ updateWiringReferences(state, oldId, newId)
94
96
  } else {
95
97
  const latest = group.explicit.reduce((a, b) =>
96
98
  a.version > b.version ? a : b
@@ -98,8 +100,43 @@ export function resolveLatestVersions(
98
100
  state.rpc.internalMeta[baseName] = latest.id
99
101
  }
100
102
 
103
+ if (state.rpc.exposedMeta[baseName]) {
104
+ const latestId = state.rpc.internalMeta[baseName]!
105
+ state.rpc.exposedMeta[baseName] = latestId
106
+ for (const entry of group.explicit) {
107
+ state.rpc.exposedMeta[entry.id] = entry.id
108
+ const fileEntry = state.rpc.internalFiles.get(entry.id)
109
+ if (fileEntry) {
110
+ state.rpc.exposedFiles.set(entry.id, fileEntry)
111
+ }
112
+ }
113
+ if (group.unversioned) {
114
+ state.rpc.exposedMeta[latestId] = latestId
115
+ const fileEntry = state.rpc.internalFiles.get(latestId)
116
+ if (fileEntry) {
117
+ state.rpc.exposedFiles.set(latestId, fileEntry)
118
+ }
119
+ }
120
+ }
121
+
101
122
  for (const entry of group.explicit) {
102
123
  state.rpc.invokedFunctions.add(entry.id)
103
124
  }
104
125
  }
105
126
  }
127
+
128
+ function updateWiringReferences(
129
+ state: InspectorState,
130
+ oldId: string,
131
+ newId: string
132
+ ): void {
133
+ if (state.http) {
134
+ for (const methods of Object.values(state.http.meta)) {
135
+ for (const meta of Object.values(methods)) {
136
+ if (meta.pikkuFuncId === oldId) {
137
+ meta.pikkuFuncId = newId
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }