@pikku/inspector 0.9.6-next.0 → 0.10.0

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 (84) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/add/add-channel.d.ts +5 -1
  3. package/dist/add/add-channel.js +51 -32
  4. package/dist/add/add-cli.d.ts +4 -0
  5. package/dist/add/add-cli.js +128 -23
  6. package/dist/add/add-file-extends-core-type.js +3 -2
  7. package/dist/add/add-file-with-factory.d.ts +2 -2
  8. package/dist/add/add-file-with-factory.js +34 -1
  9. package/dist/add/add-functions.js +52 -5
  10. package/dist/add/add-http-route.js +19 -12
  11. package/dist/add/add-mcp-prompt.js +20 -13
  12. package/dist/add/add-mcp-resource.js +24 -14
  13. package/dist/add/add-mcp-tool.js +23 -13
  14. package/dist/add/add-middleware.js +51 -12
  15. package/dist/add/add-permission.d.ts +1 -2
  16. package/dist/add/add-permission.js +275 -19
  17. package/dist/add/add-queue-worker.js +10 -12
  18. package/dist/add/add-schedule.js +9 -10
  19. package/dist/error-codes.d.ts +35 -0
  20. package/dist/error-codes.js +40 -0
  21. package/dist/index.d.ts +4 -0
  22. package/dist/index.js +3 -0
  23. package/dist/inspector.js +20 -1
  24. package/dist/types.d.ts +31 -3
  25. package/dist/utils/ensure-function-metadata.d.ts +6 -0
  26. package/dist/utils/ensure-function-metadata.js +18 -0
  27. package/dist/utils/extract-function-name.d.ts +2 -2
  28. package/dist/utils/extract-function-name.js +13 -8
  29. package/dist/utils/filter-inspector-state.d.ts +6 -0
  30. package/dist/utils/filter-inspector-state.js +382 -0
  31. package/dist/utils/filter-utils.d.ts +10 -0
  32. package/dist/utils/filter-utils.js +66 -2
  33. package/dist/utils/find-root-dir.d.ts +23 -0
  34. package/dist/utils/find-root-dir.js +55 -0
  35. package/dist/utils/get-files-and-methods.d.ts +2 -1
  36. package/dist/utils/get-files-and-methods.js +2 -1
  37. package/dist/utils/get-property-value.d.ts +9 -0
  38. package/dist/utils/get-property-value.js +20 -0
  39. package/dist/utils/middleware.d.ts +1 -1
  40. package/dist/utils/middleware.js +7 -7
  41. package/dist/utils/permissions.d.ts +43 -0
  42. package/dist/utils/permissions.js +178 -0
  43. package/dist/utils/post-process.d.ts +16 -0
  44. package/dist/utils/post-process.js +132 -0
  45. package/dist/utils/serialize-inspector-state.d.ts +179 -0
  46. package/dist/utils/serialize-inspector-state.js +170 -0
  47. package/dist/visit.js +3 -2
  48. package/package.json +4 -4
  49. package/src/add/add-channel.ts +92 -40
  50. package/src/add/add-cli.ts +188 -29
  51. package/src/add/add-file-extends-core-type.ts +5 -2
  52. package/src/add/add-file-with-factory.ts +45 -2
  53. package/src/add/add-functions.ts +60 -5
  54. package/src/add/add-http-route.ts +46 -21
  55. package/src/add/add-mcp-prompt.ts +42 -21
  56. package/src/add/add-mcp-prompt.ts.tmp +0 -0
  57. package/src/add/add-mcp-resource.ts +50 -24
  58. package/src/add/add-mcp-resource.ts.tmp +0 -0
  59. package/src/add/add-mcp-tool.ts +48 -21
  60. package/src/add/add-middleware.ts +74 -15
  61. package/src/add/add-permission.ts +364 -22
  62. package/src/add/add-queue-worker.ts +22 -25
  63. package/src/add/add-schedule.ts +19 -20
  64. package/src/error-codes.ts +43 -0
  65. package/src/index.ts +7 -0
  66. package/src/inspector.ts +22 -1
  67. package/src/types.ts +38 -3
  68. package/src/utils/ensure-function-metadata.ts +24 -0
  69. package/src/utils/extract-function-name.ts +20 -8
  70. package/src/utils/filter-inspector-state.test.ts +1433 -0
  71. package/src/utils/filter-inspector-state.ts +526 -0
  72. package/src/utils/filter-utils.test.ts +350 -1
  73. package/src/utils/filter-utils.ts +82 -2
  74. package/src/utils/find-root-dir.ts +68 -0
  75. package/src/utils/get-files-and-methods.ts +8 -0
  76. package/src/utils/get-property-value.ts +27 -0
  77. package/src/utils/middleware.ts +14 -7
  78. package/src/utils/permissions.test.ts +327 -0
  79. package/src/utils/permissions.ts +262 -0
  80. package/src/utils/post-process.ts +178 -0
  81. package/src/utils/serialize-inspector-state.ts +375 -0
  82. package/src/utils/test-data/inspector-state.json +1680 -0
  83. package/src/visit.ts +4 -2
  84. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @pikku/inspector
