@pikku/inspector 0.12.49 → 0.12.52

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 (104) hide show
  1. package/CHANGELOG.md +501 -0
  2. package/OPTIMIZATION-PLAN.md +9 -3
  3. package/dist/add/add-auth.d.ts +1 -1
  4. package/dist/add/add-auth.js +1 -1
  5. package/dist/add/add-cli.js +7 -0
  6. package/dist/add/add-credential.js +1 -1
  7. package/dist/add/add-functions.js +156 -7
  8. package/dist/add/add-http-route.js +16 -6
  9. package/dist/add/add-middleware.js +46 -3
  10. package/dist/add/add-personas.d.ts +13 -0
  11. package/dist/add/add-personas.js +210 -0
  12. package/dist/add/add-scope.js +1 -1
  13. package/dist/add/add-secret.js +1 -1
  14. package/dist/add/add-system-role.d.ts +2 -0
  15. package/dist/add/add-system-role.js +102 -0
  16. package/dist/add/add-variable.js +1 -1
  17. package/dist/add/add-wire-addon.js +31 -0
  18. package/dist/error-codes.d.ts +10 -1
  19. package/dist/error-codes.js +11 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/inspector.js +33 -1
  22. package/dist/types.d.ts +46 -2
  23. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  24. package/dist/utils/annotate-http-route-auth.js +48 -0
  25. package/dist/utils/extract-function-name.js +13 -4
  26. package/dist/utils/extract-function-node.js +18 -1
  27. package/dist/utils/extract-secret-usage.d.ts +9 -0
  28. package/dist/utils/extract-secret-usage.js +61 -0
  29. package/dist/utils/literal-properties.d.ts +22 -0
  30. package/dist/utils/literal-properties.js +69 -0
  31. package/dist/utils/load-addon-functions-meta.js +26 -1
  32. package/dist/utils/permissions.js +8 -0
  33. package/dist/utils/post-process.d.ts +68 -3
  34. package/dist/utils/post-process.js +243 -13
  35. package/dist/utils/schema-generator.d.ts +24 -0
  36. package/dist/utils/schema-generator.js +118 -19
  37. package/dist/utils/secret-alias-services.d.ts +8 -0
  38. package/dist/utils/secret-alias-services.js +45 -0
  39. package/dist/utils/secret-brokers.d.ts +2 -0
  40. package/dist/utils/secret-brokers.js +9 -0
  41. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  42. package/dist/utils/serialize-inspector-state.js +18 -0
  43. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  44. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  45. package/dist/utils/validate-exposed-functions-gated.js +85 -0
  46. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  47. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  48. package/dist/visit.js +4 -0
  49. package/package.json +3 -3
  50. package/src/add/add-auth.test.ts +5 -3
  51. package/src/add/add-auth.ts +1 -1
  52. package/src/add/add-cli.ts +8 -0
  53. package/src/add/add-credential.ts +1 -1
  54. package/src/add/add-functions.test.ts +263 -1
  55. package/src/add/add-functions.ts +219 -9
  56. package/src/add/add-http-route.ts +20 -7
  57. package/src/add/add-middleware.test.ts +196 -0
  58. package/src/add/add-middleware.ts +64 -4
  59. package/src/add/add-personas.test.ts +268 -0
  60. package/src/add/add-personas.ts +276 -0
  61. package/src/add/add-scenario-step.test.ts +49 -3
  62. package/src/add/add-scope.test.ts +30 -30
  63. package/src/add/add-scope.ts +1 -1
  64. package/src/add/add-secret.ts +1 -1
  65. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  66. package/src/add/add-system-role.test.ts +214 -0
  67. package/src/add/add-system-role.ts +164 -0
  68. package/src/add/add-variable.ts +1 -1
  69. package/src/add/add-wire-addon.test.ts +111 -0
  70. package/src/add/add-wire-addon.ts +29 -0
  71. package/src/add/addon-bans.test.ts +4 -4
  72. package/src/add/config-value-metadata.test.ts +11 -11
  73. package/src/add/wire-name-literal.test.ts +2 -2
  74. package/src/error-codes.ts +13 -0
  75. package/src/index.ts +4 -1
  76. package/src/inspector.ts +39 -0
  77. package/src/types.ts +47 -2
  78. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  79. package/src/utils/annotate-http-route-auth.ts +52 -0
  80. package/src/utils/extract-function-name.ts +14 -4
  81. package/src/utils/extract-function-node.ts +18 -6
  82. package/src/utils/extract-secret-usage.test.ts +81 -0
  83. package/src/utils/extract-secret-usage.ts +73 -0
  84. package/src/utils/literal-properties.ts +97 -0
  85. package/src/utils/load-addon-functions-meta.ts +32 -2
  86. package/src/utils/permissions-shorthand.test.ts +79 -0
  87. package/src/utils/permissions.ts +7 -0
  88. package/src/utils/post-process.test.ts +133 -0
  89. package/src/utils/post-process.ts +310 -12
  90. package/src/utils/schema-cache.test.ts +124 -0
  91. package/src/utils/schema-generator.test.ts +62 -1
  92. package/src/utils/schema-generator.ts +138 -21
  93. package/src/utils/secret-alias-services.test.ts +146 -0
  94. package/src/utils/secret-alias-services.ts +58 -0
  95. package/src/utils/secret-brokers.test.ts +53 -0
  96. package/src/utils/secret-brokers.ts +11 -0
  97. package/src/utils/serialize-inspector-state.ts +28 -0
  98. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  99. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  100. package/src/utils/validate-exposed-functions-gated.ts +94 -0
  101. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  102. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  103. package/src/visit.ts +4 -0
  104. package/tsconfig.tsbuildinfo +1 -1
