@pikku/inspector 0.12.103 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/add/add-cli.js +21 -11
  3. package/dist/add/add-functions.js +8 -8
  4. package/dist/add/add-virtual-users.d.ts +13 -0
  5. package/dist/add/{add-personas.js → add-virtual-users.js} +33 -33
  6. package/dist/add/add-workflow.js +14 -10
  7. package/dist/error-codes.d.ts +4 -3
  8. package/dist/error-codes.js +4 -3
  9. package/dist/inspector.js +5 -3
  10. package/dist/types.d.ts +3 -3
  11. package/dist/utils/extract-function-name.js +1 -1
  12. package/dist/utils/get-property-value.js +1 -1
  13. package/dist/utils/load-addon-functions-meta.js +1 -0
  14. package/dist/utils/post-process.d.ts +36 -36
  15. package/dist/utils/post-process.js +198 -173
  16. package/dist/utils/serialize-inspector-state.d.ts +2 -2
  17. package/dist/utils/serialize-inspector-state.js +6 -6
  18. package/dist/utils/single-declaration.d.ts +3 -3
  19. package/dist/utils/single-declaration.js +3 -3
  20. package/dist/utils/validate-single-step-workflows.d.ts +3 -0
  21. package/dist/utils/validate-single-step-workflows.js +32 -0
  22. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +3 -3
  23. package/dist/utils/workflow/dsl/patterns.d.ts +3 -3
  24. package/dist/utils/workflow/dsl/patterns.js +4 -4
  25. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +6 -6
  26. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -5
  27. package/dist/visit.js +2 -2
  28. package/knowledge/decisions/a-channel-with-auth-false-marks-its-connect-and-disconnect-sessionless.md +22 -0
  29. package/knowledge/decisions/a-declared-scope-id-never-shadows-a-definition.md +20 -0
  30. package/knowledge/decisions/a-dsl-step-virtual-user-is-the-literal-name-in-its-options.md +27 -0
  31. package/knowledge/decisions/a-feature-flags-anyof-is-read-from-the-ast-and-an-empty-list-is-refused.md +20 -0
  32. package/knowledge/decisions/a-non-literal-string-array-reads-as-absent.md +25 -0
  33. package/knowledge/decisions/a-permission-with-an-underscore-data-parameter-takes-no-data.md +20 -0
  34. package/knowledge/decisions/a-provider-login-virtual-user-is-never-runnable.md +27 -0
  35. package/knowledge/decisions/a-remote-addon-bound-credential-or-secret-must-be-wired.md +27 -0
  36. package/knowledge/decisions/a-route-auth-overrides-its-groups-auth.md +22 -0
  37. package/knowledge/decisions/a-route-requires-a-session-when-any-auth-signal-says-so.md +29 -0
  38. package/knowledge/decisions/a-webhook-source-with-verify-gets-a-singleton-secret-credential.md +21 -0
  39. package/knowledge/decisions/a-wire-with-auth-disabled-cannot-point-at-a-pikkufunc.md +21 -0
  40. package/knowledge/decisions/addon-contracts-cannot-wire-or-declare-handlers.md +25 -0
  41. package/knowledge/decisions/addon-declarations-name-the-secrets-and-credentials-the-app-lends-it.md +19 -0
  42. package/knowledge/decisions/an-inline-wiring-function-is-sessionless-unless-built-with-pikkufunc.md +23 -0
  43. package/knowledge/decisions/better-auth-admin-plugin-is-refused.md +24 -0
  44. package/knowledge/decisions/define-screens-scopes-must-be-string-literals.md +18 -0
  45. package/knowledge/decisions/index.md +38 -0
  46. package/knowledge/decisions/inspector-records-a-non-literal-global-grant-as-source-text.md +20 -0
  47. package/knowledge/decisions/only-one-pikku-better-auth-export-per-codebase.md +19 -0
  48. package/knowledge/decisions/scope-references-and-secret-aliases-are-critical-diagnostics.md +23 -0
  49. package/knowledge/decisions/scope-segments-may-not-be-wildcards-or-contain-colons.md +23 -0
  50. package/knowledge/decisions/shorthand-permission-entries-are-extracted.md +22 -0
  51. package/knowledge/decisions/system-roles-declare-literal-unqualified-scopes.md +28 -0
  52. package/knowledge/decisions/the-inspector-records-agent-permissions-only-to-aggregate-services.md +14 -0
  53. package/knowledge/decisions/vault-secrets-reach-no-sink-and-pii-stays-out-of-logs-and-webhooks.md +30 -0
  54. package/knowledge/decisions/webhook-gateway-wrappers-are-sessionless-and-gateway-auth-runs-inside-the-handler.md +26 -0
  55. package/knowledge/index.md +14 -0
  56. package/knowledge/questions/does-a-non-literal-remote-addon-auth-get-checked.md +26 -0
  57. package/knowledge/questions/index.md +16 -0
  58. package/knowledge/questions/is-an-incomplete-oauth2-block-dropped-without-a-log.md +23 -0
  59. package/knowledge/questions/should-app-functions-named-like-scenario-instrumentation-be-refused.md +28 -0
  60. package/knowledge/questions/should-the-openapi-document-default-every-operation-to-a-bearer-or-api-key.md +22 -0
  61. package/package.json +3 -3
  62. package/dist/add/add-personas.d.ts +0 -13