2
2
 
3
+ ## 0.10.0
4
+
5
+ This release includes significant improvements across the framework including tree-shaking support, middleware/permission factories, enhanced CLI functionality, improved TypeScript type safety, and comprehensive test strategies.
6
+
7
+ For complete details, see https://pikku.dev/changelogs/0_10_0.md
8
+
3
9
  ## 0.9.6-next.0
4
10
 
5
11
  ### Patch Changes
@@ -1,11 +1,15 @@
1
1
  import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
2
3
  import type { ChannelMeta } from '@pikku/core/channel';
3
4
  import type { InspectorState, AddWiring } from '../types.js';
4
5
  /**
5
6
  * Build out the nested message-routes by looking up each handler
6
7
  * in state.functions.meta instead of re-inferring it here.
7
8
  */
8
- export declare function addMessagesRoutes(obj: ts.ObjectLiteralExpression, state: InspectorState, checker: ts.TypeChecker): ChannelMeta['messageWirings'];
9
+ export declare function addMessagesRoutes(logger: {
10
+ error: (msg: string) => void;
11
+ critical: (code: ErrorCode, msg: string) => void;
12
+ }, obj: ts.ObjectLiteralExpression, state: InspectorState, checker: ts.TypeChecker): ChannelMeta['messageWirings'];
9
13
  /**
10
14
  * Inspect addChannel calls, look up all handlers in state.functions.meta,
11
15
  * and emit one entry into state.channels.meta.
@@ -1,11 +1,11 @@
1
1
  import * as ts from 'typescript';
2
- import { getPropertyValue } from '../utils/get-property-value.js';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ import { getPropertyValue, getPropertyTags, } from '../utils/get-property-value.js';
3
4
  import { pathToRegexp } from 'path-to-regexp';
4
- import { PikkuWiringTypes } from '@pikku/core';
5
5
  import { extractFunctionName } from '../utils/extract-function-name.js';
6
6
  import { getPropertyAssignmentInitializer } from '../utils/type-utils.js';
7
- import { matchesFilters } from '../utils/filter-utils.js';
8
7
  import { resolveMiddleware } from '../utils/middleware.js';
8
+ import { extractWireNames } from '../utils/post-process.js';
9
9
  /**
10
10
  * Safely get the "initializer" expression of a property-like AST node:
11
11
  * - for `foo: expr`, returns `expr`
@@ -25,7 +25,7 @@ function getInitializerOf(elem) {
25
25
  * Resolve a handler expression (Identifier, CallExpression, or { func })
26
26
  * into its underlying function name.
27
27
  */
28
- function getHandlerNameFromExpression(expr, checker) {
28
+ function getHandlerNameFromExpression(expr, checker, rootDir) {
29
29
  // Handle direct identifier case (which includes shorthand properties)
30
30
  if (ts.isIdentifier(expr)) {
31
31
  const sym = checker.getSymbolAtLocation(expr);
@@ -44,31 +44,31 @@ function getHandlerNameFromExpression(expr, checker) {
44
44
  ts.isArrowFunction(decl.initializer) ||
45
45
  ts.isFunctionExpression(decl.initializer)) {
46
46
  // Extract function name from the declaration's initializer
47
- const { pikkuFuncName } = extractFunctionName(decl.initializer, checker);
47
+ const { pikkuFuncName } = extractFunctionName(decl.initializer, checker, rootDir);
48
48
  return pikkuFuncName;
49
49
  }
50
50
  }
51
51
  // For function declarations, use directly
52
52
  else if (ts.isFunctionDeclaration(decl)) {
53
- const { pikkuFuncName } = extractFunctionName(decl, checker);
53
+ const { pikkuFuncName } = extractFunctionName(decl, checker, rootDir);
54
54
  return pikkuFuncName;
55
55
  }
56
56
  }
57
57
  }
58
58
  // Fallback: try to extract directly from the identifier
59
- const { pikkuFuncName } = extractFunctionName(expr, checker);
59
+ const { pikkuFuncName } = extractFunctionName(expr, checker, rootDir);
60
60
  return pikkuFuncName;
61
61
  }
62
62
  // Handle call expressions
63
63
  if (ts.isCallExpression(expr)) {
64
- const { pikkuFuncName } = extractFunctionName(expr, checker);
64
+ const { pikkuFuncName } = extractFunctionName(expr, checker, rootDir);
65
65
  return pikkuFuncName;
66
66
  }
67
67
  // Handle object literals with 'func' property