@@ -4,6 +4,24 @@ import { extractFunctionName, funcIdToTypeName, } from '../utils/extract-functio
4
4
  import { extractFunctionNode } from '../utils/extract-function-node.js';
5
5
  import { extractUsedWires } from '../utils/extract-services.js';
6
6
  import { formatVersionedId, parseVersionedId } from '@pikku/core';
7
+ /** Binding keys a `pikkuScenarioStep` may declare, in report order. */
8
+ const SCENARIO_SURFACE_KEYS = [
9
+ 'browser',
10
+ 'cli',
11
+ 'default',
12
+ ];
13
+ /**
14
+ * The step wrappers, and who acts in each.
15
+ *
16
+ * Which function was called *is* the kind. That is the whole reason these are
17
+ * three declarations rather than a `kind:` field on one: a string literal can be
18
+ * copied into an addon package by a consumer, a called identifier cannot.
19
+ */
20
+ const SCENARIO_STEP_KINDS = new Map([
21
+ ['pikkuScenarioStep', 'persona'],
22
+ ['pikkuPlatformScenarioStep', 'platform'],
23
+ ['pikkuAddonScenarioStep', 'addon'],
24
+ ]);
7
25
  import { getPropertyValue, getArrayPropertyValue, getCommonWireMetaData, } from '../utils/get-property-value.js';
8
26
  import { canonicalJSON, hashString } from '../utils/hash.js';
9
27
  import { resolveMiddleware } from '../utils/middleware.js';
@@ -261,6 +279,66 @@ function computeImplementationHash(args) {
261
279
  config: objectNode ? printNode(objectNode) : null,
262
280
  }));
263
281
  }