@@ -1,16 +1,16 @@
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";
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 === "wire")
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 === "tag") {
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 === "http" && "route" in item) {
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 (!("typesLookup" in state)) {
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(["rpc", "mcp", "channel", "userSession"]);
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 ["get", "post", "put", "patch", "delete", "head", "options"]) {
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("workflowStart:") ||
214
- id.startsWith("workflowStatus:") ||
215
- id.startsWith("workflow:"));
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("workflowService");
218
- requiredServices.add("workflowRunService");
219
- requiredServices.add("schedulerService");
220
- requiredServices.add("queueService");
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 || state.functions.meta["queue:pikku-incoming-webhooks"] !== undefined) {
227
- requiredServices.add("incomingWebhookService");
228
- requiredServices.add("queueService");
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 === "scenario")
251
+ if (graph.source === 'scenario')
243
252
  continue;
244
253
  const toKebab = (s) => s
245
- .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
246
- .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
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 (!("rpcName" in node) || !node.rpcName)
267
+ if (!('rpcName' in node) || !node.rpcName)
259
268
  continue;
260
269
  const rpcName = node.rpcName;
261
- const funcId = state.rpc?.internalMeta?.[rpcName] ?? state.rpc?.exposedMeta?.[rpcName] ?? rpcName;
262
- const funcMeta = (state.functions.meta[funcId] ?? state.functions.meta[rpcName]);
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("agentStorage");
277
- requiredServices.add("agentRunState");
278
- requiredServices.add("agentRunService");
279
- requiredServices.add("agentRunner");
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("eventHub");
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("scopeService");
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("featureFlags");
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("analyticsService");
316
+ requiredServices.add('analyticsService');
305
317
  }
306
318
  if ((state.credentials?.definitions?.length ?? 0) > 0) {
307
- requiredServices.add("credentialService");
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(["config", "logger", "variables", "schema", "secrets"]);
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) && !defaultServices.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(", ") || "none"}`);
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,7 @@ 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(", ") || "none"}`);
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'}`);
392
411
  }
393
412
  }
394
413
  }
@@ -401,7 +420,7 @@ export function validateAddonUses(logger, state) {
401
420
  for (const [pkg, target] of Object.entries(addonDecl.uses ?? {})) {
402
421
  const wired = wireAddonDeclarations.get(target);
403
422
  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(", ")}`);
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(', ')}`);
405
424
  }
406
425
  else if (wired.package !== pkg) {
407
426
  logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, `Addon '${namespace}' uses '${pkg}' as '${target}', but '${target}' is wired from '${wired.package}'.`);
