@pikku/inspector 0.12.75 → 0.12.81

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 (219) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/dist/add/add-analytics.d.ts +9 -0
  3. package/dist/add/add-analytics.js +178 -0
  4. package/dist/add/add-auth.js +0 -54
  5. package/dist/add/add-feature-flag.d.ts +2 -0
  6. package/dist/add/add-feature-flag.js +162 -0
  7. package/dist/add/add-wire-addon.js +1 -0
  8. package/dist/add/add-wire-remote-addon.js +1 -0
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.js +3 -1
  11. package/dist/inspector.js +4 -0
  12. package/dist/surface/collect-surface.d.ts +58 -0
  13. package/dist/surface/collect-surface.js +613 -0
  14. package/dist/types.d.ts +20 -11
  15. package/dist/utils/filter-inspector-state.js +14 -0
  16. package/dist/utils/post-process.js +15 -0
  17. package/dist/utils/read-module-specifiers.d.ts +8 -0
  18. package/dist/utils/read-module-specifiers.js +23 -0
  19. package/dist/utils/read-tsconfig-out-dir.d.ts +5 -0
  20. package/dist/utils/read-tsconfig-out-dir.js +12 -0
  21. package/dist/utils/resolve-deploy-target.d.ts +7 -0
  22. package/dist/utils/resolve-deploy-target.js +15 -7
  23. package/dist/utils/serialize-inspector-state.d.ts +5 -0
  24. package/dist/utils/serialize-inspector-state.js +8 -0
  25. package/dist/visit.js +4 -0
  26. package/package.json +16 -6
  27. package/run-tests.sh +21 -6
  28. package/src/add/add-addon-bans.ts +0 -85
  29. package/src/add/add-agent-scorer.test.ts +0 -197
  30. package/src/add/add-agent-scorer.ts +0 -83
  31. package/src/add/add-agent.test.ts +0 -87
  32. package/src/add/add-agent.ts +0 -638
  33. package/src/add/add-approval-description.ts +0 -76
  34. package/src/add/add-auth.test.ts +0 -791
  35. package/src/add/add-auth.ts +0 -424
  36. package/src/add/add-channel.ts +0 -778
  37. package/src/add/add-cli-renderers.test.ts +0 -74
  38. package/src/add/add-cli.ts +0 -1006
  39. package/src/add/add-credential.ts +0 -186
  40. package/src/add/add-feature.test.ts +0 -306
  41. package/src/add/add-feature.ts +0 -253
  42. package/src/add/add-file-extends-core-type.ts +0 -66
  43. package/src/add/add-file-with-config.ts +0 -45
  44. package/src/add/add-file-with-factory.test.ts +0 -134
  45. package/src/add/add-file-with-factory.ts +0 -288
  46. package/src/add/add-functions.test.ts +0 -743
  47. package/src/add/add-functions.ts +0 -1548
  48. package/src/add/add-gateway.ts +0 -143
  49. package/src/add/add-http-route-ref.test.ts +0 -149
  50. package/src/add/add-http-route.ts +0 -530
  51. package/src/add/add-http-routes.ts +0 -471
  52. package/src/add/add-keyed-wiring.ts +0 -180
  53. package/src/add/add-mcp-prompt.ts +0 -157
  54. package/src/add/add-mcp-resource.ts +0 -175
  55. package/src/add/add-middleware.test.ts +0 -196
  56. package/src/add/add-middleware.ts +0 -879
  57. package/src/add/add-permission-auth.test.ts +0 -59
  58. package/src/add/add-permission.ts +0 -311
  59. package/src/add/add-personas.test.ts +0 -406
  60. package/src/add/add-personas.ts +0 -287
  61. package/src/add/add-queue-worker.ts +0 -117
  62. package/src/add/add-rpc-invocations.test.ts +0 -287
  63. package/src/add/add-rpc-invocations.ts +0 -221
  64. package/src/add/add-scenario-step.test.ts +0 -509
  65. package/src/add/add-schedule.ts +0 -120
  66. package/src/add/add-scope.test.ts +0 -419
  67. package/src/add/add-scope.ts +0 -221
  68. package/src/add/add-secret.ts +0 -10
  69. package/src/add/add-subject-scenario-steps.test.ts +0 -161
  70. package/src/add/add-system-role.test.ts +0 -297
  71. package/src/add/add-system-role.ts +0 -162
  72. package/src/add/add-trigger.ts +0 -165
  73. package/src/add/add-variable.ts +0 -10
  74. package/src/add/add-wire-addon.test.ts +0 -185
  75. package/src/add/add-wire-addon.ts +0 -146
  76. package/src/add/add-wire-remote-addon.ts +0 -80
  77. package/src/add/add-workflow-edge-cases.test.ts +0 -201
  78. package/src/add/add-workflow-fanout.test.ts +0 -166
  79. package/src/add/add-workflow-graph-foreach.test.ts +0 -174
  80. package/src/add/add-workflow-graph-input.test.ts +0 -94
  81. package/src/add/add-workflow-graph-notes.test.ts +0 -74
  82. package/src/add/add-workflow-graph.ts +0 -644
  83. package/src/add/add-workflow-on-error.test.ts +0 -118
  84. package/src/add/add-workflow-return-switch.test.ts +0 -121
  85. package/src/add/add-workflow-silent-drops.test.ts +0 -261
  86. package/src/add/add-workflow-sleep-expression.test.ts +0 -96
  87. package/src/add/add-workflow.test.ts +0 -155
  88. package/src/add/add-workflow.ts +0 -482
  89. package/src/add/addon-bans.test.ts +0 -121
  90. package/src/add/addon-contracts.test.ts +0 -221
  91. package/src/add/allow-escape-hatches.test.ts +0 -144
  92. package/src/add/audit-meta.test.ts +0 -75
  93. package/src/add/classified-sink-check.test.ts +0 -347
  94. package/src/add/config-value-metadata.test.ts +0 -263
  95. package/src/add/dynamic-import-check.test.ts +0 -119
  96. package/src/add/expect-eventually-scenario-only.test.ts +0 -108
  97. package/src/add/function-body-span.test.ts +0 -96
  98. package/src/add/inline-wiring-function.test.ts +0 -104
  99. package/src/add/pii-check.test.ts +0 -258
  100. package/src/add/rpc-type-cast.test.ts +0 -123
  101. package/src/add/scenario-instrumentation.test.ts +0 -89
  102. package/src/add/scenario-instrumentation.ts +0 -42
  103. package/src/add/scenario-marker.test.ts +0 -89
  104. package/src/add/scenario-not-extractable.test.ts +0 -159
  105. package/src/add/scenario-services.test.ts +0 -103
  106. package/src/add/scenario-skip.test.ts +0 -83
  107. package/src/add/validate-workflow-graph-addons.test.ts +0 -102
  108. package/src/add/wire-name-literal.test.ts +0 -117
  109. package/src/error-codes-emitted.test.ts +0 -76
  110. package/src/error-codes.ts +0 -145
  111. package/src/index.ts +0 -66
  112. package/src/inspector.ts +0 -575
  113. package/src/schema-generator.ts +0 -1
  114. package/src/source-file-cache.ts +0 -73
  115. package/src/tsconfig-paths.test.ts +0 -90
  116. package/src/types-map.ts +0 -141
  117. package/src/types.ts +0 -775
  118. package/src/utils/annotate-http-route-auth.test.ts +0 -161
  119. package/src/utils/annotate-http-route-auth.ts +0 -53
  120. package/src/utils/check-classified-sinks.ts +0 -215
  121. package/src/utils/check-pii-output.ts +0 -120
  122. package/src/utils/compute-diagnostics.test.ts +0 -106
  123. package/src/utils/compute-required-schemas.ts +0 -48
  124. package/src/utils/contract-hashes.test.ts +0 -553
  125. package/src/utils/contract-hashes.ts +0 -387
  126. package/src/utils/custom-types-generator.test.ts +0 -124
  127. package/src/utils/custom-types-generator.ts +0 -150
  128. package/src/utils/detect-schema-vendor.test.ts +0 -123
  129. package/src/utils/detect-schema-vendor.ts +0 -120
  130. package/src/utils/does-type-extend-core-type.ts +0 -53
  131. package/src/utils/ensure-function-metadata.ts +0 -385
  132. package/src/utils/extract-function-name.ts +0 -612
  133. package/src/utils/extract-function-node.ts +0 -70
  134. package/src/utils/extract-node-value.test.ts +0 -117
  135. package/src/utils/extract-node-value.ts +0 -172
  136. package/src/utils/extract-secret-usage.test.ts +0 -81
  137. package/src/utils/extract-secret-usage.ts +0 -73
  138. package/src/utils/extract-services.ts +0 -70
  139. package/src/utils/extract-surface-usage.test.ts +0 -147
  140. package/src/utils/extract-surface-usage.ts +0 -121
  141. package/src/utils/filter-inspector-state.test.ts +0 -2232
  142. package/src/utils/filter-inspector-state.ts +0 -1298
  143. package/src/utils/filter-utils.test.ts +0 -843
  144. package/src/utils/filter-utils.ts +0 -161
  145. package/src/utils/find-root-dir.ts +0 -68
  146. package/src/utils/find-zod-transform.test.ts +0 -70
  147. package/src/utils/find-zod-transform.ts +0 -103
  148. package/src/utils/get-exported-variable-name.ts +0 -48
  149. package/src/utils/get-files-and-methods.ts +0 -157
  150. package/src/utils/get-property-value.test.ts +0 -141
  151. package/src/utils/get-property-value.ts +0 -331
  152. package/src/utils/hash.ts +0 -26
  153. package/src/utils/literal-properties.ts +0 -97
  154. package/src/utils/load-addon-functions-meta.test.ts +0 -157
  155. package/src/utils/load-addon-functions-meta.ts +0 -508
  156. package/src/utils/load-addon-scopes.test.ts +0 -138
  157. package/src/utils/middleware.test.ts +0 -204
  158. package/src/utils/middleware.ts +0 -303
  159. package/src/utils/nested-project-filter.test.ts +0 -53
  160. package/src/utils/nested-project-filter.ts +0 -28
  161. package/src/utils/permissions-shorthand.test.ts +0 -79
  162. package/src/utils/permissions.test.ts +0 -125
  163. package/src/utils/permissions.ts +0 -129
  164. package/src/utils/post-process.test.ts +0 -1094
  165. package/src/utils/post-process.ts +0 -1604
  166. package/src/utils/resolve-addon-package.ts +0 -106
  167. package/src/utils/resolve-core-type.test.ts +0 -142
  168. package/src/utils/resolve-core-type.ts +0 -35
  169. package/src/utils/resolve-deploy-target.test.ts +0 -135
  170. package/src/utils/resolve-deploy-target.ts +0 -65
  171. package/src/utils/resolve-function-meta.ts +0 -25
  172. package/src/utils/resolve-function-types.ts +0 -42
  173. package/src/utils/resolve-identifier.ts +0 -46
  174. package/src/utils/resolve-ref-contract.ts +0 -71
  175. package/src/utils/resolve-versions.test.ts +0 -499
  176. package/src/utils/resolve-versions.ts +0 -228
  177. package/src/utils/schema-cache.test.ts +0 -124
  178. package/src/utils/schema-generator.test.ts +0 -272
  179. package/src/utils/schema-generator.ts +0 -780
  180. package/src/utils/secret-alias-services.test.ts +0 -146
  181. package/src/utils/secret-alias-services.ts +0 -58
  182. package/src/utils/serialize-inspector-state.ts +0 -742
  183. package/src/utils/serialize-mcp-json.ts +0 -150
  184. package/src/utils/serialize-middleware-groups-meta.ts +0 -36
  185. package/src/utils/serialize-openapi-json.ts +0 -277
  186. package/src/utils/serialize-permissions-groups-meta.ts +0 -7
  187. package/src/utils/single-declaration.ts +0 -57
  188. package/src/utils/type-utils.ts +0 -145
  189. package/src/utils/validate-auth-sessionless.ts +0 -30
  190. package/src/utils/validate-exposed-functions-gated.test.ts +0 -230
  191. package/src/utils/validate-exposed-functions-gated.ts +0 -103
  192. package/src/utils/validate-tags-resolve-to-middleware.test.ts +0 -108
  193. package/src/utils/validate-tags-resolve-to-middleware.ts +0 -48
  194. package/src/utils/workflow/derive-workflow-plan.test.ts +0 -178
  195. package/src/utils/workflow/derive-workflow-plan.ts +0 -117
  196. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +0 -1465
  197. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +0 -71
  198. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +0 -99
  199. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +0 -161
  200. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +0 -121
  201. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +0 -116
  202. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +0 -145
  203. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +0 -71
  204. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +0 -2254
  205. package/src/utils/workflow/dsl/index.ts +0 -11
  206. package/src/utils/workflow/dsl/patterns.ts +0 -440
  207. package/src/utils/workflow/dsl/validation.ts +0 -203
  208. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +0 -35
  209. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +0 -543
  210. package/src/utils/workflow/graph/fanout-node-identity.test.ts +0 -105
  211. package/src/utils/workflow/graph/finalize-workflow-wires.ts +0 -310
  212. package/src/utils/workflow/graph/finalize-workflows.test.ts +0 -50
  213. package/src/utils/workflow/graph/finalize-workflows.ts +0 -130
  214. package/src/utils/workflow/graph/index.ts +0 -28
  215. package/src/utils/workflow/graph/serialize-workflow-graph.ts +0 -240
  216. package/src/utils/workflow/graph/workflow-graph.types.ts +0 -281
  217. package/src/visit.ts +0 -181
  218. package/tsconfig.json +0 -19
  219. package/tsconfig.tsbuildinfo +0 -1