282
+ /**
283
+ * Registers a `pikkuRemoteChannelFunc` declaration: a name, its schemas and its
284
+ * description, with no body to inspect. The name has to reach `rpc.internalMeta`
285
+ * for `channel.remote` to resolve the schemas it validates each call against.
286
+ */
287
+ const addRemoteChannelFunc = ({ state, logger, node, pikkuFuncId, name, exportedName, inputSchemaRef, outputSchemaRef, meta, }) => {
288
+ const sourceFile = node.getSourceFile().fileName;
289
+ if (!exportedName) {
290
+ logger.error(`• Remote channel function '${name}' is not exported, this is not allowed.`);
291
+ return;
292
+ }
293
+ // Two files declaring the same capability is the collision that matters:
294
+ // both ids are the bare name, so the id-compatibility check below reads them
295
+ // as the same function and lets the second overwrite the first.
296
+ const existingFunction = state.functions.meta[pikkuFuncId];
297
+ if (existingFunction &&
298
+ existingFunction.sourceFile &&
299
+ existingFunction.sourceFile !== sourceFile) {
300
+ logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
301
+ `'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`);
302
+ return;
303
+ }
304
+ const existing = state.rpc.internalMeta[name];
305
+ if (existing && !areCompatibleFunctionIds(existing, pikkuFuncId)) {
306
+ logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
307
+ `It already points to '${existing}' in '${resolveExistingFunctionSource(state, existing) || 'unknown file'}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`);
308
+ return;
309
+ }
310
+ const capitalizedName = funcIdToTypeName(name);
311
+ const registerSchema = (ref, suffix) => {
312
+ if (!ref)
313
+ return null;
314
+ const schemaName = `${capitalizedName}${suffix}`;
315
+ state.schemaLookup.set(schemaName, ref);
316
+ state.functions.typesMap.addCustomType(schemaName, 'unknown', []);
317
+ return schemaName;
318
+ };
319
+ const inputSchemaName = registerSchema(inputSchemaRef, 'Input');
320
+ const outputSchemaName = registerSchema(outputSchemaRef, 'Output');
321
+ state.functions.meta[pikkuFuncId] = {
322
+ pikkuFuncId,
323
+ functionType: 'remote',
324
+ funcWrapper: 'pikkuRemoteChannelFunc',
325
+ sessionless: true,
326
+ name,
327
+ services: { optimized: true, services: [] },
328
+ inputSchemaName,
329
+ outputSchemaName,
330
+ inputs: inputSchemaName ? [inputSchemaName] : [],
331
+ outputs: outputSchemaName ? [outputSchemaName] : [],
332
+ sourceFile,
333
+ exportedName,
334
+ ...meta,
335
+ };
336
+ state.functions.files.set(pikkuFuncId, { path: sourceFile, exportedName });
337
+ state.rpc.internalMeta[name] = pikkuFuncId;
338
+ // Imported so a local call gets the factory's "this runs on the client"
339
+ // error rather than a bare lookup miss.
340
+ state.rpc.internalFiles.set(pikkuFuncId, { path: sourceFile, exportedName });
341
+ };
264
342
  /**
265
343
  * Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
266
344
  * then push into state.functions.meta.
@@ -274,18 +352,19 @@ export const addFunctions = (logger, node, checker, state, options) => {
274
352
  return;
275
353
  }
276
354
  // Match identifiers that contain both "pikku" and "func" (case insensitive),
277
- // plus pikkuScenario and pikkuScenarioStep (workflow wrappers without "func"
278
- // in their names)
355
+ // plus pikkuScenario and the three scenario step wrappers (workflow wrappers
356
+ // without "func" in their names)
279
357
  const pikkuFuncPattern = /pikku.*func/i;
280
358
  if (!pikkuFuncPattern.test(expression.text) &&
281
359
  expression.text !== 'pikkuScenario' &&
282
- expression.text !== 'pikkuScenarioStep') {
360
+ !SCENARIO_STEP_KINDS.has(expression.text)) {
283
361
  return;
284
362
  }
285
363
  // A scenario step is bundled like any invoked function, but is deliberately
286
364
  // never RPC-registered: a step may drive a browser, so exposing it over the
287
365
  // network would put a test harness on the public API surface.
288
- const isScenarioStep = expression.text === 'pikkuScenarioStep';
366
+ const scenarioStepKind = SCENARIO_STEP_KINDS.get(expression.text);
367
+ const isScenarioStep = scenarioStepKind !== undefined;
289
368
  // A scenario's own body. Marked so the codegen partitions and the deploy
290
369
  // analyzer can tell it apart from an application workflow without having to
291
370
  // reach into the workflow graph.
@@ -316,6 +395,15 @@ export const addFunctions = (logger, node, checker, state, options) => {
316
395
  let expose;
317
396
  let remote;
318
397
  let mcp;
398
+ /**
399
+ * A sessionless function's own `auth: true`. `sessionless` records the
400
+ * baseline — a `pikkuFunc` always needs a session — while this records the
401
+ * tightening a `pikkuSessionlessFunc` applies to itself, which is otherwise
402
+ * invisible to anything reading meta.
403
+ */
404
+ let auth;
405
+ /** The author's claim that the body authorizes its own callers. */
406
+ let selfAuthenticated;
319
407
  let readonly_;
320
408
  let deploy;
321
409
  let approvalRequired;
@@ -323,7 +411,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
323
411
  let workflowQueued;
324
412
  let workflowRetries;
325
413
  let workflowTimeout;
326
- let scenarioStepBrowser;
414
+ let scenarioStepSurfaces;
415
+ let scenarioStepAddon;
327
416
  let scenarioStepTemplate;
328
417
  let scopes;
329
418
  let version;