@@ -416,12 +435,12 @@ export function validateAddonContentGrants(logger, state) {
416
435
  const buckets = new Set(Array.from(wireAddonDeclarations.entries()).map(([namespace, decl]) => decl.contentBucket ?? namespace));
417
436
  for (const [namespace, decl] of wireAddonDeclarations.entries()) {
418
437
  for (const [prefix, mode] of Object.entries(decl.contentGrants ?? {})) {
419
- if (mode !== "read" && mode !== "write") {
438
+ if (mode !== 'read' && mode !== 'write') {
420
439
  logger.critical(ErrorCode.ADDON_CONTENT_GRANT_INVALID, `Addon '${namespace}' grants '${prefix}' as '${mode}', but a content grant must be 'read' or 'write'.`);
421
440
  }
422
- const first = prefix.split("/").find((segment) => segment !== "");
441
+ const first = prefix.split('/').find((segment) => segment !== '');
423
442
  if (!first || !buckets.has(first)) {
424
- 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(", ")}`);
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(', ')}`);
425
444
  }
426
445
  }
427
446
  }
@@ -432,8 +451,8 @@ export function validateCredentialOverrides(logger, state) {
432
451
  return;
433
452
  const credentialNames = new Set(state.credentials?.definitions.map((d) => d.name) ?? []);
434
453
  /** Only a rename has a target to check; a mode-only override renames nothing. */
435
- const renameTarget = (override) => typeof override === "string" ? override : override.name;
436
- const overrideMode = (override) => typeof override === "string" ? undefined : override.mode;
454
+ const renameTarget = (override) => typeof override === 'string' ? override : override.name;
455
+ const overrideMode = (override) => typeof override === 'string' ? undefined : override.mode;
437
456
  /**
438
457
  * Which declaration set each resolved name's mode. Credential generation
439
458
  * writes one shared metadata entry per name, so two declarations that map
@@ -450,8 +469,10 @@ export function validateCredentialOverrides(logger, state) {
450
469
  const resolvedName = renamed ?? logicalName;
451
470
  if (!credentialNames.has(resolvedName)) {
452
471
  const availableCredentials = Array.from(credentialNames);
453
- const target = renamed ? `'${logicalName}' -> '${resolvedName}'` : `'${logicalName}'`;
454
- 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"}`);
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'}`);
455
476
  }
456
477
  const mode = overrideMode(override);
457
478
  if (!mode)
@@ -467,7 +488,7 @@ export function validateCredentialOverrides(logger, state) {
467
488
  const resolvedName = (override ? renameTarget(override) : undefined) ?? logicalName;
468
489
  if (!credentialNames.has(resolvedName)) {
469
490
  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(", ") || "none"}`);
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'}`);
471
492
  }
472
493
  }
473
494
  }
@@ -485,7 +506,7 @@ export function validateVariableOverrides(logger, state) {
485
506
  for (const [logicalName, resolvedName] of Object.entries(addonDecl.variableOverrides)) {
486
507
  if (!variableIds.has(resolvedName)) {
487
508
  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(", ") || "none"}`);
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'}`);
489
510
  }
490
511
  }
491
512
  }
@@ -501,11 +522,11 @@ export function validateVariableOverrides(logger, state) {
501
522
  * attempt means it is genuinely absent.
502
523
  */
503
524
  const isPackageResolvable = (pkg, dirs) => {
504
- const NOT_FOUND = new Set(["MODULE_NOT_FOUND", "ERR_MODULE_NOT_FOUND"]);
525
+ const NOT_FOUND = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND']);
505
526
  for (const dir of dirs) {
506
527
  let req;
507
528
  try {
508
- req = createRequire(join(dir, "package.json"));
529
+ req = createRequire(join(dir, 'package.json'));
509
530
  }
510
531
  catch {
511
532
  return true; // cannot ask; do not accuse
@@ -559,7 +580,7 @@ export function validateRemoteAddonDependencies(logger, state) {
559
580
  const dirs = addonResolutionDirs(state.rootDir, decl.file);
560
581
  if (isPackageResolvable(decl.package, dirs))
561
582
  continue;
562
- 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.`);
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.`);
563
584
  }
564
585
  // A remote addon belongs in the devDependencies of the package that wires
565
586
  // it, which in a workspace is not the root.
@@ -570,7 +591,7 @@ export function validateRemoteAddonDependencies(logger, state) {
570
591
  let manifest = null;
571
592
  if (existsSync(path)) {
572
593
  try {
573
- manifest = JSON.parse(readFileSync(path, "utf-8"));
594
+ manifest = JSON.parse(readFileSync(path, 'utf-8'));
574
595
  }
575
596
  catch (e) {
576
597
  logger.warn(`Could not read ${path} to verify remote addon dependencies: ${e?.message ?? e}`);
@@ -582,7 +603,7 @@ export function validateRemoteAddonDependencies(logger, state) {
582
603
  for (const [namespace, decl] of wireAddonDeclarations.entries()) {
583
604
  if (!decl.remote)
584
605
  continue;
585
- const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0], "package.json");
606
+ const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0], 'package.json');
586
607
  const pkgJson = readManifest(pkgJsonPath);
587
608
  if (!pkgJson)
588
609
  continue; // no manifest to check (e.g. some tests)
@@ -614,10 +635,10 @@ export function validateRemoteAddonAuth(logger, state) {
614
635
  if (!decl.remote)
615
636
  continue;
616
637
  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(", ") || "none"}`);
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'}`);
618
639
  }
619
640
  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(", ") || "none"}`);
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'}`);
621
642
  }
