@pikku/inspector 0.12.42 → 0.12.44

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 (126) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/dist/add/add-addon-bans.js +1 -0
  3. package/dist/add/add-ai-agent.d.ts +3 -1
  4. package/dist/add/add-ai-agent.js +82 -0
  5. package/dist/add/add-credential.js +5 -1
  6. package/dist/add/add-functions.js +10 -9
  7. package/dist/add/add-gateway.js +3 -0
  8. package/dist/add/add-http-route.js +2 -2
  9. package/dist/add/add-keyed-wiring.js +2 -0
  10. package/dist/add/add-mcp-prompt.js +0 -1
  11. package/dist/add/add-mcp-resource.js +0 -1
  12. package/dist/add/add-permission.d.ts +1 -1
  13. package/dist/add/add-permission.js +3 -177
  14. package/dist/add/add-queue-worker.js +3 -0
  15. package/dist/add/add-schedule.js +3 -0
  16. package/dist/add/add-scope.d.ts +2 -0
  17. package/dist/add/add-scope.js +146 -0
  18. package/dist/add/add-trigger.js +3 -0
  19. package/dist/add/add-wire-remote-addon.d.ts +10 -0
  20. package/dist/add/add-wire-remote-addon.js +66 -0
  21. package/dist/add/add-workflow-graph.js +32 -3
  22. package/dist/add/add-workflow.js +9 -4
  23. package/dist/error-codes.d.ts +3 -0
  24. package/dist/error-codes.js +4 -0
  25. package/dist/inspector.js +11 -5
  26. package/dist/types.d.ts +19 -13
  27. package/dist/utils/custom-types-generator.js +2 -1
  28. package/dist/utils/ensure-function-metadata.d.ts +15 -0
  29. package/dist/utils/ensure-function-metadata.js +24 -0
  30. package/dist/utils/filter-inspector-state.js +9 -0
  31. package/dist/utils/get-property-value.d.ts +7 -2
  32. package/dist/utils/get-property-value.js +48 -1
  33. package/dist/utils/load-addon-functions-meta.js +81 -6
  34. package/dist/utils/permissions.d.ts +1 -21
  35. package/dist/utils/permissions.js +17 -108
  36. package/dist/utils/post-process.d.ts +30 -1
  37. package/dist/utils/post-process.js +150 -72
  38. package/dist/utils/schema-generator.js +19 -4
  39. package/dist/utils/serialize-inspector-state.d.ts +13 -8
  40. package/dist/utils/serialize-inspector-state.js +12 -6
  41. package/dist/utils/serialize-permissions-groups-meta.d.ts +0 -2
  42. package/dist/utils/serialize-permissions-groups-meta.js +0 -26
  43. package/dist/utils/workflow/derive-workflow-plan.js +9 -3
  44. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
  45. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +274 -100
  46. package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
  47. package/dist/utils/workflow/dsl/patterns.js +11 -0
  48. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +76 -5
  49. package/dist/utils/workflow/graph/finalize-workflows.d.ts +7 -0
  50. package/dist/utils/workflow/graph/finalize-workflows.js +11 -2
  51. package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +2 -0
  52. package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -0
  53. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +8 -4
  54. package/dist/visit.js +4 -0
  55. package/package.json +3 -3
  56. package/run-tests.sh +1 -1
  57. package/src/add/add-addon-bans.ts +1 -0
  58. package/src/add/add-ai-agent.test.ts +87 -0
  59. package/src/add/add-ai-agent.ts +122 -0
  60. package/src/add/add-credential.ts +8 -0
  61. package/src/add/add-functions.ts +10 -12
  62. package/src/add/add-gateway.ts +11 -0
  63. package/src/add/add-http-route.ts +2 -8
  64. package/src/add/add-keyed-wiring.ts +2 -0
  65. package/src/add/add-mcp-prompt.ts +0 -1
  66. package/src/add/add-mcp-resource.ts +0 -1
  67. package/src/add/add-permission.ts +4 -242
  68. package/src/add/add-queue-worker.ts +11 -0
  69. package/src/add/add-schedule.ts +11 -0
  70. package/src/add/add-scope.test.ts +346 -0
  71. package/src/add/add-scope.ts +225 -0
  72. package/src/add/add-trigger.ts +11 -0
  73. package/src/add/add-wire-remote-addon.ts +77 -0
  74. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  75. package/src/add/add-workflow-fanout.test.ts +60 -0
  76. package/src/add/add-workflow-graph-input.test.ts +94 -0
  77. package/src/add/add-workflow-graph-notes.test.ts +74 -0
  78. package/src/add/add-workflow-graph.ts +35 -3
  79. package/src/add/add-workflow-on-error.test.ts +118 -0
  80. package/src/add/add-workflow-return-switch.test.ts +121 -0
  81. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  82. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  83. package/src/add/add-workflow.ts +9 -4
  84. package/src/add/config-value-metadata.test.ts +169 -0
  85. package/src/add/inline-wiring-function.test.ts +104 -0
  86. package/src/add/validate-workflow-graph-addons.test.ts +102 -0
  87. package/src/error-codes.ts +5 -0
  88. package/src/inspector.ts +14 -4
  89. package/src/types.ts +16 -17
  90. package/src/utils/custom-types-generator.test.ts +26 -1
  91. package/src/utils/custom-types-generator.ts +2 -1
  92. package/src/utils/ensure-function-metadata.ts +42 -0
  93. package/src/utils/filter-inspector-state.test.ts +0 -2
  94. package/src/utils/filter-inspector-state.ts +9 -0
  95. package/src/utils/get-property-value.test.ts +141 -0
  96. package/src/utils/get-property-value.ts +62 -1
  97. package/src/utils/load-addon-functions-meta.test.ts +157 -0
  98. package/src/utils/load-addon-functions-meta.ts +94 -6
  99. package/src/utils/load-addon-scopes.test.ts +138 -0
  100. package/src/utils/permissions.test.ts +7 -232
  101. package/src/utils/permissions.ts +20 -160
  102. package/src/utils/post-process.test.ts +269 -4
  103. package/src/utils/post-process.ts +216 -95
  104. package/src/utils/schema-generator.test.ts +164 -0
  105. package/src/utils/schema-generator.ts +26 -3
  106. package/src/utils/serialize-inspector-state.ts +22 -25
  107. package/src/utils/serialize-permissions-groups-meta.ts +0 -28
  108. package/src/utils/workflow/derive-workflow-plan.test.ts +41 -3
  109. package/src/utils/workflow/derive-workflow-plan.ts +11 -4
  110. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  111. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  112. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  113. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  114. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  115. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  116. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  117. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +311 -105
  118. package/src/utils/workflow/dsl/patterns.ts +18 -0
  119. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +82 -10
  120. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  121. package/src/utils/workflow/graph/finalize-workflows.test.ts +50 -0
  122. package/src/utils/workflow/graph/finalize-workflows.ts +13 -2
  123. package/src/utils/workflow/graph/serialize-workflow-graph.ts +6 -0
  124. package/src/utils/workflow/graph/workflow-graph.types.ts +11 -2
  125. package/src/visit.ts +4 -0
  126. package/tsconfig.tsbuildinfo +1 -1