@@ -390,13 +479,48 @@ export const addFunctions = (logger, node, checker, state, options) => {
390
479
  expose = getPropertyValue(firstArg, 'expose');
391
480
  remote = getPropertyValue(firstArg, 'remote');
392
481
  mcp = getPropertyValue(firstArg, 'mcp');
482
+ auth = getPropertyValue(firstArg, 'auth');
483
+ selfAuthenticated = getPropertyValue(firstArg, 'selfAuthenticated');
393
484
  readonly_ = getPropertyValue(firstArg, 'readonly');
394
485
  deploy = getPropertyValue(firstArg, 'deploy');
395
486
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired');
396
487
  workflowQueued = getPropertyValue(firstArg, 'workflowQueued');
397
488
  workflowRetries = getPropertyValue(firstArg, 'workflowRetries');
398
489
  workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout');
399
- scenarioStepBrowser = getPropertyValue(firstArg, 'browser');
490
+ // A step declares one implementation per surface it supports; the binding
491
+ // keys that are present ARE the declaration, so there is nothing to read a
492
+ // value from — only names to collect.
493
+ const declaredSurfaces = SCENARIO_SURFACE_KEYS.filter((surface) => firstArg.properties.some((prop) => (ts.isPropertyAssignment(prop) ||
494
+ ts.isMethodDeclaration(prop) ||
495
+ ts.isShorthandPropertyAssignment(prop)) &&
496
+ ts.isIdentifier(prop.name) &&
497
+ prop.name.text === surface));
498
+ if (scenarioStepKind === 'platform' || scenarioStepKind === 'addon') {
499
+ // Neither kind has surfaces. Nobody clicks "Stripe's webhook arrives", and
500
+ // there is no human behind "the platform has expired the trial" — so both
501
+ // take a single `func`, and a binding key here is a coded error rather
502
+ // than a convention nobody reads.
503
+ if (declaredSurfaces.length > 0) {
504
+ logger.error(`• ${expression.text}('${name}') declares ${declaredSurfaces.join('/')} — a ${scenarioStepKind} step has one implementation, declared as \`func\`. Surfaces are how a person drives the system, and nobody drives this one.`);
505
+ return;
506
+ }
507
+ // The phase rule depends on it: an assertion runs every witness it has and
508
+ // fails if they disagree, so a step with exactly one witness by
509
+ // construction has to be unable to look like it has several.
510
+ scenarioStepSurfaces = ['default'];
511
+ if (scenarioStepKind === 'addon') {
512
+ scenarioStepAddon = getPropertyValue(firstArg, 'addon');
513
+ if (!scenarioStepAddon) {
514
+ logger.error(`• pikkuAddonScenarioStep('${name}') needs a string literal \`addon\` naming the addon whose system acts — the same name its wireAddon declares.`);
515
+ return;
516
+ }
517
+ }
518
+ }
519
+ else {
520
+ scenarioStepSurfaces = declaredSurfaces.length
521
+ ? declaredSurfaces
522
+ : undefined;
523
+ }
400
524
  scenarioStepTemplate = getPropertyValue(firstArg, 'template');
401
525
  scopes = getArrayPropertyValue(firstArg, 'scopes') ?? undefined;
402
526
  // Extract approvalDescription identifier reference
@@ -488,6 +612,23 @@ export const addFunctions = (logger, node, checker, state, options) => {
488
612
  }
489
613
  pikkuFuncId = formatVersionedId(baseName, version);
490
614
  }
615
+ // A remote channel capability is a contract with no body: the connected
616
+ // client answers it. Everything past this point reads the handler, so it gets
617
+ // its own path rather than a nullable handler threaded through all of it.
618
+ if (expression.text === 'pikkuRemoteChannelFunc') {
619
+ addRemoteChannelFunc({
620
+ state,
621
+ logger,
622
+ node,
623
+ pikkuFuncId,
624
+ name,
625
+ exportedName,
626
+ inputSchemaRef,
627
+ outputSchemaRef,
628
+ meta: { version, title, tags, summary, description, errors },
629
+ });
630
+ return;
631
+ }
491
632
  const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc';
492
633
  const isListFunc = expression.text === 'pikkuListFunc';
493
634
  const mcpEnabled = mcp || isMCPToolFunc;
@@ -795,6 +936,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
795
936
  inputs: inputNames.filter((n) => n !== 'void') ?? null,
796
937
  outputs: outputNames.filter((n) => n !== 'void') ?? null,
797
938
  expose: expose || undefined,
939
+ auth: auth || undefined,
940
+ selfAuthenticated: selfAuthenticated || undefined,
798
941
  remote: remote || undefined,
799
942
  scenarioStep: isScenarioStep || undefined,
800
943
  scenario: isScenario || undefined,
