@pikku/inspector 0.10.2 → 0.11.1

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 (82) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/add/add-channel.js +11 -10
  3. package/dist/add/add-file-with-factory.js +10 -10
  4. package/dist/add/add-functions.js +65 -44
  5. package/dist/add/add-http-route.js +5 -4
  6. package/dist/add/add-mcp-prompt.js +6 -5
  7. package/dist/add/add-mcp-resource.js +6 -5
  8. package/dist/add/add-mcp-tool.js +6 -5
  9. package/dist/add/add-middleware.js +1 -1
  10. package/dist/add/add-permission.js +1 -1
  11. package/dist/add/add-queue-worker.js +6 -5
  12. package/dist/add/add-schedule.js +5 -4
  13. package/dist/add/add-workflow.d.ts +6 -0
  14. package/dist/add/add-workflow.js +178 -0
  15. package/dist/error-codes.d.ts +5 -1
  16. package/dist/error-codes.js +5 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +1 -0
  19. package/dist/inspector.js +13 -5
  20. package/dist/types.d.ts +27 -9
  21. package/dist/utils/extract-function-node.d.ts +10 -0
  22. package/dist/utils/extract-function-node.js +38 -0
  23. package/dist/utils/extract-node-value.d.ts +32 -0
  24. package/dist/utils/extract-node-value.js +103 -0
  25. package/dist/utils/extract-service-metadata.d.ts +19 -0
  26. package/dist/utils/extract-service-metadata.js +244 -0
  27. package/dist/utils/get-files-and-methods.d.ts +3 -3
  28. package/dist/utils/get-files-and-methods.js +3 -3
  29. package/dist/utils/get-property-value.d.ts +13 -6
  30. package/dist/utils/get-property-value.js +51 -43
  31. package/dist/utils/post-process.d.ts +9 -0
  32. package/dist/utils/post-process.js +30 -3
  33. package/dist/utils/serialize-inspector-state.d.ts +21 -4
  34. package/dist/utils/serialize-inspector-state.js +18 -8
  35. package/dist/utils/type-utils.d.ts +4 -0
  36. package/dist/utils/type-utils.js +55 -0
  37. package/dist/utils/write-service-metadata.d.ts +13 -0
  38. package/dist/utils/write-service-metadata.js +37 -0
  39. package/dist/visit.js +4 -2
  40. package/dist/workflow/extract-simple-workflow.d.ts +15 -0
  41. package/dist/workflow/extract-simple-workflow.js +803 -0
  42. package/dist/workflow/patterns.d.ts +39 -0
  43. package/dist/workflow/patterns.js +138 -0
  44. package/dist/workflow/validation.d.ts +28 -0
  45. package/dist/workflow/validation.js +124 -0
  46. package/package.json +4 -4
  47. package/src/add/add-channel.ts +37 -17
  48. package/src/add/add-file-with-factory.ts +10 -10
  49. package/src/add/add-functions.ts +81 -57
  50. package/src/add/add-http-route.ts +10 -5
  51. package/src/add/add-mcp-prompt.ts +11 -7
  52. package/src/add/add-mcp-resource.ts +11 -7
  53. package/src/add/add-mcp-tool.ts +11 -7
  54. package/src/add/add-middleware.ts +1 -1
  55. package/src/add/add-permission.ts +1 -1
  56. package/src/add/add-queue-worker.ts +11 -12
  57. package/src/add/add-schedule.ts +10 -5
  58. package/src/add/add-workflow.ts +241 -0
  59. package/src/error-codes.ts +6 -0
  60. package/src/index.ts +2 -0
  61. package/src/inspector.ts +19 -5
  62. package/src/types.ts +24 -9
  63. package/src/utils/extract-function-node.ts +58 -0
  64. package/src/utils/extract-node-value.ts +132 -0
  65. package/src/utils/extract-service-metadata.ts +353 -0
  66. package/src/utils/filter-inspector-state.test.ts +3 -3
  67. package/src/utils/filter-utils.test.ts +45 -51
  68. package/src/utils/get-files-and-methods.ts +11 -11
  69. package/src/utils/get-property-value.ts +60 -53
  70. package/src/utils/permissions.test.ts +3 -3
  71. package/src/utils/post-process.ts +56 -3
  72. package/src/utils/serialize-inspector-state.ts +37 -15
  73. package/src/utils/test-data/inspector-state.json +13 -9
  74. package/src/utils/type-utils.ts +69 -0
  75. package/src/utils/write-service-metadata.ts +51 -0
  76. package/src/visit.ts +5 -3
  77. package/src/workflow/extract-simple-workflow.ts +1035 -0
  78. package/src/workflow/patterns.ts +182 -0
  79. package/src/workflow/validation.ts +153 -0
  80. package/tsconfig.tsbuildinfo +1 -1
  81. package/src/add/add-mcp-prompt.ts.tmp +0 -0
  82. package/src/add/add-mcp-resource.ts.tmp +0 -0