@@ -1,1604 +0,0 @@
1
- import type {
2
- InspectorState,
3
- InspectorLogger,
4
- InspectorOptions,
5
- MiddlewareGroupMeta,
6
- InspectorDiagnostic,
7
- } from '../types.js'
8
- import type {
9
- FunctionServicesMeta,
10
- PermissionMetadata,
11
- } from '@pikku/core/function'
12
- import type { MiddlewareMetadata } from '@pikku/core/middleware'
13
- import type ts from 'typescript'
14
- import { existsSync, readFileSync } from 'node:fs'
15
- import { createRequire } from 'node:module'
16
- import { join } from 'node:path'
17
- import { extractTypeKeys } from './type-utils.js'
18
- import { ErrorCode } from '../error-codes.js'
19
- import { findSecretAliasServices } from './secret-alias-services.js'
20
- import { resolveCoreType } from './resolve-core-type.js'
21
- import { relative } from 'node:path'
22
- import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
23
- import { flattenScopeDefinitions } from '@pikku/core/scope'
24
- import type { WorkflowStepMeta } from '@pikku/core/workflow'
25
- import type { ScenarioStepMeta } from '@pikku/core/scenario'
26
- import { DYNAMIC_SCENARIO_STEP_TARGET } from './workflow/dsl/patterns.js'
27
-
28
- /**
29
- * Stamp the inspected authorize/callbacks service set onto the generated auth
30
- * handler's function meta.
31
- *
32
- * The CLI generates `export const authHandler = pikkuSessionlessFunc({ func:
33
- * createAuthHandler(...).func })`. That `func` is an opaque property access, so
34
- * normal extraction records zero services for the handler — which would leave
35
- * the deployed auth worker without `kysely`/`variables`/`secrets` and break
36
- * `authorize` at runtime. `add-auth` already computed the real dependency set
37
- * (from the pikkuBetterAuth source) into `state.auth.definition.services`; copy it
38
- * onto the handler meta. Re-derived every inspect and ordered BEFORE
39
- * `aggregateRequiredServices` so it flows into `requiredServices`.
40
- */
41
- export function stampAuthHandlerServices(
42
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
43
- ): void {
44
- const definition = state.auth.definition
45
- if (!definition) return
46
- const handlerMeta = state.functions.meta[AUTH_HANDLER_FUNC_ID]
47
- if (!handlerMeta) return
48
- handlerMeta.services = {
49
- optimized: definition.services.optimized,
50
- services: [...definition.services.services],
51
- }
52
- }
53
-
54
- /**
55
- * Helper to extract wire-level middleware/permission names from metadata.
56
- * Only extracts type:'wire' variants (individual middleware/permissions).
57
- * Skips type:'http' and type:'tag' (reference groups, not individuals).
58
- */
59
- export function extractWireNames(
60
- list?: Array<MiddlewareMetadata | PermissionMetadata>
61
- ): string[] {
62
- if (!list) return []
63
- return list
64
- .filter(
65
- (item): item is { type: 'wire'; name: string } => item.type === 'wire'
66
- )
67
- .map((item) => item.name)
68
- }
69
-
70
- /**
71
- * Helper to expand middleware groups into individual names and add their
72
- * services to the aggregation. Handles tag-based and HTTP-pattern-based
73
- * middleware groups. Permissions are function-scoped only and carry no groups.
74
- */
75
- function expandAndAddGroupServices(
76
- list: MiddlewareMetadata[] | undefined,
77
- state: InspectorState | Omit<InspectorState, 'typesLookup'>,
78
- addServices: (services: FunctionServicesMeta | undefined) => void
79
- ): void {
80
- if (!list) return
81
-
82
- for (const item of list) {
83
- if (item.type === 'tag') {
84
- const groupMeta = state.middleware.tagMiddleware.get(item.tag)
85
- if (groupMeta?.services) {
86
- addServices(groupMeta.services)
87
- }
88
- } else if (item.type === 'http' && 'route' in item) {
89
- const groupMeta = state.http.routeMiddleware.get(item.route)
90
- if (groupMeta?.services) {
91
- addServices(groupMeta.services)
92
- }
93
- }
94
- }
95
- }
96
-
97
- /**
98
- * Extracts all service names from SingletonServices and Services types.
99
- * This provides the complete list of available services for code generation.
100
- * Only runs if typesLookup is available (omitted in deserialized states).
101
- */
102
- function extractAllServices(
103
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
104
- ): void {
105
- // Skip if typesLookup is not available (e.g., deserialized state)
106
- if (!('typesLookup' in state)) {
107
- return
108
- }
109
-
110
- // Extract all singleton services from the SingletonServices type
111
- const singletonServicesType = resolveCoreType(
112
- state.typesLookup,
113
- state.singletonServicesTypeImportMap
114
- )
115
- if (singletonServicesType) {
116
- state.serviceAggregation.allSingletonServices = extractTypeKeys(
117
- singletonServicesType
118
- ).sort()
119
- }
120
-
121
- // Extract all services from the Services type
122
- const servicesType = resolveCoreType(
123
- state.typesLookup,
124
- state.wireServicesTypeImportMap
125
- )
126
- if (servicesType) {
127
- // Wire services are those in Services but not in SingletonServices
128
- const singletonSet = new Set(state.serviceAggregation.allSingletonServices)
129
- state.serviceAggregation.allWireServices = extractTypeKeys(servicesType)
130
- .filter((name) => !singletonSet.has(name))
131
- .sort()
132
- }
133
- }
134
-
135
- /**
136
- * Aggregates all required services from wired functions, middleware, and permissions.
137
- * Must be called after AST traversal completes.
138
- *
139
- * Note: usedFunctions, usedMiddleware, and usedPermissions are tracked directly
140
- * in the add-* methods during AST traversal for efficiency.
141
- */
142
- export function aggregateRequiredServices(
143
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
144
- ): void {
145
- // First, extract all available services from types
146
- extractAllServices(state)
147
-
148
- const { requiredServices, usedFunctions, usedMiddleware, usedPermissions } =
149
- state.serviceAggregation
150
-
151
- // Internal services (always excluded from tree-shaking)
152
- const internalServices = new Set(['rpc', 'mcp', 'channel', 'userSession'])
153
-
154
- const addServices = (services: FunctionServicesMeta | undefined) => {
155
- if (!services || !services.services) return
156
- services.services.forEach((service) => {
157
- if (!internalServices.has(service)) {
158
- requiredServices.add(service)
159
- }
160
- })
161
- }
162
-
163
- // 1. Services from used functions
164
- usedFunctions.forEach((funcName) => {
165
- const funcMeta = state.functions.meta[funcName]
166
- if (funcMeta?.services) {
167
- addServices(funcMeta.services)
168
- }
169
- })
170
-
171
- // 1b. Services the auth factory touches, read from the definition rather than
172
- // from the generated handler.
173
- //
174
- // `stampAuthHandlerServices` copies them onto that handler's meta, but on the
175
- // first run in a clean checkout the handler's file has not been written yet,
176
- // so there is nothing to stamp and nothing to aggregate — and the services map
177
- // comes out claiming `kysely` (or whatever else `authorize` reads) is unused.
178
- // The second run, with the file on disk, says the opposite. The definition is
179
- // inspected from hand-written source either way, so taking the answer from
180
- // there makes a clean build agree with an incremental one.
181
- if (state.auth?.definition) {
182
- addServices(state.auth.definition.services)
183
- }
184
-
185
- // 2. Services from used middleware (individual + groups)
186
- usedMiddleware.forEach((middlewareName) => {
187
- const middlewareMeta = state.middleware.definitions[middlewareName]
188
- if (middlewareMeta?.services) {
189
- addServices(middlewareMeta.services)
190
- }
191
- })
192
-
193
- // 3. Services from used permissions (individual + groups)
194
- usedPermissions.forEach((permissionName) => {
195
- const permissionMeta = state.permissions.definitions[permissionName]
196
- if (permissionMeta?.services) {
197
- addServices(permissionMeta.services)
198
- }
199
- })
200
-
201
- // 4. Services from middleware/permission groups used in wirings
202
- // We need to check all wirings and expand any tag/HTTP-pattern groups they use
203
- for (const method of [
204
- 'get',
205
- 'post',
206
- 'put',
207
- 'patch',
208
- 'delete',
209
- 'head',
210
- 'options',
211
- ] as const) {
212
- for (const routeMeta of Object.values(state.http.meta[method])) {
213
- expandAndAddGroupServices(routeMeta.middleware, state, addServices)
214
- }
215
- }
216
-
217
- // Also check other wiring types (channels, queues, schedulers, MCP)
218
- for (const channelMeta of Object.values(state.channels.meta)) {
219
- expandAndAddGroupServices(channelMeta.middleware, state, addServices)
220
- }
221
-
222
- for (const queueMeta of Object.values(state.queueWorkers.meta)) {
223
- expandAndAddGroupServices(queueMeta.middleware, state, addServices)
224
- }
225
-
226
- for (const scheduleMeta of Object.values(state.scheduledTasks.meta)) {
227
- expandAndAddGroupServices(scheduleMeta.middleware, state, addServices)
228
- }
229
-
230
- for (const toolMeta of Object.values(state.mcpEndpoints.toolsMeta)) {
231
- expandAndAddGroupServices(toolMeta.middleware, state, addServices)
232
- }
233
-
234
- for (const promptMeta of Object.values(state.mcpEndpoints.promptsMeta)) {
235
- expandAndAddGroupServices(promptMeta.middleware, state, addServices)
236
- }
237
-
238
- for (const resourceMeta of Object.values(state.mcpEndpoints.resourcesMeta)) {
239
- expandAndAddGroupServices(resourceMeta.middleware, state, addServices)
240
- }
241
-
242
- // 5. Services from session service factories
243
- for (const singletonServices of state.wireServicesMeta.values()) {
244
- singletonServices.forEach((service) => {
245
- if (!internalServices.has(service)) {
246
- requiredServices.add(service)
247
- }
248
- })
249
- }
250
-
251
- // 6. Implicit platform services required by wiring types
252
- // Workflows need workflowService + workflowRunService + schedulerService + queueService.
253
- // Check workflow definitions, graph meta, AND helper functions (workflowStart:*, etc.)
254
- // that wrap workflow operations but don't destructure the services.
255
- const hasWorkflows =
256
- Object.keys(state.workflows.graphMeta).length > 0 ||
257
- Object.keys(state.workflows.meta).length > 0 ||
258
- Object.keys(state.functions.meta).some(
259
- (id) =>
260
- id.startsWith('workflowStart:') ||
261
- id.startsWith('workflowStatus:') ||
262
- id.startsWith('workflow:')
263
- )
264
- if (hasWorkflows) {
265
- requiredServices.add('workflowService')
266
- requiredServices.add('workflowRunService')
267
- requiredServices.add('schedulerService')
268
- requiredServices.add('queueService')
269
- }
270
-
271
- // 6b. Inject synthetic queue workers for workflow graph steps.
272
- // Each workflow gets an orchestrator queue and per-step queues.
273
- // Without these, the PikkuWorkflowService constructor can't find
274
- // per-workflow queue entries and falls back to shared queue names.
275
- for (const [, graph] of Object.entries(state.workflows.graphMeta)) {
276
- if (!graph.nodes || !graph.name) continue
277
- // A scenario is a workflow, but it only ever runs in-process under
278
- // `pikku scenario run` — no step of one is dispatched through a queue. The
279
- // synthetic entries were pure leakage: they put a
280
- // `wf-orchestrator-<scenario>` worker into the app's queue meta, which every
281
- // bundle imports and a provider then creates as a real production queue.
282
- if (graph.source === 'scenario') continue
283
-
284
- const toKebab = (s: string) =>
285
- s
286
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
287
- .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
288
- .toLowerCase()
289
-
290
- // Orchestrator queue
291
- const orchQueueName = `wf-orchestrator-${toKebab(graph.name)}`
292
- if (!state.queueWorkers.meta[orchQueueName]) {
293
- state.queueWorkers.meta[orchQueueName] = {
294
- name: orchQueueName,
295
- pikkuFuncId: `pikkuWorkflowOrchestrator:${graph.name}`,
296
- }
297
- }
298
-
299
- // Per-step queues — only for steps explicitly marked workflowQueued: true
300
- for (const node of Object.values(graph.nodes)) {
301
- if (!('rpcName' in node) || !node.rpcName) continue
302
- const rpcName = node.rpcName as string
303
- const funcId =
304
- state.rpc?.internalMeta?.[rpcName] ??
305
- state.rpc?.exposedMeta?.[rpcName] ??
306
- rpcName
307
- const funcMeta = (state.functions.meta[funcId] ??
308
- state.functions.meta[rpcName]) as { workflowQueued?: boolean }
309
- if (funcMeta?.workflowQueued !== true) continue
310
- const stepQueueName = `wf-step-${toKebab(rpcName)}`
311
- if (!state.queueWorkers.meta[stepQueueName]) {
312
- state.queueWorkers.meta[stepQueueName] = {
313
- name: stepQueueName,
314
- pikkuFuncId: `pikkuWorkflowWorker:${rpcName}`,
315
- }
316
- }
317
- }
318
- }
319
-
320
- // AI agents need agentStorage + agentRunState + agentRunService + agentRunner
321
- if (Object.keys(state.agents.agentsMeta).length > 0) {
322
- requiredServices.add('agentStorage')
323
- requiredServices.add('agentRunState')
324
- requiredServices.add('agentRunService')
325
- requiredServices.add('agentRunner')
326
- }
327
-
328
- // Channels need eventHub for pub/sub
329
- if (Object.keys(state.channels.meta).length > 0) {
330
- requiredServices.add('eventHub')
331
- }
332
-
333
- // Declared scopes need scopeService to resolve a grant. Nothing in a project
334
- // destructures it — the generated auth layer reaches it — so the declaration
335
- // is the only signal there is.
336
- if ((state.scopes?.definitions?.length ?? 0) > 0) {
337
- requiredServices.add('scopeService')
338
- }
339
-
340
- // 7. Services that consumed addons need from the parent project.
341
- const addonFnServices = new Map<string, string[] | undefined>()
342
- for (const [namespace, fns] of Object.entries(state.addonFunctions ?? {})) {
343
- for (const [id, meta] of Object.entries(fns)) {
344
- addonFnServices.set(
345
- `${namespace}:${id}`,
346
- (meta as { services?: FunctionServicesMeta })?.services?.services
347
- )
348
- }
349
- }
350
- const parentDeclared = state.addonRequiredParentServices ?? []
351
- const parentDeclaredSet = new Set(parentDeclared)
352
- const defaultServices = new Set([
353
- 'config',
354
- 'logger',
355
- 'variables',
356
- 'schema',
357
- 'secrets',
358
- ])
359
- let usesAddonFn = false
360
- let addonFactoryNeeded = false
361
- for (const funcId of usedFunctions) {
362
- if (!addonFnServices.has(funcId)) continue
363
- usesAddonFn = true
364
- const services = addonFnServices.get(funcId)
365
- if (!services) {
366
- addonFactoryNeeded = true
367
- continue
368
- }
369
- for (const service of services) {
370
- if (parentDeclaredSet.has(service)) {
371
- requiredServices.add(service)
372
- } else if (
373
- !internalServices.has(service) &&
374
- !defaultServices.has(service)
375
- ) {
376
- addonFactoryNeeded = true
377
- }
378
- }
379
- }
380
- if (usesAddonFn && addonFactoryNeeded) {
381
- for (const service of parentDeclared) {
382
- requiredServices.add(service)
383
- }
384
- }
385
-
386
- // 8. When this project IS an addon, what it needs from its parent is whatever
387
- // its functions use minus whatever its own factory builds. Reading only the
388
- // factory's second parameter missed everything the functions destructure and
389
- // the factory never names — `kysely` above all — which left the contract empty
390
- // and let a consumer omit it and fail at the first query instead of at build.
391
- if (state.addonServicesFactorySeen) {
392
- // Every function an addon declares is part of its published surface — a
393
- // consumer calls it — so none of them are reachable from a local wiring and
394
- // `usedFunctions` is empty. Aggregating only from wirings left the services
395
- // map claiming an addon needs nothing, which is what drove addons to list
396
- // their own services under `forceRequiredServices` by hand.
397
- for (const funcMeta of Object.values(state.functions.meta)) {
398
- addServices(funcMeta?.services)
399
- }
400
-
401
- const createdSet = new Set(state.addonCreatedServices ?? [])
402
- const parentServices = new Set(parentDeclared)
403
- for (const service of requiredServices) {
404
- if (
405
- !createdSet.has(service) &&
406
- !internalServices.has(service) &&
407
- !defaultServices.has(service)
408
- ) {
409
- parentServices.add(service)
410
- }
411
- }
412
- state.addonRequiredParentServices = [...parentServices]
413
- }
414
- }
415
-
416
- export function validateSecretOverrides(
417
- logger: InspectorLogger,
418
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
419
- ): void {
420
- const { wireAddonDeclarations } = state.rpc
421
- if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
422
-
423
- // secretOverrides key on (and resolve to) SECRET IDs — the string the addon
424
- // passes to getSecret — so validate against secretId, falling back to name for
425
- // older meta without a secretId field.
426
- const secretIds = new Set(
427
- state.secrets.definitions.map((d: any) => d.secretId ?? d.name)
428
- )
429
-
430
- for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
431
- for (const [logicalName, resolvedName] of Object.entries(
432
- addonDecl.secretOverrides ?? {}
433
- )) {
434
- if (!secretIds.has(resolvedName)) {
435
- const availableSecrets = Array.from(secretIds)
436
- logger.critical(
437
- ErrorCode.INVALID_VALUE,
438
- `Secret override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`
439
- )
440
- }
441
- }
442
-
443
- // A grant names the secret as the addon reads it, so it resolves through
444
- // the override map before it can be looked up in the project.
445
- for (const logicalName of addonDecl.secretGrants ?? []) {
446
- const resolvedName =
447
- addonDecl.secretOverrides?.[logicalName] ?? logicalName
448
- if (!secretIds.has(resolvedName)) {
449
- const availableSecrets = Array.from(secretIds)
450
- logger.critical(
451
- ErrorCode.INVALID_VALUE,
452
- `Secret grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`
453
- )
454
- }
455
- }
456
- }
457
- }
458
-
459
- export function validateCredentialOverrides(
460
- logger: InspectorLogger,
461
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
462
- ): void {
463
- const { wireAddonDeclarations } = state.rpc
464
- if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
465
-
466
- const credentialNames = new Set(
467
- state.credentials?.definitions.map((d) => d.name) ?? []
468
- )
469
-
470
- for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
471
- for (const [logicalName, resolvedName] of Object.entries(
472
- addonDecl.credentialOverrides ?? {}
473
- )) {
474
- if (!credentialNames.has(resolvedName)) {
475
- const availableCredentials = Array.from(credentialNames)
476
- logger.critical(
477
- ErrorCode.INVALID_VALUE,
478
- `Credential override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`
479
- )
480
- }
481
- }
482
-
483
- for (const logicalName of addonDecl.credentialGrants ?? []) {
484
- const resolvedName =
485
- addonDecl.credentialOverrides?.[logicalName] ?? logicalName
486
- if (!credentialNames.has(resolvedName)) {
487
- const availableCredentials = Array.from(credentialNames)
488
- logger.critical(
489
- ErrorCode.INVALID_VALUE,
490
- `Credential grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`
491
- )
492
- }
493
- }
494
- }
495
- }
496
-
497
- export function validateVariableOverrides(
498
- logger: InspectorLogger,
499
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
500
- ): void {
501
- const { wireAddonDeclarations } = state.rpc
502
- if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
503
-
504
- // variableOverrides key on (and resolve to) VARIABLE IDs, so validate against
505
- // variableId, falling back to name for older meta without a variableId field.
506
- const variableIds = new Set(
507
- state.variables.definitions.map((d: any) => d.variableId ?? d.name)
508
- )
509
-
510
- for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
511
- if (!addonDecl.variableOverrides) continue
512
-
513
- for (const [logicalName, resolvedName] of Object.entries(
514
- addonDecl.variableOverrides
515
- )) {
516
- if (!variableIds.has(resolvedName)) {
517
- const availableVariables = Array.from(variableIds)
518
- logger.critical(
519
- ErrorCode.INVALID_VALUE,
520
- `Variable override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a variable that does not exist. Available variables: ${availableVariables.join(', ') || 'none'}`
521
- )
522
- }
523
- }
524
- }
525
- }
526
-
527
- /**
528
- * Can `pkg` be resolved from `rootDir`, by any means the loader would use?
529
- *
530
- * Resolution is attempted against `<pkg>/package.json` first and the bare
531
- * specifier second, because neither alone is conclusive. A package with a
532
- * restrictive `exports` map refuses the first with
533
- * `ERR_PACKAGE_PATH_NOT_EXPORTED` — which is not a failure at all: reaching the
534
- * exports map means the package was found. Only a module-not-found from both
535
- * attempts means it is genuinely absent.
536
- */
537
- const isPackageResolvable = (pkg: string, rootDir: string): boolean => {
538
- const NOT_FOUND = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND'])
539
- let req: ReturnType<typeof createRequire>
540
- try {
541
- req = createRequire(join(rootDir, 'package.json'))
542
- } catch {
543
- return true // cannot ask; do not accuse
544
- }
545
- for (const specifier of [`${pkg}/package.json`, pkg]) {
546
- try {
547
- req.resolve(specifier)
548
- return true
549
- } catch (e: any) {
550
- if (!NOT_FOUND.has(e?.code)) return true
551
- }
552
- }
553
- return false
554
- }
555
-
556
- /**
557
- * Every wired addon must be installed, in the bucket matching how it is used.
558
- *
559
- * A `wireRemoteAddon` package ships types only — its handlers run on the host —
560
- * so it MUST be a devDependency, not a production dependency (a prod dep would
561
- * drag in the runtime deps remote consumption exists to avoid).
562
- *
563
- * A plain `wireAddon` only has to be *resolvable*. The failure it allows is
564
- * quiet in a way the remote one is not — an absent package makes the metadata
565
- * load fail, which is caught and downgraded to a warning, so every
566
- * `ref('<namespace>:…')` resolves against nothing and the surface is dead at
567
- * runtime. The generated console is the common case: `scaffold.console` emits
568
- * `wireAddon({ package: '@pikku/addon-console' })` and refs into it, and a
569
- * project that never installed the package gets a console that 404s with
570
- * nothing in the build output saying why.
571
- *
572
- * Deliberately a resolution check and not a `package.json` one. An addon is
573
- * discovered because it is *wired*, not because it is a declared dependency —
574
- * addons arrive by workspace link, by a local `addons/` directory recorded in
575
- * `pikku-addons.json`, and by plain install, and only the last of those shows
576
- * up in `dependencies`. Asking the resolver is the only question that matches
577
- * what the loader will actually do.
578
- */
579
- export function validateRemoteAddonDependencies(
580
- logger: InspectorLogger,
581
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
582
- ): void {
583
- const { wireAddonDeclarations } = state.rpc
584
- if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
585
-
586
- // Local addons first, and independently of the manifest below: their check
587
- // asks the resolver, not `package.json`, so a project without a manifest (as
588
- // in some tests) must not skip it.
589
- for (const [namespace, decl] of wireAddonDeclarations.entries()) {
590
- if (decl.remote) continue
591
- if (isPackageResolvable(decl.package, state.rootDir)) continue
592
- logger.critical(
593
- ErrorCode.ADDON_NOT_INSTALLED,
594
- `Addon '${namespace}' ('${decl.package}') is wired with wireAddon but cannot be resolved — every ref('${namespace}:…') will resolve to nothing and the surface will be dead at runtime. Install it, or remove the wireAddon call.`
595
- )
596
- }
597
-
598
- if (!Array.from(wireAddonDeclarations.values()).some((d) => d.remote)) return
599
-
600
- const pkgJsonPath = join(state.rootDir, 'package.json')
601
- if (!existsSync(pkgJsonPath)) return // no manifest to check (e.g. some tests)
602
-
603
- let pkgJson: {
604
- dependencies?: Record<string, string>
605
- devDependencies?: Record<string, string>
606
- }
607
- try {
608
- pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
609
- } catch (e: any) {
610
- logger.warn(
611
- `Could not read ${pkgJsonPath} to verify remote addon dependencies: ${e?.message ?? e}`
612
- )
613
- return
614
- }
615
-
616
- const prodDeps = pkgJson.dependencies ?? {}
617
- const devDeps = pkgJson.devDependencies ?? {}
618
-
619
- for (const [namespace, decl] of wireAddonDeclarations.entries()) {
620
- if (!decl.remote) continue
621
- if (decl.package in devDeps) continue // correct
622
-
623
- if (decl.package in prodDeps) {
624
- logger.critical(
625
- ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY,
626
- `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".`
627
- )
628
- } else {
629
- logger.critical(
630
- ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY,
631
- `Remote addon '${namespace}' ('${decl.package}') is wired with wireRemoteAddon but is not in "devDependencies". Add '${decl.package}' to "devDependencies" (types only).`
632
- )
633
- }
634
- }
635
- }
636
-
637
- /**
638
- * The auth a `wireRemoteAddon` consumer binds must reference a slot the consumer
639
- * actually declares: a `credentialId` must be a wired credential, a `secretId`
640
- * a wired secret. A custom `resolve()` and a public (omitted) surface are not
641
- * statically checkable and are left to runtime.
642
- */
643
- export function validateRemoteAddonAuth(
644
- logger: InspectorLogger,
645
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
646
- ): void {
647
- const { wireAddonDeclarations } = state.rpc
648
- if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
649
-
650
- const credentialNames = new Set(
651
- state.credentials?.definitions.map((d) => d.name) ?? []
652
- )
653
- const secretNames = new Set(state.secrets.definitions.map((d) => d.name))
654
-
655
- for (const [namespace, decl] of wireAddonDeclarations.entries()) {
656
- if (!decl.remote) continue
657
-
658
- if (decl.authCredentialId && !credentialNames.has(decl.authCredentialId)) {
659
- logger.critical(
660
- ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED,
661
- `Remote addon '${namespace}' binds auth.credentialId '${decl.authCredentialId}', but no such credential is wired. Available credentials: ${Array.from(credentialNames).join(', ') || 'none'}`
662
- )
663
- }
664
- if (decl.authSecretId && !secretNames.has(decl.authSecretId)) {
665
- logger.critical(
666
- ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED,
667
- `Remote addon '${namespace}' binds auth.secretId '${decl.authSecretId}', but no such secret is wired. Available secrets: ${Array.from(secretNames).join(', ') || 'none'}`
668
- )
669
- }
670
- }
671
- }
672
-
673
- export function computeResolvedIOTypes(state: InspectorState): void {
674
- const { functions } = state
675
- for (const [pikkuFuncId, meta] of Object.entries(functions.meta)) {
676
- const input = meta.inputs?.[0]
677
- const output = meta.outputs?.[0]
678
-
679
- let inputType = 'null'
680
- if (input) {
681
- try {
682
- inputType = functions.typesMap.getTypeMeta(input).uniqueName
683
- } catch {
684
- inputType = input
685
- }
686
- }
687
-
688
- let outputType = 'null'
689
- if (output) {
690
- try {
691
- outputType = functions.typesMap.getTypeMeta(output).uniqueName
692
- } catch {
693
- outputType = output
694
- }
695
- }
696
-
697
- state.resolvedIOTypes[pikkuFuncId] = { inputType, outputType }
698
-
699
- if (meta.inputSchemaName && inputType !== 'null') {
700
- meta.inputSchemaName = inputType
701
- }
702
- if (meta.outputSchemaName && outputType !== 'null') {
703
- meta.outputSchemaName = outputType
704
- }
705
- if (meta.inputs) {
706
- meta.inputs = meta.inputs.map((name) => {
707
- try {
708
- return functions.typesMap.getTypeMeta(name).uniqueName
709
- } catch {
710
- return name
711
- }
712
- })
713
- }
714
- if (meta.outputs) {
715
- meta.outputs = meta.outputs.map((name) => {
716
- try {
717
- return functions.typesMap.getTypeMeta(name).uniqueName
718
- } catch {
719
- return name
720
- }
721
- })
722
- }
723
- }
724
- }
725
-
726
- const serializeGroupMap = (
727
- groupMap: Map<string, MiddlewareGroupMeta>
728
- ): Record<string, MiddlewareGroupMeta> => {
729
- const result: Record<string, MiddlewareGroupMeta> = {}
730
- for (const [key, meta] of groupMap.entries()) {
731
- result[key] = {
732
- exportName: meta.exportName,
733
- sourceFile: meta.sourceFile,
734
- position: meta.position,
735
- services: meta.services,
736
- count: meta.count,
737
- instanceIds: meta.instanceIds,
738
- isFactory: meta.isFactory,
739
- ...(meta.additionalRegistrations && {
740
- additionalRegistrations: meta.additionalRegistrations,
741
- }),
742
- }
743
- }
744
- return result
745
- }
746
-
747
- export function computeMiddlewareGroupsMeta(state: InspectorState): void {
748
- state.middlewareGroupsMeta = {
749
- definitions: state.middleware.definitions,
750
- instances: state.middleware.instances,
751
- httpGroups: serializeGroupMap(state.http.routeMiddleware),
752
- tagGroups: serializeGroupMap(state.middleware.tagMiddleware),
753
- channelMiddleware: {
754
- definitions: state.channelMiddleware.definitions,
755
- instances: state.channelMiddleware.instances,
756
- tagGroups: serializeGroupMap(state.channelMiddleware.tagMiddleware),
757
- },
758
- }
759
- }
760
-
761
- export function computePermissionsGroupsMeta(state: InspectorState): void {
762
- state.permissionsGroupsMeta = {
763
- definitions: state.permissions.definitions,
764
- }
765
- }
766
-
767
- const PRIMITIVE_TYPES = new Set([
768
- 'boolean',
769
- 'string',
770
- 'number',
771
- 'null',
772
- 'undefined',
773
- 'void',
774
- 'any',
775
- 'unknown',
776
- 'never',
777
- ])
778
-
779
- export function computeRequiredSchemas(
780
- state: InspectorState,
781
- options: InspectorOptions
782
- ): void {
783
- const { functions, schemaLookup } = state
784
- const schemasFromTypes = options.schemaConfig?.schemasFromTypes
785
-
786
- state.requiredSchemas = new Set<string>([
787
- ...Object.values(functions.meta)
788
- .flatMap(({ inputs, outputs }) => {
789
- const types: (string | undefined)[] = []
790
- if (inputs?.[0]) {
791
- try {
792
- types.push(functions.typesMap.getUniqueName(inputs[0]))
793
- } catch {
794
- types.push(inputs[0])
795
- }
796
- }
797
- if (outputs?.[0]) {
798
- try {
799
- types.push(functions.typesMap.getUniqueName(outputs[0]))
800
- } catch {
801
- types.push(outputs[0])
802
- }
803
- }
804
- return types
805
- })
806
- .filter((s): s is string => !!s && !PRIMITIVE_TYPES.has(s)),
807
- ...functions.typesMap.customTypes.keys(),
808
- ...(schemasFromTypes || []),
809
- ...Array.from(schemaLookup.keys()),
810
- ])
811
- }
812
-
813
- /**
814
- * Every schema a function's contract names has to exist once codegen is done.
815
- *
816
- * The way it stops existing is quiet. `generateCustomTypes` hands the schema
817
- * generator a virtual source file that *imports* each named contract type, so a
818
- * type that is declared but never exported resolves to nothing and yields no
819
- * schema — while the function's meta still carries the name. Nothing downstream
820
- * reads that name until the first call, which fails with a runtime
821
- * `MissingSchemaError` in a deployment rather than in the build that caused it.
822
- *
823
- * Reported rather than critical: the reference is only *guaranteed* to break
824
- * the functions that are actually invoked, so this blocks CI through
825
- * `--fail-on-error` without failing a dev server over a function nobody calls.
826
- */
827
- export function unresolvedSchemaReferences(state: InspectorState): string[] {
828
- const { functions, schemas } = state
829
- const available = new Set(Object.keys(schemas ?? {}))
830
- const unresolved: string[] = []
831
-
832
- const resolve = (typeName: string): string => {
833
- try {
834
- return functions.typesMap.getUniqueName(typeName)
835
- } catch {
836
- return typeName
837
- }
838
- }
839
-
840
- for (const [funcName, meta] of Object.entries(functions.meta)) {
841
- for (const [role, declared] of [
842
- ['input', meta.inputs?.[0]],
843
- ['output', meta.outputs?.[0]],
844
- ] as const) {
845
- if (!declared || PRIMITIVE_TYPES.has(declared)) continue
846
- const resolved = resolve(declared)
847
- if (PRIMITIVE_TYPES.has(resolved) || available.has(resolved)) continue
848
- unresolved.push(`${funcName}.${role} → '${resolved}'`)
849
- }
850
- }
851
-
852
- return unresolved
853
- }
854
-
855
- export function validateSchemaReferences(
856
- logger: InspectorLogger,
857
- state: InspectorState
858
- ): void {
859
- const unresolved = unresolvedSchemaReferences(state)
860
-
861
- if (unresolved.length === 0) return
862
-
863
- logger.diagnostic({
864
- severity: 'error',
865
- code: ErrorCode.SCHEMA_REFERENCE_UNRESOLVED,
866
- message:
867
- `${unresolved.length} function contract${unresolved.length === 1 ? '' : 's'} ` +
868
- `name${unresolved.length === 1 ? 's' : ''} a schema that was never generated, so ` +
869
- `calling ${unresolved.length === 1 ? 'it' : 'them'} fails at runtime with ` +
870
- `MissingSchemaError: ${unresolved.join(', ')}. ` +
871
- `The usual cause is a named type used as the function's input/output that ` +
872
- `is declared but not exported — export it, or inline the type at the call site.`,
873
- })
874
- }
875
-
876
- /**
877
- * A model is either concrete (`provider/model`) or an alias, which must appear
878
- * in the `models` table from pikku.config.json.
879
- */
880
- export function validateAgentModels(
881
- logger: InspectorLogger,
882
- state: InspectorState | Omit<InspectorState, 'typesLookup'>,
883
- modelAliases: Record<string, string> = {}
884
- ): void {
885
- for (const [, meta] of Object.entries(state.agents.agentsMeta)) {
886
- const model = meta.model
887
- if (!model) {
888
- logger.critical(
889
- ErrorCode.MISSING_MODEL,
890
- `AI agent '${meta.name}' is missing the 'model' property.`
891
- )
892
- continue
893
- }
894
- // `hasOwn`, not a truthy lookup: every object literal inherits `toString`,
895
- // `constructor` and `__proto__`, so a bare model named after one of them
896
- // would read as a configured alias and pass a check nothing else repeats.
897
- if (!model.includes('/') && !Object.hasOwn(modelAliases, model)) {
898
- const known = Object.keys(modelAliases).sort()
899
- logger.critical(
900
- ErrorCode.INVALID_MODEL,
901
- `AI agent '${meta.name}' uses model '${model}', which is neither provider-qualified as '<provider>/<model>' (e.g. 'openai/gpt-4') nor an alias in the "models" table of pikku.config.json. ` +
902
- (known.length
903
- ? `Known aliases: ${known.join(', ')}.`
904
- : `No "models" aliases are configured.`)
905
- )
906
- }
907
- }
908
- }
909
-
910
- /**
911
- * Scenarios are pure stories of remote RPCs (same rule as client-side CLI
912
- * renderers): the func may only destructure logger/config — everything else
913
- * must go through actor steps so the flow runs against the TARGET
914
- * environment, never local services.
915
- *
916
- * This runs in post-processing rather than in `addWorkflow` because
917
- * `addWorkflow` runs in the `visitSetup` pass, which completes before
918
- * `addFunctions` (`visitFunctions`) has populated `state.functions.meta` — so
919
- * the check read `undefined` there and never fired.
920
- */
921
- export function validateScenarioServices(
922
- logger: InspectorLogger,
923
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
924
- ): void {
925
- for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
926
- if (!meta.scenario) continue
927
- const funcMeta = state.functions.meta[meta.pikkuFuncId]
928
- if (!funcMeta?.services) continue
929
- const disallowed = funcMeta.services.services.filter(
930
- (svc) => svc !== 'logger' && svc !== 'config'
931
- )
932
- if (disallowed.length > 0) {
933
- logger.critical(
934
- ErrorCode.SCENARIO_HAS_SERVICES,
935
- `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
936
- `Scenarios may only use 'logger'/'config' — drive everything else through ` +
937
- `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
938
- `runs against the target environment.`
939
- )
940
- }
941
- }
942
- }
943
-
944
- /**
945
- * Does this step's prose open by naming the actor who is already its subject?
946
- *
947
- * `composeStepProse` renders the actor as the sentence's subject, so a step
948
- * authored as `'sam' creates the client` reads "Given sam 'sam' creates the
949
- * client". Only the subject position counts: a persona keyed after a role noun
950
- * ("admin") must still be able to say "creates the admin client", and naming
951
- * someone mid-sentence — "sends sam an invite" — is ordinary prose.
952
- */
953
- export const proseOpensWithActor = (prose: string, actor: string): boolean => {
954
- if (!actor) {
955
- return false
956
- }
957
- const escaped = actor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
958
- return new RegExp(`^["'“‘\\s]*${escaped}(?:['’]s)?\\b`, 'i').test(prose)
959
- }
960
-
961
- /**
962
- * The prose that will actually render for one step, in the runtime's own
963
- * precedence: a call-site description overrides the step's template, which
964
- * overrides its declared description, which falls back to the durable name.
965
- *
966
- * Every candidate is returned rather than only the winner, because a template
967
- * is authored where the actor is not yet known — so it is wrong at every call
968
- * site that runs it as the persona it names, not just the winning one.
969
- */
970
- const renderableProse = (
971
- step: ScenarioStepMeta,
972
- stepMeta: { description?: string; scenarioStepTemplate?: string } | undefined
973
- ): string[] => {
974
- const description = step.options?.description
975
- if (description) {
976
- return [step.stepName, description]
977
- }
978
- return [
979
- step.stepName,
980
- stepMeta?.scenarioStepTemplate,
981
- stepMeta?.description,
982
- ].filter((prose): prose is string => typeof prose === 'string')
983
- }
984
-
985
- /**
986
- * A step's prose must not open by naming the actor it already runs as (PKU681).
987
- *
988
- * The reporter renders the actor as the sentence's subject, so `'sam' creates
989
- * the client` run as `actors.sam` reads "Given sam 'sam' creates the client" —
990
- * and the hardcoded copy silently desyncs the moment the call site changes
991
- * actor. Only the subject position is refused; naming someone mid-sentence is
992
- * ordinary prose, and a persona keyed after a role noun can still say "creates
993
- * the admin client".
994
- */
995
- const validateStepProse = (
996
- logger: InspectorLogger,
997
- workflowName: string,
998
- step: ScenarioStepMeta,
999
- stepMeta: { description?: string; scenarioStepTemplate?: string } | undefined
1000
- ): void => {
1001
- const actor = step.actor
1002
- if (!actor) {
1003
- return
1004
- }
1005
- const offender = renderableProse(step, stepMeta).find((prose) =>
1006
- proseOpensWithActor(prose, actor)
1007
- )
1008
- if (offender === undefined) {
1009
- return
1010
- }
1011
- logger.critical(
1012
- ErrorCode.SCENARIO_STEP_PROSE_NAMES_ACTOR,
1013
- `Scenario '${workflowName}' names its actor in the step prose: '${offender}' runs as '${actor}'. ` +
1014
- `The reporter already renders the actor as the subject, so this reads ` +
1015
- `"${capitaliseFirst(step.phase)} ${actor} ${offender}". Drop the name and let ` +
1016
- `{ actor: actors.${actor} } supply it — the step reads as a bare third-person predicate.`
1017
- )
1018
- }
1019
-
1020
- const capitaliseFirst = (value: string): string =>
1021
- value.charAt(0).toUpperCase() + value.slice(1)
1022
-
1023
- /**
1024
- * Walk every scenario step, wherever it is nested, and validate the two things
1025
- * that can only be known once both the workflow meta and the function meta
1026
- * exist:
1027
- *
1028
- * - the step target must be a static string literal (PKU678) — otherwise it
1029
- * can't be bundled, typed or drawn;
1030
- * - a step that runs as a persona must be given one (PKU677) — declared by
1031
- * `actor: true`, or implied by a `browser` binding, since the browser context
1032
- * signs in as that persona. It is checked statically even though a browser
1033
- * binding only runs on a browser run: a step that can never be driven by a
1034
- * human is a latent failure, not a passing default run.
1035
- * - a scenario written as a step ladder must assert something (PKU680).
1036
- *
1037
- * It also marks the step's function as used, so `scenario run` boots the
1038
- * singletons the step actually needs.
1039
- */
1040
- export function validateScenarioSteps(
1041
- logger: InspectorLogger,
1042
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
1043
- ): void {
1044
- const phasesSeen = new Set<string>()
1045
-
1046
- const visit = (workflowName: string, steps: WorkflowStepMeta[]): void => {
1047
- for (const step of steps) {
1048
- if (step.type === 'scenarioStep') {
1049
- phasesSeen.add(step.phase)
1050
- if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
1051
- logger.critical(
1052
- ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC,
1053
- `Scenario '${workflowName}' calls step '${step.stepName}' with a target that isn't a string literal. ` +
1054
- `A step target must be statically resolvable so it can be bundled, typed and drawn — ` +
1055
- `loop over data sets instead of computing the step name.`
1056
- )
1057
- continue
1058
- }
1059
- state.serviceAggregation.usedFunctions.add(step.stepFunc)
1060
- const stepMeta = state.functions.meta[step.stepFunc]
1061
- validateStepProse(logger, workflowName, step, stepMeta)
1062
- if (stepMeta?.scenarioStepRequiresActor && !step.actor) {
1063
- const why = stepMeta.scenarioStepSurfaces?.includes('browser')
1064
- ? 'declares a browser binding'
1065
- : 'is driven by a persona'
1066
- logger.critical(
1067
- ErrorCode.SCENARIO_STEP_NEEDS_ACTOR,
1068
- `Scenario '${workflowName}' calls step '${step.stepFunc}', which ${why}, without an actor. ` +
1069
- `Pass { actor: actors.<name> } so it runs as that persona.`
1070
- )
1071
- }
1072
- continue
1073
- }
1074
- if (step.type === 'branch') {
1075
- for (const branch of step.branches) visit(workflowName, branch.steps)
1076
- if (step.elseSteps) visit(workflowName, step.elseSteps)
1077
- } else if (step.type === 'switch') {
1078
- for (const c of step.cases ?? []) {
1079
- if (c.steps) visit(workflowName, c.steps)
1080
- }
1081
- if (step.defaultSteps) visit(workflowName, step.defaultSteps)
1082
- } else if (step.type === 'fanout' && step.body) {
1083
- visit(workflowName, step.body as WorkflowStepMeta[])
1084
- } else if (step.type === 'parallel' && step.children) {
1085
- visit(workflowName, step.children as WorkflowStepMeta[])
1086
- }
1087
- }
1088
- }
1089
-
1090
- for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
1091
- phasesSeen.clear()
1092
- visit(workflowName, meta.steps ?? [])
1093
- // A scenario written as a step ladder but with no `then` proves only that
1094
- // nothing threw. It is also invisible to witness coverage — it contributes
1095
- // 0/0, so it can never lower the number — which makes an assertion-free
1096
- // flow the cheapest way to make a suite look better covered than it is.
1097
- // `asserts` is the escape for a flow whose witness is an expectation helper
1098
- // instead: those are inline steps and leave no phase behind, but a recorded
1099
- // service call or a refusal is an assertion all the same.
1100
- if (phasesSeen.size > 0 && !phasesSeen.has('then') && !meta.asserts) {
1101
- logger.critical(
1102
- ErrorCode.SCENARIO_HAS_NO_ASSERTION,
1103
- `Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
1104
- `Add a scenario.then(...) naming what the actor should now see — a flow with no 'then' ` +
1105
- `only proves nothing threw, and contributes nothing to witness coverage.`
1106
- )
1107
- }
1108
- }
1109
- }
1110
-
1111
- /**
1112
- * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
1113
- * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
1114
- * registers and codegen fails deep in type-checking with an opaque error. Fail
1115
- * early with an actionable message pointing at `scaffold: { graph: true }`.
1116
- */
1117
- export function validateWorkflowGraphAddons(
1118
- logger: InspectorLogger,
1119
- state: InspectorState
1120
- ): void {
1121
- const addonGraphWired = Array.from(
1122
- state.rpc.wireAddonDeclarations.values()
1123
- ).some((decl) => decl.package === '@pikku/addon-graph')
1124
- if (addonGraphWired) {
1125
- return
1126
- }
1127
-
1128
- for (const [name, graph] of Object.entries(state.workflows.graphMeta)) {
1129
- for (const node of Object.values(graph.nodes)) {
1130
- if (!('rpcName' in node) || typeof node.rpcName !== 'string') {
1131
- continue
1132
- }
1133
- if (!node.rpcName.startsWith('graph:')) {
1134
- continue
1135
- }
1136
- if (state.functions.meta[node.rpcName]) {
1137
- continue
1138
- }
1139
- logger.critical(
1140
- ErrorCode.WORKFLOW_GRAPH_ADDON_NOT_WIRED,
1141
- `Workflow graph '${name}' references '${node.rpcName}' but @pikku/addon-graph is not wired. ` +
1142
- `Enable "scaffold": { "graph": true } in pikku.config.json (and install @pikku/addon-graph), ` +
1143
- `or wire it manually with wireAddon({ name: 'graph', package: '@pikku/addon-graph' }).`
1144
- )
1145
- }
1146
- }
1147
- }
1148
-
1149
- /**
1150
- * Validates that Zod schemas and wiring side-effects (wireHTTPRoutes,
1151
- * addPermission, addHTTPMiddleware, etc.) do not coexist in the same file.
1152
- *
1153
- * The CLI uses tsImport to extract Zod schemas at runtime, which executes
1154
- * all top-level code in the file. Wiring calls crash during this process
1155
- * because the pikku state metadata doesn't exist in the CLI context.
1156
- */
1157
- export function validateSchemaWiringSeparation(
1158
- logger: InspectorLogger,
1159
- state: InspectorState
1160
- ): void {
1161
- // Collect files that contain schemas
1162
- const schemaFiles = new Set<string>()
1163
- for (const ref of state.schemaLookup.values()) {
1164
- schemaFiles.add(ref.sourceFile)
1165
- }
1166
-
1167
- // Collect files that contain wiring side-effects
1168
- const wiringFiles = new Set<string>()
1169
-
1170
- // HTTP route wirings
1171
- for (const file of state.http.files) {
1172
- wiringFiles.add(file)
1173
- }
1174
-
1175
- // Middleware wirings (addHTTPMiddleware calls). A group can be registered
1176
- // from more than one file, and every one of them is a wiring file.
1177
- const addGroupFiles = (groups: Map<string, MiddlewareGroupMeta>) => {
1178
- for (const meta of groups.values()) {
1179
- wiringFiles.add(meta.sourceFile)
1180
- for (const registration of meta.additionalRegistrations ?? []) {
1181
- wiringFiles.add(registration.sourceFile)
1182
- }
1183
- }
1184
- }
1185
- addGroupFiles(state.http.routeMiddleware)
1186
- addGroupFiles(state.middleware.tagMiddleware)
1187
-
1188
- // Check for overlap
1189
- for (const file of schemaFiles) {
1190
- if (wiringFiles.has(file)) {
1191
- const schemas = Array.from(state.schemaLookup.entries())
1192
- .filter(([, ref]) => ref.sourceFile === file)
1193
- .map(([name]) => name)
1194
-
1195
- logger.critical(
1196
- ErrorCode.SCHEMA_AND_WIRING_COLOCATED,
1197
- `File '${file}' contains both Zod schemas (${schemas.join(', ')}) and wiring calls (wireHTTPRoutes, addPermission, etc.). ` +
1198
- `These must be in separate files because the CLI imports schema files at runtime, which triggers wiring side-effects that crash without server context. ` +
1199
- `Move the route/wiring definitions to a dedicated wiring file.`
1200
- )
1201
- }
1202
- }
1203
- }
1204
-
1205
- /**
1206
- * Refuses a singleton service that is a `SecretService` under another name.
1207
- *
1208
- * `secrets` is omitted from every function-facing services type, but
1209
- * `pikkuServices(async ({ secrets }) => ({ cfg: secrets }))` re-exposes it under
1210
- * a name the omit does not cover. A name-based rule misses this; the type does
1211
- * not.
1212
- */
1213
- export function validateNoSecretAliasServices(
1214
- logger: InspectorLogger,
1215
- checker: ts.TypeChecker,
1216
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
1217
- ): void {
1218
- if (!('typesLookup' in state)) {
1219
- return
1220
- }
1221
- const singletonServicesType = resolveCoreType(
1222
- state.typesLookup,
1223
- state.singletonServicesTypeImportMap
1224
- )
1225
- if (!singletonServicesType) {
1226
- return
1227
- }
1228
- const aliases = findSecretAliasServices(singletonServicesType, checker)
1229
- if (aliases.length === 0) {
1230
- return
1231
- }
1232
-
1233
- const report = (kind: string, id: string, services: string[]) => {
1234
- const used = services.filter((service) => aliases.includes(service))
1235
- if (used.length === 0) {
1236
- return
1237
- }
1238
- logger.critical(
1239
- ErrorCode.SECRET_SERVICE_ALIASED,
1240
- `${kind} '${id}' receives ${used.map((s) => `'${s}'`).join(', ')}, which ${used.length === 1 ? 'is' : 'are'} a SecretService under another name. ` +
1241
- `SecretService is confined to pikkuServices, pikkuWireServices, addon service factories and middleware — give a service the secret value when you construct it and expose only what the function needs.`
1242
- )
1243
- }
1244
-
1245
- for (const [id, meta] of Object.entries(state.functions.meta)) {
1246
- report('Function', id, meta.services?.services ?? [])
1247
- }
1248
- for (const [id, def] of Object.entries(state.permissions.definitions)) {
1249
- report('Permission', id, def.services?.services ?? [])
1250
- }
1251
- }
1252
-
1253
- /**
1254
- * Reports secret reads the catalogue does not account for.
1255
- *
1256
- * `getSecret('X')` with no matching `defineSecret` is a value that will be missing
1257
- * at first request rather than at deploy; a non-literal key is a read the
1258
- * manifest cannot cover, so a per-unit scope cannot be narrowed around it.
1259
- */
1260
- export function validateSecretUsage(
1261
- logger: InspectorLogger,
1262
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
1263
- ): void {
1264
- const declared = new Set(state.secrets.definitions.map((d) => d.secretId))
1265
- for (const definition of state.secrets.definitions) {
1266
- if (definition.oauth2?.tokenSecretId) {
1267
- declared.add(definition.oauth2.tokenSecretId)
1268
- }
1269
- }
1270
-
1271
- for (const [file, usage] of state.secrets.usage) {
1272
- const relativeFile = relative(state.rootDir, file)
1273
- const undeclared = usage.keys.filter((key) => !declared.has(key))
1274
- if (undeclared.length > 0) {
1275
- logger.diagnostic({
1276
- severity: 'warn',
1277
- code: ErrorCode.SECRET_NOT_DECLARED,
1278
- message: `${relativeFile} reads ${undeclared.map((k) => `'${k}'`).join(', ')}, which no defineSecret declares. Declare it so it is validated at deploy time and shown to whoever has to provision it.`,
1279
- })
1280
- }
1281
- if (usage.dynamic.length > 0) {
1282
- logger.diagnostic({
1283
- severity: 'warn',
1284
- code: ErrorCode.SECRET_KEY_NOT_STATIC,
1285
- message: `${relativeFile} reads a secret with a non-literal key (${usage.dynamic.join(', ')}). The deployment cannot narrow its secret scope around a key it cannot see.`,
1286
- })
1287
- }
1288
- }
1289
- }
1290
-
1291
- export function computeDiagnostics(state: InspectorState): void {
1292
- const diagnostics: InspectorDiagnostic[] = []
1293
-
1294
- for (const [id, meta] of Object.entries(state.functions.meta)) {
1295
- // Skip framework-synthesized functions: generated wrappers (auth.gen.ts's
1296
- // opaque authHandler, the cli channel's raw dispatcher) and synthetic route
1297
- // bridges that reference addon functions (id `http:<method>:<route>`, no
1298
- // source file). The user can't edit any of these, so a destructure lint
1299
- // meant to nudge them about their own code must not fail the build over them.
1300
- if (!meta.sourceFile || meta.sourceFile.endsWith('.gen.ts')) {
1301
- continue
1302
- }
1303
- if (meta.services && !meta.services.optimized) {
1304
- diagnostics.push({
1305
- code: ErrorCode.SERVICES_NOT_DESTRUCTURED,
1306
- message: `Function '${id}' does not destructure its services parameter, preventing tree-shaking optimization.`,
1307
- sourceFile: meta.pikkuFuncId,
1308
- position: 0,
1309
- })
1310
- }
1311
- if (meta.wires && !meta.wires.optimized) {
1312
- diagnostics.push({
1313
- code: ErrorCode.WIRES_NOT_DESTRUCTURED,
1314
- message: `Function '${id}' does not destructure its wires parameter, preventing tree-shaking optimization.`,
1315
- sourceFile: meta.pikkuFuncId,
1316
- position: 0,
1317
- })
1318
- }
1319
- if (state.functions.dynamicImportIds.has(id)) {
1320
- diagnostics.push({
1321
- code: ErrorCode.FUNCTION_DYNAMIC_IMPORT,
1322
- message: `Function '${id}' performs a runtime dynamic 'import(...)' in its body. Move the import to the top of the module (static import) or into your services/wireServices setup — function bodies run on every invocation, so a dynamic import there adds latency and defeats bundling/tree-shaking.`,
1323
- sourceFile: meta.pikkuFuncId,
1324
- position: 0,
1325
- })
1326
- }
1327
- }
1328
-
1329
- for (const [id, def] of Object.entries(state.middleware.definitions)) {
1330
- if (def.services && !def.services.optimized) {
1331
- diagnostics.push({
1332
- code: ErrorCode.SERVICES_NOT_DESTRUCTURED,
1333
- message: `Middleware '${id}' does not destructure its services parameter, preventing tree-shaking optimization.`,
1334
- sourceFile: def.sourceFile,
1335
- position: def.position,
1336
- })
1337
- }
1338
- if (def.wires && !def.wires.optimized) {
1339
- diagnostics.push({
1340
- code: ErrorCode.WIRES_NOT_DESTRUCTURED,
1341
- message: `Middleware '${id}' does not destructure its wires parameter, preventing tree-shaking optimization.`,
1342
- sourceFile: def.sourceFile,
1343
- position: def.position,
1344
- })
1345
- }
1346
- }
1347
-
1348
- for (const [id, def] of Object.entries(state.permissions.definitions)) {
1349
- if (def.services && !def.services.optimized) {
1350
- diagnostics.push({
1351
- code: ErrorCode.SERVICES_NOT_DESTRUCTURED,
1352
- message: `Permission '${id}' does not destructure its services parameter, preventing tree-shaking optimization.`,
1353
- sourceFile: def.sourceFile,
1354
- position: def.position,
1355
- })
1356
- }
1357
- if (def.wires && !def.wires.optimized) {
1358
- diagnostics.push({
1359
- code: ErrorCode.WIRES_NOT_DESTRUCTURED,
1360
- message: `Permission '${id}' does not destructure its wires parameter, preventing tree-shaking optimization.`,
1361
- sourceFile: def.sourceFile,
1362
- position: def.position,
1363
- })
1364
- }
1365
- }
1366
-
1367
- state.diagnostics = diagnostics
1368
- }
1369
-
1370
- /**
1371
- * Validates that every scope referenced by a function is declared via
1372
- * `defineScope`. Runs after all visitors, so declaration order does not matter.
1373
- *
1374
- * A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
1375
- * to be declared, and grants its whole subtree.
1376
- */
1377
- export function validateScopeReferences(
1378
- logger: InspectorLogger,
1379
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
1380
- ): void {
1381
- const declared = new Set(
1382
- flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id)
1383
- )
1384
-
1385
- for (const [funcName, meta] of Object.entries(state.functions.meta)) {
1386
- if (!meta.scopes?.length) continue
1387
-
1388
- for (const scope of meta.scopes) {
1389
- // A trailing wildcard grants a subtree; the node it hangs off must exist.
1390
- const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope
1391
-
1392
- if (scope === '*') {
1393
- logger.critical(
1394
- ErrorCode.INVALID_VALUE,
1395
- `Function '${funcName}' requires the bare wildcard scope '*'. A function must require a specific scope — '*' is only meaningful as a grant.`
1396
- )
1397
- continue
1398
- }
1399
-
1400
- if (!declared.has(declaredForm)) {
1401
- const available = Array.from(declared)
1402
- logger.critical(
1403
- ErrorCode.INVALID_VALUE,
1404
- `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`
1405
- )
1406
- }
1407
- }
1408
- }
1409
- }
1410
-
1411
- /**
1412
- * Resolves every `tools:` entry on every agent and reports the ones that do not
1413
- * land on a real function, or that land on one with nothing to tell the model.
1414
- *
1415
- * `ref('todos:listTodos')` is a bare string to the inspector — `ref` is not
1416
- * resolved, only unwrapped — so until now a reference to a function that never
1417
- * existed generated cleanly and failed at agent-run time. Addon namespaces come
1418
- * from `wireAddon`, and each addon's generated metadata is read off disk, which
1419
- * is the only view of it this project has.
1420
- *
1421
- * The description check is separate and opt-in (`--strict-meta`), because the
1422
- * description is what the model is told the tool does: without one, the tool is
1423
- * offered to it under its own name (`agent-prepare.ts`), which is a silent
1424
- * quality loss rather than a failure. A title deliberately does not satisfy it —
1425
- * a title labels a tool in a UI, it does not tell a model when to reach for it.
1426
- */
1427
- export function validateAgentToolReferences(
1428
- logger: InspectorLogger,
1429
- state: InspectorState | Omit<InspectorState, 'typesLookup'>,
1430
- options: InspectorOptions = {}
1431
- ): void {
1432
- const agents = Object.entries(state.agents.agentsMeta)
1433
- if (agents.length === 0) return
1434
-
1435
- const { wireAddonDeclarations } = state.rpc
1436
-
1437
- for (const [agentKey, agent] of agents) {
1438
- const where = agent.sourceFile ? ` (${agent.sourceFile})` : ''
1439
-
1440
- for (const tool of agent.tools ?? []) {
1441
- // A workflow reference is resolved against the workflow map, not here.
1442
- if (tool.startsWith('workflow:')) continue
1443
-
1444
- const separator = tool.indexOf(':')
1445
- let meta: { description?: string; title?: string } | undefined
1446
-
1447
- if (separator === -1) {
1448
- const funcId = state.rpc.internalMeta[tool] ?? tool
1449
- meta = state.functions.meta[funcId]
1450
- if (!meta) {
1451
- logger.critical(
1452
- ErrorCode.AGENT_TOOL_NOT_FOUND,
1453
- `AI agent '${agentKey}'${where} references tool '${tool}', which is not a function in this project.`
1454
- )
1455
- continue
1456
- }
1457
- } else {
1458
- const namespace = tool.slice(0, separator)
1459
- const funcName = tool.slice(separator + 1)
1460
- const addon = wireAddonDeclarations?.get(namespace)
1461
- if (!addon) {
1462
- const known = Array.from(wireAddonDeclarations?.keys() ?? [])
1463
- logger.critical(
1464
- ErrorCode.AGENT_TOOL_UNKNOWN_NAMESPACE,
1465
- `AI agent '${agentKey}'${where} references tool '${tool}', but no addon is wired under the namespace '${namespace}'. ` +
1466
- `Wired namespaces: ${known.join(', ') || 'none'}.`
1467
- )
1468
- continue
1469
- }
1470
-
1471
- // An addon that has not been built yet contributed no metadata, which
1472
- // is not the same as one whose function is missing.
1473
- const addonMeta = state.addonFunctions[namespace]
1474
- if (!addonMeta) continue
1475
-
1476
- meta = addonMeta[funcName]
1477
- if (!meta) {
1478
- logger.critical(
1479
- ErrorCode.AGENT_TOOL_NOT_FOUND,
1480
- `AI agent '${agentKey}'${where} references tool '${tool}', but addon '${namespace}' ('${addon.package}') exposes no function '${funcName}'.`
1481
- )
1482
- continue
1483
- }
1484
- }
1485
-
1486
- if (options.strictMeta && !meta.description) {
1487
- logger.critical(
1488
- ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION,
1489
- `AI agent '${agentKey}'${where} uses tool '${tool}', which has no description. ` +
1490
- `An agent tool's description is what the model is told it does; without one it is offered the tool under its own name. ` +
1491
- `Add a 'description' to '${tool}'${meta.title ? " (a 'title' does not count — it labels the tool, it does not explain it)" : ''}.`
1492
- )
1493
- }
1494
- }
1495
- }
1496
- }
1497
-
1498
- /**
1499
- * Validates that every scorer an agent asks to be graded by exists.
1500
- *
1501
- * The runtime warns and skips an unresolvable name rather than failing a run
1502
- * that has already answered the user — which means a typo would otherwise cost
1503
- * nothing visible and simply grade nothing, forever.
1504
- */
1505
- export function validateAgentScorerReferences(
1506
- logger: InspectorLogger,
1507
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
1508
- ): void {
1509
- const known = Object.keys(state.scorers.scorersMeta)
1510
-
1511
- for (const [agentKey, agent] of Object.entries(state.agents.agentsMeta)) {
1512
- const where = agent.sourceFile ? ` (${agent.sourceFile})` : ''
1513
- for (const scorer of agent.scorers ?? []) {
1514
- if (known.includes(scorer)) continue
1515
- logger.critical(
1516
- ErrorCode.AGENT_SCORER_NOT_FOUND,
1517
- `AI agent '${agentKey}'${where} asks to be graded by scorer '${scorer}', which is not declared in this project. ` +
1518
- `Declared scorers: ${known.join(', ') || 'none'}.`
1519
- )
1520
- }
1521
- }
1522
- }
1523
-
1524
- /**
1525
- * Validates that every scope granted by a system role is declared via
1526
- * `defineScope`. Runs after all visitors, so declaration order does not matter.
1527
- *
1528
- * The same rule functions get, for a sharper reason: a role granting an
1529
- * undeclared scope is not merely useless, it is *invisibly* useless. No
1530
- * function checks the scope, so the role appears to work — every call a
1531
- * persona holding it makes succeeds or fails for unrelated reasons, and the
1532
- * authorization boundary it was written to describe is never exercised.
1533
- *
1534
- * A `*` suffix grants a subtree; the node it hangs off must exist.
1535
- */
1536
- export function validateSystemRoleScopes(
1537
- logger: InspectorLogger,
1538
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
1539
- ): void {
1540
- if (!state.systemRoles.definitions.length) {
1541
- return
1542
- }
1543
-
1544
- const declared = new Set(
1545
- flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id)
1546
- )
1547
-
1548
- for (const role of state.systemRoles.definitions) {
1549
- for (const scope of role.scopes) {
1550
- if (scope === '*') {
1551
- logger.critical(
1552
- ErrorCode.INVALID_VALUE,
1553
- `System role '${role.name}' grants the bare wildcard scope '*'. Grant the roots it should cover instead — '*' hides what the role actually confers.`
1554
- )
1555
- continue
1556
- }
1557
-
1558
- const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope
1559
- if (!declared.has(declaredForm)) {
1560
- const available = Array.from(declared)
1561
- logger.critical(
1562
- ErrorCode.INVALID_VALUE,
1563
- `System role '${role.name}' grants scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`
1564
- )
1565
- }
1566
- }
1567
- }
1568
- }
1569
-
1570
- /**
1571
- * Validates that every role a persona names is declared with
1572
- * `defineSystemRole`. Runs after all visitors, so declaration order does not
1573
- * matter.
1574
- *
1575
- * Custom roles are refused rather than tolerated. A role composed in the
1576
- * console does not exist at build time and can be deleted after it, so a
1577
- * persona pinned to one silently stops testing what it claims to — and the
1578
- * symptom is a scatter of 403s that read like authorization findings rather
1579
- * than like a deleted role.
1580
- */
1581
- export function validatePersonaRoles(
1582
- logger: InspectorLogger,
1583
- state: InspectorState | Omit<InspectorState, 'typesLookup'>
1584
- ): void {
1585
- if (!state.personas.definitions.length) {
1586
- return
1587
- }
1588
-
1589
- const declared = new Set(state.systemRoles.definitions.map((r) => r.name))
1590
-
1591
- for (const persona of state.personas.definitions) {
1592
- for (const role of persona.roles) {
1593
- if (!declared.has(role)) {
1594
- const available = Array.from(declared).sort()
1595
- logger.critical(
1596
- ErrorCode.INVALID_VALUE,
1597
- `Persona '${persona.id}' holds role '${role}', which is not declared with defineSystemRole. ` +
1598
- `A persona may only name a system role — a role composed in the console can be deleted, and the persona would go on claiming to test it. ` +
1599
- `Declared roles: ${available.join(', ') || 'none'}`
1600
- )
1601
- }
1602
- }
1603
- }
1604
- }