@@ -806,7 +949,13 @@ export const addFunctions = (logger, node, checker, state, options) => {
806
949
  workflowQueued: workflowQueued === true ? true : undefined,
807
950
  workflowRetries: workflowRetries ?? undefined,
808
951
  workflowTimeout: workflowTimeout ?? undefined,
809
- scenarioStepBrowser: scenarioStepBrowser === true ? true : undefined,
952
+ scenarioStepSurfaces,
953
+ // `persona` is the default reading of an unmarked step, so it is left off
954
+ // rather than written into every step meta a project already has.
955
+ scenarioStepKind: scenarioStepKind && scenarioStepKind !== 'persona'
956
+ ? scenarioStepKind
957
+ : undefined,
958
+ scenarioStepAddon,
810
959
  scenarioStepTemplate: scenarioStepTemplate || undefined,
811
960
  scopes: scopes ?? undefined,
812
961
  implementationHash,
@@ -145,17 +145,23 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
145
145
  inlineMeta.sessionless = targetMeta.sessionless;
146
146
  }
147
147
  }
148
+ // The route's own `auth`, falling back to the group's. Recorded on the meta
149
+ // below so an audit can answer "is this route open?" without re-reading the
150
+ // source: the route can tighten a sessionless function, and until this was
151
+ // written down that tightening existed only in the wiring.
152
+ const routeAuthProp = getPropertyValue(obj, 'auth');
153
+ const resolvedAuth = routeAuthProp === true || routeAuthProp === false
154
+ ? routeAuthProp
155
+ : inheritedAuth;
148
156
  // For inline functions (e.g. oauth2 handlers), propagate sessionless: true
149
157
  // when auth is explicitly disabled at the route or group level — the
150
158
  // developer opted out of auth so no session is required.