@@ -0,0 +1,241 @@
1
+ import * as ts from 'typescript'
2
+ import { AddWiring, InspectorState } from '../types.js'
3
+ import { extractFunctionName } from '../utils/extract-function-name.js'
4
+ import { extractFunctionNode } from '../utils/extract-function-node.js'
5
+ import { ErrorCode } from '../error-codes.js'
6
+ import { WorkflowStepMeta } from '@pikku/core/workflow'
7
+ import {
8
+ extractStringLiteral,
9
+ isStringLike,
10
+ isFunctionLike,
11
+ extractDescription,
12
+ extractDuration,
13
+ } from '../utils/extract-node-value.js'
14
+ import { extractSimpleWorkflow } from '../workflow/extract-simple-workflow.js'
15
+ import { getCommonWireMetaData } from '../utils/get-property-value.js'
16
+
17
+ /**
18
+ * Scan for workflow.do(), workflow.sleep(), and workflow.cancel() calls to extract workflow steps
19
+ */
20
+ function getWorkflowInvocations(
21
+ node: ts.Node,
22
+ checker: ts.TypeChecker,
23
+ state: InspectorState,
24
+ workflowName: string,
25
+ steps: WorkflowStepMeta[]
26
+ ) {
27
+ // Look for property access expressions: workflow.do or workflow.sleep
28
+ if (ts.isPropertyAccessExpression(node)) {
29
+ const { name } = node
30
+
31
+ // Check if this is accessing 'do' or 'sleep' property
32
+ if (name.text === 'do' || name.text === 'sleep' || name.text === 'cancel') {
33
+ // Check if the parent is a call expression
34
+ const parent = node.parent
35
+ if (ts.isCallExpression(parent) && parent.expression === node) {
36
+ const args = parent.arguments
37
+
38
+ if (name.text === 'do' && args.length >= 2) {
39
+ // workflow.do(stepName, rpcName|fn, data?, options?)
40
+ const stepNameArg = args[0]
41
+ const secondArg = args[1]
42
+ const optionsArg =
43
+ args.length >= 3 ? args[args.length - 1] : undefined
44
+
45
+ const stepName = extractStringLiteral(stepNameArg, checker)
46
+ const description =
47
+ extractDescription(optionsArg, checker) ?? undefined
48
+
49
+ // Determine form by checking 2nd argument type
50
+ if (isStringLike(secondArg, checker)) {
51
+ // RPC form: workflow.do(stepName, rpcName, data, options?)
52
+ const rpcName = extractStringLiteral(secondArg, checker)
53
+ steps.push({
54
+ type: 'rpc',
55
+ stepName,
56
+ rpcName,
57
+ })
58
+ state.rpc.invokedFunctions.add(rpcName)
59
+ } else if (isFunctionLike(secondArg)) {
60
+ // Inline form: workflow.do(stepName, fn, options?)
61
+ steps.push({
62
+ type: 'inline',
63
+ stepName: stepName || '<dynamic>',
64
+ description: description || '<dynamic>',
65
+ })
66
+ }
67
+ } else if (name.text === 'sleep' && args.length >= 2) {
68
+ // workflow.sleep(stepName, duration)
69
+ const stepNameArg = args[0]
70
+ const durationArg = args[1]
71
+
72
+ const stepName = extractStringLiteral(stepNameArg, checker)
73
+ const duration = extractDuration(durationArg, checker)
74
+
75
+ steps.push({
76
+ type: 'sleep',
77
+ stepName: stepName || '<dynamic>',
78
+ duration: duration || '<dynamic>',
79
+ })
80
+ } else if (name.text === 'cancel') {
81
+ // workflow.cancel(reason?)
82
+ steps.push({
83
+ type: 'cancel',
84
+ })
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ // Don't recurse into nested functions - only look at top-level workflow calls
91
+ ts.forEachChild(node, (child) => {
92
+ if (
93
+ ts.isFunctionDeclaration(child) ||
94
+ ts.isFunctionExpression(child) ||
95
+ ts.isArrowFunction(child)
96
+ ) {
97
+ return
98
+ }
99
+ getWorkflowInvocations(child, checker, state, workflowName, steps)
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Inspector for pikkuWorkflow() and pikkuSimpleWorkflow() calls
105
+ * Detects workflow registration and extracts metadata
106
+ */
107
+ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
108
+ if (!ts.isCallExpression(node)) {
109
+ return
110
+ }
111
+
112
+ const args = node.arguments
113
+ const firstArg = args[0]
114
+ const expression = node.expression
115
+
116
+ if (!ts.isIdentifier(expression)) {
117
+ return
118
+ }
119
+
120
+ let wrapperType: 'simple' | 'regular' | null = null
121
+ if (expression.text === 'pikkuWorkflowFunc') {
122
+ wrapperType = 'regular'
123
+ } else if (expression.text === 'pikkuSimpleWorkflowFunc') {
124
+ wrapperType = 'simple'
125
+ } else {
126
+ return
127
+ }
128
+
129
+ if (!firstArg) {
130
+ return
131
+ }
132
+
133
+ // Extract workflow name and metadata using same logic as add-functions
134
+ const { pikkuFuncName, name, exportedName } = extractFunctionName(
135
+ node,
136
+ checker,
137
+ state.rootDir
138
+ )
139
+
140
+ const workflowName = exportedName || name
141
+
142
+ if (!workflowName) {
143
+ logger.critical(
144
+ ErrorCode.MISSING_NAME,
145
+ `Could not determine workflow name from export.`
146
+ )
147
+ return
148
+ }
149
+
150
+ // Extract the function node (either direct function or from config.func)
151
+ const { funcNode, resolvedFunc } = extractFunctionNode(firstArg, checker)
152
+
153
+ // Extract metadata if using object form
154
+ let tags: string[] | undefined
155
+ let summary: string | undefined
156
+ let description: string | undefined
157
+ let errors: string[] | undefined
158
+
159
+ if (ts.isObjectLiteralExpression(firstArg)) {
160
+ const metadata = getCommonWireMetaData(
161
+ firstArg,
162
+ 'Workflow',
163
+ workflowName,
164
+ logger
165
+ )
166
+ tags = metadata.tags
167
+ summary = metadata.summary
168
+ description = metadata.description
169
+ errors = metadata.errors
170
+ }
171
+
172
+ // Validate that we got a valid function
173
+ if (
174
+ ts.isObjectLiteralExpression(firstArg) &&
175
+ (!funcNode || funcNode === firstArg)
176
+ ) {
177
+ logger.critical(
178
+ ErrorCode.MISSING_FUNC,
179
+ `No valid 'func' property for workflow '${workflowName}'.`
180
+ )
181
+ return
182
+ }
183
+
184
+ if (!resolvedFunc) {
185
+ logger.critical(
186
+ ErrorCode.MISSING_FUNC,
187
+ `Could not resolve workflow function for '${workflowName}'.`
188
+ )
189
+ return
190
+ }
191
+
192
+ // Track workflow file for wiring generation
193
+ if (exportedName) {
194
+ state.workflows.files.set(pikkuFuncName, {
195
+ path: node.getSourceFile().fileName,
196
+ exportedName,
197
+ })
198
+ }
199
+
200
+ let steps: WorkflowStepMeta[] = []
201
+ let simple: boolean | undefined = undefined
202
+
203
+ // Try simple workflow extraction first
204
+ // Pass the whole CallExpression node so findWorkflowFunction can find the arrow function
205
+ const result = extractSimpleWorkflow(node, checker)
206
+
207
+ if (result.status === 'ok' && result.steps) {
208
+ // Simple extraction succeeded
209
+ steps = result.steps
210
+ simple = true
211
+ } else {
212
+ // Simple extraction failed
213
+ if (wrapperType === 'simple') {
214
+ // For pikkuSimpleWorkflowFunc, this is a critical error
215
+ logger.critical(
216
+ ErrorCode.INVALID_SIMPLE_WORKFLOW,
217
+ `Workflow '${workflowName}' uses pikkuSimpleWorkflowFunc but does not conform to simple workflow DSL:\n${result.reason || 'Unknown error'}`
218
+ )
219
+ return
220
+ } else {
221
+ // For pikkuWorkflowFunc, fall back to basic extraction
222
+ logger.debug(
223
+ `Workflow '${workflowName}' could not be extracted as simple workflow: ${result.reason || 'Unknown error'}. Falling back to basic extraction.`
224
+ )
225
+ simple = false
226
+ }
227
+ }
228
+
229
+ getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps)
230
+
231
+ state.workflows.meta[workflowName] = {
232
+ pikkuFuncName,
233
+ workflowName,
234
+ steps,
235
+ simple,
236
+ summary,
237
+ description,
238
+ errors,
239
+ tags,
240
+ }
241
+ }
@@ -19,6 +19,9 @@ export enum ErrorCode {
19
19
  MISSING_QUEUE_NAME = 'PKU384',
20
20
  MISSING_CHANNEL_NAME = 'PKU400',
21
21
  CLI_CLIENTSIDE_RENDERER_HAS_SERVICES = 'PKU672',
22
+ DYNAMIC_STEP_NAME = 'PKU529',
23
+ WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
24
+ INVALID_SIMPLE_WORKFLOW = 'PKU641',
22
25
 
23
26
  // Configuration errors
24
27
  CONFIG_TYPE_NOT_FOUND = 'PKU426',
@@ -40,4 +43,7 @@ export enum ErrorCode {
40
43
  PERMISSION_TAG_INVALID = 'PKU836',
41
44
  PERMISSION_EMPTY_ARRAY = 'PKU937',
42
45
  PERMISSION_PATTERN_INVALID = 'PKU975',
46
+
47
+ // Feature Flag
48
+ WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = 'PKU901',
43
49
  }
package/src/index.ts CHANGED
@@ -14,3 +14,5 @@ export {
14
14
  } from './utils/serialize-inspector-state.js'
15
15
  export type { SerializableInspectorState } from './utils/serialize-inspector-state.js'
16
16
  export { filterInspectorState } from './utils/filter-inspector-state.js'
17
+ export { writeAllServiceMetadata } from './utils/write-service-metadata.js'
18
+ export type { ServiceMetadata } from './utils/extract-service-metadata.js'
package/src/inspector.ts CHANGED
@@ -5,7 +5,10 @@ import { TypesMap } from './types-map.js'
5
5
  import { InspectorState, InspectorLogger, InspectorOptions } from './types.js'
6
6
  import { getFilesAndMethods } from './utils/get-files-and-methods.js'
7
7
  import { findCommonAncestor } from './utils/find-root-dir.js'
8
- import { aggregateRequiredServices } from './utils/post-process.js'
8
+ import {
9
+ aggregateRequiredServices,
10
+ extractServiceInterfaceMetadata,
11
+ } from './utils/post-process.js'
9
12
 
10
13
  /**
11
14
  * Creates an initial/empty inspector state with all required properties initialized
@@ -16,12 +19,12 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
16
19
  return {
17
20
  rootDir,
18
21
  singletonServicesTypeImportMap: new Map(),
19
- sessionServicesTypeImportMap: new Map(),
22
+ wireServicesTypeImportMap: new Map(),
20
23
  userSessionTypeImportMap: new Map(),
21
24
  configTypeImportMap: new Map(),
22
25
  singletonServicesFactories: new Map(),
23
- sessionServicesFactories: new Map(),
24
- sessionServicesMeta: new Map(),
26
+ wireServicesFactories: new Map(),
27
+ wireServicesMeta: new Map(),
25
28
  configFactories: new Map(),
26
29
  filesAndMethods: {},
27
30
  filesAndMethodsErrors: new Map(),
@@ -58,6 +61,10 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
58
61
  meta: {},
59
62
  files: new Set(),
60
63
  },
64
+ workflows: {
65
+ meta: {},
66
+ files: new Map(),
67
+ },
61
68
  rpc: {
62
69
  internalMeta: {},
63
70
  internalFiles: new Map(),
@@ -92,8 +99,9 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
92
99
  usedMiddleware: new Set(),
93
100
  usedPermissions: new Set(),
94
101
  allSingletonServices: [],
95
- allSessionServices: [],
102
+ allWireServices: [],
96
103
  },
104
+ serviceMetadata: [],
97
105
  }
98
106
  }
99
107
 
@@ -173,6 +181,12 @@ export const inspect = (
173
181
  logger.debug(
174
182
  `Aggregate required services completed in ${(performance.now() - startAggregate).toFixed(2)}ms`
175
183
  )
184
+
185
+ const startServiceMeta = performance.now()
186
+ extractServiceInterfaceMetadata(state, checker)
187
+ logger.debug(
188
+ `Extract service metadata completed in ${(performance.now() - startServiceMeta).toFixed(2)}ms`
189
+ )
176
190
  }
177
191
 
178
192
  return state
package/src/types.ts CHANGED
@@ -2,7 +2,8 @@ import * as ts from 'typescript'
2
2
  import { ChannelsMeta } from '@pikku/core/channel'
3
3
  import { HTTPWiringsMeta } from '@pikku/core/http'
4
4
  import { ScheduledTasksMeta } from '@pikku/core/scheduler'
5
- import { queueWorkersMeta } from '@pikku/core/queue'
5
+ import { QueueWorkersMeta } from '@pikku/core/queue'
6
+ import { WorkflowsMeta } from '@pikku/core/workflow'
6
7
  import { MCPResourceMeta, MCPToolMeta, MCPPromptMeta } from '@pikku/core/mcp'
7
8
  import { CLIMeta } from '@pikku/core/cli'
8
9
  import { TypesMap } from './types-map.js'
@@ -119,7 +120,7 @@ export type InspectorOptions = Partial<{
119
120
  configFileType: string
120
121
  userSessionType: string
121
122
  singletonServicesFactoryType: string
122
- sessionServicesFactoryType: string
123
+ wireServicesFactoryType: string
123
124
  }>
124
125
  }>
125
126
 
@@ -146,7 +147,7 @@ export interface InspectorFilesAndMethods {
146
147
  type: string
147
148
  typePath: string
148
149
  }
149
- sessionServicesType?: {
150
+ wireServicesType?: {
150
151
  file: string
151
152
  variable: string
152
153
  type: string
@@ -176,7 +177,7 @@ export interface InspectorFilesAndMethods {
176
177
  type: string
177
178
  typePath: string
178
179
  }
179
- sessionServicesFactory?: {
180
+ wireServicesFactory?: {
180
181
  file: string
181
182
  variable: string
182
183
  type: string
@@ -187,12 +188,12 @@ export interface InspectorFilesAndMethods {
187
188
  export interface InspectorState {
188
189
  rootDir: string // Root directory inferred from source files
189
190
  singletonServicesTypeImportMap: PathToNameAndType
190
- sessionServicesTypeImportMap: PathToNameAndType
191
+ wireServicesTypeImportMap: PathToNameAndType
191
192
  userSessionTypeImportMap: PathToNameAndType
192
193
  configTypeImportMap: PathToNameAndType
193
194
  singletonServicesFactories: PathToNameAndType
194
- sessionServicesFactories: PathToNameAndType
195
- sessionServicesMeta: Map<string, string[]> // variable name -> singleton services consumed
195
+ wireServicesFactories: PathToNameAndType
196
+ wireServicesMeta: Map<string, string[]> // variable name -> singleton services consumed
196
197
  configFactories: PathToNameAndType
197
198
  filesAndMethods: InspectorFilesAndMethods
198
199
  filesAndMethodsErrors: Map<string, PathToNameAndType>
@@ -205,9 +206,13 @@ export interface InspectorState {
205
206
  files: Set<string>
206
207
  }
207
208
  queueWorkers: {
208
- meta: queueWorkersMeta
209
+ meta: QueueWorkersMeta
209
210
  files: Set<string>
210
211
  }
212
+ workflows: {
213
+ meta: WorkflowsMeta
214
+ files: Map<string, { path: string; exportedName: string }>
215
+ }
211
216
  rpc: {
212
217
  internalMeta: Record<string, string>
213
218
  internalFiles: Map<string, { path: string; exportedName: string }>
@@ -233,6 +238,16 @@ export interface InspectorState {
233
238
  usedMiddleware: Set<string> // Middleware names used by wired functions
234
239
  usedPermissions: Set<string> // Permission names used by wired functions
235
240
  allSingletonServices: string[] // All services available in SingletonServices type
236
- allSessionServices: string[] // All services available in Services type (excluding SingletonServices)
241
+ allWireServices: string[] // All services available in Services type (excluding SingletonServices)
237
242
  }
243
+ serviceMetadata: Array<{
244
+ name: string
245
+ summary: string
246
+ description: string
247
+ package: string
248
+ path: string
249
+ version: string
250
+ interface: string
251
+ expandedProperties: Record<string, string>
252
+ }>
238
253
  }
@@ -0,0 +1,58 @@
1
+ import * as ts from 'typescript'
2
+ import {
3
+ getPropertyAssignmentInitializer,
4
+ resolveFunctionDeclaration,
5
+ } from './type-utils.js'
6
+
7
+ /**
8
+ * Extracts the actual function node from a pikkuFunc/pikkuWorkflowFunc call
9
+ * Handles both direct function form and config object form { func: ... }
10
+ */
11
+ export function extractFunctionNode(
12
+ firstArg: ts.Expression,
13
+ checker: ts.TypeChecker
14
+ ): {
15
+ funcNode: ts.Node
16
+ resolvedFunc: ts.Node | null
17
+ isDirectFunction: boolean
18
+ } {
19
+ let funcNode: ts.Node = firstArg
20
+ let isDirectFunction = true
21
+
22
+ // Check if first argument is a config object with 'func' property
23
+ if (ts.isObjectLiteralExpression(firstArg)) {
24
+ isDirectFunction = false
25
+ const funcInitializer = getPropertyAssignmentInitializer(
26
+ firstArg,
27
+ 'func',
28
+ true,
29
+ checker
30
+ )
31
+
32
+ if (funcInitializer) {
33
+ funcNode = funcInitializer
34
+ } else {
35
+ // Return the original node if no func property found
36
+ // Caller should handle validation
37
+ funcNode = firstArg
38
+ }
39
+ }
40
+
41
+ // Resolve identifier to get the actual function node
42
+ if (ts.isIdentifier(funcNode)) {
43
+ const symbol = checker.getSymbolAtLocation(funcNode)
44
+ const decl = symbol?.valueDeclaration || symbol?.declarations?.[0]
45
+ if (decl && ts.isVariableDeclaration(decl) && decl.initializer) {
46
+ funcNode = decl.initializer
47
+ }
48
+ }
49
+
50
+ // Resolve function declaration for deeper analysis
51
+ const resolvedFunc = resolveFunctionDeclaration(funcNode, checker)
52
+
53
+ return {
54
+ funcNode,
55
+ resolvedFunc,
56
+ isDirectFunction,
57
+ }
58
+ }
@@ -0,0 +1,132 @@
1
+ import * as ts from 'typescript'
2
+
3
+ /**
4
+ * Extract string literal value from a TypeScript node.
5
+ * Handles string literals, template literals (including placeholders),
6
+ * and constant variable references.
7
+ */
8
+ export function extractStringLiteral(
9
+ node: ts.Node,
10
+ checker: ts.TypeChecker
11
+ ): string {
12
+ if (ts.isStringLiteral(node)) {
13
+ return node.text
14
+ }
15
+
16
+ if (ts.isNoSubstitutionTemplateLiteral(node)) {
17
+ return node.text
18
+ }
19
+
20
+ if (ts.isTemplateExpression(node)) {
21
+ // reconstruct: `head + ${expr} + middle + ${expr} + tail`
22
+ let result = node.head.text
23
+ for (const span of node.templateSpans) {
24
+ const exprText = span.expression.getText()
25
+ result += '${' + exprText + '}' + span.literal.text
26
+ }
27
+ return result
28
+ }
29
+
30
+ // Try to evaluate constant identifiers
31
+ if (ts.isIdentifier(node)) {
32
+ const symbol = checker.getSymbolAtLocation(node)
33
+ if (
34
+ symbol?.valueDeclaration &&
35
+ ts.isVariableDeclaration(symbol.valueDeclaration)
36
+ ) {
37
+ const init = symbol.valueDeclaration.initializer
38
+ if (init) {
39
+ return extractStringLiteral(init, checker)
40
+ }
41
+ }
42
+ }
43
+
44
+ throw new Error('Unable to extract string literal from node')
45
+ }
46
+
47
+ /**
48
+ * Check if node is string-like (string literal or template expression)
49
+ */
50
+ export function isStringLike(node: ts.Node, _checker: ts.TypeChecker): boolean {
51
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
52
+ return true
53
+ }
54
+ // Check if it's a template string with substitutions
55
+ if (ts.isTemplateExpression(node)) {
56
+ return true
57
+ }
58
+ return false
59
+ }
60
+
61
+ /**
62
+ * Check if node is function-like (arrow, function expression, or function declaration)
63
+ */
64
+ export function isFunctionLike(node: ts.Node): boolean {
65
+ return (
66
+ ts.isArrowFunction(node) ||
67
+ ts.isFunctionExpression(node) ||
68
+ ts.isFunctionDeclaration(node)
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Extract number literal value from a node
74
+ */
75
+ export function extractNumberLiteral(node: ts.Node): number | null {
76
+ if (ts.isNumericLiteral(node)) {
77
+ return Number(node.text)
78
+ }
79
+ return null
80
+ }
81
+
82
+ /**
83
+ * Extract a property value from an object literal expression
84
+ * Returns the extracted value or null if not found/cannot extract
85
+ */
86
+ export function extractPropertyString(
87
+ objNode: ts.ObjectLiteralExpression,
88
+ propertyName: string,
89
+ checker: ts.TypeChecker
90
+ ): string | null {
91
+ for (const prop of objNode.properties) {
92
+ if (
93
+ ts.isPropertyAssignment(prop) &&
94
+ ts.isIdentifier(prop.name) &&
95
+ prop.name.text === propertyName
96
+ ) {
97
+ return extractStringLiteral(prop.initializer, checker)
98
+ }
99
+ }
100
+ return null
101
+ }
102
+
103
+ /**
104
+ * Extract description from options object
105
+ */
106
+ export function extractDescription(
107
+ optionsNode: ts.Node | undefined,
108
+ checker: ts.TypeChecker
109
+ ): string | null {
110
+ if (!optionsNode || !ts.isObjectLiteralExpression(optionsNode)) {
111
+ return null
112
+ }
113
+ return extractPropertyString(optionsNode, 'description', checker)
114
+ }
115
+
116
+ /**
117
+ * Extract duration value (number or string)
118
+ */
119
+ export function extractDuration(
120
+ node: ts.Node,
121
+ checker: ts.TypeChecker
122
+ ): string | number | null {
123
+ const numValue = extractNumberLiteral(node)
124
+ if (numValue !== null) {
125
+ return numValue
126
+ }
127
+ try {
128
+ return extractStringLiteral(node, checker)
129
+ } catch {
130
+ return null
131
+ }
132
+ }