622
643
  }
623
644
  }
@@ -626,7 +647,7 @@ export function computeResolvedIOTypes(state) {
626
647
  for (const [pikkuFuncId, meta] of Object.entries(functions.meta)) {
627
648
  const input = meta.inputs?.[0];
628
649
  const output = meta.outputs?.[0];
629
- let inputType = "null";
650
+ let inputType = 'null';
630
651
  if (input) {
631
652
  try {
632
653
  inputType = functions.typesMap.getTypeMeta(input).uniqueName;
@@ -635,7 +656,7 @@ export function computeResolvedIOTypes(state) {
635
656
  inputType = input;
636
657
  }
637
658
  }
638
- let outputType = "null";
659
+ let outputType = 'null';
639
660
  if (output) {
640
661
  try {
641
662
  outputType = functions.typesMap.getTypeMeta(output).uniqueName;
@@ -645,10 +666,10 @@ export function computeResolvedIOTypes(state) {
645
666
  }
646
667
  }
647
668
  state.resolvedIOTypes[pikkuFuncId] = { inputType, outputType };
648
- if (meta.inputSchemaName && inputType !== "null") {
669
+ if (meta.inputSchemaName && inputType !== 'null') {
649
670
  meta.inputSchemaName = inputType;
650
671
  }
651
- if (meta.outputSchemaName && outputType !== "null") {
672
+ if (meta.outputSchemaName && outputType !== 'null') {
652
673
  meta.outputSchemaName = outputType;
653
674
  }
654
675
  if (meta.inputs) {
@@ -710,15 +731,15 @@ export function computePermissionsGroupsMeta(state) {
710
731
  };
711
732
  }
712
733
  const PRIMITIVE_TYPES = new Set([
713
- "boolean",
714
- "string",
715
- "number",
716
- "null",
717
- "undefined",
718
- "void",
719
- "any",
720
- "unknown",
721
- "never",
734
+ 'boolean',
735
+ 'string',
736
+ 'number',
737
+ 'null',
738
+ 'undefined',
739
+ 'void',
740
+ 'any',
741
+ 'unknown',
742
+ 'never',
722
743
  ]);
723
744
  export function computeRequiredSchemas(state, options) {
724
745
  const { functions, schemaLookup } = state;
@@ -779,8 +800,8 @@ export function unresolvedSchemaReferences(state) {
779
800
  };
780
801
  for (const [funcName, meta] of Object.entries(functions.meta)) {
781
802
  for (const [role, declared] of [
782
- ["input", meta.inputs?.[0]],
783
- ["output", meta.outputs?.[0]],
803
+ ['input', meta.inputs?.[0]],
804
+ ['output', meta.outputs?.[0]],
784
805
  ]) {
785
806
  if (!declared || PRIMITIVE_TYPES.has(declared))
786
807
  continue;
@@ -797,12 +818,12 @@ export function validateSchemaReferences(logger, state) {
797
818
  if (unresolved.length === 0)
798
819
  return;
799
820
  logger.diagnostic({
800
- severity: "error",
821
+ severity: 'error',
801
822
  code: ErrorCode.SCHEMA_REFERENCE_UNRESOLVED,
802
- message: `${unresolved.length} function contract${unresolved.length === 1 ? "" : "s"} ` +
803
- `name${unresolved.length === 1 ? "s" : ""} a schema that was never generated, so ` +
804
- `calling ${unresolved.length === 1 ? "it" : "them"} fails at runtime with ` +
805
- `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(', ')}. ` +
806
827
  `The usual cause is a named type used as the function's input/output that ` +
807
828
  `is declared but not exported — export it, or inline the type at the call site.`,
808
829
  });
@@ -821,11 +842,11 @@ export function validateAgentModels(logger, state, modelAliases = {}) {
821
842
  // `hasOwn`, not a truthy lookup: every object literal inherits `toString`,
822
843
  // `constructor` and `__proto__`, so a bare model named after one of them
823
844
  // would read as a configured alias and pass a check nothing else repeats.
824
- if (!model.includes("/") && !Object.hasOwn(modelAliases, model)) {
845
+ if (!model.includes('/') && !Object.hasOwn(modelAliases, model)) {
825
846
  const known = Object.keys(modelAliases).sort();
826
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. ` +
827
848
  (known.length
828
- ? `Known aliases: ${known.join(", ")}.`
849
+ ? `Known aliases: ${known.join(', ')}.`
829
850
  : `No "models" aliases are configured.`));
830
851
  }
831
852
  }
@@ -833,7 +854,7 @@ export function validateAgentModels(logger, state, modelAliases = {}) {
833
854
  /**
834
855
  * Scenarios are pure stories of remote RPCs (same rule as client-side CLI
835
856
  * renderers): the func may only destructure logger/config — everything else
836
- * must go through actor steps so the flow runs against the TARGET
857
+ * must go through virtual user steps so the flow runs against the TARGET
837
858
  * environment, never local services.
838
859
  *
839
860
  * This runs in post-processing rather than in `addWorkflow` because
@@ -848,30 +869,30 @@ export function validateScenarioServices(logger, state) {
848
869
  const funcMeta = state.functions.meta[meta.pikkuFuncId];
849
870
  if (!funcMeta?.services)
850
871
  continue;
851
- const disallowed = funcMeta.services.services.filter((svc) => svc !== "logger" && svc !== "config");
872
+ const disallowed = funcMeta.services.services.filter((svc) => svc !== 'logger' && svc !== 'config');
852
873
  if (disallowed.length > 0) {
853
- 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(', ')}. ` +
854
875
  `Scenarios may only use 'logger'/'config' — drive everything else through ` +
855
- `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
876
+ `virtualUser steps (workflow.do(step, rpc, data, { virtualUser: virtualUsers.x })) so the flow ` +
856
877
  `runs against the target environment.`);
857
878
  }
858
879
  }
859
880
  }
860
881
  /**
861
- * Does this step's prose open by naming the actor who is already its subject?
882
+ * Does this step's prose open by naming the virtual user who is already its subject?
862
883
  *
863
- * `composeStepProse` renders the actor as the sentence's subject, so a step
884
+ * `composeStepProse` renders the virtual user as the sentence's subject, so a step
864
885
  * authored as `'sam' creates the client` reads "Given sam 'sam' creates the
865
- * client". Only the subject position counts: a persona keyed after a role noun
886
+ * client". Only the subject position counts: a virtual user keyed after a role noun
866
887
  * ("admin") must still be able to say "creates the admin client", and naming
867
888
  * someone mid-sentence — "sends sam an invite" — is ordinary prose.
868
889
  */
869
- export const proseOpensWithActor = (prose, actor) => {
870
- if (!actor) {
890
+ export const proseOpensWithVirtualUser = (prose, virtualUser) => {
891
+ if (!virtualUser) {
871
892
  return false;
872
893
  }
873
- const escaped = actor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
874
- return new RegExp(`^["'“‘\\s]*${escaped}(?:['’]s)?\\b`, "i").test(prose);
894
+ const escaped = virtualUser.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
895
+ return new RegExp(`^["'“‘\\s]*${escaped}(?:['’]s)?\\b`, 'i').test(prose);
875
896
  };
876
897
  /**
877
898
  * The prose that will actually render for one step, in the runtime's own
@@ -879,39 +900,43 @@ export const proseOpensWithActor = (prose, actor) => {
879
900
  * overrides its declared description, which falls back to the durable name.
880
901
  *
881
902
  * Every candidate is returned rather than only the winner, because a template
882
- * is authored where the actor is not yet known — so it is wrong at every call
883
- * site that runs it as the persona it names, not just the winning one.
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.
884
905
  */
885
906
  const renderableProse = (step, stepMeta) => {
886
907
  const description = step.options?.description;
887
908
  if (description) {
888
909
  return [step.stepName, description];
889
910
  }
890
- return [step.stepName, stepMeta?.scenarioStepTemplate, stepMeta?.description].filter((prose) => typeof prose === "string");
911
+ return [
912
+ step.stepName,
913
+ stepMeta?.scenarioStepTemplate,
914
+ stepMeta?.description,
915
+ ].filter((prose) => typeof prose === 'string');
891
916
  };
892
917
  /**
893
- * A step's prose must not open by naming the actor it already runs as (PKU681).
918
+ * A step's prose must not open by naming the virtual user it already runs as (PKU681).
894
919
  *
895
- * The reporter renders the actor as the sentence's subject, so `'sam' creates
896
- * the client` run as `actors.sam` reads "Given sam 'sam' creates the client" —
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" —
897
922
  * and the hardcoded copy silently desyncs the moment the call site changes
898
- * actor. Only the subject position is refused; naming someone mid-sentence is
899
- * ordinary prose, and a persona keyed after a role noun can still say "creates
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
900
925
  * the admin client".
901
926
  */
902
927
  const validateStepProse = (logger, workflowName, step, stepMeta) => {
903
- const actor = step.actor;
904
- if (!actor) {
928
+ const virtualUser = step.virtualUser;
929
+ if (!virtualUser) {
905
930
  return;
906
931
  }
907
- const offender = renderableProse(step, stepMeta).find((prose) => proseOpensWithActor(prose, actor));
932
+ const offender = renderableProse(step, stepMeta).find((prose) => proseOpensWithVirtualUser(prose, virtualUser));
908
933
  if (offender === undefined) {
909
934
  return;
910
935
  }
911
- logger.critical(ErrorCode.SCENARIO_STEP_PROSE_NAMES_ACTOR, `Scenario '${workflowName}' names its actor in the step prose: '${offender}' runs as '${actor}'. ` +
912
- `The reporter already renders the actor as the subject, so this reads ` +
913
- `"${capitaliseFirst(step.phase)} ${actor} ${offender}". Drop the name and let ` +
914
- `{ actor: actors.${actor} } supply it — the step reads as a bare third-person predicate.`);
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.`);
915
940
  };
916
941
  const capitaliseFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
917
942
  /**
@@ -921,9 +946,9 @@ const capitaliseFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1
921
946
  *
922
947
  * - the step target must be a static string literal (PKU678) — otherwise it
923
948
  * can't be bundled, typed or drawn;
924
- * - a step that runs as a persona must be given one (PKU677) — declared by
925
- * `actor: true`, or implied by a `browser` binding, since the browser context
926
- * signs in as that persona. It is checked statically even though a browser
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
927
952
  * binding only runs on a browser run: a step that can never be driven by a
928
953
  * human is a latent failure, not a passing default run.
929
954
  * - a scenario written as a step ladder must assert something (PKU680).
@@ -935,7 +960,7 @@ export function validateScenarioSteps(logger, state) {
935
960
  const phasesSeen = new Set();
936
961
  const visit = (workflowName, steps) => {
937
962
  for (const step of steps) {
938
- if (step.type === "scenarioStep") {
963
+ if (step.type === 'scenarioStep') {
939
964
  phasesSeen.add(step.phase);
940
965
  if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
941
966
  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 +971,22 @@ export function validateScenarioSteps(logger, state) {
946
971
  state.serviceAggregation.usedFunctions.add(step.stepFunc);
947
972
  const stepMeta = state.functions.meta[step.stepFunc];
948
973
  validateStepProse(logger, workflowName, step, stepMeta);
949
- if (stepMeta?.scenarioStepRequiresActor && !step.actor) {
950
- const why = stepMeta.scenarioStepSurfaces?.includes("browser")
951
- ? "declares a browser binding"
952
- : "is driven by a persona";
953
- logger.critical(ErrorCode.SCENARIO_STEP_NEEDS_ACTOR, `Scenario '${workflowName}' calls step '${step.stepFunc}', which ${why}, without an actor. ` +
954
- `Pass { actor: actors.<name> } so it runs as that persona.`);
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.`);
955
980
  }
956
981
  continue;
957
982
  }
958
- if (step.type === "branch") {
983
+ if (step.type === 'branch') {
959
984
  for (const branch of step.branches)
960
985
  visit(workflowName, branch.steps);
961
986
  if (step.elseSteps)
962
987
  visit(workflowName, step.elseSteps);
963
988
  }
964
- else if (step.type === "switch") {
989
+ else if (step.type === 'switch') {
965
990
  for (const c of step.cases ?? []) {
966
991
  if (c.steps)
967
992
  visit(workflowName, c.steps);
@@ -969,10 +994,10 @@ export function validateScenarioSteps(logger, state) {
969
994
  if (step.defaultSteps)
970
995
  visit(workflowName, step.defaultSteps);
971
996
  }
972
- else if (step.type === "fanout" && step.body) {
997
+ else if (step.type === 'fanout' && step.body) {
973
998
  visit(workflowName, step.body);
974
999
  }
975
- else if (step.type === "parallel" && step.children) {
1000
+ else if (step.type === 'parallel' && step.children) {
976
1001
  visit(workflowName, step.children);
977
1002
  }
978
1003
  }
@@ -987,9 +1012,9 @@ export function validateScenarioSteps(logger, state) {
987
1012
  // `asserts` is the escape for a flow whose witness is an expectation helper
988
1013
  // instead: those are inline steps and leave no phase behind, but a recorded
989
1014
  // service call or a refusal is an assertion all the same.
990
- if (phasesSeen.size > 0 && !phasesSeen.has("then") && !meta.asserts) {
991
- logger.critical(ErrorCode.SCENARIO_HAS_NO_ASSERTION, `Scenario '${workflowName}' has ${[...phasesSeen].join("/")} steps but never asserts. ` +
992
- `Add a scenario.then(...) naming what the actor should now see — a flow with no 'then' ` +
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' ` +
993
1018
  `only proves nothing threw, and contributes nothing to witness coverage.`);
994
1019
  }
995
1020
  }
@@ -1049,17 +1074,17 @@ export function validateScenarioFeatures(logger, state) {
1049
1074
  .map((feature) => feature.exportedName);
1050
1075
  if (unreadable.length > 0) {
1051
1076
  logger.diagnostic({
1052
- severity: "warn",
1077
+ severity: 'warn',
1053
1078
  code: ErrorCode.SCENARIO_HAS_NO_FEATURE,
1054
- message: `${unowned.map((name) => `'${name}'`).join(", ")} appear in no feature's \`scenarios\` array, ` +
1055
- `but ${unreadable.map((name) => `'${name}'`).join(", ")} build theirs with a spread or a call, so one of them may still be listed there. ` +
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. ` +
1056
1081
  `List those scenarios literally and this becomes a definite answer either way.`,
1057
1082
  });
1058
1083
  return;
1059
1084
  }
1060
1085
  for (const name of unowned) {
1061
1086
  logger.diagnostic({
1062
- severity: "error",
1087
+ severity: 'error',
1063
1088
  code: ErrorCode.SCENARIO_HAS_NO_FEATURE,
1064
1089
  message: `Scenario '${name}' is listed by no pikkuFeature, so nothing downstream can reach it — ` +
1065
1090
  `a run stamps no feature onto its results, the console groups it under none, and a guide cannot cite it. ` +
@@ -1074,16 +1099,16 @@ export function validateScenarioFeatures(logger, state) {
1074
1099
  * early with an actionable message pointing at `scaffold: { graph: true }`.
1075
1100
  */
1076
1101
  export function validateWorkflowGraphAddons(logger, state) {
1077
- const addonGraphWired = Array.from(state.rpc.wireAddonDeclarations.values()).some((decl) => decl.package === "@pikku/addon-graph");
1102
+ const addonGraphWired = Array.from(state.rpc.wireAddonDeclarations.values()).some((decl) => decl.package === '@pikku/addon-graph');
1078
1103
  if (addonGraphWired) {
1079
1104
  return;
1080
1105
  }
1081
1106
  for (const [name, graph] of Object.entries(state.workflows.graphMeta)) {
1082
1107
  for (const node of Object.values(graph.nodes)) {
1083
- if (!("rpcName" in node) || typeof node.rpcName !== "string") {
1108
+ if (!('rpcName' in node) || typeof node.rpcName !== 'string') {
1084
1109
  continue;
1085
1110
  }
1086
- if (!node.rpcName.startsWith("graph:")) {
1111
+ if (!node.rpcName.startsWith('graph:')) {
1087
1112
  continue;
1088
1113
  }
1089
1114
  if (state.functions.meta[node.rpcName]) {
@@ -1133,7 +1158,7 @@ export function validateSchemaWiringSeparation(logger, state) {
1133
1158
  const schemas = Array.from(state.schemaLookup.entries())
1134
1159
  .filter(([, ref]) => ref.sourceFile === file)
1135
1160
  .map(([name]) => name);
1136
- logger.critical(ErrorCode.SCHEMA_AND_WIRING_COLOCATED, `File '${file}' contains both Zod schemas (${schemas.join(", ")}) and wiring calls (wireHTTPRoutes, addPermission, etc.). ` +
1161
+ logger.critical(ErrorCode.SCHEMA_AND_WIRING_COLOCATED, `File '${file}' contains both Zod schemas (${schemas.join(', ')}) and wiring calls (wireHTTPRoutes, addPermission, etc.). ` +
1137
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. ` +
1138
1163
  `Move the route/wiring definitions to a dedicated wiring file.`);
1139
1164
  }
@@ -1148,7 +1173,7 @@ export function validateSchemaWiringSeparation(logger, state) {
1148
1173
  * not.
1149
1174
  */
1150
1175
  export function validateNoSecretAliasServices(logger, checker, state) {
1151
- if (!("typesLookup" in state)) {
1176
+ if (!('typesLookup' in state)) {
1152
1177
  return;
1153
1178
  }
1154
1179
  const singletonServicesType = resolveCoreType(state.typesLookup, state.singletonServicesTypeImportMap);
@@ -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(", ")}, which ${used.length === 1 ? "is" : "are"} a SecretService under another name. ` +
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("Function", id, meta.services?.services ?? []);
1196
+ report('Function', id, meta.services?.services ?? []);
1172
1197
  }
1173
1198
  for (const [id, def] of Object.entries(state.permissions.definitions)) {
1174
- report("Permission", id, def.services?.services ?? []);
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("BETTER_AUTH_SECRET");
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: "warn",
1234
+ severity: 'warn',
1210
1235
  code: ErrorCode.SECRET_NOT_DECLARED,
1211
- 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.`,
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: "warn",
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(", ")}). The deployment cannot narrow its secret scope around a key it cannot see.`,
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(".gen.ts") ||
1233
- meta.functionType === "inline" ||
1234
- meta.functionType === "helper") {
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(":*") ? scope.slice(0, -2) : scope;
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(", ") || "none"}`);
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("workflow:"))
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(", ") || "none"}.`);
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(", ") || "none"}.`);
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
- * persona holding it makes succeeds or fails for unrelated reasons, and the
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(":*") ? scope.slice(0, -2) : scope;
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(", ") || "none"}`);
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 persona names is declared with
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
- * persona pinned to one silently stops testing what it claims to — and the
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 validatePersonaRoles(logger, state) {
1454
- if (!state.personas.definitions.length) {
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 persona of state.personas.definitions) {
1459
- for (const role of persona.roles) {
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, `Persona '${persona.id}' holds role '${role}', which is not declared with defineSystemRole. ` +
1463
- `A persona may only name a system role — a role composed in the console can be deleted, and the persona would go on claiming to test it. ` +
1464
- `Declared roles: ${available.join(", ") || "none"}`);
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
  }