68
68
  if (ts.isObjectLiteralExpression(expr)) {
69
69
  const fnProp = getPropertyAssignmentInitializer(expr, 'func', true, checker);
70
70
  if (fnProp) {
71
- return getHandlerNameFromExpression(fnProp, checker);
71
+ return getHandlerNameFromExpression(fnProp, checker, rootDir);
72
72
  }
73
73
  }
74
74
  return null;
@@ -77,7 +77,7 @@ function getHandlerNameFromExpression(expr, checker) {
77
77
  * Build out the nested message-routes by looking up each handler
78
78
  * in state.functions.meta instead of re-inferring it here.
79
79
  */
80
- export function addMessagesRoutes(obj, state, checker) {
80
+ export function addMessagesRoutes(logger, obj, state, checker) {
81
81
  const result = {};
82
82
  const onMsgRouteProp = getPropertyAssignmentInitializer(obj, 'onMessageWiring', true, checker);
83
83
  if (!onMsgRouteProp)
@@ -128,7 +128,7 @@ export function addMessagesRoutes(obj, state, checker) {
128
128
  if (ts.isArrowFunction(importDecl.initializer) ||
129
129
  ts.isFunctionExpression(importDecl.initializer) ||
130
130
  ts.isCallExpression(importDecl.initializer)) {
131
- const { pikkuFuncName } = extractFunctionName(importDecl.initializer, checker);
131
+ const { pikkuFuncName } = extractFunctionName(importDecl.initializer, checker, state.rootDir);
132
132
  const handlerName = pikkuFuncName;
133
133
  // Look up in the registry
134
134
  const fnMeta = state.functions.meta[handlerName];
@@ -142,7 +142,7 @@ export function addMessagesRoutes(obj, state, checker) {
142
142
  }
143
143
  else if (ts.isFunctionDeclaration(importDecl)) {
144
144
  // Extract from the function declaration
145
- const { pikkuFuncName } = extractFunctionName(importDecl, checker);
145
+ const { pikkuFuncName } = extractFunctionName(importDecl, checker, state.rootDir);
146
146
  const handlerName = pikkuFuncName;
147
147
  // Look up in the registry
148
148
  const fnMeta = state.functions.meta[handlerName];
@@ -168,7 +168,7 @@ export function addMessagesRoutes(obj, state, checker) {
168
168
  const exportDecl = exportDecls[0];
169
169
  if (ts.isVariableDeclaration(exportDecl) &&
170
170
  exportDecl.initializer) {
171
- const { pikkuFuncName } = extractFunctionName(exportDecl.initializer, checker);
171
+ const { pikkuFuncName } = extractFunctionName(exportDecl.initializer, checker, state.rootDir);
172
172
  const handlerName = pikkuFuncName;
173
173
  const fnMeta = state.functions.meta[handlerName];
174
174
  if (fnMeta) {
@@ -179,7 +179,7 @@ export function addMessagesRoutes(obj, state, checker) {
179
179
  }
180
180
  }
181
181
  else if (ts.isFunctionDeclaration(exportDecl)) {
182
- const { pikkuFuncName } = extractFunctionName(exportDecl, checker);
182
+ const { pikkuFuncName } = extractFunctionName(exportDecl, checker, state.rootDir);
183
183
  const handlerName = pikkuFuncName;
184
184
  const fnMeta = state.functions.meta[handlerName];
185
185
  if (fnMeta) {
@@ -205,7 +205,7 @@ export function addMessagesRoutes(obj, state, checker) {
205
205
  if (possibleMatch) {
206
206
  const fnMeta = state.functions.meta[possibleMatch];
207
207
  if (!fnMeta) {
208
- console.error(`No function metadata found for handler '${possibleMatch}'`);
208
+ logger.critical(ErrorCode.FUNCTION_METADATA_NOT_FOUND, `No function metadata found for handler '${possibleMatch}'`);
209
209
  continue;
210
210
  }
211
211
  result[channelKey][routeKey] = {
@@ -234,7 +234,7 @@ export function addMessagesRoutes(obj, state, checker) {
234
234
  // If we found the actual function, extract its name
235
235
  if (actualFunction) {
236
236
  // Extract the function name directly from the actual function
237
- const { pikkuFuncName } = extractFunctionName(actualFunction, checker);
237
+ const { pikkuFuncName } = extractFunctionName(actualFunction, checker, state.rootDir);
238
238
  const handlerName = pikkuFuncName;
239
239
  // Now use this handlerName to look up in the registry
240
240
  const fnMeta = state.functions.meta[handlerName];
@@ -249,14 +249,14 @@ export function addMessagesRoutes(obj, state, checker) {
249
249
  }
250
250
  }
251
251
  // Normal processing for non-shorthand properties
252
- const handlerName = getHandlerNameFromExpression(init, checker);
252
+ const handlerName = getHandlerNameFromExpression(init, checker, state.rootDir);
253
253
  if (!handlerName) {
254
- console.error(`Could not resolve handler for message route '${routeKey}'`);
254
+ logger.error(`Could not resolve handler for message route '${routeKey}'`);
255
255
  continue;
256
256
  }
257
257
  const fnMeta = state.functions.meta[handlerName];
258
258
  if (!fnMeta) {
259
- console.error(`No function metadata found for handler '${handlerName}'`);
259
+ logger.critical(ErrorCode.FUNCTION_METADATA_NOT_FOUND, `No function metadata found for handler '${handlerName}'`);
260
260
  continue;
261
261
  }
262
262
  result[channelKey][routeKey] = {
@@ -271,7 +271,6 @@ export function addMessagesRoutes(obj, state, checker) {
271
271
  * and emit one entry into state.channels.meta.
272
272
  */
273
273
  export const addChannel = (logger, node, checker, state, options) => {
274
- const filters = options.filters || {};
275
274
  if (!ts.isCallExpression(node))
276
275
  return;
277
276
  const { expression, arguments: args } = node;
@@ -284,7 +283,7 @@ export const addChannel = (logger, node, checker, state, options) => {
284
283
  const name = getPropertyValue(obj, 'name');
285
284
  const route = getPropertyValue(obj, 'route') ?? '';
286
285
  if (!name) {
287
- console.error('Channel name is required');
286
+ logger.critical(ErrorCode.MISSING_CHANNEL_NAME, 'Channel name is required');
288
287
  return;
289
288
  }
290
289
  // path parameters
@@ -294,18 +293,16 @@ export const addChannel = (logger, node, checker, state, options) => {
294
293
  .map((k) => k.name)
295
294
  : [];
296
295
  const docs = getPropertyValue(obj, 'docs');
297
- const tags = getPropertyValue(obj, 'tags');
296
+ const tags = getPropertyTags(obj, 'Channel', route, logger);
298
297
  const query = getPropertyValue(obj, 'query');
299
- const filePath = node.getSourceFile().fileName;
300
- if (!matchesFilters(filters, { tags }, { type: PikkuWiringTypes.channel, name, filePath }, logger))
301
- return;
302
298
  const connect = getPropertyAssignmentInitializer(obj, 'onConnect', false, checker);
303
299
  const disconnect = getPropertyAssignmentInitializer(obj, 'onDisconnect', false, checker);
304
300
  // default onMessage handler
305
301
  let message = null;
306
302
  const onMsgProp = getPropertyAssignmentInitializer(obj, 'onMessage', false, checker);
307
303
  if (onMsgProp) {
308
- const handlerName = onMsgProp && getHandlerNameFromExpression(onMsgProp, checker);
304
+ const handlerName = onMsgProp &&
305
+ getHandlerNameFromExpression(onMsgProp, checker, state.rootDir);
309
306
  const fnMeta = handlerName && state.functions.meta[handlerName];
310
307
  if (!fnMeta) {
311
308
  console.error(`No function metadata for onMessage handler '${handlerName}'`);
@@ -313,15 +310,35 @@ export const addChannel = (logger, node, checker, state, options) => {
313
310
  }
314
311
  else {
315
312
  message = {
316
- pikkuFuncName: extractFunctionName(onMsgProp, checker)
317
- .pikkuFuncName,
313
+ pikkuFuncName: extractFunctionName(onMsgProp, checker, state.rootDir).pikkuFuncName,
318
314
  };
319
315
  }
320
316
  }
321
317
  // nested message-routes
322
- const messageWirings = addMessagesRoutes(obj, state, checker);
318
+ const messageWirings = addMessagesRoutes(logger, obj, state, checker);
323
319
  // --- resolve middleware ---
324
320
  const middleware = resolveMiddleware(state, obj, tags, checker);
321
+ // --- track used functions/middleware for service aggregation ---
322
+ // Track connect/disconnect/message handlers
323
+ if (connect) {
324
+ const connectFuncName = extractFunctionName(connect, checker, state.rootDir).pikkuFuncName;
325
+ state.serviceAggregation.usedFunctions.add(connectFuncName);
326
+ }
327
+ if (disconnect) {
328
+ const disconnectFuncName = extractFunctionName(disconnect, checker, state.rootDir).pikkuFuncName;
329
+ state.serviceAggregation.usedFunctions.add(disconnectFuncName);
330
+ }
331
+ if (message) {
332
+ state.serviceAggregation.usedFunctions.add(message.pikkuFuncName);
333
+ }
334
+ // Track message wiring handlers
335
+ for (const channelHandlers of Object.values(messageWirings)) {
336
+ for (const handler of Object.values(channelHandlers)) {
337
+ state.serviceAggregation.usedFunctions.add(handler.pikkuFuncName);
338
+ }
339
+ }
340
+ // Track middleware
341
+ extractWireNames(middleware).forEach((name) => state.serviceAggregation.usedMiddleware.add(name));
325
342
  // record into state
326
343
  state.channels.files.add(node.getSourceFile().fileName);
327
344
  state.channels.meta[name] = {
@@ -338,12 +355,14 @@ export const addChannel = (logger, node, checker, state, options) => {
338
355
  // params
339
356
  // ),
340
357
  connect: connect
341
- ? { pikkuFuncName: extractFunctionName(connect, checker).pikkuFuncName }
358
+ ? {
359
+ pikkuFuncName: extractFunctionName(connect, checker, state.rootDir)
360
+ .pikkuFuncName,
361
+ }
342
362
  : null,
343
363
  disconnect: disconnect
344
364
  ? {
345
- pikkuFuncName: extractFunctionName(disconnect, checker)
346
- .pikkuFuncName,
365
+ pikkuFuncName: extractFunctionName(disconnect, checker, state.rootDir).pikkuFuncName,
347
366
  }
348
367
  : null,
349
368
  message,
@@ -3,3 +3,7 @@ import { AddWiring } from '../types.js';
3
3
  * Adds CLI command metadata to the inspector state
4
4
  */
5
5
  export declare const addCLI: AddWiring;
6
+ /**
7
+ * Adds CLI renderer metadata to the inspector state
8
+ */
9
+ export declare const addCLIRenderers: AddWiring;
@@ -1,7 +1,10 @@
1
1
  import ts from 'typescript';
2
2
  import { extractFunctionName } from '../utils/extract-function-name.js';
3
3
  import { resolveMiddleware } from '../utils/middleware.js';
4
+ import { extractWireNames } from '../utils/post-process.js';
4
5
  import { getPropertyValue } from '../utils/get-property-value.js';
6
+ // Track if we've warned about missing Config type to avoid duplicate warnings
7
+ const configTypeWarningShown = new Set();
5
8
  /**
6
9
  * Adds CLI command metadata to the inspector state
7
10
  */
@@ -33,18 +36,39 @@ export const addCLI = (logger, node, typeChecker, inspectorState, options) => {
33
36
  return;
34
37
  }
35
38
  // Add this program to the CLI metadata
36
- inspectorState.cli.meta[cliConfig.programName] = cliConfig.programMeta;
39
+ inspectorState.cli.meta.programs[cliConfig.programName] =
40
+ cliConfig.programMeta;
37
41
  };
38
42
  /**
39
43
  * Processes a CLI configuration object
40
44
  */
41
45
  function processCLIConfig(logger, node, sourceFile, typeChecker, inspectorState, options) {
42
46
  let programName = '';
47
+ let programTags;
43
48
  const programMeta = {
44
49
  program: '',
45
50
  commands: {},
46
51
  options: {},
47
52
  };
53
+ // First pass: extract program name and tags
54
+ for (const prop of node.properties) {
55
+ if (!ts.isPropertyAssignment(prop))
56
+ continue;
57
+ if (!ts.isIdentifier(prop.name))
58
+ continue;
59
+ const propName = prop.name.text;
60
+ if (propName === 'program' && ts.isStringLiteral(prop.initializer)) {
61
+ programName = prop.initializer.text;
62
+ programMeta.program = programName;
63
+ }
64
+ else if (propName === 'tags') {
65
+ programTags = getPropertyValue(node, 'tags') || undefined;
66
+ }
67
+ }
68
+ if (!programName) {
69
+ return null;
70
+ }
71
+ // Second pass: process other properties with program tags available
48
72
  for (const prop of node.properties) {
49
73
  if (!ts.isPropertyAssignment(prop))
50
74
  continue;
@@ -53,14 +77,12 @@ function processCLIConfig(logger, node, sourceFile, typeChecker, inspectorState,
53
77
  const propName = prop.name.text;
54
78
  switch (propName) {
55
79
  case 'program':
56
- if (ts.isStringLiteral(prop.initializer)) {
57
- programName = prop.initializer.text;
58
- programMeta.program = programName;
59
- }
80
+ case 'tags':
81
+ // Already handled in first pass
60
82
  break;
61
83
  case 'commands':
62
84
  if (ts.isObjectLiteralExpression(prop.initializer)) {
63
- programMeta.commands = processCommands(logger, prop.initializer, sourceFile, typeChecker, programName, inspectorState, options);
85
+ programMeta.commands = processCommands(logger, prop.initializer, sourceFile, typeChecker, programName, inspectorState, options, programTags);
64
86
  }
65
87
  break;
66
88
  case 'options':
@@ -69,30 +91,39 @@ function processCLIConfig(logger, node, sourceFile, typeChecker, inspectorState,
69
91
  }
70
92
  break;
71
93
  case 'render':
72
- // Track that a default renderer exists
73
- programMeta.defaultRenderName = 'defaultRenderer';
94
+ // Extract the actual renderer function name
95
+ programMeta.defaultRenderName = extractFunctionName(prop.initializer, typeChecker, inspectorState.rootDir).pikkuFuncName;
74
96
  break;
75
97
  }
76
98
  }
77
- if (!programName) {
78
- return null;
79
- }
80
99
  return { programName, programMeta };
81
100
  }
82
101
  /**
83
102
  * Processes the commands object
84
103
  */
85
- function processCommands(logger, node, sourceFile, typeChecker, programName, inspectorState, options) {
104
+ function processCommands(logger, node, sourceFile, typeChecker, programName, inspectorState, options, programTags) {
86
105
  const commands = {};
106
+ let defaultCommandName = null;
87
107
  for (const prop of node.properties) {
88
108
  if (!ts.isPropertyAssignment(prop))
89
109
  continue;
90
110
  const commandName = getPropertyName(prop);
91
111
  if (!commandName)
92
112
  continue;
93
- const commandMeta = processCommand(logger, inspectorState, options, commandName, prop.initializer, sourceFile, typeChecker, programName);
113
+ const commandMeta = processCommand(logger, inspectorState, options, commandName, prop.initializer, sourceFile, typeChecker, programName, [], programTags);
94
114
  if (commandMeta) {
95
115
  commands[commandName] = commandMeta;
116
+ // Validate only one default command
117
+ if (commandMeta.isDefault) {
118
+ if (defaultCommandName !== null) {
119
+ const position = prop.getStart(sourceFile);
120
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
121
+ throw new Error(`Multiple default commands found in CLI program "${programName}" at ${sourceFile.fileName}:${line + 1}:${character + 1}.\n` +
122
+ `Commands "${defaultCommandName}" and "${commandName}" are both marked as default.\n` +
123
+ `Only one command can be marked as default per program.`);
124
+ }
125
+ defaultCommandName = commandName;
126
+ }
96
127
  }
97
128
  }
98
129
  return commands;
@@ -100,14 +131,14 @@ function processCommands(logger, node, sourceFile, typeChecker, programName, ins
100
131
  /**
101
132
  * Processes a single command
102
133
  */
103
- function processCommand(logger, inspectorState, options, name, node, sourceFile, typeChecker, programName, parentPath = []) {
134
+ function processCommand(logger, inspectorState, options, name, node, sourceFile, typeChecker, programName, parentPath = [], programTags) {
104
135
  const fullPath = [...parentPath, name];
105
136
  // Handle shorthand (just a function)
106
137
  if (ts.isIdentifier(node) ||
107
138
  ts.isArrowFunction(node) ||
108
139
  ts.isFunctionExpression(node)) {
109
140
  return {
110
- pikkuFuncName: extractFunctionName(node, typeChecker).pikkuFuncName,
141
+ pikkuFuncName: extractFunctionName(node, typeChecker, inspectorState.rootDir).pikkuFuncName,
111
142
  positionals: [],
112
143
  options: {},
113
144
  };
@@ -120,7 +151,7 @@ function processCommand(logger, inspectorState, options, name, node, sourceFile,
120
151
  node.arguments.length > 0 &&
121
152
  ts.isObjectLiteralExpression(node.arguments[0])) {
122
153
  // Process the object literal argument
123
- return processCommand(logger, inspectorState, options, name, node.arguments[0], sourceFile, typeChecker, programName, parentPath);
154
+ return processCommand(logger, inspectorState, options, name, node.arguments[0], sourceFile, typeChecker, programName, parentPath, programTags);
124
155
  }
125
156
  return null;
126
157
  }
@@ -144,7 +175,7 @@ function processCommand(logger, inspectorState, options, name, node, sourceFile,
144
175
  continue;
145
176
  const propName = prop.name.text;
146
177
  if (propName === 'func') {
147
- pikkuFuncName = extractFunctionName(prop.initializer, typeChecker).pikkuFuncName;
178
+ pikkuFuncName = extractFunctionName(prop.initializer, typeChecker, inspectorState.rootDir).pikkuFuncName;
148
179
  meta.pikkuFuncName = pikkuFuncName;
149
180
  }
150
181
  else if (propName === 'options' &&
@@ -155,11 +186,17 @@ function processCommand(logger, inspectorState, options, name, node, sourceFile,
155
186
  tags = getPropertyValue(node, 'tags') || undefined;
156
187
  }
157
188
  }
189
+ // Merge program-level tags with command-level tags
190
+ const allTags = [...(programTags || []), ...(tags || [])];
158
191
  // Resolve middleware
159
- const middleware = resolveMiddleware(inspectorState, node, tags, typeChecker);
192
+ const middleware = resolveMiddleware(inspectorState, node, allTags.length > 0 ? allTags : undefined, typeChecker);
160
193
  if (middleware) {
161
194
  meta.middleware = middleware;
162
195
  }
196
+ // Add merged tags to metadata
197
+ if (allTags.length > 0) {
198
+ meta.tags = allTags;
199
+ }
163
200
  // Second pass: process all properties
164
201
  for (const prop of node.properties) {
165
202
  if (!ts.isPropertyAssignment(prop))
@@ -183,7 +220,7 @@ function processCommand(logger, inspectorState, options, name, node, sourceFile,
183
220
  // Already handled in first pass
184
221
  break;
185
222
  case 'render':
186
- meta.renderName = extractFunctionName(prop.initializer, typeChecker).pikkuFuncName;
223
+ meta.renderName = extractFunctionName(prop.initializer, typeChecker, inspectorState.rootDir).pikkuFuncName;
187
224
  break;
188
225
  case 'options':
189
226
  // Process with pikkuFuncName from first pass
@@ -200,15 +237,25 @@ function processCommand(logger, inspectorState, options, name, node, sourceFile,
200
237
  const subName = getPropertyName(subProp);
201
238
  if (!subName)
202
239
  continue;
203
- const subCommand = processCommand(logger, inspectorState, options, subName, subProp.initializer, sourceFile, typeChecker, programName, fullPath);
240
+ const subCommand = processCommand(logger, inspectorState, options, subName, subProp.initializer, sourceFile, typeChecker, programName, fullPath, programTags);
204
241
  if (subCommand) {
205
242
  meta.subcommands[subName] = subCommand;
206
243
  }
207
244
  }
208
245
  }
209
246
  break;
247
+ case 'isDefault':
248
+ if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword ||
249
+ prop.initializer.kind === ts.SyntaxKind.FalseKeyword) {
250
+ meta.isDefault = prop.initializer.kind === ts.SyntaxKind.TrueKeyword;
251
+ }
252
+ break;
210
253
  }
211
254
  }
255
+ // --- track used functions/middleware for service aggregation ---
256
+ inspectorState.serviceAggregation.usedFunctions.add(meta.pikkuFuncName);
257
+ extractWireNames(meta.middleware).forEach((name) => inspectorState.serviceAggregation.usedMiddleware.add(name));
258
+ // Note: subcommands are tracked recursively when they're processed
212
259
  return meta;
213
260
  }
214
261
  /**
@@ -382,14 +429,21 @@ function extractEnumFromConfigType(logger, propertyName, typeChecker, inspectorS
382
429
  // Look for Config type in typesLookup
383
430
  const configTypes = inspectorState.typesLookup.get('Config');
384
431
  if (!configTypes || configTypes.length === 0) {
385
- logger.warn(`Warning: Could not find Config type in typesLookup for option "${propertyName}". ` +
386
- `Make sure you have a Config interface extending CoreConfig in your codebase.`);
432
+ // Only warn once per CLI file to avoid spamming logs
433
+ if (!configTypeWarningShown.has('missing-config-type')) {
434
+ configTypeWarningShown.add('missing-config-type');
435
+ logger.warn(`Could not find Config type in typesLookup. ` +
436
+ `Make sure you have a Config interface extending CoreConfig in your codebase.`);
437
+ }
387
438
  return null;
388
439
  }
389
440
  // Use the first Config type (there should only be one)
390
441
  const configType = configTypes[0];
391
442
  if (!configType) {
392
- logger.warn(`Warning: Config type is undefined in typesLookup for option "${propertyName}".`);
443
+ if (!configTypeWarningShown.has('undefined-config-type')) {
444
+ configTypeWarningShown.add('undefined-config-type');
445
+ logger.warn(`Config type is undefined in typesLookup.`);
446
+ }
393
447
  return null;
394
448
  }
395
449
  // Extract enum from the property
@@ -459,3 +513,54 @@ function parseCommandPattern(pattern) {
459
513
  }
460
514
  return positionals;
461
515
  }
516
+ /**
517
+ * Adds CLI renderer metadata to the inspector state
518
+ */
519
+ export const addCLIRenderers = (logger, node, typeChecker, inspectorState, options) => {
520
+ if (!ts.isCallExpression(node))
521
+ return;
522
+ const { expression, arguments: args, typeArguments } = node;
523
+ // Only handle pikkuCLIRender calls
524
+ if (!ts.isIdentifier(expression) || expression.text !== 'pikkuCLIRender') {
525
+ return;
526
+ }
527
+ if (args.length === 0)
528
+ return;
529
+ // Extract renderer name
530
+ const { pikkuFuncName, exportedName } = extractFunctionName(node, typeChecker, inspectorState.rootDir);
531
+ // Get the source file path
532
+ const sourceFile = node.getSourceFile();
533
+ const filePath = sourceFile.fileName;
534
+ // Extract services from type parameters (second type param is Services)
535
+ const services = {
536
+ optimized: true,
537
+ services: [],
538
+ };
539
+ if (typeArguments && typeArguments.length >= 2) {
540
+ // Second type parameter is the Services type
541
+ const servicesTypeNode = typeArguments[1];
542
+ if (servicesTypeNode) {
543
+ const servicesType = typeChecker.getTypeFromTypeNode(servicesTypeNode);
544
+ // Extract property names from the Services type
545
+ const properties = servicesType.getProperties();
546
+ for (const prop of properties) {
547
+ services.services.push(prop.getName());
548
+ }
549
+ // If no specific services found, it might be using the full services object
550
+ if (properties.length === 0) {
551
+ services.optimized = false;
552
+ }
553
+ }
554
+ }
555
+ // Store renderer metadata
556
+ inspectorState.cli.meta.renderers[pikkuFuncName] = {
557
+ name: pikkuFuncName,
558
+ exportedName: exportedName ?? undefined,
559
+ services,
560
+ filePath,
561
+ };
562
+ // Add to files map if exported
563
+ if (exportedName) {
564
+ inspectorState.cli.files.add(filePath);
565
+ }
566
+ };
@@ -23,11 +23,12 @@ export const addFileExtendsCoreType = (node, checker, methods, expectedTypeName,
23
23
  }
24
24
  const variables = methods.get(fileName) || [];
25
25
  if (!typeName) {
26
- throw new Error('TODO');
26
+ throw new Error(`Found anonymous ${ts.isClassDeclaration(node) ? 'class' : 'interface'} extending ${expectedTypeName} in ${fileName}. ` +
27
+ `Classes and interfaces that extend core types must have a name.`);
27
28
  }
28
29
  variables.push({
29
30
  variable: typeName,
30
- type: typeName || null,
31
+ type: typeName,
31
32
  typePath: extendedTypeDeclarationPath,
32
33
  });
33
34
  methods.set(fileName, variables);
@@ -1,3 +1,3 @@
1
1
  import * as ts from 'typescript';
2
- import { PathToNameAndType } from '../types.js';
3
- export declare const addFileWithFactory: (node: ts.Node, checker: ts.TypeChecker, methods: PathToNameAndType | undefined, expectedTypeName: string) => void;
2
+ import { PathToNameAndType, InspectorState } from '../types.js';
3
+ export declare const addFileWithFactory: (node: ts.Node, checker: ts.TypeChecker, methods: PathToNameAndType | undefined, expectedTypeName: string, state?: InspectorState) => void;
@@ -1,5 +1,6 @@
1
1
  import * as ts from 'typescript';
2
- export const addFileWithFactory = (node, checker, methods = new Map(), expectedTypeName) => {
2
+ import { extractServicesFromFunction } from '../utils/extract-services.js';
3
+ export const addFileWithFactory = (node, checker, methods = new Map(), expectedTypeName, state) => {
3
4
  if (ts.isVariableDeclaration(node)) {
4
5
  const fileName = node.getSourceFile().fileName;
5
6
  const variableTypeNode = node.type;
@@ -23,6 +24,22 @@ export const addFileWithFactory = (node, checker, methods = new Map(), expectedT
23
24
  typePath: typeDeclarationPath,
24
25
  });
25
26
  methods.set(fileName, variables);
27
+ // Extract singleton services for CreateSessionServices factories
28
+ if (expectedTypeName === 'CreateSessionServices' &&
29
+ state &&
30
+ node.initializer) {
31
+ let functionNode;
32
+ if (ts.isArrowFunction(node.initializer)) {
33
+ functionNode = node.initializer;
34
+ }
35
+ else if (ts.isFunctionExpression(node.initializer)) {
36
+ functionNode = node.initializer;
37
+ }
38
+ if (functionNode) {
39
+ const servicesMeta = extractServicesFromFunction(functionNode);
40
+ state.sessionServicesMeta.set(variableName, servicesMeta.services);
41
+ }
42
+ }
26
43
  }
27
44
  // Handle qualified type names if necessary
28
45
  else if (ts.isQualifiedName(typeNameNode)) {
@@ -41,6 +58,22 @@ export const addFileWithFactory = (node, checker, methods = new Map(), expectedT
41
58
  typePath: typeDeclarationPath,
42
59
  });
43
60
  methods.set(fileName, variables);
61
+ // Extract singleton services for CreateSessionServices factories
62
+ if (expectedTypeName === 'CreateSessionServices' &&
63
+ state &&
64
+ node.initializer) {
65
+ let functionNode;
66
+ if (ts.isArrowFunction(node.initializer)) {
67
+ functionNode = node.initializer;
68
+ }
69
+ else if (ts.isFunctionExpression(node.initializer)) {
70
+ functionNode = node.initializer;
71
+ }
72
+ if (functionNode) {
73
+ const servicesMeta = extractServicesFromFunction(functionNode);
74
+ state.sessionServicesMeta.set(variableName, servicesMeta.services);
75
+ }
76
+ }
44
77
  }
45
78
  }
46
79
  }