@pikku/inspector 0.12.102 → 0.12.106
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 +61 -0
- package/dist/add/add-cli.js +21 -11
- package/dist/add/add-define-screens.d.ts +8 -0
- package/dist/add/add-define-screens.js +140 -0
- package/dist/add/add-functions.js +8 -8
- 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-wire-addon.js +39 -2
- package/dist/add/add-workflow.js +14 -10
- package/dist/error-codes.d.ts +9 -3
- package/dist/error-codes.js +9 -3
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +7 -3
- package/dist/types.d.ts +45 -3
- package/dist/utils/extract-function-name.js +1 -1
- package/dist/utils/get-property-value.js +1 -1
- package/dist/utils/load-addon-functions-meta.d.ts +0 -5
- package/dist/utils/load-addon-functions-meta.js +53 -0
- package/dist/utils/post-process.d.ts +36 -34
- package/dist/utils/post-process.js +227 -169
- package/dist/utils/serialize-inspector-state.d.ts +7 -2
- package/dist/utils/serialize-inspector-state.js +10 -6
- 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 +3 -3
- package/dist/utils/workflow/dsl/patterns.js +4 -4
- 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/knowledge/decisions/a-channel-with-auth-false-marks-its-connect-and-disconnect-sessionless.md +22 -0
- package/knowledge/decisions/a-declared-scope-id-never-shadows-a-definition.md +20 -0
- package/knowledge/decisions/a-dsl-step-virtual-user-is-the-literal-name-in-its-options.md +27 -0
- package/knowledge/decisions/a-feature-flags-anyof-is-read-from-the-ast-and-an-empty-list-is-refused.md +20 -0
- package/knowledge/decisions/a-non-literal-string-array-reads-as-absent.md +25 -0
- package/knowledge/decisions/a-permission-with-an-underscore-data-parameter-takes-no-data.md +20 -0
- package/knowledge/decisions/a-provider-login-virtual-user-is-never-runnable.md +27 -0
- package/knowledge/decisions/a-remote-addon-bound-credential-or-secret-must-be-wired.md +27 -0
- package/knowledge/decisions/a-route-auth-overrides-its-groups-auth.md +22 -0
- package/knowledge/decisions/a-route-requires-a-session-when-any-auth-signal-says-so.md +29 -0
- package/knowledge/decisions/a-webhook-source-with-verify-gets-a-singleton-secret-credential.md +21 -0
- package/knowledge/decisions/a-wire-with-auth-disabled-cannot-point-at-a-pikkufunc.md +21 -0
- package/knowledge/decisions/addon-contracts-cannot-wire-or-declare-handlers.md +25 -0
- package/knowledge/decisions/addon-declarations-name-the-secrets-and-credentials-the-app-lends-it.md +19 -0
- package/knowledge/decisions/an-inline-wiring-function-is-sessionless-unless-built-with-pikkufunc.md +23 -0
- package/knowledge/decisions/better-auth-admin-plugin-is-refused.md +24 -0
- package/knowledge/decisions/define-screens-scopes-must-be-string-literals.md +18 -0
- package/knowledge/decisions/index.md +38 -0
- package/knowledge/decisions/inspector-records-a-non-literal-global-grant-as-source-text.md +20 -0
- package/knowledge/decisions/only-one-pikku-better-auth-export-per-codebase.md +19 -0
- package/knowledge/decisions/scope-references-and-secret-aliases-are-critical-diagnostics.md +23 -0
- package/knowledge/decisions/scope-segments-may-not-be-wildcards-or-contain-colons.md +23 -0
- package/knowledge/decisions/shorthand-permission-entries-are-extracted.md +22 -0
- package/knowledge/decisions/system-roles-declare-literal-unqualified-scopes.md +28 -0
- package/knowledge/decisions/the-inspector-records-agent-permissions-only-to-aggregate-services.md +14 -0
- package/knowledge/decisions/vault-secrets-reach-no-sink-and-pii-stays-out-of-logs-and-webhooks.md +30 -0
- package/knowledge/decisions/webhook-gateway-wrappers-are-sessionless-and-gateway-auth-runs-inside-the-handler.md +26 -0
- package/knowledge/index.md +14 -0
- package/knowledge/questions/does-a-non-literal-remote-addon-auth-get-checked.md +26 -0
- package/knowledge/questions/index.md +16 -0
- package/knowledge/questions/is-an-incomplete-oauth2-block-dropped-without-a-log.md +23 -0
- package/knowledge/questions/should-app-functions-named-like-scenario-instrumentation-be-refused.md +28 -0
- package/knowledge/questions/should-the-openapi-document-default-every-operation-to-a-bearer-or-api-key.md +22 -0
- package/package.json +3 -3
- package/dist/add/add-personas.d.ts +0 -13
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from
|
|
2
|
-
import { createRequire } from
|
|
3
|
-
import { join } from
|
|
4
|
-
import { extractTypeKeys } from
|
|
5
|
-
import { ErrorCode } from
|
|
6
|
-
import { findSecretAliasServices } from
|
|
7
|
-
import { deriveOAuth2AppSecrets } from
|
|
8
|
-
import { resolveCoreType } from
|
|
9
|
-
import { relative } from
|
|
10
|
-
import { AUTH_HANDLER_FUNC_ID } from
|
|
11
|
-
import { flattenScopeDefinitions } from
|
|
12
|
-
import { DYNAMIC_SCENARIO_STEP_TARGET } from
|
|
13
|
-
import { addonResolutionDirs } from
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { extractTypeKeys } from './type-utils.js';
|
|
5
|
+
import { ErrorCode } from '../error-codes.js';
|
|
6
|
+
import { findSecretAliasServices } from './secret-alias-services.js';
|
|
7
|
+
import { deriveOAuth2AppSecrets } from '@pikku/core/secret';
|
|
8
|
+
import { resolveCoreType } from './resolve-core-type.js';
|
|
9
|
+
import { relative } from 'node:path';
|
|
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';
|
|
14
14
|
/**
|
|
15
15
|
* Stamp the inspected authorize/callbacks service set onto the generated auth
|
|
16
16
|
* handler's function meta.
|
|
@@ -45,7 +45,7 @@ export function extractWireNames(list) {
|
|
|
45
45
|
if (!list)
|
|
46
46
|
return [];
|
|
47
47
|
return list
|
|
48
|
-
.filter((item) => item.type ===
|
|
48
|
+
.filter((item) => item.type === 'wire')
|
|
49
49
|
.map((item) => item.name);
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
@@ -57,13 +57,13 @@ function expandAndAddGroupServices(list, state, addServices) {
|
|
|
57
57
|
if (!list)
|
|
58
58
|
return;
|
|
59
59
|
for (const item of list) {
|
|
60
|
-
if (item.type ===
|
|
60
|
+
if (item.type === 'tag') {
|
|
61
61
|
const groupMeta = state.middleware.tagMiddleware.get(item.tag);
|
|
62
62
|
if (groupMeta?.services) {
|
|
63
63
|
addServices(groupMeta.services);
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
-
else if (item.type ===
|
|
66
|
+
else if (item.type === 'http' && 'route' in item) {
|
|
67
67
|
const groupMeta = state.http.routeMiddleware.get(item.route);
|
|
68
68
|
if (groupMeta?.services) {
|
|
69
69
|
addServices(groupMeta.services);
|
|
@@ -78,7 +78,7 @@ function expandAndAddGroupServices(list, state, addServices) {
|
|
|
78
78
|
*/
|
|
79
79
|
function extractAllServices(state) {
|
|
80
80
|
// Skip if typesLookup is not available (e.g., deserialized state)
|
|
81
|
-
if (!(
|
|
81
|
+
if (!('typesLookup' in state)) {
|
|
82
82
|
return;
|
|
83
83
|
}
|
|
84
84
|
// Extract all singleton services from the SingletonServices type
|
|
@@ -126,7 +126,7 @@ export function aggregateRequiredServices(state) {
|
|
|
126
126
|
extractAllServices(state);
|
|
127
127
|
const { requiredServices, usedFunctions, usedMiddleware, usedPermissions } = state.serviceAggregation;
|
|
128
128
|
// Internal services (always excluded from tree-shaking)
|
|
129
|
-
const internalServices = new Set([
|
|
129
|
+
const internalServices = new Set(['rpc', 'mcp', 'channel', 'userSession']);
|
|
130
130
|
const addServices = (services) => {
|
|
131
131
|
if (!services || !services.services)
|
|
132
132
|
return;
|
|
@@ -172,7 +172,15 @@ export function aggregateRequiredServices(state) {
|
|
|
172
172
|
});
|
|
173
173
|
// 4. Services from middleware/permission groups used in wirings
|
|
174
174
|
// We need to check all wirings and expand any tag/HTTP-pattern groups they use
|
|
175
|
-
for (const method of [
|
|
175
|
+
for (const method of [
|
|
176
|
+
'get',
|
|
177
|
+
'post',
|
|
178
|
+
'put',
|
|
179
|
+
'patch',
|
|
180
|
+
'delete',
|
|
181
|
+
'head',
|
|
182
|
+
'options',
|
|
183
|
+
]) {
|
|
176
184
|
for (const routeMeta of Object.values(state.http.meta[method])) {
|
|
177
185
|
expandAndAddGroupServices(routeMeta.middleware, state, addServices);
|
|
178
186
|
}
|
|
@@ -210,22 +218,23 @@ export function aggregateRequiredServices(state) {
|
|
|
210
218
|
// that wrap workflow operations but don't destructure the services.
|
|
211
219
|
const hasWorkflows = Object.keys(state.workflows.graphMeta).length > 0 ||
|
|
212
220
|
Object.keys(state.workflows.meta).length > 0 ||
|
|
213
|
-
Object.keys(state.functions.meta).some((id) => id.startsWith(
|
|
214
|
-
id.startsWith(
|
|
215
|
-
id.startsWith(
|
|
221
|
+
Object.keys(state.functions.meta).some((id) => id.startsWith('workflowStart:') ||
|
|
222
|
+
id.startsWith('workflowStatus:') ||
|
|
223
|
+
id.startsWith('workflow:'));
|
|
216
224
|
if (hasWorkflows) {
|
|
217
|
-
requiredServices.add(
|
|
218
|
-
requiredServices.add(
|
|
219
|
-
requiredServices.add(
|
|
220
|
-
requiredServices.add(
|
|
225
|
+
requiredServices.add('workflowService');
|
|
226
|
+
requiredServices.add('workflowRunService');
|
|
227
|
+
requiredServices.add('schedulerService');
|
|
228
|
+
requiredServices.add('queueService');
|
|
221
229
|
}
|
|
222
230
|
// Webhook source routes and their worker are generated and destructure
|
|
223
231
|
// nothing, so they are found by id: the route accepts into the queue through
|
|
224
232
|
// incomingWebhookService and the worker records attempts through it.
|
|
225
233
|
const hasWebhookSources = Object.values(state.triggers?.webhookSourceMeta ?? {}).some(({ method, route }) => state.functions.meta[`http:${method}:${route}`] !== undefined);
|
|
226
|
-
if (hasWebhookSources ||
|
|
227
|
-
|
|
228
|
-
requiredServices.add(
|
|
234
|
+
if (hasWebhookSources ||
|
|
235
|
+
state.functions.meta['queue:pikku-incoming-webhooks'] !== undefined) {
|
|
236
|
+
requiredServices.add('incomingWebhookService');
|
|
237
|
+
requiredServices.add('queueService');
|
|
229
238
|
}
|
|
230
239
|
// 6b. Inject synthetic queue workers for workflow graph steps.
|
|
231
240
|
// Each workflow gets an orchestrator queue and per-step queues.
|
|
@@ -239,11 +248,11 @@ export function aggregateRequiredServices(state) {
|
|
|
239
248
|
// synthetic entries were pure leakage: they put a
|
|
240
249
|
// `wf-orchestrator-<scenario>` worker into the app's queue meta, which every
|
|
241
250
|
// bundle imports and a provider then creates as a real production queue.
|
|
242
|
-
if (graph.source ===
|
|
251
|
+
if (graph.source === 'scenario')
|
|
243
252
|
continue;
|
|
244
253
|
const toKebab = (s) => s
|
|
245
|
-
.replace(/([a-z0-9])([A-Z])/g,
|
|
246
|
-
.replace(/([A-Z])([A-Z][a-z])/g,
|
|
254
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
255
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
247
256
|
.toLowerCase();
|
|
248
257
|
// Orchestrator queue
|
|
249
258
|
const orchQueueName = `wf-orchestrator-${toKebab(graph.name)}`;
|
|
@@ -255,11 +264,14 @@ export function aggregateRequiredServices(state) {
|
|
|
255
264
|
}
|
|
256
265
|
// Per-step queues — only for steps explicitly marked workflowQueued: true
|
|
257
266
|
for (const node of Object.values(graph.nodes)) {
|
|
258
|
-
if (!(
|
|
267
|
+
if (!('rpcName' in node) || !node.rpcName)
|
|
259
268
|
continue;
|
|
260
269
|
const rpcName = node.rpcName;
|
|
261
|
-
const funcId = state.rpc?.internalMeta?.[rpcName] ??
|
|
262
|
-
|
|
270
|
+
const funcId = state.rpc?.internalMeta?.[rpcName] ??
|
|
271
|
+
state.rpc?.exposedMeta?.[rpcName] ??
|
|
272
|
+
rpcName;
|
|
273
|
+
const funcMeta = (state.functions.meta[funcId] ??
|
|
274
|
+
state.functions.meta[rpcName]);
|
|
263
275
|
if (funcMeta?.workflowQueued !== true)
|
|
264
276
|
continue;
|
|
265
277
|
const stepQueueName = `wf-step-${toKebab(rpcName)}`;
|
|
@@ -273,20 +285,20 @@ export function aggregateRequiredServices(state) {
|
|
|
273
285
|
}
|
|
274
286
|
// AI agents need agentStorage + agentRunState + agentRunService + agentRunner
|
|
275
287
|
if (Object.keys(state.agents.agentsMeta).length > 0) {
|
|
276
|
-
requiredServices.add(
|
|
277
|
-
requiredServices.add(
|
|
278
|
-
requiredServices.add(
|
|
279
|
-
requiredServices.add(
|
|
288
|
+
requiredServices.add('agentStorage');
|
|
289
|
+
requiredServices.add('agentRunState');
|
|
290
|
+
requiredServices.add('agentRunService');
|
|
291
|
+
requiredServices.add('agentRunner');
|
|
280
292
|
}
|
|
281
293
|
// Channels need eventHub for pub/sub
|
|
282
294
|
if (Object.keys(state.channels.meta).length > 0) {
|
|
283
|
-
requiredServices.add(
|
|
295
|
+
requiredServices.add('eventHub');
|
|
284
296
|
}
|
|
285
297
|
// Declared scopes need scopeService to resolve a grant. Nothing in a project
|
|
286
298
|
// destructures it — the generated auth layer reaches it — so the declaration
|
|
287
299
|
// is the only signal there is.
|
|
288
300
|
if ((state.scopes?.definitions?.length ?? 0) > 0) {
|
|
289
|
-
requiredServices.add(
|
|
301
|
+
requiredServices.add('scopeService');
|
|
290
302
|
}
|
|
291
303
|
// Declared feature flags need featureFlags for the same reason, from the
|
|
292
304
|
// other side: `assertFeatureAvailable` reads the source out of the singleton
|
|
@@ -294,17 +306,17 @@ export function aggregateRequiredServices(state) {
|
|
|
294
306
|
// ever says the project has flags. Without this the flag tables are left out
|
|
295
307
|
// of the generated migration and every gate silently resolves open.
|
|
296
308
|
if ((state.featureFlags?.definitions?.length ?? 0) > 0) {
|
|
297
|
-
requiredServices.add(
|
|
309
|
+
requiredServices.add('featureFlags');
|
|
298
310
|
}
|
|
299
311
|
// A `defineAnalyticsEvents` declaration means events are emitted, and the
|
|
300
312
|
// invocation buffer flushes into `analyticsService`. Nothing destructures
|
|
301
313
|
// that either — functions are handed the request-scoped `analytics` — so the
|
|
302
314
|
// declaration is again the only signal.
|
|
303
315
|
if ((state.analytics?.length ?? 0) > 0) {
|
|
304
|
-
requiredServices.add(
|
|
316
|
+
requiredServices.add('analyticsService');
|
|
305
317
|
}
|
|
306
318
|
if ((state.credentials?.definitions?.length ?? 0) > 0) {
|
|
307
|
-
requiredServices.add(
|
|
319
|
+
requiredServices.add('credentialService');
|
|
308
320
|
}
|
|
309
321
|
// 7. Services that consumed addons need from the parent project.
|
|
310
322
|
const addonFnServices = new Map();
|
|
@@ -315,7 +327,13 @@ export function aggregateRequiredServices(state) {
|
|
|
315
327
|
}
|
|
316
328
|
const parentDeclared = state.addonRequiredParentServices ?? [];
|
|
317
329
|
const parentDeclaredSet = new Set(parentDeclared);
|
|
318
|
-
const defaultServices = new Set([
|
|
330
|
+
const defaultServices = new Set([
|
|
331
|
+
'config',
|
|
332
|
+
'logger',
|
|
333
|
+
'variables',
|
|
334
|
+
'schema',
|
|
335
|
+
'secrets',
|
|
336
|
+
]);
|
|
319
337
|
let usesAddonFn = false;
|
|
320
338
|
let addonFactoryNeeded = false;
|
|
321
339
|
for (const funcId of usedFunctions) {
|
|
@@ -331,7 +349,8 @@ export function aggregateRequiredServices(state) {
|
|
|
331
349
|
if (parentDeclaredSet.has(service)) {
|
|
332
350
|
requiredServices.add(service);
|
|
333
351
|
}
|
|
334
|
-
else if (!internalServices.has(service) &&
|
|
352
|
+
else if (!internalServices.has(service) &&
|
|
353
|
+
!defaultServices.has(service)) {
|
|
335
354
|
addonFactoryNeeded = true;
|
|
336
355
|
}
|
|
337
356
|
}
|
|
@@ -379,7 +398,7 @@ export function validateSecretOverrides(logger, state) {
|
|
|
379
398
|
for (const [logicalName, resolvedName] of Object.entries(addonDecl.secretOverrides ?? {})) {
|
|
380
399
|
if (!secretIds.has(resolvedName)) {
|
|
381
400
|
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(
|
|
401
|
+
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
402
|
}
|
|
384
403
|
}
|
|
385
404
|
// A grant names the secret as the addon reads it, so it resolves through
|
|
@@ -388,7 +407,40 @@ export function validateSecretOverrides(logger, state) {
|
|
|
388
407
|
const resolvedName = addonDecl.secretOverrides?.[logicalName] ?? logicalName;
|
|
389
408
|
if (!secretIds.has(resolvedName)) {
|
|
390
409
|
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(
|
|
410
|
+
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'}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
export function validateAddonUses(logger, state) {
|
|
416
|
+
const { wireAddonDeclarations } = state.rpc;
|
|
417
|
+
if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
|
|
418
|
+
return;
|
|
419
|
+
for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
|
|
420
|
+
for (const [pkg, target] of Object.entries(addonDecl.uses ?? {})) {
|
|
421
|
+
const wired = wireAddonDeclarations.get(target);
|
|
422
|
+
if (!wired) {
|
|
423
|
+
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(', ')}`);
|
|
424
|
+
}
|
|
425
|
+
else if (wired.package !== pkg) {
|
|
426
|
+
logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, `Addon '${namespace}' uses '${pkg}' as '${target}', but '${target}' is wired from '${wired.package}'.`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
export function validateAddonContentGrants(logger, state) {
|
|
432
|
+
const { wireAddonDeclarations } = state.rpc;
|
|
433
|
+
if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
|
|
434
|
+
return;
|
|
435
|
+
const buckets = new Set(Array.from(wireAddonDeclarations.entries()).map(([namespace, decl]) => decl.contentBucket ?? namespace));
|
|
436
|
+
for (const [namespace, decl] of wireAddonDeclarations.entries()) {
|
|
437
|
+
for (const [prefix, mode] of Object.entries(decl.contentGrants ?? {})) {
|
|
438
|
+
if (mode !== 'read' && mode !== 'write') {
|
|
439
|
+
logger.critical(ErrorCode.ADDON_CONTENT_GRANT_INVALID, `Addon '${namespace}' grants '${prefix}' as '${mode}', but a content grant must be 'read' or 'write'.`);
|
|
440
|
+
}
|
|
441
|
+
const first = prefix.split('/').find((segment) => segment !== '');
|
|
442
|
+
if (!first || !buckets.has(first)) {
|
|
443
|
+
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(', ')}`);
|
|
392
444
|
}
|
|
393
445
|
}
|
|
394
446
|
}
|
|
@@ -399,8 +451,8 @@ export function validateCredentialOverrides(logger, state) {
|
|
|
399
451
|
return;
|
|
400
452
|
const credentialNames = new Set(state.credentials?.definitions.map((d) => d.name) ?? []);
|
|
401
453
|
/** Only a rename has a target to check; a mode-only override renames nothing. */
|
|
402
|
-
const renameTarget = (override) => typeof override ===
|
|
403
|
-
const overrideMode = (override) => typeof override ===
|
|
454
|
+
const renameTarget = (override) => typeof override === 'string' ? override : override.name;
|
|
455
|
+
const overrideMode = (override) => typeof override === 'string' ? undefined : override.mode;
|
|
404
456
|
/**
|
|
405
457
|
* Which declaration set each resolved name's mode. Credential generation
|
|
406
458
|
* writes one shared metadata entry per name, so two declarations that map
|
|
@@ -417,8 +469,10 @@ export function validateCredentialOverrides(logger, state) {
|
|
|
417
469
|
const resolvedName = renamed ?? logicalName;
|
|
418
470
|
if (!credentialNames.has(resolvedName)) {
|
|
419
471
|
const availableCredentials = Array.from(credentialNames);
|
|
420
|
-
const target = renamed
|
|
421
|
-
|
|
472
|
+
const target = renamed
|
|
473
|
+
? `'${logicalName}' -> '${resolvedName}'`
|
|
474
|
+
: `'${logicalName}'`;
|
|
475
|
+
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'}`);
|
|
422
476
|
}
|
|
423
477
|
const mode = overrideMode(override);
|
|
424
478
|
if (!mode)
|
|
@@ -434,7 +488,7 @@ export function validateCredentialOverrides(logger, state) {
|
|
|
434
488
|
const resolvedName = (override ? renameTarget(override) : undefined) ?? logicalName;
|
|
435
489
|
if (!credentialNames.has(resolvedName)) {
|
|
436
490
|
const availableCredentials = Array.from(credentialNames);
|
|
437
|
-
logger.critical(ErrorCode.INVALID_VALUE, `Credential grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(
|
|
491
|
+
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'}`);
|
|
438
492
|
}
|
|
439
493
|
}
|
|
440
494
|
}
|
|
@@ -452,7 +506,7 @@ export function validateVariableOverrides(logger, state) {
|
|
|
452
506
|
for (const [logicalName, resolvedName] of Object.entries(addonDecl.variableOverrides)) {
|
|
453
507
|
if (!variableIds.has(resolvedName)) {
|
|
454
508
|
const availableVariables = Array.from(variableIds);
|
|
455
|
-
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(
|
|
509
|
+
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'}`);
|
|
456
510
|
}
|
|
457
511
|
}
|
|
458
512
|
}
|
|
@@ -468,11 +522,11 @@ export function validateVariableOverrides(logger, state) {
|
|
|
468
522
|
* attempt means it is genuinely absent.
|
|
469
523
|
*/
|
|
470
524
|
const isPackageResolvable = (pkg, dirs) => {
|
|
471
|
-
const NOT_FOUND = new Set([
|
|
525
|
+
const NOT_FOUND = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND']);
|
|
472
526
|
for (const dir of dirs) {
|
|
473
527
|
let req;
|
|
474
528
|
try {
|
|
475
|
-
req = createRequire(join(dir,
|
|
529
|
+
req = createRequire(join(dir, 'package.json'));
|
|
476
530
|
}
|
|
477
531
|
catch {
|
|
478
532
|
return true; // cannot ask; do not accuse
|
|
@@ -526,7 +580,7 @@ export function validateRemoteAddonDependencies(logger, state) {
|
|
|
526
580
|
const dirs = addonResolutionDirs(state.rootDir, decl.file);
|
|
527
581
|
if (isPackageResolvable(decl.package, dirs))
|
|
528
582
|
continue;
|
|
529
|
-
logger.critical(ErrorCode.ADDON_NOT_INSTALLED, `Addon '${namespace}' ('${decl.package}') is wired with wireAddon but cannot be resolved from ${dirs.join(
|
|
583
|
+
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.`);
|
|
530
584
|
}
|
|
531
585
|
// A remote addon belongs in the devDependencies of the package that wires
|
|
532
586
|
// it, which in a workspace is not the root.
|
|
@@ -537,7 +591,7 @@ export function validateRemoteAddonDependencies(logger, state) {
|
|
|
537
591
|
let manifest = null;
|
|
538
592
|
if (existsSync(path)) {
|
|
539
593
|
try {
|
|
540
|
-
manifest = JSON.parse(readFileSync(path,
|
|
594
|
+
manifest = JSON.parse(readFileSync(path, 'utf-8'));
|
|
541
595
|
}
|
|
542
596
|
catch (e) {
|
|
543
597
|
logger.warn(`Could not read ${path} to verify remote addon dependencies: ${e?.message ?? e}`);
|
|
@@ -549,7 +603,7 @@ export function validateRemoteAddonDependencies(logger, state) {
|
|
|
549
603
|
for (const [namespace, decl] of wireAddonDeclarations.entries()) {
|
|
550
604
|
if (!decl.remote)
|
|
551
605
|
continue;
|
|
552
|
-
const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0],
|
|
606
|
+
const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0], 'package.json');
|
|
553
607
|
const pkgJson = readManifest(pkgJsonPath);
|
|
554
608
|
if (!pkgJson)
|
|
555
609
|
continue; // no manifest to check (e.g. some tests)
|
|
@@ -581,10 +635,10 @@ export function validateRemoteAddonAuth(logger, state) {
|
|
|
581
635
|
if (!decl.remote)
|
|
582
636
|
continue;
|
|
583
637
|
if (decl.authCredentialId && !credentialNames.has(decl.authCredentialId)) {
|
|
584
|
-
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(
|
|
638
|
+
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'}`);
|
|
585
639
|
}
|
|
586
640
|
if (decl.authSecretId && !secretNames.has(decl.authSecretId)) {
|
|
587
|
-
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(
|
|
641
|
+
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'}`);
|
|
588
642
|
}
|
|
589
643
|
}
|
|
590
644
|
}
|
|
@@ -593,7 +647,7 @@ export function computeResolvedIOTypes(state) {
|
|
|
593
647
|
for (const [pikkuFuncId, meta] of Object.entries(functions.meta)) {
|
|
594
648
|
const input = meta.inputs?.[0];
|
|
595
649
|
const output = meta.outputs?.[0];
|
|
596
|
-
let inputType =
|
|
650
|
+
let inputType = 'null';
|
|
597
651
|
if (input) {
|
|
598
652
|
try {
|
|
599
653
|
inputType = functions.typesMap.getTypeMeta(input).uniqueName;
|
|
@@ -602,7 +656,7 @@ export function computeResolvedIOTypes(state) {
|
|
|
602
656
|
inputType = input;
|
|
603
657
|
}
|
|
604
658
|
}
|
|
605
|
-
let outputType =
|
|
659
|
+
let outputType = 'null';
|
|
606
660
|
if (output) {
|
|
607
661
|
try {
|
|
608
662
|
outputType = functions.typesMap.getTypeMeta(output).uniqueName;
|
|
@@ -612,10 +666,10 @@ export function computeResolvedIOTypes(state) {
|
|
|
612
666
|
}
|
|
613
667
|
}
|
|
614
668
|
state.resolvedIOTypes[pikkuFuncId] = { inputType, outputType };
|
|
615
|
-
if (meta.inputSchemaName && inputType !==
|
|
669
|
+
if (meta.inputSchemaName && inputType !== 'null') {
|
|
616
670
|
meta.inputSchemaName = inputType;
|
|
617
671
|
}
|
|
618
|
-
if (meta.outputSchemaName && outputType !==
|
|
672
|
+
if (meta.outputSchemaName && outputType !== 'null') {
|
|
619
673
|
meta.outputSchemaName = outputType;
|
|
620
674
|
}
|
|
621
675
|
if (meta.inputs) {
|
|
@@ -677,15 +731,15 @@ export function computePermissionsGroupsMeta(state) {
|
|
|
677
731
|
};
|
|
678
732
|
}
|
|
679
733
|
const PRIMITIVE_TYPES = new Set([
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
734
|
+
'boolean',
|
|
735
|
+
'string',
|
|
736
|
+
'number',
|
|
737
|
+
'null',
|
|
738
|
+
'undefined',
|
|
739
|
+
'void',
|
|
740
|
+
'any',
|
|
741
|
+
'unknown',
|
|
742
|
+
'never',
|
|
689
743
|
]);
|
|
690
744
|
export function computeRequiredSchemas(state, options) {
|
|
691
745
|
const { functions, schemaLookup } = state;
|
|
@@ -746,8 +800,8 @@ export function unresolvedSchemaReferences(state) {
|
|
|
746
800
|
};
|
|
747
801
|
for (const [funcName, meta] of Object.entries(functions.meta)) {
|
|
748
802
|
for (const [role, declared] of [
|
|
749
|
-
[
|
|
750
|
-
[
|
|
803
|
+
['input', meta.inputs?.[0]],
|
|
804
|
+
['output', meta.outputs?.[0]],
|
|
751
805
|
]) {
|
|
752
806
|
if (!declared || PRIMITIVE_TYPES.has(declared))
|
|
753
807
|
continue;
|
|
@@ -764,12 +818,12 @@ export function validateSchemaReferences(logger, state) {
|
|
|
764
818
|
if (unresolved.length === 0)
|
|
765
819
|
return;
|
|
766
820
|
logger.diagnostic({
|
|
767
|
-
severity:
|
|
821
|
+
severity: 'error',
|
|
768
822
|
code: ErrorCode.SCHEMA_REFERENCE_UNRESOLVED,
|
|
769
|
-
message: `${unresolved.length} function contract${unresolved.length === 1 ?
|
|
770
|
-
`name${unresolved.length === 1 ?
|
|
771
|
-
`calling ${unresolved.length === 1 ?
|
|
772
|
-
`MissingSchemaError: ${unresolved.join(
|
|
823
|
+
message: `${unresolved.length} function contract${unresolved.length === 1 ? '' : 's'} ` +
|
|
824
|
+
`name${unresolved.length === 1 ? 's' : ''} a schema that was never generated, so ` +
|
|
825
|
+
`calling ${unresolved.length === 1 ? 'it' : 'them'} fails at runtime with ` +
|
|
826
|
+
`MissingSchemaError: ${unresolved.join(', ')}. ` +
|
|
773
827
|
`The usual cause is a named type used as the function's input/output that ` +
|
|
774
828
|
`is declared but not exported — export it, or inline the type at the call site.`,
|
|
775
829
|
});
|
|
@@ -788,11 +842,11 @@ export function validateAgentModels(logger, state, modelAliases = {}) {
|
|
|
788
842
|
// `hasOwn`, not a truthy lookup: every object literal inherits `toString`,
|
|
789
843
|
// `constructor` and `__proto__`, so a bare model named after one of them
|
|
790
844
|
// would read as a configured alias and pass a check nothing else repeats.
|
|
791
|
-
if (!model.includes(
|
|
845
|
+
if (!model.includes('/') && !Object.hasOwn(modelAliases, model)) {
|
|
792
846
|
const known = Object.keys(modelAliases).sort();
|
|
793
847
|
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. ` +
|
|
794
848
|
(known.length
|
|
795
|
-
? `Known aliases: ${known.join(
|
|
849
|
+
? `Known aliases: ${known.join(', ')}.`
|
|
796
850
|
: `No "models" aliases are configured.`));
|
|
797
851
|
}
|
|
798
852
|
}
|
|
@@ -800,7 +854,7 @@ export function validateAgentModels(logger, state, modelAliases = {}) {
|
|
|
800
854
|
/**
|
|
801
855
|
* Scenarios are pure stories of remote RPCs (same rule as client-side CLI
|
|
802
856
|
* renderers): the func may only destructure logger/config — everything else
|
|
803
|
-
* must go through
|
|
857
|
+
* must go through virtual user steps so the flow runs against the TARGET
|
|
804
858
|
* environment, never local services.
|
|
805
859
|
*
|
|
806
860
|
* This runs in post-processing rather than in `addWorkflow` because
|
|
@@ -815,30 +869,30 @@ export function validateScenarioServices(logger, state) {
|
|
|
815
869
|
const funcMeta = state.functions.meta[meta.pikkuFuncId];
|
|
816
870
|
if (!funcMeta?.services)
|
|
817
871
|
continue;
|
|
818
|
-
const disallowed = funcMeta.services.services.filter((svc) => svc !==
|
|
872
|
+
const disallowed = funcMeta.services.services.filter((svc) => svc !== 'logger' && svc !== 'config');
|
|
819
873
|
if (disallowed.length > 0) {
|
|
820
|
-
logger.critical(ErrorCode.SCENARIO_HAS_SERVICES, `Scenario '${workflowName}' destructures services: ${disallowed.join(
|
|
874
|
+
logger.critical(ErrorCode.SCENARIO_HAS_SERVICES, `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
|
|
821
875
|
`Scenarios may only use 'logger'/'config' — drive everything else through ` +
|
|
822
|
-
`
|
|
876
|
+
`virtualUser steps (workflow.do(step, rpc, data, { virtualUser: virtualUsers.x })) so the flow ` +
|
|
823
877
|
`runs against the target environment.`);
|
|
824
878
|
}
|
|
825
879
|
}
|
|
826
880
|
}
|
|
827
881
|
/**
|
|
828
|
-
* Does this step's prose open by naming the
|
|
882
|
+
* Does this step's prose open by naming the virtual user who is already its subject?
|
|
829
883
|
*
|
|
830
|
-
* `composeStepProse` renders the
|
|
884
|
+
* `composeStepProse` renders the virtual user as the sentence's subject, so a step
|
|
831
885
|
* authored as `'sam' creates the client` reads "Given sam 'sam' creates the
|
|
832
|
-
* client". Only the subject position counts: a
|
|
886
|
+
* client". Only the subject position counts: a virtual user keyed after a role noun
|
|
833
887
|
* ("admin") must still be able to say "creates the admin client", and naming
|
|
834
888
|
* someone mid-sentence — "sends sam an invite" — is ordinary prose.
|
|
835
889
|
*/
|
|
836
|
-
export const
|
|
837
|
-
if (!
|
|
890
|
+
export const proseOpensWithVirtualUser = (prose, virtualUser) => {
|
|
891
|
+
if (!virtualUser) {
|
|
838
892
|
return false;
|
|
839
893
|
}
|
|
840
|
-
const escaped =
|
|
841
|
-
return new RegExp(`^["'“‘\\s]*${escaped}(?:['’]s)?\\b`,
|
|
894
|
+
const escaped = virtualUser.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
895
|
+
return new RegExp(`^["'“‘\\s]*${escaped}(?:['’]s)?\\b`, 'i').test(prose);
|
|
842
896
|
};
|
|
843
897
|
/**
|
|
844
898
|
* The prose that will actually render for one step, in the runtime's own
|
|
@@ -846,39 +900,43 @@ export const proseOpensWithActor = (prose, actor) => {
|
|
|
846
900
|
* overrides its declared description, which falls back to the durable name.
|
|
847
901
|
*
|
|
848
902
|
* Every candidate is returned rather than only the winner, because a template
|
|
849
|
-
* is authored where the
|
|
850
|
-
* site that runs it as the
|
|
903
|
+
* is authored where the virtual user is not yet known — so it is wrong at every call
|
|
904
|
+
* site that runs it as the virtual user it names, not just the winning one.
|
|
851
905
|
*/
|
|
852
906
|
const renderableProse = (step, stepMeta) => {
|
|
853
907
|
const description = step.options?.description;
|
|
854
908
|
if (description) {
|
|
855
909
|
return [step.stepName, description];
|
|
856
910
|
}
|
|
857
|
-
return [
|
|
911
|
+
return [
|
|
912
|
+
step.stepName,
|
|
913
|
+
stepMeta?.scenarioStepTemplate,
|
|
914
|
+
stepMeta?.description,
|
|
915
|
+
].filter((prose) => typeof prose === 'string');
|
|
858
916
|
};
|
|
859
917
|
/**
|
|
860
|
-
* A step's prose must not open by naming the
|
|
918
|
+
* A step's prose must not open by naming the virtual user it already runs as (PKU681).
|
|
861
919
|
*
|
|
862
|
-
* The reporter renders the
|
|
863
|
-
* the client` run as `
|
|
920
|
+
* The reporter renders the virtual user as the sentence's subject, so `'sam' creates
|
|
921
|
+
* the client` run as `virtualUsers.sam` reads "Given sam 'sam' creates the client" —
|
|
864
922
|
* and the hardcoded copy silently desyncs the moment the call site changes
|
|
865
|
-
*
|
|
866
|
-
* ordinary prose, and a
|
|
923
|
+
* virtual user. Only the subject position is refused; naming someone mid-sentence is
|
|
924
|
+
* ordinary prose, and a virtual user keyed after a role noun can still say "creates
|
|
867
925
|
* the admin client".
|
|
868
926
|
*/
|
|
869
927
|
const validateStepProse = (logger, workflowName, step, stepMeta) => {
|
|
870
|
-
const
|
|
871
|
-
if (!
|
|
928
|
+
const virtualUser = step.virtualUser;
|
|
929
|
+
if (!virtualUser) {
|
|
872
930
|
return;
|
|
873
931
|
}
|
|
874
|
-
const offender = renderableProse(step, stepMeta).find((prose) =>
|
|
932
|
+
const offender = renderableProse(step, stepMeta).find((prose) => proseOpensWithVirtualUser(prose, virtualUser));
|
|
875
933
|
if (offender === undefined) {
|
|
876
934
|
return;
|
|
877
935
|
}
|
|
878
|
-
logger.critical(ErrorCode.
|
|
879
|
-
`The reporter already renders the
|
|
880
|
-
`"${capitaliseFirst(step.phase)} ${
|
|
881
|
-
`{
|
|
936
|
+
logger.critical(ErrorCode.SCENARIO_STEP_PROSE_NAMES_VIRTUAL_USER, `Scenario '${workflowName}' names its virtual user in the step prose: '${offender}' runs as '${virtualUser}'. ` +
|
|
937
|
+
`The reporter already renders the virtual user as the subject, so this reads ` +
|
|
938
|
+
`"${capitaliseFirst(step.phase)} ${virtualUser} ${offender}". Drop the name and let ` +
|
|
939
|
+
`{ virtualUser: virtualUsers.${virtualUser} } supply it — the step reads as a bare third-person predicate.`);
|
|
882
940
|
};
|
|
883
941
|
const capitaliseFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
884
942
|
/**
|
|
@@ -888,9 +946,9 @@ const capitaliseFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1
|
|
|
888
946
|
*
|
|
889
947
|
* - the step target must be a static string literal (PKU678) — otherwise it
|
|
890
948
|
* can't be bundled, typed or drawn;
|
|
891
|
-
* - a step that runs as a
|
|
892
|
-
* `
|
|
893
|
-
* signs in as that
|
|
949
|
+
* - a step that runs as a virtual user must be given one (PKU677) — declared by
|
|
950
|
+
* `virtualUser: true`, or implied by a `browser` binding, since the browser context
|
|
951
|
+
* signs in as that virtual user. It is checked statically even though a browser
|
|
894
952
|
* binding only runs on a browser run: a step that can never be driven by a
|
|
895
953
|
* human is a latent failure, not a passing default run.
|
|
896
954
|
* - a scenario written as a step ladder must assert something (PKU680).
|
|
@@ -902,7 +960,7 @@ export function validateScenarioSteps(logger, state) {
|
|
|
902
960
|
const phasesSeen = new Set();
|
|
903
961
|
const visit = (workflowName, steps) => {
|
|
904
962
|
for (const step of steps) {
|
|
905
|
-
if (step.type ===
|
|
963
|
+
if (step.type === 'scenarioStep') {
|
|
906
964
|
phasesSeen.add(step.phase);
|
|
907
965
|
if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
|
|
908
966
|
logger.critical(ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC, `Scenario '${workflowName}' calls step '${step.stepName}' with a target that isn't a string literal. ` +
|
|
@@ -913,22 +971,22 @@ export function validateScenarioSteps(logger, state) {
|
|
|
913
971
|
state.serviceAggregation.usedFunctions.add(step.stepFunc);
|
|
914
972
|
const stepMeta = state.functions.meta[step.stepFunc];
|
|
915
973
|
validateStepProse(logger, workflowName, step, stepMeta);
|
|
916
|
-
if (stepMeta?.
|
|
917
|
-
const why = stepMeta.scenarioStepSurfaces?.includes(
|
|
918
|
-
?
|
|
919
|
-
:
|
|
920
|
-
logger.critical(ErrorCode.
|
|
921
|
-
`Pass {
|
|
974
|
+
if (stepMeta?.scenarioStepRequiresVirtualUser && !step.virtualUser) {
|
|
975
|
+
const why = stepMeta.scenarioStepSurfaces?.includes('browser')
|
|
976
|
+
? 'declares a browser binding'
|
|
977
|
+
: 'is driven by a virtual user';
|
|
978
|
+
logger.critical(ErrorCode.SCENARIO_STEP_NEEDS_VIRTUAL_USER, `Scenario '${workflowName}' calls step '${step.stepFunc}', which ${why}, without a virtual user. ` +
|
|
979
|
+
`Pass { virtualUser: virtualUsers.<name> } so it runs as that virtual user.`);
|
|
922
980
|
}
|
|
923
981
|
continue;
|
|
924
982
|
}
|
|
925
|
-
if (step.type ===
|
|
983
|
+
if (step.type === 'branch') {
|
|
926
984
|
for (const branch of step.branches)
|
|
927
985
|
visit(workflowName, branch.steps);
|
|
928
986
|
if (step.elseSteps)
|
|
929
987
|
visit(workflowName, step.elseSteps);
|
|
930
988
|
}
|
|
931
|
-
else if (step.type ===
|
|
989
|
+
else if (step.type === 'switch') {
|
|
932
990
|
for (const c of step.cases ?? []) {
|
|
933
991
|
if (c.steps)
|
|
934
992
|
visit(workflowName, c.steps);
|
|
@@ -936,10 +994,10 @@ export function validateScenarioSteps(logger, state) {
|
|
|
936
994
|
if (step.defaultSteps)
|
|
937
995
|
visit(workflowName, step.defaultSteps);
|
|
938
996
|
}
|
|
939
|
-
else if (step.type ===
|
|
997
|
+
else if (step.type === 'fanout' && step.body) {
|
|
940
998
|
visit(workflowName, step.body);
|
|
941
999
|
}
|
|
942
|
-
else if (step.type ===
|
|
1000
|
+
else if (step.type === 'parallel' && step.children) {
|
|
943
1001
|
visit(workflowName, step.children);
|
|
944
1002
|
}
|
|
945
1003
|
}
|
|
@@ -954,9 +1012,9 @@ export function validateScenarioSteps(logger, state) {
|
|
|
954
1012
|
// `asserts` is the escape for a flow whose witness is an expectation helper
|
|
955
1013
|
// instead: those are inline steps and leave no phase behind, but a recorded
|
|
956
1014
|
// service call or a refusal is an assertion all the same.
|
|
957
|
-
if (phasesSeen.size > 0 && !phasesSeen.has(
|
|
958
|
-
logger.critical(ErrorCode.SCENARIO_HAS_NO_ASSERTION, `Scenario '${workflowName}' has ${[...phasesSeen].join(
|
|
959
|
-
`Add a scenario.then(...) naming what the
|
|
1015
|
+
if (phasesSeen.size > 0 && !phasesSeen.has('then') && !meta.asserts) {
|
|
1016
|
+
logger.critical(ErrorCode.SCENARIO_HAS_NO_ASSERTION, `Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
|
|
1017
|
+
`Add a scenario.then(...) naming what the virtual user should now see — a flow with no 'then' ` +
|
|
960
1018
|
`only proves nothing threw, and contributes nothing to witness coverage.`);
|
|
961
1019
|
}
|
|
962
1020
|
}
|
|
@@ -1016,17 +1074,17 @@ export function validateScenarioFeatures(logger, state) {
|
|
|
1016
1074
|
.map((feature) => feature.exportedName);
|
|
1017
1075
|
if (unreadable.length > 0) {
|
|
1018
1076
|
logger.diagnostic({
|
|
1019
|
-
severity:
|
|
1077
|
+
severity: 'warn',
|
|
1020
1078
|
code: ErrorCode.SCENARIO_HAS_NO_FEATURE,
|
|
1021
|
-
message: `${unowned.map((name) => `'${name}'`).join(
|
|
1022
|
-
`but ${unreadable.map((name) => `'${name}'`).join(
|
|
1079
|
+
message: `${unowned.map((name) => `'${name}'`).join(', ')} appear in no feature's \`scenarios\` array, ` +
|
|
1080
|
+
`but ${unreadable.map((name) => `'${name}'`).join(', ')} build theirs with a spread or a call, so one of them may still be listed there. ` +
|
|
1023
1081
|
`List those scenarios literally and this becomes a definite answer either way.`,
|
|
1024
1082
|
});
|
|
1025
1083
|
return;
|
|
1026
1084
|
}
|
|
1027
1085
|
for (const name of unowned) {
|
|
1028
1086
|
logger.diagnostic({
|
|
1029
|
-
severity:
|
|
1087
|
+
severity: 'error',
|
|
1030
1088
|
code: ErrorCode.SCENARIO_HAS_NO_FEATURE,
|
|
1031
1089
|
message: `Scenario '${name}' is listed by no pikkuFeature, so nothing downstream can reach it — ` +
|
|
1032
1090
|
`a run stamps no feature onto its results, the console groups it under none, and a guide cannot cite it. ` +
|
|
@@ -1041,16 +1099,16 @@ export function validateScenarioFeatures(logger, state) {
|
|
|
1041
1099
|
* early with an actionable message pointing at `scaffold: { graph: true }`.
|
|
1042
1100
|
*/
|
|
1043
1101
|
export function validateWorkflowGraphAddons(logger, state) {
|
|
1044
|
-
const addonGraphWired = Array.from(state.rpc.wireAddonDeclarations.values()).some((decl) => decl.package ===
|
|
1102
|
+
const addonGraphWired = Array.from(state.rpc.wireAddonDeclarations.values()).some((decl) => decl.package === '@pikku/addon-graph');
|
|
1045
1103
|
if (addonGraphWired) {
|
|
1046
1104
|
return;
|
|
1047
1105
|
}
|
|
1048
1106
|
for (const [name, graph] of Object.entries(state.workflows.graphMeta)) {
|
|
1049
1107
|
for (const node of Object.values(graph.nodes)) {
|
|
1050
|
-
if (!(
|
|
1108
|
+
if (!('rpcName' in node) || typeof node.rpcName !== 'string') {
|
|
1051
1109
|
continue;
|
|
1052
1110
|
}
|
|
1053
|
-
if (!node.rpcName.startsWith(
|
|
1111
|
+
if (!node.rpcName.startsWith('graph:')) {
|
|
1054
1112
|
continue;
|
|
1055
1113
|
}
|
|
1056
1114
|
if (state.functions.meta[node.rpcName]) {
|
|
@@ -1100,7 +1158,7 @@ export function validateSchemaWiringSeparation(logger, state) {
|
|
|
1100
1158
|
const schemas = Array.from(state.schemaLookup.entries())
|
|
1101
1159
|
.filter(([, ref]) => ref.sourceFile === file)
|
|
1102
1160
|
.map(([name]) => name);
|
|
1103
|
-
logger.critical(ErrorCode.SCHEMA_AND_WIRING_COLOCATED, `File '${file}' contains both Zod schemas (${schemas.join(
|
|
1161
|
+
logger.critical(ErrorCode.SCHEMA_AND_WIRING_COLOCATED, `File '${file}' contains both Zod schemas (${schemas.join(', ')}) and wiring calls (wireHTTPRoutes, addPermission, etc.). ` +
|
|
1104
1162
|
`These must be in separate files because the CLI imports schema files at runtime, which triggers wiring side-effects that crash without server context. ` +
|
|
1105
1163
|
`Move the route/wiring definitions to a dedicated wiring file.`);
|
|
1106
1164
|
}
|
|
@@ -1115,7 +1173,7 @@ export function validateSchemaWiringSeparation(logger, state) {
|
|
|
1115
1173
|
* not.
|
|
1116
1174
|
*/
|
|
1117
1175
|
export function validateNoSecretAliasServices(logger, checker, state) {
|
|
1118
|
-
if (!(
|
|
1176
|
+
if (!('typesLookup' in state)) {
|
|
1119
1177
|
return;
|
|
1120
1178
|
}
|
|
1121
1179
|
const singletonServicesType = resolveCoreType(state.typesLookup, state.singletonServicesTypeImportMap);
|
|
@@ -1131,14 +1189,14 @@ export function validateNoSecretAliasServices(logger, checker, state) {
|
|
|
1131
1189
|
if (used.length === 0) {
|
|
1132
1190
|
return;
|
|
1133
1191
|
}
|
|
1134
|
-
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. ` +
|
|
1135
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.`);
|
|
1136
1194
|
};
|
|
1137
1195
|
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
1138
|
-
report(
|
|
1196
|
+
report('Function', id, meta.services?.services ?? []);
|
|
1139
1197
|
}
|
|
1140
1198
|
for (const [id, def] of Object.entries(state.permissions.definitions)) {
|
|
1141
|
-
report(
|
|
1199
|
+
report('Permission', id, def.services?.services ?? []);
|
|
1142
1200
|
}
|
|
1143
1201
|
}
|
|
1144
1202
|
/**
|
|
@@ -1167,22 +1225,22 @@ export function validateSecretUsage(logger, state) {
|
|
|
1167
1225
|
// auth-secrets.gen.ts declares it, but that file is written after the first
|
|
1168
1226
|
// inspection of a fresh project, so its defineSecret is not seen yet.
|
|
1169
1227
|
if (state.auth?.definition)
|
|
1170
|
-
declared.add(
|
|
1228
|
+
declared.add('BETTER_AUTH_SECRET');
|
|
1171
1229
|
for (const [file, usage] of state.secrets.usage) {
|
|
1172
1230
|
const relativeFile = relative(state.rootDir, file);
|
|
1173
1231
|
const undeclared = usage.keys.filter((key) => !declared.has(key));
|
|
1174
1232
|
if (undeclared.length > 0) {
|
|
1175
1233
|
logger.diagnostic({
|
|
1176
|
-
severity:
|
|
1234
|
+
severity: 'warn',
|
|
1177
1235
|
code: ErrorCode.SECRET_NOT_DECLARED,
|
|
1178
|
-
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.`,
|
|
1179
1237
|
});
|
|
1180
1238
|
}
|
|
1181
1239
|
if (usage.dynamic.length > 0) {
|
|
1182
1240
|
logger.diagnostic({
|
|
1183
|
-
severity:
|
|
1241
|
+
severity: 'warn',
|
|
1184
1242
|
code: ErrorCode.SECRET_KEY_NOT_STATIC,
|
|
1185
|
-
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.`,
|
|
1186
1244
|
});
|
|
1187
1245
|
}
|
|
1188
1246
|
}
|
|
@@ -1196,9 +1254,9 @@ export function computeDiagnostics(state) {
|
|
|
1196
1254
|
// source file). The user can't edit any of these, so a destructure lint
|
|
1197
1255
|
// meant to nudge them about their own code must not fail the build over them.
|
|
1198
1256
|
if (!meta.sourceFile ||
|
|
1199
|
-
meta.sourceFile.endsWith(
|
|
1200
|
-
meta.functionType ===
|
|
1201
|
-
meta.functionType ===
|
|
1257
|
+
meta.sourceFile.endsWith('.gen.ts') ||
|
|
1258
|
+
meta.functionType === 'inline' ||
|
|
1259
|
+
meta.functionType === 'helper') {
|
|
1202
1260
|
continue;
|
|
1203
1261
|
}
|
|
1204
1262
|
if (meta.services && !meta.services.optimized) {
|
|
@@ -1278,14 +1336,14 @@ export function validateScopeReferences(logger, state) {
|
|
|
1278
1336
|
continue;
|
|
1279
1337
|
for (const scope of meta.scopes) {
|
|
1280
1338
|
// A trailing wildcard grants a subtree; the node it hangs off must exist.
|
|
1281
|
-
const declaredForm = scope.endsWith(
|
|
1282
|
-
if (scope ===
|
|
1339
|
+
const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope;
|
|
1340
|
+
if (scope === '*') {
|
|
1283
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.`);
|
|
1284
1342
|
continue;
|
|
1285
1343
|
}
|
|
1286
1344
|
if (!declared.has(declaredForm)) {
|
|
1287
1345
|
const available = Array.from(declared);
|
|
1288
|
-
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'}`);
|
|
1289
1347
|
}
|
|
1290
1348
|
}
|
|
1291
1349
|
}
|
|
@@ -1312,12 +1370,12 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
1312
1370
|
return;
|
|
1313
1371
|
const { wireAddonDeclarations } = state.rpc;
|
|
1314
1372
|
for (const [agentKey, agent] of agents) {
|
|
1315
|
-
const where = agent.sourceFile ? ` (${agent.sourceFile})` :
|
|
1373
|
+
const where = agent.sourceFile ? ` (${agent.sourceFile})` : '';
|
|
1316
1374
|
for (const tool of agent.tools ?? []) {
|
|
1317
1375
|
// A workflow reference is resolved against the workflow map, not here.
|
|
1318
|
-
if (tool.startsWith(
|
|
1376
|
+
if (tool.startsWith('workflow:'))
|
|
1319
1377
|
continue;
|
|
1320
|
-
const separator = tool.indexOf(
|
|
1378
|
+
const separator = tool.indexOf(':');
|
|
1321
1379
|
let meta;
|
|
1322
1380
|
if (separator === -1) {
|
|
1323
1381
|
const funcId = state.rpc.internalMeta[tool] ?? tool;
|
|
@@ -1334,7 +1392,7 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
1334
1392
|
if (!addon) {
|
|
1335
1393
|
const known = Array.from(wireAddonDeclarations?.keys() ?? []);
|
|
1336
1394
|
logger.critical(ErrorCode.AGENT_TOOL_UNKNOWN_NAMESPACE, `AI agent '${agentKey}'${where} references tool '${tool}', but no addon is wired under the namespace '${namespace}'. ` +
|
|
1337
|
-
`Wired namespaces: ${known.join(
|
|
1395
|
+
`Wired namespaces: ${known.join(', ') || 'none'}.`);
|
|
1338
1396
|
continue;
|
|
1339
1397
|
}
|
|
1340
1398
|
// An addon that has not been built yet contributed no metadata, which
|
|
@@ -1351,7 +1409,7 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
1351
1409
|
if (options.strictMeta && !meta.description) {
|
|
1352
1410
|
logger.critical(ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION, `AI agent '${agentKey}'${where} uses tool '${tool}', which has no description. ` +
|
|
1353
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. ` +
|
|
1354
|
-
`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)" : ''}.`);
|
|
1355
1413
|
}
|
|
1356
1414
|
}
|
|
1357
1415
|
}
|
|
@@ -1366,12 +1424,12 @@ export function validateAgentToolReferences(logger, state, options = {}) {
|
|
|
1366
1424
|
export function validateAgentScorerReferences(logger, state) {
|
|
1367
1425
|
const known = Object.keys(state.scorers.scorersMeta);
|
|
1368
1426
|
for (const [agentKey, agent] of Object.entries(state.agents.agentsMeta)) {
|
|
1369
|
-
const where = agent.sourceFile ? ` (${agent.sourceFile})` :
|
|
1427
|
+
const where = agent.sourceFile ? ` (${agent.sourceFile})` : '';
|
|
1370
1428
|
for (const scorer of agent.scorers ?? []) {
|
|
1371
1429
|
if (known.includes(scorer))
|
|
1372
1430
|
continue;
|
|
1373
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. ` +
|
|
1374
|
-
`Declared scorers: ${known.join(
|
|
1432
|
+
`Declared scorers: ${known.join(', ') || 'none'}.`);
|
|
1375
1433
|
}
|
|
1376
1434
|
}
|
|
1377
1435
|
}
|
|
@@ -1382,7 +1440,7 @@ export function validateAgentScorerReferences(logger, state) {
|
|
|
1382
1440
|
* The same rule functions get, for a sharper reason: a role granting an
|
|
1383
1441
|
* undeclared scope is not merely useless, it is *invisibly* useless. No
|
|
1384
1442
|
* function checks the scope, so the role appears to work — every call a
|
|
1385
|
-
*
|
|
1443
|
+
* virtual user holding it makes succeeds or fails for unrelated reasons, and the
|
|
1386
1444
|
* authorization boundary it was written to describe is never exercised.
|
|
1387
1445
|
*
|
|
1388
1446
|
* A `*` suffix grants a subtree; the node it hangs off must exist.
|
|
@@ -1394,41 +1452,41 @@ export function validateSystemRoleScopes(logger, state) {
|
|
|
1394
1452
|
const declared = new Set(flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id));
|
|
1395
1453
|
for (const role of state.systemRoles.definitions) {
|
|
1396
1454
|
for (const scope of role.scopes) {
|
|
1397
|
-
if (scope ===
|
|
1455
|
+
if (scope === '*') {
|
|
1398
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.`);
|
|
1399
1457
|
continue;
|
|
1400
1458
|
}
|
|
1401
|
-
const declaredForm = scope.endsWith(
|
|
1459
|
+
const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope;
|
|
1402
1460
|
if (!declared.has(declaredForm)) {
|
|
1403
1461
|
const available = Array.from(declared);
|
|
1404
|
-
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'}`);
|
|
1405
1463
|
}
|
|
1406
1464
|
}
|
|
1407
1465
|
}
|
|
1408
1466
|
}
|
|
1409
1467
|
/**
|
|
1410
|
-
* Validates that every role a
|
|
1468
|
+
* Validates that every role a virtual user names is declared with
|
|
1411
1469
|
* `defineSystemRole`. Runs after all visitors, so declaration order does not
|
|
1412
1470
|
* matter.
|
|
1413
1471
|
*
|
|
1414
1472
|
* Custom roles are refused rather than tolerated. A role composed in the
|
|
1415
1473
|
* console does not exist at build time and can be deleted after it, so a
|
|
1416
|
-
*
|
|
1474
|
+
* virtual user pinned to one silently stops testing what it claims to — and the
|
|
1417
1475
|
* symptom is a scatter of 403s that read like authorization findings rather
|
|
1418
1476
|
* than like a deleted role.
|
|
1419
1477
|
*/
|
|
1420
|
-
export function
|
|
1421
|
-
if (!state.
|
|
1478
|
+
export function validateVirtualUserRoles(logger, state) {
|
|
1479
|
+
if (!state.virtualUsers.definitions.length) {
|
|
1422
1480
|
return;
|
|
1423
1481
|
}
|
|
1424
1482
|
const declared = new Set(state.systemRoles.definitions.map((r) => r.name));
|
|
1425
|
-
for (const
|
|
1426
|
-
for (const role of
|
|
1483
|
+
for (const virtualUser of state.virtualUsers.definitions) {
|
|
1484
|
+
for (const role of virtualUser.roles) {
|
|
1427
1485
|
if (!declared.has(role)) {
|
|
1428
1486
|
const available = Array.from(declared).sort();
|
|
1429
|
-
logger.critical(ErrorCode.INVALID_VALUE, `
|
|
1430
|
-
`A
|
|
1431
|
-
`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'}`);
|
|
1432
1490
|
}
|
|
1433
1491
|
}
|
|
1434
1492
|
}
|