@pikku/inspector 0.12.103 → 0.12.107
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/dist/add/add-auth.d.ts +1 -9
- package/dist/add/add-auth.js +64 -17
- package/dist/add/add-cli.js +25 -11
- package/dist/add/add-functions.js +35 -39
- package/dist/add/add-keyed-wiring.js +22 -2
- package/dist/add/add-middleware.js +17 -16
- package/dist/add/add-permission.d.ts +0 -3
- package/dist/add/add-permission.js +213 -22
- package/dist/add/add-scope.d.ts +1 -7
- package/dist/add/add-scope.js +1 -21
- package/dist/add/add-virtual-users.d.ts +13 -0
- package/dist/add/{add-personas.js → add-virtual-users.js} +33 -33
- package/dist/add/add-workflow.js +14 -10
- package/dist/add/check-indirect-registrations.d.ts +9 -0
- package/dist/add/check-indirect-registrations.js +114 -0
- package/dist/error-codes.d.ts +5 -3
- package/dist/error-codes.js +5 -3
- package/dist/finalize.d.ts +10 -0
- package/dist/finalize.js +102 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.js +6 -5
- package/dist/initial-state.d.ts +7 -0
- package/dist/initial-state.js +216 -0
- package/dist/inspector.d.ts +6 -4
- package/dist/inspector.js +28 -300
- package/dist/lite.d.ts +26 -0
- package/dist/lite.js +25 -0
- package/dist/source-file-cache.d.ts +11 -0
- package/dist/source-file-cache.js +34 -10
- package/dist/types-map.js +2 -1
- package/dist/types.d.ts +35 -3
- package/dist/utils/apply-declared-scopes.d.ts +8 -0
- package/dist/utils/apply-declared-scopes.js +21 -0
- package/dist/utils/canonical-type-string.d.ts +14 -0
- package/dist/utils/canonical-type-string.js +162 -0
- package/dist/utils/context-based-id.d.ts +7 -0
- package/dist/utils/context-based-id.js +9 -0
- package/dist/utils/custom-types-generator.d.ts +15 -1
- package/dist/utils/custom-types-generator.js +17 -5
- package/dist/utils/ensure-function-metadata.d.ts +0 -14
- package/dist/utils/ensure-function-metadata.js +4 -1
- package/dist/utils/extract-function-name.d.ts +1 -1
- package/dist/utils/extract-function-name.js +2 -4
- package/dist/utils/filter-inspector-state.js +31 -1
- package/dist/utils/get-property-value.d.ts +5 -0
- package/dist/utils/get-property-value.js +15 -1
- package/dist/utils/inspector-constants.d.ts +16 -0
- package/dist/utils/inspector-constants.js +16 -0
- package/dist/utils/load-addon-functions-meta.js +1 -0
- package/dist/utils/post-process.d.ts +35 -36
- package/dist/utils/post-process.js +218 -193
- package/dist/utils/resolve-callee-name.d.ts +27 -0
- package/dist/utils/resolve-callee-name.js +147 -0
- package/dist/utils/resolve-service-types.d.ts +8 -0
- package/dist/utils/resolve-service-types.js +21 -0
- package/dist/utils/schema-generator.js +29 -2
- package/dist/utils/serialize-inspector-state.d.ts +9 -3
- package/dist/utils/serialize-inspector-state.js +22 -7
- package/dist/utils/single-declaration.d.ts +3 -3
- package/dist/utils/single-declaration.js +3 -3
- package/dist/utils/validate-single-step-workflows.d.ts +3 -0
- package/dist/utils/validate-single-step-workflows.js +32 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +3 -3
- package/dist/utils/workflow/dsl/patterns.d.ts +4 -11
- package/dist/utils/workflow/dsl/patterns.js +5 -11
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +6 -6
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -5
- package/dist/visit.js +4 -2
- package/package.json +25 -9
- package/CHANGELOG.md +0 -4318
- package/OPTIMIZATION-PLAN.md +0 -201
- package/dist/add/add-personas.d.ts +0 -13
- package/run-tests.sh +0 -68
- package/src/utils/test-data/inspector-state.json +0 -1687
|
@@ -1,16 +1,12 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from
|
|
2
|
-
import { createRequire } from
|
|
3
|
-
import { join } from
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { AUTH_HANDLER_FUNC_ID } from "../add/add-auth.js";
|
|
11
|
-
import { flattenScopeDefinitions } from "@pikku/core/scope";
|
|
12
|
-
import { DYNAMIC_SCENARIO_STEP_TARGET } from "./workflow/dsl/patterns.js";
|
|
13
|
-
import { addonResolutionDirs } from "./addon-resolution.js";
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ErrorCode } from '../error-codes.js';
|
|
5
|
+
import { deriveOAuth2AppSecrets } from '@pikku/core/secret';
|
|
6
|
+
import { relative } from 'node:path';
|
|
7
|
+
import { AUTH_HANDLER_FUNC_ID, DYNAMIC_SCENARIO_STEP_TARGET, } from './inspector-constants.js';
|
|
8
|
+
import { flattenScopeDefinitions } from '@pikku/core/scope';
|
|
9
|
+
import { addonResolutionDirs } from './addon-resolution.js';
|
|
14
10
|
/**
|
|
15
11
|
* Stamp the inspected authorize/callbacks service set onto the generated auth
|
|
16
12
|
* handler's function meta.
|
|
@@ -45,7 +41,7 @@ export function extractWireNames(list) {
|
|
|
45
41
|
if (!list)
|
|
46
42
|
return [];
|
|
47
43
|
return list
|
|
48
|
-
.filter((item) => item.type ===
|
|
44
|
+
.filter((item) => item.type === 'wire')
|
|
49
45
|
.map((item) => item.name);
|
|
50
46
|
}
|
|
51
47
|
/**
|
|
@@ -57,13 +53,13 @@ function expandAndAddGroupServices(list, state, addServices) {
|
|
|
57
53
|
if (!list)
|
|
58
54
|
return;
|
|
59
55
|
for (const item of list) {
|
|
60
|
-
if (item.type ===
|
|
56
|
+
if (item.type === 'tag') {
|
|
61
57
|
const groupMeta = state.middleware.tagMiddleware.get(item.tag);
|
|
62
58
|
if (groupMeta?.services) {
|
|
63
59
|
addServices(groupMeta.services);
|
|
64
60
|
}
|
|
65
61
|
}
|
|
66
|
-
else if (item.type ===
|
|
62
|
+
else if (item.type === 'http' && 'route' in item) {
|
|
67
63
|
const groupMeta = state.http.routeMiddleware.get(item.route);
|
|
68
64
|
if (groupMeta?.services) {
|
|
69
65
|
addServices(groupMeta.services);
|
|
@@ -72,26 +68,25 @@ function expandAndAddGroupServices(list, state, addServices) {
|
|
|
72
68
|
}
|
|
73
69
|
}
|
|
74
70
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
71
|
+
* Applies the service names of the SingletonServices and Services types, which
|
|
72
|
+
* `resolveServiceTypes` read off the checker during analysis. This provides the
|
|
73
|
+
* complete list of available services for code generation.
|
|
74
|
+
* Skipped when analysis did not resolve them (e.g. an older serialized state).
|
|
78
75
|
*/
|
|
79
76
|
function extractAllServices(state) {
|
|
80
|
-
|
|
81
|
-
if (!
|
|
77
|
+
const resolved = state.serviceTypes;
|
|
78
|
+
if (!resolved) {
|
|
82
79
|
return;
|
|
83
80
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
if (resolved.singletonServices) {
|
|
82
|
+
state.serviceAggregation.allSingletonServices = [
|
|
83
|
+
...resolved.singletonServices,
|
|
84
|
+
];
|
|
88
85
|
}
|
|
89
|
-
|
|
90
|
-
const servicesType = resolveCoreType(state.typesLookup, state.wireServicesTypeImportMap);
|
|
91
|
-
if (servicesType) {
|
|
86
|
+
if (resolved.services) {
|
|
92
87
|
// Wire services are those in Services but not in SingletonServices
|
|
93
88
|
const singletonSet = new Set(state.serviceAggregation.allSingletonServices);
|
|
94
|
-
state.serviceAggregation.allWireServices =
|
|
89
|
+
state.serviceAggregation.allWireServices = resolved.services
|
|
95
90
|
.filter((name) => !singletonSet.has(name))
|
|
96
91
|
.sort();
|
|
97
92
|
markSingletonServicesOnly(state, new Set(state.serviceAggregation.allWireServices));
|
|
@@ -126,7 +121,7 @@ export function aggregateRequiredServices(state) {
|
|
|
126
121
|
extractAllServices(state);
|
|
127
122
|
const { requiredServices, usedFunctions, usedMiddleware, usedPermissions } = state.serviceAggregation;
|
|
128
123
|
// Internal services (always excluded from tree-shaking)
|
|
129
|
-
const internalServices = new Set([
|
|
124
|
+
const internalServices = new Set(['rpc', 'mcp', 'channel', 'userSession']);
|
|
130
125
|
const addServices = (services) => {
|
|
131
126
|
if (!services || !services.services)
|
|
132
127
|
return;
|
|
@@ -170,9 +165,29 @@ export function aggregateRequiredServices(state) {
|
|
|
170
165
|
addServices(permissionMeta.services);
|
|
171
166
|
}
|
|
172
167
|
});
|
|
168
|
+
// 3b. Global permissions and global middleware apply to every function, so
|
|
169
|
+
// whatever they touch is required wherever any function runs.
|
|
170
|
+
for (const registration of state.permissions?.global ?? []) {
|
|
171
|
+
for (const definitionId of registration.definitionIds) {
|
|
172
|
+
addServices(state.permissions.definitions[definitionId]?.services);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const [instanceId, instance] of Object.entries(state.middleware?.instances ?? {})) {
|
|
176
|
+
if (instanceId.startsWith('global:')) {
|
|
177
|
+
addServices(state.middleware.definitions[instance.definitionId]?.services);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
173
180
|
// 4. Services from middleware/permission groups used in wirings
|
|
174
181
|
// We need to check all wirings and expand any tag/HTTP-pattern groups they use
|
|
175
|
-
for (const method of [
|
|
182
|
+
for (const method of [
|
|
183
|
+
'get',
|
|
184
|
+
'post',
|
|
185
|
+
'put',
|
|
186
|
+
'patch',
|
|
187
|
+
'delete',
|
|
188
|
+
'head',
|
|
189
|
+
'options',
|
|
190
|
+
]) {
|
|
176
191
|
for (const routeMeta of Object.values(state.http.meta[method])) {
|
|
177
192
|
expandAndAddGroupServices(routeMeta.middleware, state, addServices);
|
|
178
193
|
}
|
|
@@ -210,22 +225,23 @@ export function aggregateRequiredServices(state) {
|
|
|
210
225
|
// that wrap workflow operations but don't destructure the services.
|
|
211
226
|
const hasWorkflows = Object.keys(state.workflows.graphMeta).length > 0 ||
|
|
212
227
|
Object.keys(state.workflows.meta).length > 0 ||
|
|
213
|
-
Object.keys(state.functions.meta).some((id) => id.startsWith(
|
|
214
|
-
id.startsWith(
|
|
215
|
-
id.startsWith(
|
|
228
|
+
Object.keys(state.functions.meta).some((id) => id.startsWith('workflowStart:') ||
|
|
229
|
+
id.startsWith('workflowStatus:') ||
|
|
230
|
+
id.startsWith('workflow:'));
|
|
216
231
|
if (hasWorkflows) {
|
|
217
|
-
requiredServices.add(
|
|
218
|
-
requiredServices.add(
|
|
219
|
-
requiredServices.add(
|
|
220
|
-
requiredServices.add(
|
|
232
|
+
requiredServices.add('workflowService');
|
|
233
|
+
requiredServices.add('workflowRunService');
|
|
234
|
+
requiredServices.add('schedulerService');
|
|
235
|
+
requiredServices.add('queueService');
|
|
221
236
|
}
|
|
222
237
|
// Webhook source routes and their worker are generated and destructure
|
|
223
238
|
// nothing, so they are found by id: the route accepts into the queue through
|
|
224
239
|
// incomingWebhookService and the worker records attempts through it.
|
|
225
240
|
const hasWebhookSources = Object.values(state.triggers?.webhookSourceMeta ?? {}).some(({ method, route }) => state.functions.meta[`http:${method}:${route}`] !== undefined);
|
|
226
|
-
if (hasWebhookSources ||
|
|
227
|
-
|
|
228
|
-
requiredServices.add(
|
|
241
|
+
if (hasWebhookSources ||
|
|
242
|
+
state.functions.meta['queue:pikku-incoming-webhooks'] !== undefined) {
|
|
243
|
+
requiredServices.add('incomingWebhookService');
|
|
244
|
+
requiredServices.add('queueService');
|
|
229
245
|
}
|
|
230
246
|
// 6b. Inject synthetic queue workers for workflow graph steps.
|
|
231
247
|
// Each workflow gets an orchestrator queue and per-step queues.
|
|
@@ -239,11 +255,11 @@ export function aggregateRequiredServices(state) {
|
|
|
239
255
|
// synthetic entries were pure leakage: they put a
|
|
240
256
|
// `wf-orchestrator-<scenario>` worker into the app's queue meta, which every
|
|
241
257
|
// bundle imports and a provider then creates as a real production queue.
|
|
242
|
-
if (graph.source ===
|
|
258
|
+
if (graph.source === 'scenario')
|
|
243
259
|
continue;
|
|
244
260
|
const toKebab = (s) => s
|
|
245
|
-
.replace(/([a-z0-9])([A-Z])/g,
|
|
246
|
-
.replace(/([A-Z])([A-Z][a-z])/g,
|
|
261
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
262
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
247
263
|
.toLowerCase();
|
|
248
264
|
// Orchestrator queue
|
|
249
265
|
const orchQueueName = `wf-orchestrator-${toKebab(graph.name)}`;
|
|
@@ -255,11 +271,14 @@ export function aggregateRequiredServices(state) {
|
|
|
255
271
|
}
|
|
256
272
|
// Per-step queues — only for steps explicitly marked workflowQueued: true
|
|
257
273
|
for (const node of Object.values(graph.nodes)) {
|
|
258
|
-
if (!(
|
|
274
|
+
if (!('rpcName' in node) || !node.rpcName)
|
|
259
275
|
continue;
|
|
260
276
|
const rpcName = node.rpcName;
|
|
261
|
-
const funcId = state.rpc?.internalMeta?.[rpcName] ??
|
|
262
|
-
|
|
277
|
+
const funcId = state.rpc?.internalMeta?.[rpcName] ??
|
|
278
|
+
state.rpc?.exposedMeta?.[rpcName] ??
|
|
279
|
+
rpcName;
|
|
280
|
+
const funcMeta = (state.functions.meta[funcId] ??
|
|
281
|
+
state.functions.meta[rpcName]);
|
|
263
282
|
if (funcMeta?.workflowQueued !== true)
|
|
264
283
|
continue;
|
|
265
284
|
const stepQueueName = `wf-step-${toKebab(rpcName)}`;
|
|
@@ -273,20 +292,20 @@ export function aggregateRequiredServices(state) {
|
|
|
273
292
|
}
|
|
274
293
|
// AI agents need agentStorage + agentRunState + agentRunService + agentRunner
|
|
275
294
|
if (Object.keys(state.agents.agentsMeta).length > 0) {
|
|
276
|
-
requiredServices.add(
|
|
277
|
-
requiredServices.add(
|
|
278
|
-
requiredServices.add(
|
|
279
|
-
requiredServices.add(
|
|
295
|
+
requiredServices.add('agentStorage');
|
|
296
|
+
requiredServices.add('agentRunState');
|
|
297
|
+
requiredServices.add('agentRunService');
|
|
298
|
+
requiredServices.add('agentRunner');
|
|
280
299
|
}
|
|
281
300
|
// Channels need eventHub for pub/sub
|
|
282
301
|
if (Object.keys(state.channels.meta).length > 0) {
|
|
283
|
-
requiredServices.add(
|
|
302
|
+
requiredServices.add('eventHub');
|
|
284
303
|
}
|
|
285
304
|
// Declared scopes need scopeService to resolve a grant. Nothing in a project
|
|
286
305
|
// destructures it — the generated auth layer reaches it — so the declaration
|
|
287
306
|
// is the only signal there is.
|
|
288
307
|
if ((state.scopes?.definitions?.length ?? 0) > 0) {
|
|
289
|
-
requiredServices.add(
|
|
308
|
+
requiredServices.add('scopeService');
|
|
290
309
|
}
|
|
291
310
|
// Declared feature flags need featureFlags for the same reason, from the
|
|
292
311
|
// other side: `assertFeatureAvailable` reads the source out of the singleton
|
|
@@ -294,17 +313,17 @@ export function aggregateRequiredServices(state) {
|
|
|
294
313
|
// ever says the project has flags. Without this the flag tables are left out
|
|
295
314
|
// of the generated migration and every gate silently resolves open.
|
|
296
315
|
if ((state.featureFlags?.definitions?.length ?? 0) > 0) {
|
|
297
|
-
requiredServices.add(
|
|
316
|
+
requiredServices.add('featureFlags');
|
|
298
317
|
}
|
|
299
318
|
// A `defineAnalyticsEvents` declaration means events are emitted, and the
|
|
300
319
|
// invocation buffer flushes into `analyticsService`. Nothing destructures
|
|
301
320
|
// that either — functions are handed the request-scoped `analytics` — so the
|
|
302
321
|
// declaration is again the only signal.
|
|
303
322
|
if ((state.analytics?.length ?? 0) > 0) {
|
|
304
|
-
requiredServices.add(
|
|
323
|
+
requiredServices.add('analyticsService');
|
|
305
324
|
}
|
|
306
325
|
if ((state.credentials?.definitions?.length ?? 0) > 0) {
|
|
307
|
-
requiredServices.add(
|
|
326
|
+
requiredServices.add('credentialService');
|
|
308
327
|
}
|
|
309
328
|
// 7. Services that consumed addons need from the parent project.
|
|
310
329
|
const addonFnServices = new Map();
|
|
@@ -315,7 +334,13 @@ export function aggregateRequiredServices(state) {
|
|
|
315
334
|
}
|
|
316
335
|
const parentDeclared = state.addonRequiredParentServices ?? [];
|
|
317
336
|
const parentDeclaredSet = new Set(parentDeclared);
|
|
318
|
-
const defaultServices = new Set([
|
|
337
|
+
const defaultServices = new Set([
|
|
338
|
+
'config',
|
|
339
|
+
'logger',
|
|
340
|
+
'variables',
|
|
341
|
+
'schema',
|
|
342
|
+
'secrets',
|
|
343
|
+
]);
|
|
319
344
|
let usesAddonFn = false;
|
|
320
345
|
let addonFactoryNeeded = false;
|
|
321
346
|
for (const funcId of usedFunctions) {
|
|
@@ -331,7 +356,8 @@ export function aggregateRequiredServices(state) {
|
|
|
331
356
|
if (parentDeclaredSet.has(service)) {
|
|
332
357
|
requiredServices.add(service);
|
|
333
358
|
}
|
|
334
|
-
else if (!internalServices.has(service) &&
|
|
359
|
+
else if (!internalServices.has(service) &&
|
|
360
|
+
!defaultServices.has(service)) {
|
|
335
361
|
addonFactoryNeeded = true;
|
|
336
362
|
}
|
|
337
363
|
}
|
|
@@ -379,7 +405,7 @@ export function validateSecretOverrides(logger, state) {
|
|
|
379
405
|
for (const [logicalName, resolvedName] of Object.entries(addonDecl.secretOverrides ?? {})) {
|
|
380
406
|
if (!secretIds.has(resolvedName)) {
|
|
381
407
|
const availableSecrets = Array.from(secretIds);
|
|
382
|
-
logger.critical(ErrorCode.INVALID_VALUE, `Secret override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(
|
|
408
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Secret override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`);
|
|
383
409
|
}
|
|
384
410
|
}
|
|
385
411
|
// A grant names the secret as the addon reads it, so it resolves through
|
|
@@ -388,7 +414,7 @@ export function validateSecretOverrides(logger, state) {
|
|
|
388
414
|
const resolvedName = addonDecl.secretOverrides?.[logicalName] ?? logicalName;
|
|
389
415
|
if (!secretIds.has(resolvedName)) {
|
|
390
416
|
const availableSecrets = Array.from(secretIds);
|
|
391
|
-
logger.critical(ErrorCode.INVALID_VALUE, `Secret grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(
|
|
417
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Secret grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`);
|
|
392
418
|
}
|
|
393
419
|
}
|
|
394
420
|
}
|
|
@@ -401,7 +427,7 @@ export function validateAddonUses(logger, state) {
|
|
|
401
427
|
for (const [pkg, target] of Object.entries(addonDecl.uses ?? {})) {
|
|
402
428
|
const wired = wireAddonDeclarations.get(target);
|
|
403
429
|
if (!wired) {
|
|
404
|
-
logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, `Addon '${namespace}' uses '${pkg}' as '${target}', but no addon is wired under the name '${target}'. Wired: ${Array.from(wireAddonDeclarations.keys()).join(
|
|
430
|
+
logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, `Addon '${namespace}' uses '${pkg}' as '${target}', but no addon is wired under the name '${target}'. Wired: ${Array.from(wireAddonDeclarations.keys()).join(', ')}`);
|
|
405
431
|
}
|
|
406
432
|
else if (wired.package !== pkg) {
|
|
407
433
|
logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, `Addon '${namespace}' uses '${pkg}' as '${target}', but '${target}' is wired from '${wired.package}'.`);
|
|
@@ -416,12 +442,12 @@ export function validateAddonContentGrants(logger, state) {
|
|
|
416
442
|
const buckets = new Set(Array.from(wireAddonDeclarations.entries()).map(([namespace, decl]) => decl.contentBucket ?? namespace));
|
|
417
443
|
for (const [namespace, decl] of wireAddonDeclarations.entries()) {
|
|
418
444
|
for (const [prefix, mode] of Object.entries(decl.contentGrants ?? {})) {
|
|
419
|
-
if (mode !==
|
|
445
|
+
if (mode !== 'read' && mode !== 'write') {
|
|
420
446
|
logger.critical(ErrorCode.ADDON_CONTENT_GRANT_INVALID, `Addon '${namespace}' grants '${prefix}' as '${mode}', but a content grant must be 'read' or 'write'.`);
|
|
421
447
|
}
|
|
422
|
-
const first = prefix.split(
|
|
448
|
+
const first = prefix.split('/').find((segment) => segment !== '');
|
|
423
449
|
if (!first || !buckets.has(first)) {
|
|
424
|
-
logger.critical(ErrorCode.ADDON_CONTENT_GRANT_INVALID, `Addon '${namespace}' grants content under '${prefix}', but '${first ??
|
|
450
|
+
logger.critical(ErrorCode.ADDON_CONTENT_GRANT_INVALID, `Addon '${namespace}' grants content under '${prefix}', but '${first ?? ''}' is not the content bucket of any wired addon. Buckets: ${Array.from(buckets).join(', ')}`);
|
|
425
451
|
}
|
|
426
452
|
}
|
|
427
453
|
}
|
|
@@ -432,8 +458,8 @@ export function validateCredentialOverrides(logger, state) {
|
|
|
432
458
|
return;
|
|
433
459
|
const credentialNames = new Set(state.credentials?.definitions.map((d) => d.name) ?? []);
|
|
434
460
|
/** Only a rename has a target to check; a mode-only override renames nothing. */
|
|
435
|
-
const renameTarget = (override) => typeof override ===
|
|
436
|
-
const overrideMode = (override) => typeof override ===
|
|
461
|
+
const renameTarget = (override) => typeof override === 'string' ? override : override.name;
|
|
462
|
+
const overrideMode = (override) => typeof override === 'string' ? undefined : override.mode;
|
|
437
463
|
/**
|
|
438
464
|
* Which declaration set each resolved name's mode. Credential generation
|
|
439
465
|
* writes one shared metadata entry per name, so two declarations that map
|
|
@@ -450,8 +476,10 @@ export function validateCredentialOverrides(logger, state) {
|
|
|
450
476
|
const resolvedName = renamed ?? logicalName;
|
|
451
477
|
if (!credentialNames.has(resolvedName)) {
|
|
452
478
|
const availableCredentials = Array.from(credentialNames);
|
|
453
|
-
const target = renamed
|
|
454
|
-
|
|
479
|
+
const target = renamed
|
|
480
|
+
? `'${logicalName}' -> '${resolvedName}'`
|
|
481
|
+
: `'${logicalName}'`;
|
|
482
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Credential override ${target} in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`);
|
|
455
483
|
}
|
|
456
484
|
const mode = overrideMode(override);
|
|
457
485
|
if (!mode)
|
|
@@ -467,7 +495,7 @@ export function validateCredentialOverrides(logger, state) {
|
|
|
467
495
|
const resolvedName = (override ? renameTarget(override) : undefined) ?? logicalName;
|
|
468
496
|
if (!credentialNames.has(resolvedName)) {
|
|
469
497
|
const availableCredentials = Array.from(credentialNames);
|
|
470
|
-
logger.critical(ErrorCode.INVALID_VALUE, `Credential grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(
|
|
498
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Credential grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`);
|
|
471
499
|
}
|
|
472
500
|
}
|
|
473
501
|
}
|
|
@@ -485,7 +513,7 @@ export function validateVariableOverrides(logger, state) {
|
|
|
485
513
|
for (const [logicalName, resolvedName] of Object.entries(addonDecl.variableOverrides)) {
|
|
486
514
|
if (!variableIds.has(resolvedName)) {
|
|
487
515
|
const availableVariables = Array.from(variableIds);
|
|
488
|
-
logger.critical(ErrorCode.INVALID_VALUE, `Variable override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a variable that does not exist. Available variables: ${availableVariables.join(
|
|
516
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Variable override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a variable that does not exist. Available variables: ${availableVariables.join(', ') || 'none'}`);
|
|
489
517
|
}
|
|
490
518
|
}
|
|
491
519
|
}
|
|
@@ -501,11 +529,11 @@ export function validateVariableOverrides(logger, state) {
|
|
|
501
529
|
* attempt means it is genuinely absent.
|
|
502
530
|
*/
|
|
503
531
|
const isPackageResolvable = (pkg, dirs) => {
|
|
504
|
-
const NOT_FOUND = new Set([
|
|
532
|
+
const NOT_FOUND = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND']);
|
|
505
533
|
for (const dir of dirs) {
|
|
506
534
|
let req;
|
|
507
535
|
try {
|
|
508
|
-
req = createRequire(join(dir,
|
|
536
|
+
req = createRequire(join(dir, 'package.json'));
|
|
509
537
|
}
|
|
510
538
|
catch {
|
|
511
539
|
return true; // cannot ask; do not accuse
|
|
@@ -559,7 +587,7 @@ export function validateRemoteAddonDependencies(logger, state) {
|
|
|
559
587
|
const dirs = addonResolutionDirs(state.rootDir, decl.file);
|
|
560
588
|
if (isPackageResolvable(decl.package, dirs))
|
|
561
589
|
continue;
|
|
562
|
-
logger.critical(ErrorCode.ADDON_NOT_INSTALLED, `Addon '${namespace}' ('${decl.package}') is wired with wireAddon but cannot be resolved from ${dirs.join(
|
|
590
|
+
logger.critical(ErrorCode.ADDON_NOT_INSTALLED, `Addon '${namespace}' ('${decl.package}') is wired with wireAddon but cannot be resolved from ${dirs.join(' or ')} — every ref('${namespace}:…') will resolve to nothing and the surface will be dead at runtime. Add it to the dependencies of ${join(dirs[0], 'package.json')} and install, or remove the wireAddon call.`);
|
|
563
591
|
}
|
|
564
592
|
// A remote addon belongs in the devDependencies of the package that wires
|
|
565
593
|
// it, which in a workspace is not the root.
|
|
@@ -570,7 +598,7 @@ export function validateRemoteAddonDependencies(logger, state) {
|
|
|
570
598
|
let manifest = null;
|
|
571
599
|
if (existsSync(path)) {
|
|
572
600
|
try {
|
|
573
|
-
manifest = JSON.parse(readFileSync(path,
|
|
601
|
+
manifest = JSON.parse(readFileSync(path, 'utf-8'));
|
|
574
602
|
}
|
|
575
603
|
catch (e) {
|
|
576
604
|
logger.warn(`Could not read ${path} to verify remote addon dependencies: ${e?.message ?? e}`);
|
|
@@ -582,7 +610,7 @@ export function validateRemoteAddonDependencies(logger, state) {
|
|
|
582
610
|
for (const [namespace, decl] of wireAddonDeclarations.entries()) {
|
|
583
611
|
if (!decl.remote)
|
|
584
612
|
continue;
|
|
585
|
-
const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0],
|
|
613
|
+
const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0], 'package.json');
|
|
586
614
|
const pkgJson = readManifest(pkgJsonPath);
|
|
587
615
|
if (!pkgJson)
|
|
588
616
|
continue; // no manifest to check (e.g. some tests)
|
|
@@ -614,10 +642,10 @@ export function validateRemoteAddonAuth(logger, state) {
|
|
|
614
642
|
if (!decl.remote)
|
|
615
643
|
continue;
|
|
616
644
|
if (decl.authCredentialId && !credentialNames.has(decl.authCredentialId)) {
|
|
617
|
-
logger.critical(ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED, `Remote addon '${namespace}' binds auth.credentialId '${decl.authCredentialId}', but no such credential is wired. Available credentials: ${Array.from(credentialNames).join(
|
|
645
|
+
logger.critical(ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED, `Remote addon '${namespace}' binds auth.credentialId '${decl.authCredentialId}', but no such credential is wired. Available credentials: ${Array.from(credentialNames).join(', ') || 'none'}`);
|
|
618
646
|
}
|
|
619
647
|
if (decl.authSecretId && !secretNames.has(decl.authSecretId)) {
|
|
620
|
-
logger.critical(ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED, `Remote addon '${namespace}' binds auth.secretId '${decl.authSecretId}', but no such secret is wired. Available secrets: ${Array.from(secretNames).join(
|
|
648
|
+
logger.critical(ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED, `Remote addon '${namespace}' binds auth.secretId '${decl.authSecretId}', but no such secret is wired. Available secrets: ${Array.from(secretNames).join(', ') || 'none'}`);
|
|
621
649
|
}
|
|
622
650
|
}
|
|
623
651
|
}
|
|
@@ -626,7 +654,7 @@ export function computeResolvedIOTypes(state) {
|
|
|
626
654
|
for (const [pikkuFuncId, meta] of Object.entries(functions.meta)) {
|
|
627
655
|
const input = meta.inputs?.[0];
|
|
628
656
|
const output = meta.outputs?.[0];
|
|
629
|
-
let inputType =
|
|
657
|
+
let inputType = 'null';
|
|
630
658
|
if (input) {
|
|
631
659
|
try {
|
|
632
660
|
inputType = functions.typesMap.getTypeMeta(input).uniqueName;
|
|
@@ -635,7 +663,7 @@ export function computeResolvedIOTypes(state) {
|
|
|
635
663
|
inputType = input;
|
|
636
664
|
}
|
|
637
665
|
}
|
|
638
|
-
let outputType =
|
|
666
|
+
let outputType = 'null';
|
|
639
667
|
if (output) {
|
|
640
668
|
try {
|
|
641
669
|
outputType = functions.typesMap.getTypeMeta(output).uniqueName;
|
|
@@ -645,10 +673,10 @@ export function computeResolvedIOTypes(state) {
|
|
|
645
673
|
}
|
|
646
674
|
}
|
|
647
675
|
state.resolvedIOTypes[pikkuFuncId] = { inputType, outputType };
|
|
648
|
-
if (meta.inputSchemaName && inputType !==
|
|
676
|
+
if (meta.inputSchemaName && inputType !== 'null') {
|
|
649
677
|
meta.inputSchemaName = inputType;
|
|
650
678
|
}
|
|
651
|
-
if (meta.outputSchemaName && outputType !==
|
|
679
|
+
if (meta.outputSchemaName && outputType !== 'null') {
|
|
652
680
|
meta.outputSchemaName = outputType;
|
|
653
681
|
}
|
|
654
682
|
if (meta.inputs) {
|
|
@@ -710,15 +738,15 @@ export function computePermissionsGroupsMeta(state) {
|
|
|
710
738
|
};
|
|
711
739
|
}
|
|
712
740
|
const PRIMITIVE_TYPES = new Set([
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
741
|
+
'boolean',
|
|
742
|
+
'string',
|
|
743
|
+
'number',
|
|
744
|
+
'null',
|
|
745
|
+
'undefined',
|
|
746
|
+
'void',
|
|
747
|
+
'any',
|
|
748
|
+
'unknown',
|
|
749
|
+
'never',
|
|
722
750
|
]);
|
|
723
751
|
export function computeRequiredSchemas(state, options) {
|
|
724
752
|
const { functions, schemaLookup } = state;
|
|
@@ -779,8 +807,8 @@ export function unresolvedSchemaReferences(state) {
|
|
|
779
807
|
};
|
|
780
808
|
for (const [funcName, meta] of Object.entries(functions.meta)) {
|
|
781
809
|
for (const [role, declared] of [
|
|
782
|
-
[
|
|
783
|
-
[
|
|
810
|
+
['input', meta.inputs?.[0]],
|
|
811
|
+
['output', meta.outputs?.[0]],
|
|
784
812
|
]) {
|
|
785
813
|
if (!declared || PRIMITIVE_TYPES.has(declared))
|
|
786
814
|
continue;
|
|
@@ -797,12 +825,12 @@ export function validateSchemaReferences(logger, state) {
|
|
|
797
825
|
if (unresolved.length === 0)
|
|
798
826
|
return;
|
|
799
827
|
logger.diagnostic({
|
|
800
|
-
severity:
|
|
828
|
+
severity: 'error',
|
|
801
829
|
code: ErrorCode.SCHEMA_REFERENCE_UNRESOLVED,
|
|
802
|
-
message: `${unresolved.length} function contract${unresolved.length === 1 ?
|
|
803
|
-
`name${unresolved.length === 1 ?
|
|
804
|
-
`calling ${unresolved.length === 1 ?
|
|
805
|
-
`MissingSchemaError: ${unresolved.join(
|
|
830
|
+
message: `${unresolved.length} function contract${unresolved.length === 1 ? '' : 's'} ` +
|
|
831
|
+
`name${unresolved.length === 1 ? 's' : ''} a schema that was never generated, so ` +
|
|
832
|
+
`calling ${unresolved.length === 1 ? 'it' : 'them'} fails at runtime with ` +
|
|
833
|
+
`MissingSchemaError: ${unresolved.join(', ')}. ` +
|
|
806
834
|
`The usual cause is a named type used as the function's input/output that ` +
|
|
807
835
|
`is declared but not exported — export it, or inline the type at the call site.`,
|
|
808
836
|
});
|
|
@@ -821,11 +849,11 @@ export function validateAgentModels(logger, state, modelAliases = {}) {
|
|
|
821
849
|
// `hasOwn`, not a truthy lookup: every object literal inherits `toString`,
|
|
822
850
|
// `constructor` and `__proto__`, so a bare model named after one of them
|
|
823
851
|
// would read as a configured alias and pass a check nothing else repeats.
|
|
824
|
-
if (!model.includes(
|
|
852
|
+
if (!model.includes('/') && !Object.hasOwn(modelAliases, model)) {
|
|
825
853
|
const known = Object.keys(modelAliases).sort();
|
|
826
854
|
logger.critical(ErrorCode.INVALID_MODEL, `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. ` +
|
|
827
855
|
(known.length
|
|
828
|
-
? `Known aliases: ${known.join(
|
|
856
|
+
? `Known aliases: ${known.join(', ')}.`
|
|
829
857
|
: `No "models" aliases are configured.`));
|
|
830
858
|
}
|
|
831
859
|
}
|
|
@@ -833,7 +861,7 @@ export function validateAgentModels(logger, state, modelAliases = {}) {
|
|
|
833
861
|
/**
|
|
834
862
|
* Scenarios are pure stories of remote RPCs (same rule as client-side CLI
|
|
835
863
|
* renderers): the func may only destructure logger/config — everything else
|
|
836
|
-
* must go through
|
|
864
|
+
* must go through virtual user steps so the flow runs against the TARGET
|
|
837
865
|
* environment, never local services.
|
|
838
866
|
*
|
|
839
867
|
* This runs in post-processing rather than in `addWorkflow` because
|
|
@@ -848,30 +876,30 @@ export function validateScenarioServices(logger, state) {
|
|
|
848
876
|
const funcMeta = state.functions.meta[meta.pikkuFuncId];
|
|
849
877
|
if (!funcMeta?.services)
|
|
850
878
|
continue;
|
|
851
|
-
const disallowed = funcMeta.services.services.filter((svc) => svc !==
|
|
879
|
+
const disallowed = funcMeta.services.services.filter((svc) => svc !== 'logger' && svc !== 'config');
|
|
852
880
|
if (disallowed.length > 0) {
|
|
853
|
-
logger.critical(ErrorCode.SCENARIO_HAS_SERVICES, `Scenario '${workflowName}' destructures services: ${disallowed.join(
|
|
881
|
+
logger.critical(ErrorCode.SCENARIO_HAS_SERVICES, `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
|
|
854
882
|
`Scenarios may only use 'logger'/'config' — drive everything else through ` +
|
|
855
|
-
`
|
|
883
|
+
`virtualUser steps (workflow.do(step, rpc, data, { virtualUser: virtualUsers.x })) so the flow ` +
|
|
856
884
|
`runs against the target environment.`);
|
|
857
885
|
}
|
|
858
886
|
}
|
|
859
887
|
}
|
|
860
888
|
/**
|
|
861
|
-
* Does this step's prose open by naming the
|
|
889
|
+
* Does this step's prose open by naming the virtual user who is already its subject?
|
|
862
890
|
*
|
|
863
|
-
* `composeStepProse` renders the
|
|
891
|
+
* `composeStepProse` renders the virtual user as the sentence's subject, so a step
|
|
864
892
|
* authored as `'sam' creates the client` reads "Given sam 'sam' creates the
|
|
865
|
-
* client". Only the subject position counts: a
|
|
893
|
+
* client". Only the subject position counts: a virtual user keyed after a role noun
|
|
866
894
|
* ("admin") must still be able to say "creates the admin client", and naming
|
|
867
895
|
* someone mid-sentence — "sends sam an invite" — is ordinary prose.
|
|
868
896
|
*/
|
|
869
|
-
export const
|
|
870
|
-
if (!
|
|
897
|
+
export const proseOpensWithVirtualUser = (prose, virtualUser) => {
|
|
898
|
+
if (!virtualUser) {
|
|
871
899
|
return false;
|
|
872
900
|
}
|
|
873
|
-
const escaped =
|
|
874
|
-
return new RegExp(`^["'“‘\\s]*${escaped}(?:['’]s)?\\b`,
|
|
901
|
+
const escaped = virtualUser.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
902
|
+
return new RegExp(`^["'“‘\\s]*${escaped}(?:['’]s)?\\b`, 'i').test(prose);
|
|
875
903
|
};
|
|
876
904
|
/**
|
|
877
905
|
* The prose that will actually render for one step, in the runtime's own
|
|
@@ -879,39 +907,43 @@ export const proseOpensWithActor = (prose, actor) => {
|
|
|
879
907
|
* overrides its declared description, which falls back to the durable name.
|
|
880
908
|
*
|
|
881
909
|
* Every candidate is returned rather than only the winner, because a template
|
|
882
|
-
* is authored where the
|
|
883
|
-
* site that runs it as the
|
|
910
|
+
* is authored where the virtual user is not yet known — so it is wrong at every call
|
|
911
|
+
* site that runs it as the virtual user it names, not just the winning one.
|
|
884
912
|
*/
|
|
885
913
|
const renderableProse = (step, stepMeta) => {
|
|
886
914
|
const description = step.options?.description;
|
|
887
915
|
if (description) {
|
|
888
916
|
return [step.stepName, description];
|
|
889
917
|
}
|
|
890
|
-
return [
|
|
918
|
+
return [
|
|
919
|
+
step.stepName,
|
|
920
|
+
stepMeta?.scenarioStepTemplate,
|
|
921
|
+
stepMeta?.description,
|
|
922
|
+
].filter((prose) => typeof prose === 'string');
|
|
891
923
|
};
|
|
892
924
|
/**
|
|
893
|
-
* A step's prose must not open by naming the
|
|
925
|
+
* A step's prose must not open by naming the virtual user it already runs as (PKU681).
|
|
894
926
|
*
|
|
895
|
-
* The reporter renders the
|
|
896
|
-
* the client` run as `
|
|
927
|
+
* The reporter renders the virtual user as the sentence's subject, so `'sam' creates
|
|
928
|
+
* the client` run as `virtualUsers.sam` reads "Given sam 'sam' creates the client" —
|
|
897
929
|
* and the hardcoded copy silently desyncs the moment the call site changes
|
|
898
|
-
*
|
|
899
|
-
* ordinary prose, and a
|
|
930
|
+
* virtual user. Only the subject position is refused; naming someone mid-sentence is
|
|
931
|
+
* ordinary prose, and a virtual user keyed after a role noun can still say "creates
|
|
900
932
|
* the admin client".
|
|
901
933
|
*/
|
|
902
934
|
const validateStepProse = (logger, workflowName, step, stepMeta) => {
|
|
903
|
-
const
|
|
904
|
-
if (!
|
|
935
|
+
const virtualUser = step.virtualUser;
|
|
936
|
+
if (!virtualUser) {
|
|
905
937
|
return;
|
|
906
938
|
}
|
|
907
|
-
const offender = renderableProse(step, stepMeta).find((prose) =>
|
|
939
|
+
const offender = renderableProse(step, stepMeta).find((prose) => proseOpensWithVirtualUser(prose, virtualUser));
|
|
908
940
|
if (offender === undefined) {
|
|
909
941
|
return;
|
|
910
942
|
}
|
|
911
|
-
logger.critical(ErrorCode.
|
|
912
|
-
`The reporter already renders the
|
|
913
|
-
`"${capitaliseFirst(step.phase)} ${
|
|
914
|
-
`{
|
|
943
|
+
logger.critical(ErrorCode.SCENARIO_STEP_PROSE_NAMES_VIRTUAL_USER, `Scenario '${workflowName}' names its virtual user in the step prose: '${offender}' runs as '${virtualUser}'. ` +
|
|
944
|
+
`The reporter already renders the virtual user as the subject, so this reads ` +
|
|
945
|
+
`"${capitaliseFirst(step.phase)} ${virtualUser} ${offender}". Drop the name and let ` +
|
|
946
|
+
`{ virtualUser: virtualUsers.${virtualUser} } supply it — the step reads as a bare third-person predicate.`);
|
|
915
947
|
};
|
|
916
948
|
const capitaliseFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
917
949
|
/**
|
|
@@ -921,9 +953,9 @@ const capitaliseFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1
|
|
|
921
953
|
*
|
|
922
954
|
* - the step target must be a static string literal (PKU678) — otherwise it
|
|
923
955
|
* can't be bundled, typed or drawn;
|
|
924
|
-
* - a step that runs as a
|
|
925
|
-
* `
|
|
926
|
-
* signs in as that
|
|
956
|
+
* - a step that runs as a virtual user must be given one (PKU677) — declared by
|
|
957
|
+
* `virtualUser: true`, or implied by a `browser` binding, since the browser context
|
|
958
|
+
* signs in as that virtual user. It is checked statically even though a browser
|
|
927
959
|
* binding only runs on a browser run: a step that can never be driven by a
|
|
928
960
|
* human is a latent failure, not a passing default run.
|
|
929
961
|
* - a scenario written as a step ladder must assert something (PKU680).
|
|
@@ -935,7 +967,7 @@ export function validateScenarioSteps(logger, state) {
|
|
|
935
967
|
const phasesSeen = new Set();
|
|
936
968
|
const visit = (workflowName, steps) => {
|
|
937
969
|
for (const step of steps) {
|
|
938
|
-
if (step.type ===
|
|
970
|
+
if (step.type === 'scenarioStep') {
|
|
939
971
|
phasesSeen.add(step.phase);
|
|
940
972
|
if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
|
|
941
973
|
logger.critical(ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC, `Scenario '${workflowName}' calls step '${step.stepName}' with a target that isn't a string literal. ` +
|
|
@@ -946,22 +978,22 @@ export function validateScenarioSteps(logger, state) {
|
|
|
946
978
|
state.serviceAggregation.usedFunctions.add(step.stepFunc);
|
|
947
979
|
const stepMeta = state.functions.meta[step.stepFunc];
|
|
948
980
|
validateStepProse(logger, workflowName, step, stepMeta);
|
|
949
|
-
if (stepMeta?.
|
|
950
|
-
const why = stepMeta.scenarioStepSurfaces?.includes(
|
|
951
|
-
?
|
|
952
|
-
:
|
|
953
|
-
logger.critical(ErrorCode.
|
|
954
|
-
`Pass {
|
|
981
|
+
if (stepMeta?.scenarioStepRequiresVirtualUser && !step.virtualUser) {
|
|
982
|
+
const why = stepMeta.scenarioStepSurfaces?.includes('browser')
|
|
983
|
+
? 'declares a browser binding'
|
|
984
|
+
: 'is driven by a virtual user';
|
|
985
|
+
logger.critical(ErrorCode.SCENARIO_STEP_NEEDS_VIRTUAL_USER, `Scenario '${workflowName}' calls step '${step.stepFunc}', which ${why}, without a virtual user. ` +
|
|
986
|
+
`Pass { virtualUser: virtualUsers.<name> } so it runs as that virtual user.`);
|
|
955
987
|
}
|
|
956
988
|
continue;
|
|
957
989
|
}
|
|
958
|
-
if (step.type ===
|
|
990
|
+
if (step.type === 'branch') {
|
|
959
991
|
for (const branch of step.branches)
|
|
960
992
|
visit(workflowName, branch.steps);
|
|
961
993
|
if (step.elseSteps)
|
|
962
994
|
visit(workflowName, step.elseSteps);
|
|
963
995
|
}
|
|
964
|
-
else if (step.type ===
|
|
996
|
+
else if (step.type === 'switch') {
|
|
965
997
|
for (const c of step.cases ?? []) {
|
|
966
998
|
if (c.steps)
|
|
967
999
|
visit(workflowName, c.steps);
|
|
@@ -969,10 +1001,10 @@ export function validateScenarioSteps(logger, state) {
|
|
|
969
1001
|
if (step.defaultSteps)
|
|
970
1002
|
visit(workflowName, step.defaultSteps);
|
|
971
1003
|
}
|
|
972
|
-
else if (step.type ===
|
|
1004
|
+
else if (step.type === 'fanout' && step.body) {
|
|
973
1005
|
visit(workflowName, step.body);
|
|
974
1006
|
}
|
|
975
|
-
else if (step.type ===
|
|
1007
|
+
else if (step.type === 'parallel' && step.children) {
|
|
976
1008
|
visit(workflowName, step.children);
|
|
977
1009
|
}
|
|
978
1010
|
}
|
|
@@ -987,9 +1019,9 @@ export function validateScenarioSteps(logger, state) {
|
|
|
987
1019
|
// `asserts` is the escape for a flow whose witness is an expectation helper
|
|
988
1020
|
// instead: those are inline steps and leave no phase behind, but a recorded
|
|
989
1021
|
// service call or a refusal is an assertion all the same.
|
|
990
|
-
if (phasesSeen.size > 0 && !phasesSeen.has(
|
|
991
|
-
logger.critical(ErrorCode.SCENARIO_HAS_NO_ASSERTION, `Scenario '${workflowName}' has ${[...phasesSeen].join(
|
|
992
|
-
`Add a scenario.then(...) naming what the
|
|
1022
|
+
if (phasesSeen.size > 0 && !phasesSeen.has('then') && !meta.asserts) {
|
|
1023
|
+
logger.critical(ErrorCode.SCENARIO_HAS_NO_ASSERTION, `Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
|
|
1024
|
+
`Add a scenario.then(...) naming what the virtual user should now see — a flow with no 'then' ` +
|
|
993
1025
|
`only proves nothing threw, and contributes nothing to witness coverage.`);
|
|
994
1026
|
}
|
|
995
1027
|
}
|
|
@@ -1049,17 +1081,17 @@ export function validateScenarioFeatures(logger, state) {
|
|
|
1049
1081
|
.map((feature) => feature.exportedName);
|
|
1050
1082
|
if (unreadable.length > 0) {
|
|
1051
1083
|
logger.diagnostic({
|
|
1052
|
-
severity:
|
|
1084
|
+
severity: 'warn',
|
|
1053
1085
|
code: ErrorCode.SCENARIO_HAS_NO_FEATURE,
|
|
1054
|
-
message: `${unowned.map((name) => `'${name}'`).join(
|
|
1055
|
-
`but ${unreadable.map((name) => `'${name}'`).join(
|
|
1086
|
+
message: `${unowned.map((name) => `'${name}'`).join(', ')} appear in no feature's \`scenarios\` array, ` +
|
|
1087
|
+
`but ${unreadable.map((name) => `'${name}'`).join(', ')} build theirs with a spread or a call, so one of them may still be listed there. ` +
|
|
1056
1088
|
`List those scenarios literally and this becomes a definite answer either way.`,
|
|
1057
1089
|
});
|
|
1058
1090
|
return;
|
|
1059
1091
|
}
|
|
1060
1092
|
for (const name of unowned) {
|
|
1061
1093
|
logger.diagnostic({
|
|
1062
|
-
severity:
|
|
1094
|
+
severity: 'error',
|
|
1063
1095
|
code: ErrorCode.SCENARIO_HAS_NO_FEATURE,
|
|
1064
1096
|
message: `Scenario '${name}' is listed by no pikkuFeature, so nothing downstream can reach it — ` +
|
|
1065
1097
|
`a run stamps no feature onto its results, the console groups it under none, and a guide cannot cite it. ` +
|
|
@@ -1074,16 +1106,16 @@ export function validateScenarioFeatures(logger, state) {
|
|
|
1074
1106
|
* early with an actionable message pointing at `scaffold: { graph: true }`.
|
|
1075
1107
|
*/
|
|
1076
1108
|
export function validateWorkflowGraphAddons(logger, state) {
|
|
1077
|
-
const addonGraphWired = Array.from(state.rpc.wireAddonDeclarations.values()).some((decl) => decl.package ===
|
|
1109
|
+
const addonGraphWired = Array.from(state.rpc.wireAddonDeclarations.values()).some((decl) => decl.package === '@pikku/addon-graph');
|
|
1078
1110
|
if (addonGraphWired) {
|
|
1079
1111
|
return;
|
|
1080
1112
|
}
|
|
1081
1113
|
for (const [name, graph] of Object.entries(state.workflows.graphMeta)) {
|
|
1082
1114
|
for (const node of Object.values(graph.nodes)) {
|
|
1083
|
-
if (!(
|
|
1115
|
+
if (!('rpcName' in node) || typeof node.rpcName !== 'string') {
|
|
1084
1116
|
continue;
|
|
1085
1117
|
}
|
|
1086
|
-
if (!node.rpcName.startsWith(
|
|
1118
|
+
if (!node.rpcName.startsWith('graph:')) {
|
|
1087
1119
|
continue;
|
|
1088
1120
|
}
|
|
1089
1121
|
if (state.functions.meta[node.rpcName]) {
|
|
@@ -1133,7 +1165,7 @@ export function validateSchemaWiringSeparation(logger, state) {
|
|
|
1133
1165
|
const schemas = Array.from(state.schemaLookup.entries())
|
|
1134
1166
|
.filter(([, ref]) => ref.sourceFile === file)
|
|
1135
1167
|
.map(([name]) => name);
|
|
1136
|
-
logger.critical(ErrorCode.SCHEMA_AND_WIRING_COLOCATED, `File '${file}' contains both Zod schemas (${schemas.join(
|
|
1168
|
+
logger.critical(ErrorCode.SCHEMA_AND_WIRING_COLOCATED, `File '${file}' contains both Zod schemas (${schemas.join(', ')}) and wiring calls (wireHTTPRoutes, addPermission, etc.). ` +
|
|
1137
1169
|
`These must be in separate files because the CLI imports schema files at runtime, which triggers wiring side-effects that crash without server context. ` +
|
|
1138
1170
|
`Move the route/wiring definitions to a dedicated wiring file.`);
|
|
1139
1171
|
}
|
|
@@ -1147,15 +1179,8 @@ export function validateSchemaWiringSeparation(logger, state) {
|
|
|
1147
1179
|
* a name the omit does not cover. A name-based rule misses this; the type does
|
|
1148
1180
|
* not.
|
|
1149
1181
|
*/
|
|
1150
|
-
export function validateNoSecretAliasServices(logger,
|
|
1151
|
-
|
|
1152
|
-
return;
|
|
1153
|
-
}
|
|
1154
|
-
const singletonServicesType = resolveCoreType(state.typesLookup, state.singletonServicesTypeImportMap);
|
|
1155
|
-
if (!singletonServicesType) {
|
|
1156
|
-
return;
|
|
1157
|
-
}
|
|
1158
|
-
const aliases = findSecretAliasServices(singletonServicesType, checker);
|
|
1182
|
+
export function validateNoSecretAliasServices(logger, state) {
|
|
1183
|
+
const aliases = state.serviceTypes?.secretAliases ?? [];
|
|
1159
1184
|
if (aliases.length === 0) {
|
|
1160
1185
|
return;
|
|
1161
1186
|
}
|
|
@@ -1164,14 +1189,14 @@ export function validateNoSecretAliasServices(logger, checker, state) {
|
|
|
1164
1189
|
if (used.length === 0) {
|
|
1165
1190
|
return;
|
|
1166
1191
|
}
|
|
1167
|
-
logger.critical(ErrorCode.SECRET_SERVICE_ALIASED, `${kind} '${id}' receives ${used.map((s) => `'${s}'`).join(
|
|
1192
|
+
logger.critical(ErrorCode.SECRET_SERVICE_ALIASED, `${kind} '${id}' receives ${used.map((s) => `'${s}'`).join(', ')}, which ${used.length === 1 ? 'is' : 'are'} a SecretService under another name. ` +
|
|
1168
1193
|
`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.`);
|
|
1169
1194
|
};
|
|
1170
1195
|
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
1171
|
-
report(
|
|
1196
|
+
report('Function', id, meta.services?.services ?? []);
|
|
1172
1197
|
}
|
|
1173
1198
|
for (const [id, def] of Object.entries(state.permissions.definitions)) {
|
|
1174
|
-
report(
|
|
1199
|
+
report('Permission', id, def.services?.services ?? []);
|
|
1175
1200
|
}
|
|
1176
1201
|
}
|
|
1177
1202
|
/**
|
|
@@ -1200,22 +1225,22 @@ export function validateSecretUsage(logger, state) {
|
|
|
1200
1225
|
// auth-secrets.gen.ts declares it, but that file is written after the first
|
|
1201
1226
|
// inspection of a fresh project, so its defineSecret is not seen yet.
|
|
1202
1227
|
if (state.auth?.definition)
|
|
1203
|
-
declared.add(
|
|
1228
|
+
declared.add('BETTER_AUTH_SECRET');
|
|
1204
1229
|
for (const [file, usage] of state.secrets.usage) {
|
|
1205
1230
|
const relativeFile = relative(state.rootDir, file);
|
|
1206
1231
|
const undeclared = usage.keys.filter((key) => !declared.has(key));
|
|
1207
1232
|
if (undeclared.length > 0) {
|
|
1208
1233
|
logger.diagnostic({
|
|
1209
|
-
severity:
|
|
1234
|
+
severity: 'warn',
|
|
1210
1235
|
code: ErrorCode.SECRET_NOT_DECLARED,
|
|
1211
|
-
message: `${relativeFile} reads ${undeclared.map((k) => `'${k}'`).join(
|
|
1236
|
+
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.`,
|
|
1212
1237
|
});
|
|
1213
1238
|
}
|
|
1214
1239
|
if (usage.dynamic.length > 0) {
|
|
1215
1240
|
logger.diagnostic({
|
|
1216
|
-
severity:
|
|
1241
|
+
severity: 'warn',
|
|
1217
1242
|
code: ErrorCode.SECRET_KEY_NOT_STATIC,
|
|
1218
|
-
message: `${relativeFile} reads a secret with a non-literal key (${usage.dynamic.join(
|
|
1243
|
+
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.`,
|
|
1219
1244
|
});
|
|
1220
1245
|
}
|
|
1221
1246
|
}
|
|
@@ -1229,9 +1254,9 @@ export function computeDiagnostics(state) {
|
|
|
1229
1254
|
// source file). The user can't edit any of these, so a destructure lint
|
|
1230
1255
|
// meant to nudge them about their own code must not fail the build over them.
|
|
1231
1256
|
if (!meta.sourceFile ||
|
|
1232
|
-
meta.sourceFile.endsWith(
|
|
1233
|
-
meta.functionType ===
|
|
1234
|
-
meta.functionType ===
|
|
1257
|
+
meta.sourceFile.endsWith('.gen.ts') ||
|
|
1258
|
+
meta.functionType === 'inline' ||
|
|
1259
|
+
meta.functionType === 'helper') {
|
|
1235
1260
|
continue;
|
|
1236
1261
|
}
|
|
1237
1262
|
if (meta.services && !meta.services.optimized) {
|
|
@@ -1311,14 +1336,14 @@ export function validateScopeReferences(logger, state) {
|
|
|
1311
1336
|
continue;
|
|
1312
1337
|
for (const scope of meta.scopes) {
|
|
1313
1338
|
// A trailing wildcard grants a subtree; the node it hangs off must exist.
|
|
1314
|
-
const declaredForm = scope.endsWith(
|
|
1315
|
-
if (scope ===
|
|
1339
|
+
const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope;
|
|
1340
|
+
if (scope === '*') {
|
|
1316
1341
|
logger.critical(ErrorCode.INVALID_VALUE, `Function '${funcName}' requires the bare wildcard scope '*'. A function must require a specific scope — '*' is only meaningful as a grant.`);
|
|
1317
1342
|
continue;
|
|
1318
1343
|
}
|
|
1319
1344
|
if (!declared.has(declaredForm)) {
|
|
1320
1345
|
const available = Array.from(declared);
|
|
1321
|
-
logger.critical(ErrorCode.INVALID_VALUE, `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(
|
|
1346
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`);
|
|
1322
1347
|
}
|
|
1323
1348
|
}
|
|
1324
1349
|
}
|
|
@@ -1345,12 +1370,12 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
1345
1370
|
return;
|
|
1346
1371
|
const { wireAddonDeclarations } = state.rpc;
|
|
1347
1372
|
for (const [agentKey, agent] of agents) {
|
|
1348
|
-
const where = agent.sourceFile ? ` (${agent.sourceFile})` :
|
|
1373
|
+
const where = agent.sourceFile ? ` (${agent.sourceFile})` : '';
|
|
1349
1374
|
for (const tool of agent.tools ?? []) {
|
|
1350
1375
|
// A workflow reference is resolved against the workflow map, not here.
|
|
1351
|
-
if (tool.startsWith(
|
|
1376
|
+
if (tool.startsWith('workflow:'))
|
|
1352
1377
|
continue;
|
|
1353
|
-
const separator = tool.indexOf(
|
|
1378
|
+
const separator = tool.indexOf(':');
|
|
1354
1379
|
let meta;
|
|
1355
1380
|
if (separator === -1) {
|
|
1356
1381
|
const funcId = state.rpc.internalMeta[tool] ?? tool;
|
|
@@ -1367,7 +1392,7 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
1367
1392
|
if (!addon) {
|
|
1368
1393
|
const known = Array.from(wireAddonDeclarations?.keys() ?? []);
|
|
1369
1394
|
logger.critical(ErrorCode.AGENT_TOOL_UNKNOWN_NAMESPACE, `AI agent '${agentKey}'${where} references tool '${tool}', but no addon is wired under the namespace '${namespace}'. ` +
|
|
1370
|
-
`Wired namespaces: ${known.join(
|
|
1395
|
+
`Wired namespaces: ${known.join(', ') || 'none'}.`);
|
|
1371
1396
|
continue;
|
|
1372
1397
|
}
|
|
1373
1398
|
// An addon that has not been built yet contributed no metadata, which
|
|
@@ -1384,7 +1409,7 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
1384
1409
|
if (options.strictMeta && !meta.description) {
|
|
1385
1410
|
logger.critical(ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION, `AI agent '${agentKey}'${where} uses tool '${tool}', which has no description. ` +
|
|
1386
1411
|
`An agent tool's description is what the model is told it does; without one it is offered the tool under its own name. ` +
|
|
1387
|
-
`Add a 'description' to '${tool}'${meta.title ? " (a 'title' does not count — it labels the tool, it does not explain it)" :
|
|
1412
|
+
`Add a 'description' to '${tool}'${meta.title ? " (a 'title' does not count — it labels the tool, it does not explain it)" : ''}.`);
|
|
1388
1413
|
}
|
|
1389
1414
|
}
|
|
1390
1415
|
}
|
|
@@ -1399,12 +1424,12 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
1399
1424
|
export function validateAgentScorerReferences(logger, state) {
|
|
1400
1425
|
const known = Object.keys(state.scorers.scorersMeta);
|
|
1401
1426
|
for (const [agentKey, agent] of Object.entries(state.agents.agentsMeta)) {
|
|
1402
|
-
const where = agent.sourceFile ? ` (${agent.sourceFile})` :
|
|
1427
|
+
const where = agent.sourceFile ? ` (${agent.sourceFile})` : '';
|
|
1403
1428
|
for (const scorer of agent.scorers ?? []) {
|
|
1404
1429
|
if (known.includes(scorer))
|
|
1405
1430
|
continue;
|
|
1406
1431
|
logger.critical(ErrorCode.AGENT_SCORER_NOT_FOUND, `AI agent '${agentKey}'${where} asks to be graded by scorer '${scorer}', which is not declared in this project. ` +
|
|
1407
|
-
`Declared scorers: ${known.join(
|
|
1432
|
+
`Declared scorers: ${known.join(', ') || 'none'}.`);
|
|
1408
1433
|
}
|
|
1409
1434
|
}
|
|
1410
1435
|
}
|
|
@@ -1415,7 +1440,7 @@ export function validateAgentScorerReferences(logger, state) {
|
|
|
1415
1440
|
* The same rule functions get, for a sharper reason: a role granting an
|
|
1416
1441
|
* undeclared scope is not merely useless, it is *invisibly* useless. No
|
|
1417
1442
|
* function checks the scope, so the role appears to work — every call a
|
|
1418
|
-
*
|
|
1443
|
+
* virtual user holding it makes succeeds or fails for unrelated reasons, and the
|
|
1419
1444
|
* authorization boundary it was written to describe is never exercised.
|
|
1420
1445
|
*
|
|
1421
1446
|
* A `*` suffix grants a subtree; the node it hangs off must exist.
|
|
@@ -1427,41 +1452,41 @@ export function validateSystemRoleScopes(logger, state) {
|
|
|
1427
1452
|
const declared = new Set(flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id));
|
|
1428
1453
|
for (const role of state.systemRoles.definitions) {
|
|
1429
1454
|
for (const scope of role.scopes) {
|
|
1430
|
-
if (scope ===
|
|
1455
|
+
if (scope === '*') {
|
|
1431
1456
|
logger.critical(ErrorCode.INVALID_VALUE, `System role '${role.name}' grants the bare wildcard scope '*'. Grant the roots it should cover instead — '*' hides what the role actually confers.`);
|
|
1432
1457
|
continue;
|
|
1433
1458
|
}
|
|
1434
|
-
const declaredForm = scope.endsWith(
|
|
1459
|
+
const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope;
|
|
1435
1460
|
if (!declared.has(declaredForm)) {
|
|
1436
1461
|
const available = Array.from(declared);
|
|
1437
|
-
logger.critical(ErrorCode.INVALID_VALUE, `System role '${role.name}' grants scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(
|
|
1462
|
+
logger.critical(ErrorCode.INVALID_VALUE, `System role '${role.name}' grants scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`);
|
|
1438
1463
|
}
|
|
1439
1464
|
}
|
|
1440
1465
|
}
|
|
1441
1466
|
}
|
|
1442
1467
|
/**
|
|
1443
|
-
* Validates that every role a
|
|
1468
|
+
* Validates that every role a virtual user names is declared with
|
|
1444
1469
|
* `defineSystemRole`. Runs after all visitors, so declaration order does not
|
|
1445
1470
|
* matter.
|
|
1446
1471
|
*
|
|
1447
1472
|
* Custom roles are refused rather than tolerated. A role composed in the
|
|
1448
1473
|
* console does not exist at build time and can be deleted after it, so a
|
|
1449
|
-
*
|
|
1474
|
+
* virtual user pinned to one silently stops testing what it claims to — and the
|
|
1450
1475
|
* symptom is a scatter of 403s that read like authorization findings rather
|
|
1451
1476
|
* than like a deleted role.
|
|
1452
1477
|
*/
|
|
1453
|
-
export function
|
|
1454
|
-
if (!state.
|
|
1478
|
+
export function validateVirtualUserRoles(logger, state) {
|
|
1479
|
+
if (!state.virtualUsers.definitions.length) {
|
|
1455
1480
|
return;
|
|
1456
1481
|
}
|
|
1457
1482
|
const declared = new Set(state.systemRoles.definitions.map((r) => r.name));
|
|
1458
|
-
for (const
|
|
1459
|
-
for (const role of
|
|
1483
|
+
for (const virtualUser of state.virtualUsers.definitions) {
|
|
1484
|
+
for (const role of virtualUser.roles) {
|
|
1460
1485
|
if (!declared.has(role)) {
|
|
1461
1486
|
const available = Array.from(declared).sort();
|
|
1462
|
-
logger.critical(ErrorCode.INVALID_VALUE, `
|
|
1463
|
-
`A
|
|
1464
|
-
`Declared roles: ${available.join(
|
|
1487
|
+
logger.critical(ErrorCode.INVALID_VALUE, `VirtualUser '${virtualUser.id}' holds role '${role}', which is not declared with defineSystemRole. ` +
|
|
1488
|
+
`A virtual user may only name a system role — a role composed in the console can be deleted, and the virtual user would go on claiming to test it. ` +
|
|
1489
|
+
`Declared roles: ${available.join(', ') || 'none'}`);
|
|
1465
1490
|
}
|
|
1466
1491
|
}
|
|
1467
1492
|
}
|