@pikku/inspector 0.12.21 → 0.12.23

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 (61) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/add/add-addon-bans.d.ts +7 -0
  3. package/dist/add/add-addon-bans.js +65 -0
  4. package/dist/add/add-channel.js +47 -6
  5. package/dist/add/add-cli.js +17 -0
  6. package/dist/add/add-functions.js +16 -8
  7. package/dist/add/add-http-route.d.ts +11 -1
  8. package/dist/add/add-http-route.js +37 -0
  9. package/dist/add/add-http-routes.d.ts +0 -3
  10. package/dist/add/add-http-routes.js +179 -36
  11. package/dist/add/add-workflow.js +16 -2
  12. package/dist/error-codes.d.ts +15 -1
  13. package/dist/error-codes.js +3 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/inspector.js +22 -6
  16. package/dist/types.d.ts +53 -2
  17. package/dist/utils/extract-node-value.js +19 -2
  18. package/dist/utils/get-exported-variable-name.d.ts +2 -0
  19. package/dist/utils/get-exported-variable-name.js +34 -0
  20. package/dist/utils/load-addon-functions-meta.js +98 -0
  21. package/dist/utils/resolve-addon-package.js +3 -1
  22. package/dist/utils/resolve-ref-contract.d.ts +21 -0
  23. package/dist/utils/resolve-ref-contract.js +46 -0
  24. package/dist/utils/serialize-inspector-state.d.ts +1 -0
  25. package/dist/utils/serialize-inspector-state.js +9 -0
  26. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +15 -0
  27. package/dist/visit.js +24 -19
  28. package/package.json +2 -2
  29. package/src/add/add-addon-bans.ts +84 -0
  30. package/src/add/add-auth.test.ts +3 -0
  31. package/src/add/add-channel.ts +66 -7
  32. package/src/add/add-cli-renderers.test.ts +1 -0
  33. package/src/add/add-cli.ts +30 -0
  34. package/src/add/add-functions.test.ts +13 -0
  35. package/src/add/add-functions.ts +14 -10
  36. package/src/add/add-http-route.ts +75 -1
  37. package/src/add/add-http-routes.ts +283 -41
  38. package/src/add/add-workflow-fanout.test.ts +106 -0
  39. package/src/add/add-workflow.test.ts +3 -0
  40. package/src/add/add-workflow.ts +16 -2
  41. package/src/add/addon-bans.test.ts +121 -0
  42. package/src/add/addon-contracts.test.ts +221 -0
  43. package/src/add/pii-check.test.ts +4 -0
  44. package/src/add/wire-name-literal.test.ts +3 -0
  45. package/src/error-codes.ts +18 -0
  46. package/src/index.ts +1 -0
  47. package/src/inspector.ts +25 -6
  48. package/src/types.ts +75 -2
  49. package/src/utils/extract-node-value.test.ts +49 -1
  50. package/src/utils/extract-node-value.ts +19 -2
  51. package/src/utils/filter-inspector-state.test.ts +1 -0
  52. package/src/utils/filter-utils.test.ts +1 -0
  53. package/src/utils/get-exported-variable-name.ts +48 -0
  54. package/src/utils/load-addon-functions-meta.ts +164 -0
  55. package/src/utils/resolve-addon-package.ts +6 -1
  56. package/src/utils/resolve-ref-contract.ts +71 -0
  57. package/src/utils/resolve-versions.test.ts +1 -0
  58. package/src/utils/serialize-inspector-state.ts +10 -0
  59. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +16 -0
  60. package/src/visit.ts +26 -19
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,84 @@
1
+ import * as ts from 'typescript'
2
+ import type { AddWiring } from '../types.js'
3
+ import { ErrorCode } from '../error-codes.js'
4
+
5
+ /**
6
+ * Wiring helpers an addon must not call. Addons declare contracts with the
7
+ * define* helpers and export functions; the consuming app does the wiring via
8
+ * refHTTP / refChannel / refCLI. Service declarations remain allowed.
9
+ */
10
+ const BANNED_WIRINGS = new Set([
11
+ 'wireAddon',
12
+ 'wireChannel',
13
+ 'wireCLI',
14
+ 'wireGateway',
15
+ 'wireHTTP',
16
+ 'wireHTTPRoutes',
17
+ 'wireMCPPrompt',
18
+ 'wireMCPResource',
19
+ 'wireQueueWorker',
20
+ 'wireScheduler',
21
+ 'wireTrigger',
22
+ 'wireTriggerSource',
23
+ ])
24
+
25
+ const CONTRACT_DEFINERS = new Set([
26
+ 'defineHTTPRoutes',
27
+ 'defineChannelRoutes',
28
+ 'defineCLICommands',
29
+ ])
30
+
31
+ const hasHandlerProperty = (node: ts.Node): boolean => {
32
+ let found = false
33
+ const visit = (current: ts.Node) => {
34
+ if (found) return
35
+ if (
36
+ ts.isPropertyAssignment(current) &&
37
+ (ts.isIdentifier(current.name) || ts.isStringLiteral(current.name)) &&
38
+ (current.name.text === 'middleware' ||
39
+ current.name.text === 'permissions')
40
+ ) {
41
+ found = true
42
+ return
43
+ }
44
+ ts.forEachChild(current, visit)
45
+ }
46
+ visit(node)
47
+ return found
48
+ }
49
+
50
+ /**
51
+ * Enforce addon authoring rules. Only runs when inspecting an addon package
52
+ * (options.isAddon). Addons cannot wire transports, and their contracts cannot
53
+ * carry middleware or permissions — those are the consuming app's concern.
54
+ */
55
+ export const checkAddonBans: AddWiring = (
56
+ logger,
57
+ node,
58
+ _checker,
59
+ _state,
60
+ options
61
+ ) => {
62
+ if (!options.isAddon) return
63
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) return
64
+
65
+ const name = node.expression.text
66
+
67
+ if (BANNED_WIRINGS.has(name)) {
68
+ logger.critical(
69
+ ErrorCode.ADDON_WIRING_NOT_ALLOWED,
70
+ `Addons must not call '${name}'. Declare contracts with define* and export functions; the consuming app wires them via refHTTP / refChannel / refCLI.`
71
+ )
72
+ return
73
+ }
74
+
75
+ if (CONTRACT_DEFINERS.has(name)) {
76
+ const [arg] = node.arguments
77
+ if (arg && hasHandlerProperty(arg)) {
78
+ logger.critical(
79
+ ErrorCode.ADDON_CONTRACT_HANDLERS_NOT_ALLOWED,
80
+ `Addon contract '${name}' must not declare middleware or permissions — these are applied by the consuming app, not the addon.`
81
+ )
82
+ }
83
+ }
84
+ }
@@ -13,6 +13,9 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
13
13
  info: () => {},