151
159
  {
152
160
  const inlineMeta = state.functions.meta[funcName];
153
- if (inlineMeta && inlineMeta.sessionless === undefined) {
154
- const routeAuth = getPropertyValue(obj, 'auth');
155
- const resolvedAuth = routeAuth === true || routeAuth === false ? routeAuth : inheritedAuth;
156
- if (resolvedAuth === false) {
157
- inlineMeta.sessionless = true;
158
- }
161
+ if (inlineMeta &&
162
+ inlineMeta.sessionless === undefined &&
163
+ resolvedAuth === false) {
164
+ inlineMeta.sessionless = true;
159
165
  }
160
166
  }
161
167
  // Lookup existing function metadata
@@ -239,6 +245,7 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
239
245
  route: fullRoute,
240
246
  sourceFile: sourceFile.fileName,
241
247
  method: method,
248
+ ...(resolvedAuth !== undefined && { auth: resolvedAuth }),
242
249
  params: params.length > 0 ? params : undefined,
243
250
  query: query.length > 0 ? query : undefined,
244
251
  inputTypes: undefined,
@@ -284,6 +291,9 @@ export function registerHTTPRouteMeta({ route, state, logger, sourceFile, basePa
284
291
  route: fullRoute,
285
292
  sourceFile: sourceFile.fileName,
286
293
  method: method,
294
+ ...((route.auth ?? inheritedAuth) !== undefined && {
295
+ auth: route.auth ?? inheritedAuth,
296
+ }),
287
297
  params: params.length > 0 ? params : undefined,
288
298
  inputTypes: undefined,
289
299
  tags: tags.length > 0 ? tags : undefined,
@@ -4,6 +4,49 @@ import { extractServicesFromFunction, extractUsedWires, } from '../utils/extract
4
4
  import { extractMiddlewareRefs } from '../utils/middleware.js';
5
5
  import { getPropertyValue } from '../utils/get-property-value.js';
6
6
  import { getPropertyAssignmentInitializer } from '../utils/type-utils.js';
7
+ /**
8
+ * Record a middleware group, keeping any registration already stored under the
9
+ * same pattern or tag.
10
+ *
11
+ * Several files may add middleware for one pattern — an app's own `'*'` group
12
+ * alongside the generated better-auth session bridge, say — and the runtime
13
+ * composes them. A plain `set` kept only the last, and codegen emits imports
14
+ * from what is stored, so the other file was never imported and its middleware
15
+ * silently stopped running. Merging is the only shape that cannot lose a gate;
16
+ * the first registration stays primary so single-registration groups serialize
17
+ * exactly as before.
18
+ */
19
+ function recordMiddlewareGroup(groups, key, group) {
20
+ const existing = groups.get(key);
21
+ if (!existing) {
22
+ groups.set(key, group);
23
+ return;
24
+ }
25
+ // The same call seen twice — the inspector may revisit a file — is not a
26
+ // second registration, and appending it would emit a duplicate import.
27
+ const alreadyRecorded = [
28
+ existing,
29
+ ...(existing.additionalRegistrations ?? []),
30
+ ];
31
+ if (alreadyRecorded.some((r) => r.sourceFile === group.sourceFile && r.position === group.position)) {
32
+ return;
33
+ }
34
+ existing.additionalRegistrations = [
35
+ ...(existing.additionalRegistrations ?? []),
36
+ {
37
+ exportName: group.exportName,
38
+ sourceFile: group.sourceFile,
39
+ position: group.position,
40
+ isFactory: group.isFactory,
41
+ },
42
+ ];
43
+ existing.count += group.count;
44
+ existing.instanceIds = [...existing.instanceIds, ...group.instanceIds];
45
+ existing.services = {
46
+ optimized: false,
47
+ services: Array.from(new Set([...existing.services.services, ...group.services.services])),
48
+ };
49
+ }
7
50
  function renameTempDefinitions(state, definitionIds, groupType, groupKey, storeKey = 'middleware') {
8
51
  const tempIndices = definitionIds
9
52
  .map((name, i) => (name.startsWith('__temp_') ? i : -1))
@@ -238,7 +281,7 @@ export const addMiddleware = (logger, node, checker, state) => {
238
281
  logger.warn(`• Middleware group '${exportedName}' for tag '${tag}' is not wrapped in a factory function. ` +
239
282
  `For tree-shaking, use: export const ${exportedName} = () => addTagMiddleware('${tag}', [...])`);
240
283
  }
241
- state.middleware.tagMiddleware.set(tag, {
284
+ recordMiddlewareGroup(state.middleware.tagMiddleware, tag, {
242
285
  exportName: exportedName,
243
286
  sourceFile,
244
287
  position: node.getStart(),
@@ -348,7 +391,7 @@ export const addMiddleware = (logger, node, checker, state) => {
348
391
  logger.warn(`• HTTP middleware group '${exportedName}' for pattern '${pattern}' is not wrapped in a factory function. ` +
349
392
  `For tree-shaking, use: export const ${exportedName} = () => addHTTPMiddleware('${pattern}', [...])`);
350
393
  }
351
- state.http.routeMiddleware.set(pattern, {
394
+ recordMiddlewareGroup(state.http.routeMiddleware, pattern, {
352
395
  exportName: exportedName,
353
396
  sourceFile,
354
397
  position: node.getStart(),
@@ -582,7 +625,7 @@ export const addMiddleware = (logger, node, checker, state) => {
582
625
  logger.warn(`• Channel middleware group '${exportedName}' for tag '${tag}' is not wrapped in a factory function. ` +
583
626
  `For tree-shaking, use: export const ${exportedName} = () => addChannelMiddleware('${tag}', [...])`);
584
627
  }
585
- state.channelMiddleware.tagMiddleware.set(tag, {
628
+ recordMiddlewareGroup(state.channelMiddleware.tagMiddleware, tag, {
586
629
  exportName: exportedName,
587
630
  sourceFile,
588
631
  position: node.getStart(),
@@ -0,0 +1,13 @@
1
+ import type { AddWiring } from '../types.js';
2
+ /**
3
+ * Inspector for `definePersonas()` calls.
4
+ *
5
+ * A persona is entirely declaration — a person, their roles, what they want —
6
+ * so there is no code body and nothing to resolve at runtime. What is read here
7
+ * is the whole thing, and it is generated straight to `personas.gen.json` for
8
+ * the CLI and the console to read without loading the app.
9
+ *
10
+ * `name` is the one field that cannot be defaulted. A persona without one is
11
+ * not a person, and the console has nothing to show.
12
+ */
13
+ export declare const addPersonas: AddWiring;
@@ -0,0 +1,210 @@
1
+ import * as ts from 'typescript';
2
+ import { DISPOSITIONS } from '@pikku/core/virtual-user';
3
+ import { isRunnablePersona } from '@pikku/core/persona';
4
+ import { ErrorCode } from '../error-codes.js';
5
+ import { booleanProperty, getProperty, numberProperty, stringArrayProperty, stringProperty, unwrapAs, } from '../utils/literal-properties.js';
6
+ /** A dial and the range outside which it is a mistake rather than a choice. */
7
+ const RATES = [
8
+ ['temperature', 0, 2],
9
+ ['repeatRate', 0, 1],
10
+ ['reReadRate', 0, 1],
11
+ ];
12
+ /**
13
+ * `{ moves, temperature, repeatRate, … }` — a disposition's dials, overridden.
14
+ *
15
+ * The ranges are checked here rather than at run time because these are the
16
+ * kind of typo that does not throw: `repeatRate: 18` meaning 18% would silently
17
+ * double every call the persona makes, and you would read the run as a product
18
+ * bug.
19
+ */
20
+ const tuningProperty = (config, onError) => {
21
+ const value = getProperty(config, 'tuning');
22
+ if (!value || !ts.isObjectLiteralExpression(value)) {
23
+ return undefined;
24
+ }
25
+ const tuning = {};
26
+ const movesValue = getProperty(value, 'moves');
27
+ if (movesValue && ts.isObjectLiteralExpression(movesValue)) {
28
+ const moves = {};
29
+ for (const move of ['continue', 'suspend', 'resume', 'abandon']) {
30
+ const weight = numberProperty(movesValue, move);
31
+ if (weight === undefined) {
32
+ continue;
33
+ }
34
+ if (weight < 0) {
35
+ onError(`tuning.moves.${move} must not be negative (got ${weight}).`);
36
+ continue;
37
+ }
38
+ moves[move] = weight;
39
+ }
40
+ if (Object.keys(moves).length) {
41
+ tuning.moves = moves;
42
+ }
43
+ }
44
+ for (const [name, min, max] of RATES) {
45
+ const rate = numberProperty(value, name);
46
+ if (rate === undefined) {
47
+ continue;
48
+ }
49
+ if (rate < min || rate > max) {
50
+ onError(`tuning.${name} must be between ${min} and ${max} (got ${rate}).`);
51
+ continue;
52
+ }
53
+ Object.assign(tuning, { [name]: rate });
54
+ }
55
+ for (const flag of ['emptyMemory', 'readOnly', 'invertedOracle']) {
56
+ const declared = booleanProperty(value, flag);
57
+ if (declared !== undefined) {
58
+ tuning[flag] = declared;
59
+ }
60
+ }
61
+ const instructions = stringProperty(value, 'instructions');
62
+ if (instructions !== undefined) {
63
+ tuning.instructions = instructions;
64
+ }
65
+ return Object.keys(tuning).length ? tuning : undefined;
66
+ };
67
+ const accountProperty = (value) => {
68
+ if (!value || !ts.isObjectLiteralExpression(value)) {
69
+ return undefined;
70
+ }
71
+ const provider = stringProperty(value, 'provider');
72
+ return provider === undefined ? {} : { provider };
73
+ };
74
+ const linkedAccountsProperty = (config, personaId, logger) => {
75
+ const value = getProperty(config, 'linkedAccounts');
76
+ if (!value || !ts.isObjectLiteralExpression(value)) {
77
+ return undefined;
78
+ }
79
+ const accounts = {};
80
+ for (const prop of value.properties) {
81
+ if (!ts.isPropertyAssignment(prop)) {
82
+ continue;
83
+ }
84
+ if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
85
+ logger.critical(ErrorCode.NON_LITERAL_WIRE_NAME, `Persona '${personaId}' has a linked account whose key is not a literal.`);
86
+ continue;
87
+ }
88
+ const account = accountProperty(prop.initializer);
89
+ if (!account) {
90
+ logger.critical(ErrorCode.INVALID_VALUE, `Persona '${personaId}' linked account '${prop.name.text}' must be an object literal.`);
91
+ continue;
92
+ }
93
+ accounts[prop.name.text] = account;
94
+ }
95
+ return Object.keys(accounts).length ? accounts : undefined;
96
+ };
97
+ /**
98
+ * Inspector for `definePersonas()` calls.
99
+ *
100
+ * A persona is entirely declaration — a person, their roles, what they want —
101
+ * so there is no code body and nothing to resolve at runtime. What is read here
102
+ * is the whole thing, and it is generated straight to `personas.gen.json` for
103
+ * the CLI and the console to read without loading the app.
104
+ *
105
+ * `name` is the one field that cannot be defaulted. A persona without one is
106
+ * not a person, and the console has nothing to show.
107
+ */
108
+ export const addPersonas = (logger, node, _checker, state) => {
109
+ if (!ts.isCallExpression(node)) {
110
+ return;
111
+ }
112
+ const expression = node.expression;
113
+ if (!ts.isIdentifier(expression) || expression.text !== 'definePersonas') {
114
+ return;
115
+ }
116
+ const firstArg = node.arguments[0];
117
+ if (!firstArg) {
118
+ return;
119
+ }
120
+ const unwrapped = unwrapAs(firstArg);
121
+ if (!ts.isObjectLiteralExpression(unwrapped)) {
122
+ logger.critical(ErrorCode.INVALID_VALUE, 'definePersonas() needs an inline object — personas are read statically, never evaluated.');
123
+ return;
124
+ }
125
+ const sourceFile = node.getSourceFile().fileName;
126
+ const known = Object.keys(DISPOSITIONS);
127
+ for (const prop of unwrapped.properties) {
128
+ if (!ts.isPropertyAssignment(prop)) {
129
+ continue;
130
+ }
131
+ if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
132
+ logger.critical(ErrorCode.NON_LITERAL_WIRE_NAME, 'A persona is declared with a key that is not a literal.');
133
+ continue;
134
+ }
135
+ const id = prop.name.text;
136
+ if (!ts.isObjectLiteralExpression(prop.initializer)) {
137
+ logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}' must be an object literal.`);
138
+ continue;
139
+ }
140
+ const config = prop.initializer;
141
+ const name = stringProperty(config, 'name');
142
+ if (!name) {
143
+ logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}' needs a literal 'name' — a persona is a person, and the console has nothing to show without one.`);
144
+ continue;
145
+ }
146
+ // The generated `definePersonas` types this as a union, so a bad value only
147
+ // reaches here in source that does not compile. Caught anyway: a
148
+ // disposition is the entire behaviour, and defaulting a typo to `realistic`
149
+ // would run a different person than the one that was written.
150
+ const disposition = stringProperty(config, 'disposition');
151
+ if (disposition !== undefined && !known.includes(disposition)) {
152
+ logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}' has an unknown disposition '${disposition}'. One of: ${known.join(', ')}`);
153
+ continue;
154
+ }
155
+ let tuningFailed = false;
156
+ const tuning = tuningProperty(config, (message) => {
157
+ tuningFailed = true;
158
+ logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}': ${message}`);
159
+ });
160
+ if (tuningFailed) {
161
+ continue;
162
+ }
163
+ const account = accountProperty(getProperty(config, 'account'));
164
+ const declaredRunnable = booleanProperty(config, 'runnable');
165
+ const persona = {
166
+ id,
167
+ name,
168
+ roles: stringArrayProperty(config, 'roles') ?? [],
169
+ goals: stringArrayProperty(config, 'goals') ?? [],
170
+ tags: stringArrayProperty(config, 'tags') ?? [],
171
+ runnable: isRunnablePersona({
172
+ ...(declaredRunnable !== undefined
173
+ ? { runnable: declaredRunnable }
174
+ : {}),
175
+ ...(account ? { account } : {}),
176
+ }),
177
+ sourceFile,
178
+ };
179
+ const jobTitle = stringProperty(config, 'jobTitle');
180
+ if (jobTitle !== undefined)
181
+ persona.jobTitle = jobTitle;
182
+ const description = stringProperty(config, 'description');
183
+ if (description !== undefined)
184
+ persona.description = description;
185
+ const personality = stringProperty(config, 'personality');
186
+ if (personality !== undefined)
187
+ persona.personality = personality;
188
+ if (disposition !== undefined) {
189
+ persona.disposition = disposition;
190
+ }
191
+ if (tuning !== undefined)
192
+ persona.tuning = tuning;
193
+ const fixtures = stringArrayProperty(config, 'fixtures');
194
+ if (fixtures !== undefined)
195
+ persona.fixtures = fixtures;
196
+ // Left absent rather than resolved here: "everywhere but production" is a
197
+ // fact about the config, and the inspector has no business baking today's
198
+ // environment list into a meta file that outlives it.
199
+ const environments = stringArrayProperty(config, 'environments');
200
+ if (environments !== undefined)
201
+ persona.environments = environments;
202
+ if (account !== undefined)
203
+ persona.account = account;
204
+ const linkedAccounts = linkedAccountsProperty(config, id, logger);
205
+ if (linkedAccounts !== undefined)
206
+ persona.linkedAccounts = linkedAccounts;
207
+ state.personas.files.add(sourceFile);
208
+ state.personas.definitions.push(persona);
209
+ }
210
+ };
@@ -89,7 +89,7 @@ export const addScope = (logger, node, checker, state, _options) => {
89
89
  return;
90
90
  }
91
91
  const expression = node.expression;
92
- if (!ts.isIdentifier(expression) || expression.text !== 'wireScope') {
92
+ if (!ts.isIdentifier(expression) || expression.text !== 'defineScope') {
93
93
  return;
94
94
  }
95
95
  const firstArg = node.arguments[0];
@@ -1,6 +1,6 @@
1
1
  import { createAddKeyedWiring } from './add-keyed-wiring.js';
2
2
  export const addSecret = createAddKeyedWiring({
3
- functionName: 'wireSecret',
3
+ functionName: 'defineSecret',
4
4
  idField: 'secretId',
5
5
  label: 'Secret',
6
6
  schemaPrefix: 'SecretSchema',
@@ -0,0 +1,2 @@
1
+ import type { AddWiring } from '../types.js';
2
+ export declare const addSystemRole: AddWiring;