@@ -10,9 +10,12 @@ import type {
10
10
  MiddlewareMetadata,
11
11
  PermissionMetadata,
12
12
  } from '@pikku/core'
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
13
15
  import { extractTypeKeys } from './type-utils.js'
14
16
  import { ErrorCode } from '../error-codes.js'
15
17
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
18
+ import { flattenScopeDefinitions } from '@pikku/core/scope'
16
19
 
17
20
  /**
18
21
  * Stamp the inspected authorize/callbacks service set onto the generated auth
@@ -46,7 +49,7 @@ export function stampAuthHandlerServices(
46
49
  * Skips type:'http' and type:'tag' (reference groups, not individuals).
47
50
  */
48
51
  export function extractWireNames(
49
- list?: MiddlewareMetadata[] | PermissionMetadata[]
52
+ list?: Array<MiddlewareMetadata | PermissionMetadata>
50
53
  ): string[] {
51
54
  if (!list) return []
52
55
  return list
@@ -57,34 +60,25 @@ export function extractWireNames(
57
60
  }
58
61
 
59
62
  /**
60
- * Helper to expand middleware/permission groups into individual names
61
- * and add their services to the aggregation.
62
- * This handles tag-based and HTTP-pattern-based middleware/permission groups.
63
+ * Helper to expand middleware groups into individual names and add their
64
+ * services to the aggregation. Handles tag-based and HTTP-pattern-based
65
+ * middleware groups. Permissions are function-scoped only and carry no groups.
63
66
  */
64
67
  function expandAndAddGroupServices(
65
- list: MiddlewareMetadata[] | PermissionMetadata[] | undefined,
68
+ list: MiddlewareMetadata[] | undefined,
66
69
  state: InspectorState | Omit<InspectorState, 'typesLookup'>,
67
- addServices: (services: FunctionServicesMeta | undefined) => void,
68
- isMiddleware: boolean
70
+ addServices: (services: FunctionServicesMeta | undefined) => void
69
71
  ): void {
70
72
  if (!list) return
71
73
 
72
74
  for (const item of list) {
73
75
  if (item.type === 'tag') {
74
- // Expand tag-based group
75
- const groupMeta = isMiddleware
76
- ? state.middleware.tagMiddleware.get(item.tag)
77
- : state.permissions.tagPermissions.get(item.tag)
78
-
76
+ const groupMeta = state.middleware.tagMiddleware.get(item.tag)
79
77
  if (groupMeta?.services) {
80
78
  addServices(groupMeta.services)
81
79
  }
82
80
  } else if (item.type === 'http' && 'route' in item) {
83
- // Expand HTTP-pattern-based group
84
- const groupMeta = isMiddleware
85
- ? state.http.routeMiddleware.get(item.route)
86
- : state.http.routePermissions.get(item.route)
87
-
81
+ const groupMeta = state.http.routeMiddleware.get(item.route)
88
82
  if (groupMeta?.services) {
89
83
  addServices(groupMeta.services)
90
84
  }
@@ -188,55 +182,33 @@ export function aggregateRequiredServices(
188
182
  'options',
189
183
  ] as const) {
190
184
  for (const routeMeta of Object.values(state.http.meta[method])) {
191
- expandAndAddGroupServices(routeMeta.middleware, state, addServices, true)
192
- expandAndAddGroupServices(
193
- routeMeta.permissions,
194
- state,
195
- addServices,
196
- false
197
- )
185
+ expandAndAddGroupServices(routeMeta.middleware, state, addServices)
198
186
  }
199
187
  }
200
188
 
201
189
  // Also check other wiring types (channels, queues, schedulers, MCP)
202
190
  for (const channelMeta of Object.values(state.channels.meta)) {
203
- expandAndAddGroupServices(channelMeta.middleware, state, addServices, true)
204
- expandAndAddGroupServices(
205
- channelMeta.permissions,
206
- state,
207
- addServices,
208
- false
209
- )
191
+ expandAndAddGroupServices(channelMeta.middleware, state, addServices)
210
192
  }
211
193
 
212
194
  for (const queueMeta of Object.values(state.queueWorkers.meta)) {
213
- expandAndAddGroupServices(queueMeta.middleware, state, addServices, true)
214
- // expandAndAddGroupServices(queueMeta.permissions, state, addServices, false)
195
+ expandAndAddGroupServices(queueMeta.middleware, state, addServices)
215
196
  }
216
197
 
217
198
  for (const scheduleMeta of Object.values(state.scheduledTasks.meta)) {
218
- expandAndAddGroupServices(scheduleMeta.middleware, state, addServices, true)
219
- // expandAndAddGroupServices(scheduleMeta.permissions, state, addServices, false)
199
+ expandAndAddGroupServices(scheduleMeta.middleware, state, addServices)
220
200
  }
221
201
 
222
202
  for (const toolMeta of Object.values(state.mcpEndpoints.toolsMeta)) {
223
- expandAndAddGroupServices(toolMeta.middleware, state, addServices, true)
224
- expandAndAddGroupServices(toolMeta.permissions, state, addServices, false)
203
+ expandAndAddGroupServices(toolMeta.middleware, state, addServices)
225
204
  }
226
205
 
227
206
  for (const promptMeta of Object.values(state.mcpEndpoints.promptsMeta)) {
228
- expandAndAddGroupServices(promptMeta.middleware, state, addServices, true)
229
- expandAndAddGroupServices(promptMeta.permissions, state, addServices, false)
207
+ expandAndAddGroupServices(promptMeta.middleware, state, addServices)
230
208
  }
231
209
 
232
210
  for (const resourceMeta of Object.values(state.mcpEndpoints.resourcesMeta)) {
233
- expandAndAddGroupServices(resourceMeta.middleware, state, addServices, true)
234
- expandAndAddGroupServices(
235
- resourceMeta.permissions,
236
- state,
237
- addServices,
238
- false
239
- )
211
+ expandAndAddGroupServices(resourceMeta.middleware, state, addServices)
240
212
  }
241
213
 
242
214
  // 5. Services from session service factories
@@ -378,17 +350,24 @@ export function validateSecretOverrides(
378
350
  const { wireAddonDeclarations } = state.rpc
379
351
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
380
352
 
381
- const secretNames = new Set(state.secrets.definitions.map((d) => d.name))
353
+ // secretOverrides key on (and resolve to) SECRET IDs — the string the addon
354
+ // passes to getSecret — so validate against secretId, falling back to name for
355
+ // older meta without a secretId field.
356
+ const secretIds = new Set(
357
+ state.secrets.definitions.map((d: any) => d.secretId ?? d.name)
358
+ )
382
359
 
383
360
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
384
361
  if (!addonDecl.secretOverrides) continue
385
362
 
386
- for (const secretKey of Object.keys(addonDecl.secretOverrides)) {
387
- if (!secretNames.has(secretKey)) {
388
- const availableSecrets = Array.from(secretNames)
363
+ for (const [logicalName, resolvedName] of Object.entries(
364
+ addonDecl.secretOverrides
365
+ )) {
366
+ if (!secretIds.has(resolvedName)) {
367
+ const availableSecrets = Array.from(secretIds)
389
368
  logger.critical(
390
369
  ErrorCode.INVALID_VALUE,
391
- `Secret override '${secretKey}' in addon '${namespace}' (${addonDecl.package}) does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`
370
+ `Secret override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`
392
371
  )
393
372
  }
394
373
  }
@@ -409,12 +388,14 @@ export function validateCredentialOverrides(
409
388
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
410
389
  if (!addonDecl.credentialOverrides) continue
411
390
 
412
- for (const credentialKey of Object.keys(addonDecl.credentialOverrides)) {
413
- if (!credentialNames.has(credentialKey)) {
391
+ for (const [logicalName, resolvedName] of Object.entries(
392
+ addonDecl.credentialOverrides
393
+ )) {
394
+ if (!credentialNames.has(resolvedName)) {
414
395
  const availableCredentials = Array.from(credentialNames)
415
396
  logger.critical(
416
397
  ErrorCode.INVALID_VALUE,
417
- `Credential override '${credentialKey}' in addon '${namespace}' (${addonDecl.package}) does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`
398
+ `Credential override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`
418
399
  )
419
400
  }
420
401
  }
@@ -428,23 +409,120 @@ export function validateVariableOverrides(
428
409
  const { wireAddonDeclarations } = state.rpc
429
410
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
430
411
 
431
- const variableNames = new Set(state.variables.definitions.map((d) => d.name))
412
+ // variableOverrides key on (and resolve to) VARIABLE IDs, so validate against
413
+ // variableId, falling back to name for older meta without a variableId field.
414
+ const variableIds = new Set(
415
+ state.variables.definitions.map((d: any) => d.variableId ?? d.name)
416
+ )
432
417
 
433
418
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
434
419
  if (!addonDecl.variableOverrides) continue
435
420
 
436
- for (const variableKey of Object.keys(addonDecl.variableOverrides)) {
437
- if (!variableNames.has(variableKey)) {
438
- const availableVariables = Array.from(variableNames)
421
+ for (const [logicalName, resolvedName] of Object.entries(
422
+ addonDecl.variableOverrides
423
+ )) {
424
+ if (!variableIds.has(resolvedName)) {
425
+ const availableVariables = Array.from(variableIds)
439
426
  logger.critical(
440
427
  ErrorCode.INVALID_VALUE,
441
- `Variable override '${variableKey}' in addon '${namespace}' (${addonDecl.package}) does not exist. Available variables: ${availableVariables.join(', ') || 'none'}`
428
+ `Variable override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a variable that does not exist. Available variables: ${availableVariables.join(', ') || 'none'}`
442
429
  )
443
430
  }
444
431
  }
445
432
  }
446
433
  }
447
434
 
435
+ /**
436
+ * A `wireRemoteAddon` package ships types only — its handlers run on the host —
437
+ * so it MUST be a devDependency, not a production dependency (a prod dep would
438
+ * drag in the runtime deps remote consumption exists to avoid). This is the
439
+ * mirror image of `wireAddon`, which requires a production dependency.
440
+ */
441
+ export function validateRemoteAddonDependencies(
442
+ logger: InspectorLogger,
443
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
444
+ ): void {
445
+ const { wireAddonDeclarations } = state.rpc
446
+ if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
447
+
448
+ const hasRemote = Array.from(wireAddonDeclarations.values()).some(
449
+ (d) => d.remote
450
+ )
451
+ if (!hasRemote) return
452
+
453
+ const pkgJsonPath = join(state.rootDir, 'package.json')
454
+ if (!existsSync(pkgJsonPath)) return // no manifest to check (e.g. some tests)
455
+
456
+ let pkgJson: {
457
+ dependencies?: Record<string, string>
458
+ devDependencies?: Record<string, string>
459
+ }
460
+ try {
461
+ pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
462
+ } catch (e: any) {
463
+ logger.warn(
464
+ `Could not read ${pkgJsonPath} to verify remote addon dependencies: ${e?.message ?? e}`
465
+ )
466
+ return
467
+ }
468
+
469
+ const prodDeps = pkgJson.dependencies ?? {}
470
+ const devDeps = pkgJson.devDependencies ?? {}
471
+
472
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
473
+ if (!decl.remote) continue
474
+ if (decl.package in devDeps) continue // correct
475
+
476
+ if (decl.package in prodDeps) {
477
+ logger.critical(
478
+ ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY,
479
+ `Remote addon '${namespace}' ('${decl.package}') is a production dependency, but wireRemoteAddon consumes it for types only — its handlers run on the host. Move '${decl.package}' from "dependencies" to "devDependencies".`
480
+ )
481
+ } else {
482
+ logger.critical(
483
+ ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY,
484
+ `Remote addon '${namespace}' ('${decl.package}') is wired with wireRemoteAddon but is not in "devDependencies". Add '${decl.package}' to "devDependencies" (types only).`
485
+ )
486
+ }
487
+ }
488
+ }
489
+
490
+ /**
491
+ * The auth a `wireRemoteAddon` consumer binds must reference a slot the consumer
492
+ * actually declares: a `credentialId` must be a wired credential, a `secretId`
493
+ * a wired secret. A custom `resolve()` and a public (omitted) surface are not
494
+ * statically checkable and are left to runtime.
495
+ */
496
+ export function validateRemoteAddonAuth(
497
+ logger: InspectorLogger,
498
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
499
+ ): void {
500
+ const { wireAddonDeclarations } = state.rpc
501
+ if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
502
+
503
+ const credentialNames = new Set(
504
+ state.credentials?.definitions.map((d) => d.name) ?? []
505
+ )
506
+ const secretNames = new Set(state.secrets.definitions.map((d) => d.name))
507
+
508
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
509
+ if (!decl.remote) continue
510
+
511
+ if (decl.authCredentialId && !credentialNames.has(decl.authCredentialId)) {
512
+ logger.critical(
513
+ ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED,
514
+ `Remote addon '${namespace}' binds auth.credentialId '${decl.authCredentialId}', but no such credential is wired. Available credentials: ${Array.from(credentialNames).join(', ') || 'none'}`
515
+ )
516
+ }
517
+ if (decl.authSecretId && !secretNames.has(decl.authSecretId)) {
518
+ logger.critical(
519
+ ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED,
520
+ `Remote addon '${namespace}' binds auth.secretId '${decl.authSecretId}', but no such secret is wired. Available secrets: ${Array.from(secretNames).join(', ') || 'none'}`
521
+ )
522
+ }
523
+ }
524
+ }
525
+
448
526
  export function computeResolvedIOTypes(state: InspectorState): void {
449
527
  const { functions } = state
450
528
  for (const [pikkuFuncId, meta] of Object.entries(functions.meta)) {
@@ -531,36 +609,8 @@ export function computeMiddlewareGroupsMeta(state: InspectorState): void {
531
609
  }
532
610
 
533
611
  export function computePermissionsGroupsMeta(state: InspectorState): void {
534
- const httpGroups: Record<string, any> = {}
535
- for (const [pattern, meta] of state.http.routePermissions.entries()) {
536
- httpGroups[pattern] = {
537
- exportName: meta.exportName,
538
- sourceFile: meta.sourceFile,
539
- position: meta.position,
540
- services: meta.services,
541
- count: meta.count,
542
- instanceIds: meta.instanceIds,
543
- isFactory: meta.isFactory,
544
- }
545
- }
546
-
547
- const tagGroups: Record<string, any> = {}
548
- for (const [tag, meta] of state.permissions.tagPermissions.entries()) {
549
- tagGroups[tag] = {
550
- exportName: meta.exportName,
551
- sourceFile: meta.sourceFile,
552
- position: meta.position,
553
- services: meta.services,
554
- count: meta.count,
555
- instanceIds: meta.instanceIds,
556
- isFactory: meta.isFactory,
557
- }
558
- }
559
-
560
612
  state.permissionsGroupsMeta = {
561
613
  definitions: state.permissions.definitions,
562
- httpGroups,
563
- tagGroups,
564
614
  }
565
615
  }
566
616
 
@@ -632,6 +682,44 @@ export function validateAgentModels(
632
682
  }
633
683
  }
634
684
 
685
+ /**
686
+ * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
687
+ * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
688
+ * registers and codegen fails deep in type-checking with an opaque error. Fail
689
+ * early with an actionable message pointing at `scaffold: { graph: true }`.
690
+ */
691
+ export function validateWorkflowGraphAddons(
692
+ logger: InspectorLogger,
693
+ state: InspectorState
694
+ ): void {
695
+ const addonGraphWired = Array.from(
696
+ state.rpc.wireAddonDeclarations.values()
697
+ ).some((decl) => decl.package === '@pikku/addon-graph')
698
+ if (addonGraphWired) {
699
+ return
700
+ }
701
+
702
+ for (const [name, graph] of Object.entries(state.workflows.graphMeta)) {
703
+ for (const node of Object.values(graph.nodes)) {
704
+ if (!('rpcName' in node) || typeof node.rpcName !== 'string') {
705
+ continue
706
+ }
707
+ if (!node.rpcName.startsWith('graph:')) {
708
+ continue
709
+ }
710
+ if (state.functions.meta[node.rpcName]) {
711
+ continue
712
+ }
713
+ logger.critical(
714
+ ErrorCode.WORKFLOW_GRAPH_ADDON_NOT_WIRED,
715
+ `Workflow graph '${name}' references '${node.rpcName}' but @pikku/addon-graph is not wired. ` +
716
+ `Enable "scaffold": { "graph": true } in pikku.config.json (and install @pikku/addon-graph), ` +
717
+ `or wire it manually with wireAddon({ name: 'graph', package: '@pikku/addon-graph' }).`
718
+ )
719
+ }
720
+ }
721
+ }
722
+
635
723
  /**
636
724
  * Validates that Zod schemas and wiring side-effects (wireHTTPRoutes,
637
725
  * addPermission, addHTTPMiddleware, etc.) do not coexist in the same file.
@@ -658,14 +746,6 @@ export function validateSchemaWiringSeparation(
658
746
  wiringFiles.add(file)
659
747
  }
660
748
 
661
- // Permission wirings (addPermission calls)
662
- for (const meta of state.permissions.tagPermissions.values()) {
663
- wiringFiles.add(meta.sourceFile)
664
- }
665
- for (const meta of state.http.routePermissions.values()) {
666
- wiringFiles.add(meta.sourceFile)
667
- }
668
-
669
749
  // Middleware wirings (addHTTPMiddleware calls)
670
750
  for (const meta of state.http.routeMiddleware.values()) {
671
751
  wiringFiles.add(meta.sourceFile)
@@ -769,3 +849,44 @@ export function computeDiagnostics(state: InspectorState): void {
769
849
 
770
850
  state.diagnostics = diagnostics
771
851
  }
852
+
853
+ /**
854
+ * Validates that every scope referenced by a function is declared via
855
+ * `wireScope`. Runs after all visitors, so declaration order does not matter.
856
+ *
857
+ * A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
858
+ * to be declared, and grants its whole subtree.
859
+ */
860
+ export function validateScopeReferences(
861
+ logger: InspectorLogger,
862
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
863
+ ): void {
864
+ const declared = new Set(
865
+ flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id)
866
+ )
867
+
868
+ for (const [funcName, meta] of Object.entries(state.functions.meta)) {
869
+ if (!meta.scopes?.length) continue
870
+
871
+ for (const scope of meta.scopes) {
872
+ // A trailing wildcard grants a subtree; the node it hangs off must exist.
873
+ const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope
874
+
875
+ if (scope === '*') {
876
+ logger.critical(
877
+ ErrorCode.INVALID_VALUE,
878
+ `Function '${funcName}' requires the bare wildcard scope '*'. A function must require a specific scope — '*' is only meaningful as a grant.`
879
+ )
880
+ continue
881
+ }
882
+
883
+ if (!declared.has(declaredForm)) {
884
+ const available = Array.from(declared)
885
+ logger.critical(
886
+ ErrorCode.INVALID_VALUE,
887
+ `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with wireScope. Available scopes: ${available.join(', ') || 'none'}`
888
+ )
889
+ }
890
+ }
891
+ }
892
+ }
@@ -0,0 +1,164 @@
1
+ import { test, describe, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
4
+ import { tmpdir } from 'os'
5
+ import { join } from 'path'
6
+
7
+ import { getInitialInspectorState } from '../inspector.js'
8
+ import { generateAllSchemas } from './schema-generator.js'
9
+
10
+ const debugLines: string[] = []
11
+ const logger = {
12
+ debug(message: string) {
13
+ debugLines.push(message)
14
+ },
15
+ info() {},
16
+ warn() {},
17
+ error() {},
18
+ } as any
19
+
20
+ const UNRELATED_FILE_COUNT = 300
21
+
22
+ let projectDir: string
23
+
24
+ before(() => {
25
+ projectDir = mkdtempSync(join(tmpdir(), 'pikku-schema-gen-'))
26
+ mkdirSync(join(projectDir, 'src'))
27
+ writeFileSync(
28
+ join(projectDir, 'tsconfig.json'),
29
+ JSON.stringify({
30
+ compilerOptions: {
31
+ target: 'ESNext',
32
+ module: 'NodeNext',
33
+ moduleResolution: 'NodeNext',
34
+ strict: true,
35
+ skipLibCheck: true,
36
+ },
37
+ include: ['src/**/*.ts'],
38
+ })
39
+ )
40
+ writeFileSync(
41
+ join(projectDir, 'src', 'types.ts'),
42
+ 'export type Thing = { id: string; count: number }\n'
43
+ )
44
+ // Files the tsconfig picks up but no generated custom type references. They
45
+ // stand in for the bulk of a real project, which the schema program has no
46
+ // reason to load.
47
+ for (let i = 0; i < UNRELATED_FILE_COUNT; i += 1) {
48
+ writeFileSync(
49
+ join(projectDir, 'src', `unrelated-${i}.ts`),
50
+ `export type Unrelated${i} = { a: string; b: number; c: boolean }\n`
51
+ )
52
+ }
53
+ })
54
+
55
+ after(() => {
56
+ rmSync(projectDir, { recursive: true, force: true })
57
+ })
58
+
59
+ describe('generateAllSchemas', () => {
60
+ // NOTE: this must be the first test in the file. The module-level caches are
61
+ // per-process, so only the first call actually builds a program — a later call
62
+ // reuses it and would measure a few MB no matter what.
63
+ //
64
+ // The schema generator builds a second ts.Program over the whole tsconfig —
65
+ // on a real tree that is ~2.5k files and ~600MB, and it used to be pinned at
66
+ // module scope for the life of the process. Combined with the main inspector
67
+ // program it pushed peak heap past 2GB and OOM'd `pikku all` (#982).
68
+ //
69
+ // Only the program is released; cachedTSSchemas (the small result cache that
70
+ // powers the same-process fast path) is deliberately kept.
71
+ test('does not retain the schema ts.Program after returning', async (t) => {
72
+ if (typeof global.gc !== 'function') {
73
+ t.skip('requires --expose-gc')
74
+ return
75
+ }
76
+
77
+ const state = getInitialInspectorState(projectDir) as any
78
+ // A distinct type keeps this off the cachedTSSchemas fast path, so the
79
+ // program is genuinely rebuilt (and therefore genuinely retainable) here.
80
+ state.functions.typesMap.addCustomType(
81
+ 'RetentionProbeInput',
82
+ '{ probe: string; n: number }',
83
+ []
84
+ )
85
+
86
+ global.gc!()
87
+ const before = process.memoryUsage().heapUsed
88
+
89
+ await generateAllSchemas(
90
+ logger,
91
+ { tsconfig: join(projectDir, 'tsconfig.json') },
92
+ state
93
+ )
94
+
95
+ global.gc!()
96
+ const retainedMB = (process.memoryUsage().heapUsed - before) / 1024 / 1024
97
+
98
+ // A retained program (with its SourceFiles and TypeChecker) measures tens of
99
+ // MB even for this one-file fixture; the schemas themselves are a few KB.
100
+ assert.ok(
101
+ retainedMB < 10,
102
+ `expected the schema program to be released, but ${retainedMB.toFixed(1)}MB is still retained after GC`
103
+ )
104
+ })
105
+
106
+ // The virtual file explicitly imports every type it needs, so it is a complete
107
+ // root on its own. Rooting the program at the whole tsconfig instead made it
108
+ // load the entire project (2572 files on a real tree vs 870), for no gain in
109
+ // what could be resolved (#982).
110
+ test('scopes the schema program to the virtual file import closure', async () => {
111
+ debugLines.length = 0
112
+
113
+ const state = getInitialInspectorState(projectDir) as any
114
+ state.functions.typesMap.addCustomType(
115
+ 'ScopingProbeInput',
116
+ '{ scope: string }',
117
+ []
118
+ )
119
+
120
+ await generateAllSchemas(
121
+ logger,
122
+ { tsconfig: join(projectDir, 'tsconfig.json') },
123
+ state
124
+ )
125
+
126
+ const line = debugLines.find((l) => l.includes('Created schema program'))
127
+ assert.ok(
128
+ line,
129
+ `expected a "Created schema program" debug line, got: ${debugLines.join(' | ')}`
130
+ )
131
+
132
+ const fileCount = Number(line!.match(/(\d+) files/)?.[1])
133
+ assert.ok(
134
+ Number.isFinite(fileCount),
135
+ `could not parse a file count out of: ${line}`
136
+ )
137
+ // The virtual file plus types.ts plus the default lib. Rooting at the whole
138
+ // tsconfig would pull in all UNRELATED_FILE_COUNT files on top of that.
139
+ assert.ok(
140
+ fileCount < UNRELATED_FILE_COUNT,
141
+ `expected the schema program to skip the ${UNRELATED_FILE_COUNT} unreferenced files, but it loaded ${fileCount}`
142
+ )
143
+ })
144
+
145
+ test('produces a schema for a custom type', async () => {
146
+ const state = getInitialInspectorState(projectDir) as any
147
+ state.functions.typesMap.addCustomType(
148
+ 'ThingInput',
149
+ '{ id: string; count: number }',
150
+ []
151
+ )
152
+
153
+ const schemas = await generateAllSchemas(
154
+ logger,
155
+ { tsconfig: join(projectDir, 'tsconfig.json') },
156
+ state
157
+ )
158
+
159
+ assert.ok(
160
+ Object.keys(schemas).length > 0,
161
+ 'expected at least one generated schema'
162
+ )
163
+ })
164
+ })
@@ -87,7 +87,9 @@ const inspectorVersion: string = (() => {
87
87
  try {
88
88
  const pkgUrl = new URL('../../package.json', import.meta.url)
89
89
  const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8'))
90
- return typeof pkg.version === 'string' ? pkg.version : `v${SCHEMA_CACHE_VERSION}`
90
+ return typeof pkg.version === 'string'
91
+ ? pkg.version
92
+ : `v${SCHEMA_CACHE_VERSION}`
91
93
  } catch {
92
94
  return `v${SCHEMA_CACHE_VERSION}`
93
95
  }
@@ -98,7 +100,10 @@ const inspectorVersion: string = (() => {
98
100
  // inspector version (schema-format changes ship with a version bump).
99
101
  function tsSchemaCacheKey(
100
102
  customTypesContent: string,
101
- config: { schemasFromTypes?: string[]; schema?: { additionalProperties?: boolean } }
103
+ config: {
104
+ schemasFromTypes?: string[]
105
+ schema?: { additionalProperties?: boolean }
106
+ }
102
107
  ): string {
103
108
  return createHash('sha1')
104
109
  .update(`v${SCHEMA_CACHE_VERSION}\0`)
@@ -145,6 +150,7 @@ function writeDiskTSSchemas(
145
150
  }
146
151
 
147
152
  function createProgramWithVirtualFile(
153
+ logger: InspectorLogger,
148
154
  tsconfig: string,
149
155
  virtualFilePath: string,
150
156
  virtualFileContent: string
@@ -165,7 +171,11 @@ function createProgramWithVirtualFile(
165
171
  }
166
172
 
167
173
  const resolvedVirtualPath = resolve(virtualFilePath)
168
- const fileNames = [...cachedParsedConfig.fileNames, resolvedVirtualPath]
174
+ // The virtual file imports every type it references, so it is a complete root
175
+ // on its own and TypeScript pulls in exactly the transitive closure it needs.
176
+ // Rooting at cachedParsedConfig.fileNames instead loaded the whole project
177
+ // (2572 files vs 870 on a real tree) without widening what could be resolved.
178
+ const fileNames = [resolvedVirtualPath]
169
179
 
170
180
  const defaultHost = ts.createCompilerHost(cachedParsedConfig.options)
171
181
  const customHost: ts.CompilerHost = {
@@ -200,6 +210,7 @@ function createProgramWithVirtualFile(
200
210
  },
201
211
  }
202
212
 
213
+ const startProgram = performance.now()
203
214
  const program = ts.createProgram(
204
215
  fileNames,
205
216
  cachedParsedConfig.options,
@@ -207,6 +218,9 @@ function createProgramWithVirtualFile(
207
218
  cachedSchemaProgram // reuse previous program for incremental compilation
208
219
  )
209
220
  cachedSchemaProgram = program
221
+ logger.debug(
222
+ `Created schema program in ${(performance.now() - startProgram).toFixed(0)}ms (${program.getSourceFiles().length} files)`
223
+ )
210
224
  return program
211
225
  }
212
226
 
@@ -274,6 +288,7 @@ function generateTSSchemas(
274
288
  '__pikku_virtual_types__.ts'
275
289
  )
276
290
  const program = createProgramWithVirtualFile(
291
+ logger,
277
292
  tsconfig,
278
293
  virtualFilePath,
279
294
  customTypesContent
@@ -614,6 +629,14 @@ export async function generateAllSchemas(
614
629
  zodSchemas
615
630
  )
616
631
 
632
+ // Release the program once the schemas are out of it. It spans the whole
633
+ // tsconfig (~2.5k files, ~600MB on a large tree) and was previously pinned at
634
+ // module scope for the life of the process, so it stacked on top of the main
635
+ // inspector program and pushed `pikku all` past a 2GB heap (#982). The result
636
+ // cache below is what the same-process fast path actually needs; the program
637
+ // only bought incremental reuse on a rebuild, at a cost that dwarfs it.
638
+ cachedSchemaProgram = undefined
639
+
617
640
  cachedCustomTypesContent = customTypesContent
618
641
  cachedTSSchemas = tsSchemas
619
642