@pikku/inspector 0.12.74 → 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.
- package/CHANGELOG.md +347 -0
- package/dist/add/add-analytics.d.ts +9 -0
- package/dist/add/add-analytics.js +178 -0
- package/dist/add/add-auth.js +0 -54
- package/dist/add/add-feature-flag.d.ts +2 -0
- package/dist/add/add-feature-flag.js +162 -0
- package/dist/add/add-wire-addon.js +1 -0
- package/dist/add/add-wire-remote-addon.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/inspector.js +4 -0
- package/dist/surface/collect-surface.d.ts +58 -0
- package/dist/surface/collect-surface.js +613 -0
- package/dist/types.d.ts +20 -11
- package/dist/utils/filter-inspector-state.js +23 -13
- package/dist/utils/post-process.js +15 -0
- package/dist/utils/read-module-specifiers.d.ts +8 -0
- package/dist/utils/read-module-specifiers.js +23 -0
- package/dist/utils/read-tsconfig-out-dir.d.ts +5 -0
- package/dist/utils/read-tsconfig-out-dir.js +12 -0
- package/dist/utils/resolve-deploy-target.d.ts +7 -0
- package/dist/utils/resolve-deploy-target.js +15 -7
- package/dist/utils/serialize-inspector-state.d.ts +5 -0
- package/dist/utils/serialize-inspector-state.js +8 -0
- package/dist/visit.js +4 -0
- package/package.json +16 -6
- package/run-tests.sh +21 -6
- package/src/add/add-addon-bans.ts +0 -85
- package/src/add/add-agent-scorer.test.ts +0 -197
- package/src/add/add-agent-scorer.ts +0 -83
- package/src/add/add-agent.test.ts +0 -87
- package/src/add/add-agent.ts +0 -638
- package/src/add/add-approval-description.ts +0 -76
- package/src/add/add-auth.test.ts +0 -791
- package/src/add/add-auth.ts +0 -424
- package/src/add/add-channel.ts +0 -778
- package/src/add/add-cli-renderers.test.ts +0 -74
- package/src/add/add-cli.ts +0 -1006
- package/src/add/add-credential.ts +0 -186
- package/src/add/add-feature.test.ts +0 -306
- package/src/add/add-feature.ts +0 -253
- package/src/add/add-file-extends-core-type.ts +0 -66
- package/src/add/add-file-with-config.ts +0 -45
- package/src/add/add-file-with-factory.test.ts +0 -134
- package/src/add/add-file-with-factory.ts +0 -288
- package/src/add/add-functions.test.ts +0 -743
- package/src/add/add-functions.ts +0 -1548
- package/src/add/add-gateway.ts +0 -143
- package/src/add/add-http-route-ref.test.ts +0 -149
- package/src/add/add-http-route.ts +0 -530
- package/src/add/add-http-routes.ts +0 -471
- package/src/add/add-keyed-wiring.ts +0 -180
- package/src/add/add-mcp-prompt.ts +0 -157
- package/src/add/add-mcp-resource.ts +0 -175
- package/src/add/add-middleware.test.ts +0 -196
- package/src/add/add-middleware.ts +0 -879
- package/src/add/add-permission-auth.test.ts +0 -59
- package/src/add/add-permission.ts +0 -311
- package/src/add/add-personas.test.ts +0 -406
- package/src/add/add-personas.ts +0 -287
- package/src/add/add-queue-worker.ts +0 -117
- package/src/add/add-rpc-invocations.test.ts +0 -287
- package/src/add/add-rpc-invocations.ts +0 -221
- package/src/add/add-scenario-step.test.ts +0 -509
- package/src/add/add-schedule.ts +0 -120
- package/src/add/add-scope.test.ts +0 -419
- package/src/add/add-scope.ts +0 -221
- package/src/add/add-secret.ts +0 -10
- package/src/add/add-subject-scenario-steps.test.ts +0 -161
- package/src/add/add-system-role.test.ts +0 -297
- package/src/add/add-system-role.ts +0 -162
- package/src/add/add-trigger.ts +0 -165
- package/src/add/add-variable.ts +0 -10
- package/src/add/add-wire-addon.test.ts +0 -185
- package/src/add/add-wire-addon.ts +0 -146
- package/src/add/add-wire-remote-addon.ts +0 -80
- package/src/add/add-workflow-edge-cases.test.ts +0 -201
- package/src/add/add-workflow-fanout.test.ts +0 -166
- package/src/add/add-workflow-graph-foreach.test.ts +0 -174
- package/src/add/add-workflow-graph-input.test.ts +0 -94
- package/src/add/add-workflow-graph-notes.test.ts +0 -74
- package/src/add/add-workflow-graph.ts +0 -644
- package/src/add/add-workflow-on-error.test.ts +0 -118
- package/src/add/add-workflow-return-switch.test.ts +0 -121
- package/src/add/add-workflow-silent-drops.test.ts +0 -261
- package/src/add/add-workflow-sleep-expression.test.ts +0 -96
- package/src/add/add-workflow.test.ts +0 -155
- package/src/add/add-workflow.ts +0 -482
- package/src/add/addon-bans.test.ts +0 -121
- package/src/add/addon-contracts.test.ts +0 -221
- package/src/add/allow-escape-hatches.test.ts +0 -144
- package/src/add/audit-meta.test.ts +0 -75
- package/src/add/classified-sink-check.test.ts +0 -347
- package/src/add/config-value-metadata.test.ts +0 -263
- package/src/add/dynamic-import-check.test.ts +0 -119
- package/src/add/expect-eventually-scenario-only.test.ts +0 -108
- package/src/add/function-body-span.test.ts +0 -96
- package/src/add/inline-wiring-function.test.ts +0 -104
- package/src/add/pii-check.test.ts +0 -258
- package/src/add/rpc-type-cast.test.ts +0 -123
- package/src/add/scenario-instrumentation.test.ts +0 -89
- package/src/add/scenario-instrumentation.ts +0 -42
- package/src/add/scenario-marker.test.ts +0 -89
- package/src/add/scenario-not-extractable.test.ts +0 -159
- package/src/add/scenario-services.test.ts +0 -103
- package/src/add/scenario-skip.test.ts +0 -83
- package/src/add/validate-workflow-graph-addons.test.ts +0 -102
- package/src/add/wire-name-literal.test.ts +0 -117
- package/src/error-codes-emitted.test.ts +0 -76
- package/src/error-codes.ts +0 -145
- package/src/index.ts +0 -66
- package/src/inspector.ts +0 -575
- package/src/schema-generator.ts +0 -1
- package/src/source-file-cache.ts +0 -73
- package/src/tsconfig-paths.test.ts +0 -90
- package/src/types-map.ts +0 -141
- package/src/types.ts +0 -775
- package/src/utils/annotate-http-route-auth.test.ts +0 -161
- package/src/utils/annotate-http-route-auth.ts +0 -53
- package/src/utils/check-classified-sinks.ts +0 -215
- package/src/utils/check-pii-output.ts +0 -120
- package/src/utils/compute-diagnostics.test.ts +0 -106
- package/src/utils/compute-required-schemas.ts +0 -48
- package/src/utils/contract-hashes.test.ts +0 -553
- package/src/utils/contract-hashes.ts +0 -387
- package/src/utils/custom-types-generator.test.ts +0 -124
- package/src/utils/custom-types-generator.ts +0 -150
- package/src/utils/detect-schema-vendor.test.ts +0 -123
- package/src/utils/detect-schema-vendor.ts +0 -120
- package/src/utils/does-type-extend-core-type.ts +0 -53
- package/src/utils/ensure-function-metadata.ts +0 -385
- package/src/utils/extract-function-name.ts +0 -612
- package/src/utils/extract-function-node.ts +0 -70
- package/src/utils/extract-node-value.test.ts +0 -117
- package/src/utils/extract-node-value.ts +0 -172
- package/src/utils/extract-secret-usage.test.ts +0 -81
- package/src/utils/extract-secret-usage.ts +0 -73
- package/src/utils/extract-services.ts +0 -70
- package/src/utils/extract-surface-usage.test.ts +0 -147
- package/src/utils/extract-surface-usage.ts +0 -121
- package/src/utils/filter-inspector-state.test.ts +0 -2207
- package/src/utils/filter-inspector-state.ts +0 -1302
- package/src/utils/filter-utils.test.ts +0 -843
- package/src/utils/filter-utils.ts +0 -161
- package/src/utils/find-root-dir.ts +0 -68
- package/src/utils/find-zod-transform.test.ts +0 -70
- package/src/utils/find-zod-transform.ts +0 -103
- package/src/utils/get-exported-variable-name.ts +0 -48
- package/src/utils/get-files-and-methods.ts +0 -157
- package/src/utils/get-property-value.test.ts +0 -141
- package/src/utils/get-property-value.ts +0 -331
- package/src/utils/hash.ts +0 -26
- package/src/utils/literal-properties.ts +0 -97
- package/src/utils/load-addon-functions-meta.test.ts +0 -157
- package/src/utils/load-addon-functions-meta.ts +0 -508
- package/src/utils/load-addon-scopes.test.ts +0 -138
- package/src/utils/middleware.test.ts +0 -204
- package/src/utils/middleware.ts +0 -303
- package/src/utils/nested-project-filter.test.ts +0 -53
- package/src/utils/nested-project-filter.ts +0 -28
- package/src/utils/permissions-shorthand.test.ts +0 -79
- package/src/utils/permissions.test.ts +0 -125
- package/src/utils/permissions.ts +0 -129
- package/src/utils/post-process.test.ts +0 -1094
- package/src/utils/post-process.ts +0 -1604
- package/src/utils/resolve-addon-package.ts +0 -106
- package/src/utils/resolve-core-type.test.ts +0 -142
- package/src/utils/resolve-core-type.ts +0 -35
- package/src/utils/resolve-deploy-target.test.ts +0 -135
- package/src/utils/resolve-deploy-target.ts +0 -65
- package/src/utils/resolve-function-meta.ts +0 -25
- package/src/utils/resolve-function-types.ts +0 -42
- package/src/utils/resolve-identifier.ts +0 -46
- package/src/utils/resolve-ref-contract.ts +0 -71
- package/src/utils/resolve-versions.test.ts +0 -499
- package/src/utils/resolve-versions.ts +0 -228
- package/src/utils/schema-cache.test.ts +0 -124
- package/src/utils/schema-generator.test.ts +0 -272
- package/src/utils/schema-generator.ts +0 -780
- package/src/utils/secret-alias-services.test.ts +0 -146
- package/src/utils/secret-alias-services.ts +0 -58
- package/src/utils/serialize-inspector-state.ts +0 -742
- package/src/utils/serialize-mcp-json.ts +0 -150
- package/src/utils/serialize-middleware-groups-meta.ts +0 -36
- package/src/utils/serialize-openapi-json.ts +0 -277
- package/src/utils/serialize-permissions-groups-meta.ts +0 -7
- package/src/utils/single-declaration.ts +0 -57
- package/src/utils/type-utils.ts +0 -145
- package/src/utils/validate-auth-sessionless.ts +0 -30
- package/src/utils/validate-exposed-functions-gated.test.ts +0 -230
- package/src/utils/validate-exposed-functions-gated.ts +0 -103
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +0 -108
- package/src/utils/validate-tags-resolve-to-middleware.ts +0 -48
- package/src/utils/workflow/derive-workflow-plan.test.ts +0 -178
- package/src/utils/workflow/derive-workflow-plan.ts +0 -117
- package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +0 -1465
- package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +0 -71
- package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +0 -99
- package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +0 -161
- package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +0 -121
- package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +0 -116
- package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +0 -145
- package/src/utils/workflow/dsl/extract-approval-policy.test.ts +0 -71
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +0 -2254
- package/src/utils/workflow/dsl/index.ts +0 -11
- package/src/utils/workflow/dsl/patterns.ts +0 -440
- package/src/utils/workflow/dsl/validation.ts +0 -203
- package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +0 -35
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +0 -543
- package/src/utils/workflow/graph/fanout-node-identity.test.ts +0 -105
- package/src/utils/workflow/graph/finalize-workflow-wires.ts +0 -310
- package/src/utils/workflow/graph/finalize-workflows.test.ts +0 -50
- package/src/utils/workflow/graph/finalize-workflows.ts +0 -130
- package/src/utils/workflow/graph/index.ts +0 -28
- package/src/utils/workflow/graph/serialize-workflow-graph.ts +0 -240
- package/src/utils/workflow/graph/workflow-graph.types.ts +0 -281
- package/src/visit.ts +0 -181
- package/tsconfig.json +0 -19
- 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
|
-
}
|