14
14
  warn: () => {},
15
15
  error: () => {},
16
+ diagnostic: ({ code, message }) => {
17
+ criticals.push({ code, message })
18
+ },
16
19
  critical: (code: ErrorCode, message: string) => {
17
20
  criticals.push({ code, message })
18
21
  },
@@ -21,6 +21,8 @@ import { resolveIdentifier } from '../utils/resolve-identifier.js'
21
21
  import { resolveFunctionMeta } from '../utils/resolve-function-meta.js'
22
22
  import { resolveAddonName } from '../utils/resolve-addon-package.js'
23
23
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js'
24
+ import { getExportedVariableName } from '../utils/get-exported-variable-name.js'
25
+ import { resolveRefContract } from '../utils/resolve-ref-contract.js'
24
26
 
25
27
  /**
26
28
  * Safely get the "initializer" expression of a property-like AST node:
@@ -40,6 +42,16 @@ function getInitializerOf(
40
42
  return undefined
41
43
  }
42
44
 
45
+ function getObjectPropertyName(
46
+ name: ts.PropertyName | undefined
47
+ ): string | null {
48
+ if (!name) return null
49
+ if (ts.isIdentifier(name)) return name.text
50
+ if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text
51
+ if (ts.isComputedPropertyName(name)) return null
52
+ return name.getText()
53
+ }
54
+
43
55
  /**
44
56
  * Resolve a handler expression (Identifier, CallExpression, or { func })
45
57
  * into its underlying function name.
@@ -116,6 +128,27 @@ function getHandlerNameFromExpression(
116
128
  return null
117
129
  }
118
130
 
131
+ function extractExportedChannelRoutes(
132
+ logger: {
133
+ error: (msg: string) => void
134
+ critical: (code: ErrorCode, msg: string) => void
135
+ },
136
+ routes: ts.ObjectLiteralExpression,
137
+ state: InspectorState,
138
+ checker: ts.TypeChecker
139
+ ): Record<string, ChannelMessageMeta> {
140
+ const wrapper = ts.factory.createObjectLiteralExpression([
141
+ ts.factory.createPropertyAssignment(
142
+ 'onMessageWiring',
143
+ ts.factory.createObjectLiteralExpression([
144
+ ts.factory.createPropertyAssignment('contract', routes),
145
+ ])
146
+ ),
147
+ ])
148
+
149
+ return addMessagesRoutes(logger, wrapper, state, checker).contract ?? {}
150
+ }
151
+
119
152
  /**
120
153
  * Build out the nested message-routes by looking up each handler
121
154
  * in state.functions.meta instead of re-inferring it here.
@@ -155,9 +188,24 @@ export function addMessagesRoutes(
155
188
  }
156
189
  }
157
190
 
191
+ const refContract = resolveRefContract(
192
+ chanInit,
193
+ 'refChannel',
194
+ state.exportedContracts.addonChannel
195
+ )
196
+ if (refContract) {
197
+ const refChannelKey = getObjectPropertyName(chanElem.name)
198
+ if (!refChannelKey) continue
199
+ result[refChannelKey] = {
200
+ ...refContract.contract,
201
+ }
202
+ continue
203
+ }
204
+
158
205
  if (!ts.isObjectLiteralExpression(chanInit)) continue
159
206
 
160
- const channelKey = chanElem.name!.getText()
207
+ const channelKey = getObjectPropertyName(chanElem.name)
208
+ if (!channelKey) continue
161
209
  result[channelKey] = {}
162
210
 
163
211
  for (const routeElem of chanInit.properties) {
@@ -168,11 +216,8 @@ export function addMessagesRoutes(
168
216
  const routeName = routeElem.name
169
217
  if (!routeName) continue
170
218
 
171
- let routeKey = routeName.getText()
172
- // For string literals like 'greet' or "greet", strip the quotes
173
- if (ts.isStringLiteral(routeName)) {
174
- routeKey = routeName.text
175
- }
219
+ const routeKey = getObjectPropertyName(routeName)
220
+ if (!routeKey) continue
176
221
 
177
222
  // For shorthand properties, we need to resolve the identifier to its declaration
178
223
  if (ts.isShorthandPropertyAssignment(routeElem)) {
@@ -529,6 +574,18 @@ export const addChannel: AddWiring = (
529
574
  options
530
575
  ) => {
531
576
  if (!ts.isCallExpression(node)) return
577
+ if (
578
+ ts.isIdentifier(node.expression) &&
579
+ node.expression.text === 'defineChannelRoutes'
580
+ ) {
581
+ const exportName = getExportedVariableName(node, options.sourceFile)
582
+ const [firstArg] = node.arguments
583
+ if (exportName && firstArg && ts.isObjectLiteralExpression(firstArg)) {
584
+ state.exportedContracts.channel[exportName] =
585
+ extractExportedChannelRoutes(logger, firstArg, state, checker)
586
+ }
587
+ return
588
+ }
532
589
  const { expression, arguments: args } = node
533
590
  if (!ts.isIdentifier(expression) || expression.text !== 'wireChannel') return
534
591
  const first = args[0]
@@ -664,7 +721,9 @@ export const addChannel: AddWiring = (
664
721
  state.serviceAggregation.usedFunctions.add(message.pikkuFuncId)
665
722
  }
666
723
 
667
- for (const channelHandlers of Object.values(messageWirings)) {
724
+ for (const channelHandlers of Object.values(
725
+ messageWirings as Record<string, Record<string, ChannelMessageMeta>>
726
+ )) {
668
727
  for (const handler of Object.values(channelHandlers)) {
669
728
  state.serviceAggregation.usedFunctions.add(handler.pikkuFuncId)
670
729
  }
@@ -12,6 +12,7 @@ const makeLogger = () =>
12
12
  info: () => {},
13
13
  warn: () => {},
14
14
  error: () => {},
15
+ diagnostic: () => {},
15
16
  critical: () => {},
16
17
  hasCriticalErrors: () => false,
17
18
  }) satisfies InspectorLogger
@@ -19,6 +19,8 @@ import { resolveIdentifier } from '../utils/resolve-identifier.js'
19
19
  import { resolveAddonName } from '../utils/resolve-addon-package.js'
20
20
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js'
21
21
  import { extractServicesFromFunction } from '../utils/extract-services.js'
22
+ import { getExportedVariableName } from '../utils/get-exported-variable-name.js'
23
+ import { resolveRefContract } from '../utils/resolve-ref-contract.js'
22
24
 
23
25
  // Track if we've warned about missing Config type to avoid duplicate warnings
24
26
  const configTypeWarningShown = new Set<string>()
@@ -34,6 +36,25 @@ export const addCLI: AddWiring = (
34
36
  options
35
37
  ) => {
36
38
  if (!ts.isCallExpression(node)) return
39
+ if (
40
+ ts.isIdentifier(node.expression) &&
41
+ node.expression.text === 'defineCLICommands'
42
+ ) {
43
+ const exportName = getExportedVariableName(node, options.sourceFile)
44
+ const [firstArg] = node.arguments
45
+ if (exportName && firstArg && ts.isObjectLiteralExpression(firstArg)) {
46
+ inspectorState.exportedContracts.cli[exportName] = processCommands(
47
+ logger,
48
+ firstArg,
49
+ node.getSourceFile(),
50
+ typeChecker,
51
+ exportName,
52
+ inspectorState,
53
+ options
54
+ )
55
+ }
56
+ return
57
+ }
37
58
  // Check if this is a wireCLI call
38
59
  if (!node || !node.expression) {
39
60
  return
@@ -214,6 +235,15 @@ function processCommands(
214
235
  programTags
215
236
  )
216
237
  Object.assign(commands, spreadCommands)
238
+ } else {
239
+ const refCommands = resolveRefContract(
240
+ prop.expression,
241
+ 'refCLI',
242
+ inspectorState.exportedContracts.addonCli
243
+ )
244
+ if (refCommands) {
245
+ Object.assign(commands, refCommands.contract)
246
+ }
217
247
  }
218
248
  continue
219
249
  }
@@ -39,6 +39,9 @@ describe('addFunctions duplicate name handling', () => {
39
39
  info: () => {},
40
40
  warn: () => {},
41
41
  error: () => {},
42
+ diagnostic: ({ code, message }) => {
43
+ criticals.push({ code, message })
44
+ },
42
45
  critical: (code: ErrorCode, message: string) => {
43
46
  criticals.push({ code, message })
44
47
  },
@@ -91,6 +94,9 @@ describe('addFunctions duplicate name handling', () => {
91
94
  info: () => {},
92
95
  warn: () => {},
93
96
  error: () => {},
97
+ diagnostic: ({ code, message }) => {
98
+ criticals.push({ code, message })
99
+ },
94
100
  critical: (code: ErrorCode, message: string) => {
95
101
  criticals.push({ code, message })
96
102
  },
@@ -142,6 +148,7 @@ describe('addFunctions duplicate name handling', () => {
142
148
  info: () => {},
143
149
  warn: () => {},
144
150
  error: () => {},
151
+ diagnostic: () => {},
145
152
  critical: () => {},
146
153
  hasCriticalErrors: () => false,
147
154
  }
@@ -204,6 +211,9 @@ describe('addFunctions duplicate name handling', () => {
204
211
  info: () => {},
205
212
  warn: () => {},
206
213
  error: () => {},
214
+ diagnostic: ({ code, message }) => {
215
+ criticals.push({ code, message })
216
+ },
207
217
  critical: (code: ErrorCode, message: string) => {
208
218
  criticals.push({ code, message })
209
219
  },
@@ -250,6 +260,7 @@ describe('addFunctions implementationHash', () => {
250
260
  info: () => {},
251
261
  warn: () => {},
252
262
  error: () => {},
263
+ diagnostic: () => {},
253
264
  critical: () => {},
254
265
  hasCriticalErrors: () => false,
255
266
  }
@@ -296,6 +307,7 @@ describe('addFunctions implementationHash', () => {
296
307
  info: () => {},
297
308
  warn: () => {},
298
309
  error: () => {},
310
+ diagnostic: () => {},
299
311
  critical: () => {},
300
312
  hasCriticalErrors: () => false,
301
313
  }
@@ -349,6 +361,7 @@ describe('pikkuChannelConnectionFunc generic mapping', () => {
349
361
  info: () => {},
350
362
  warn: () => {},
351
363
  error: () => {},
364
+ diagnostic: () => {},
352
365
  critical: () => {},
353
366
  hasCriticalErrors: () => false,
354
367
  }
@@ -931,23 +931,27 @@ export const addFunctions: AddWiring = (
931
931
  .map((f) => f.path)
932
932
 
933
933
  if (secretPaths.length > 0) {
934
- logger.critical(
935
- ErrorCode.PII_IN_OUTPUT,
936
- `Function '${name}' exposes secret-classified field(s) in its return type: ` +
934
+ logger.diagnostic({
935
+ severity: 'error',
936
+ code: ErrorCode.PII_IN_OUTPUT,
937
+ message:
938
+ `Function '${name}' exposes secret-classified field(s) in its return type: ` +
937
939
  secretPaths.map((p) => `'${p}'`).join(', ') +
938
940
  `.\n Secret fields must never appear in function output. ` +
939
- `Strip these fields before returning or change the column classification.`
940
- )
941
+ `Strip these fields before returning or change the column classification.`,
942
+ })
941
943
  }
942
944
 
943
945
  if (sessionless && privatePaths.length > 0) {
944
- logger.critical(
945
- ErrorCode.PII_IN_OUTPUT,
946
- `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
946
+ logger.diagnostic({
947
+ severity: 'error',
948
+ code: ErrorCode.PII_IN_OUTPUT,
949
+ message:
950
+ `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
947
951
  privatePaths.map((p) => `'${p}'`).join(', ') +
948
952
  `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
949
- `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`
950
- )
953
+ `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`,
954
+ })
951
955
  }
952
956
  }
953
957
  }
@@ -13,7 +13,11 @@ import {
13
13
  getPropertyAssignmentInitializer,
14
14
  extractTypeKeys,
15
15
  } from '../utils/type-utils.js'
16
- import type { AddWiring, InspectorState } from '../types.js'
16
+ import type {
17
+ AddWiring,
18
+ ExportedHTTPRouteConfigMeta,
19
+ InspectorState,
20
+ } from '../types.js'
17
21
  import { resolveHTTPMiddlewareFromObject } from '../utils/middleware.js'
18
22
  import { resolveHTTPPermissionsFromObject } from '../utils/permissions.js'
19
23
  import { extractWireNames } from '../utils/post-process.js'
@@ -40,6 +44,16 @@ export interface RegisterHTTPRouteParams {
40
44
  inheritedAuth?: boolean
41
45
  }
42
46
 
47
+ export interface RegisterHTTPRouteMetaParams {
48
+ route: ExportedHTTPRouteConfigMeta
49
+ state: InspectorState
50
+ logger: InspectorLogger
51
+ sourceFile: ts.SourceFile
52
+ basePath?: string
53
+ inheritedTags?: string[]
54
+ inheritedAuth?: boolean
55
+ }
56
+
43
57
  /**
44
58
  * Extract header schema reference from headers property
45
59
  */
@@ -421,6 +435,66 @@ export function registerHTTPRoute({
421
435
  }
422
436
  }
423
437
 
438
+ export function registerHTTPRouteMeta({
439
+ route,
440
+ state,
441
+ logger,
442
+ sourceFile,
443
+ basePath = '',
444
+ inheritedTags = [],
445
+ inheritedAuth,
446
+ }: RegisterHTTPRouteMetaParams): void {
447
+ const method = route.method.toLowerCase()
448
+ const fullRoute = basePath + route.route
449
+ const tags = [...inheritedTags, ...(route.tags || [])]
450
+ const funcName = route.func.pikkuFuncId
451
+ const fnMeta = resolveFunctionMeta(state, funcName)
452
+
453
+ if (!fnMeta) {
454
+ logger.critical(
455
+ ErrorCode.FUNCTION_METADATA_NOT_FOUND,
456
+ `No function metadata found for '${funcName}'.`
457
+ )
458
+ return
459
+ }
460
+
461
+ let params: string[] = []
462
+ try {
463
+ const keys = pathToRegexp(fullRoute).keys
464
+ params = keys.filter((k) => k.type === 'param').map((k) => k.name)
465
+ } catch (error) {
466
+ logger.error(
467
+ `Failed to parse route '${fullRoute}': ${error instanceof Error ? error.message : error}`
468
+ )
469
+ return
470
+ }
471
+
472
+ if (!route.func.packageName) {
473
+ computeInputTypes(
474
+ state.http.metaInputTypes,
475
+ method,
476
+ fnMeta.inputs?.[0] || null,
477
+ [],
478
+ params
479
+ )
480
+ }
481
+
482
+ state.serviceAggregation.usedFunctions.add(funcName)
483
+ state.http.files.add(sourceFile.fileName)
484
+ state.http.meta[method][fullRoute] = {
485
+ pikkuFuncId: funcName,
486
+ ...(route.func.packageName && { packageName: route.func.packageName }),
487
+ route: fullRoute,
488
+ sourceFile: sourceFile.fileName,
489
+ method: method as HTTPMethod,
490
+ params: params.length > 0 ? params : undefined,
491
+ inputTypes: undefined,
492
+ tags: tags.length > 0 ? tags : undefined,
493
+ sse: route.sse ? true : undefined,
494
+ groupBasePath: basePath || undefined,
495
+ }
496
+ }
497
+
424
498
  /**
425
499
  * Process wireHTTP calls
426
500
  */