@pikku/inspector 0.12.47 → 0.12.49

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 (58) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/LICENSE +106 -0
  3. package/README.md +28 -2
  4. package/dist/add/add-feature.d.ts +19 -0
  5. package/dist/add/add-feature.js +200 -0
  6. package/dist/add/add-functions.js +38 -9
  7. package/dist/add/add-workflow.js +22 -29
  8. package/dist/add/scenario-instrumentation.d.ts +24 -0
  9. package/dist/add/scenario-instrumentation.js +29 -0
  10. package/dist/error-codes.d.ts +3 -0
  11. package/dist/error-codes.js +3 -0
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.js +1 -0
  14. package/dist/inspector.js +39 -2
  15. package/dist/types.d.ts +43 -0
  16. package/dist/utils/extract-function-name.js +27 -0
  17. package/dist/utils/post-process.d.ts +26 -0
  18. package/dist/utils/post-process.js +92 -0
  19. package/dist/utils/serialize-inspector-state.d.ts +2 -1
  20. package/dist/utils/serialize-inspector-state.js +2 -0
  21. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +203 -19
  22. package/dist/utils/workflow/dsl/patterns.d.ts +21 -0
  23. package/dist/utils/workflow/dsl/patterns.js +30 -0
  24. package/dist/utils/workflow/dsl/validation.d.ts +5 -0
  25. package/dist/utils/workflow/dsl/validation.js +9 -0
  26. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +36 -0
  27. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -1
  28. package/dist/visit.js +2 -0
  29. package/package.json +4 -3
  30. package/src/add/add-feature.test.ts +306 -0
  31. package/src/add/add-feature.ts +253 -0
  32. package/src/add/add-functions.ts +47 -9
  33. package/src/add/add-scenario-step.test.ts +311 -0
  34. package/src/add/add-workflow-silent-drops.test.ts +98 -0
  35. package/src/add/add-workflow.ts +25 -37
  36. package/src/add/scenario-instrumentation.test.ts +89 -0
  37. package/src/add/scenario-instrumentation.ts +36 -0
  38. package/src/add/scenario-marker.test.ts +89 -0
  39. package/src/add/scenario-not-extractable.test.ts +159 -0
  40. package/src/add/scenario-services.test.ts +103 -0
  41. package/src/add/scenario-skip.test.ts +83 -0
  42. package/src/error-codes.ts +3 -0
  43. package/src/index.ts +6 -0
  44. package/src/inspector.ts +50 -1
  45. package/src/tsconfig-paths.test.ts +90 -0
  46. package/src/types.ts +45 -0
  47. package/src/utils/extract-function-name.ts +36 -0
  48. package/src/utils/post-process.test.ts +30 -1
  49. package/src/utils/post-process.ts +103 -0
  50. package/src/utils/serialize-inspector-state.ts +8 -1
  51. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
  52. package/src/utils/workflow/dsl/patterns.ts +38 -0
  53. package/src/utils/workflow/dsl/validation.ts +12 -0
  54. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
  55. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
  56. package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
  57. package/src/visit.ts +2 -0
  58. package/tsconfig.tsbuildinfo +1 -1
@@ -11,6 +11,7 @@ import { resolvePermissions } from '../utils/permissions.js';
11
11
  import { extractWireNames } from '../utils/post-process.js';
12
12
  import { ErrorCode } from '../error-codes.js';
13
13
  import { findPiiPaths } from '../utils/check-pii-output.js';
14
+ import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js';
14
15
  const isValidVariableName = (name) => {
15
16
  const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
16
17
  return regex.test(name);
@@ -273,12 +274,22 @@ export const addFunctions = (logger, node, checker, state, options) => {
273
274
  return;
274
275
  }
275
276
  // Match identifiers that contain both "pikku" and "func" (case insensitive),
276
- // plus pikkuScenario (a workflow wrapper without "func" in its name)
277
+ // plus pikkuScenario and pikkuScenarioStep (workflow wrappers without "func"
278
+ // in their names)
277
279
  const pikkuFuncPattern = /pikku.*func/i;
278
280
  if (!pikkuFuncPattern.test(expression.text) &&
279
- expression.text !== 'pikkuScenario') {
281
+ expression.text !== 'pikkuScenario' &&
282
+ expression.text !== 'pikkuScenarioStep') {
280
283
  return;
281
284
  }
285
+ // A scenario step is bundled like any invoked function, but is deliberately
286
+ // never RPC-registered: a step may drive a browser, so exposing it over the
287
+ // network would put a test harness on the public API surface.
288
+ const isScenarioStep = expression.text === 'pikkuScenarioStep';
289
+ // A scenario's own body. Marked so the codegen partitions and the deploy
290
+ // analyzer can tell it apart from an application workflow without having to
291
+ // reach into the workflow graph.
292
+ const isScenario = expression.text === 'pikkuScenario';
282
293
  // only handle calls like pikkuFunc(...)
283
294
  if (!ts.isIdentifier(expression) || !expression.text.startsWith('pikku')) {
284
295
  return;
@@ -289,6 +300,14 @@ export const addFunctions = (logger, node, checker, state, options) => {
289
300
  if (!pikkuFuncId || pikkuFuncId.startsWith('__temp_')) {
290
301
  return;
291
302
  }
303
+ // pikku's own scenario instrumentation, which older projects still carry as a
304
+ // scaffolded file in their source. It is not application code and must not be
305
+ // registered, metadata'd or deployed as if it were — `pikku dev` registers the
306
+ // implementations itself, and that is the only server that should have them.
307
+ if (isScenarioInstrumentationFunction(pikkuFuncId) ||
308
+ isScenarioInstrumentationFunction(name)) {
309
+ return;
310
+ }
292
311
  let title;
293
312
  let tags;
294
313
  let summary;
@@ -304,6 +323,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
304
323
  let workflowQueued;
305
324
  let workflowRetries;
306
325
  let workflowTimeout;
326
+ let scenarioStepBrowser;
327
+ let scenarioStepTemplate;
307
328
  let scopes;
308
329
  let version;
309
330
  let objectNode;
@@ -375,6 +396,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
375
396
  workflowQueued = getPropertyValue(firstArg, 'workflowQueued');
376
397
  workflowRetries = getPropertyValue(firstArg, 'workflowRetries');
377
398
  workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout');
399
+ scenarioStepBrowser = getPropertyValue(firstArg, 'browser');
400
+ scenarioStepTemplate = getPropertyValue(firstArg, 'template');
378
401
  scopes = getArrayPropertyValue(firstArg, 'scopes') ?? undefined;
379
402
  // Extract approvalDescription identifier reference
380
403
  for (const prop of firstArg.properties) {
@@ -773,6 +796,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
773
796
  outputs: outputNames.filter((n) => n !== 'void') ?? null,
774
797
  expose: expose || undefined,
775
798
  remote: remote || undefined,
799
+ scenarioStep: isScenarioStep || undefined,
800
+ scenario: isScenario || undefined,
776
801
  mcp: mcpEnabled || undefined,
777
802
  readonly: readonly_ || undefined,
778
803
  deploy: deploy || undefined,
@@ -781,6 +806,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
781
806
  workflowQueued: workflowQueued === true ? true : undefined,
782
807
  workflowRetries: workflowRetries ?? undefined,
783
808
  workflowTimeout: workflowTimeout ?? undefined,
809
+ scenarioStepBrowser: scenarioStepBrowser === true ? true : undefined,
810
+ scenarioStepTemplate: scenarioStepTemplate || undefined,
784
811
  scopes: scopes ?? undefined,
785
812
  implementationHash,
786
813
  version,
@@ -863,7 +890,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
863
890
  logger.error(`• Function with explicit name '${name}' is not exported, this is not allowed.`);
864
891
  return;
865
892
  }
866
- if (remote) {
893
+ if (remote && !isScenarioStep) {
867
894
  state.rpc.invokedFunctions.add(pikkuFuncId);
868
895
  // The consumer-facing surface a wireRemoteAddon imports (mirrors exposedMeta)
869
896
  state.rpc.remoteMeta[name] = pikkuFuncId;
@@ -872,7 +899,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
872
899
  exportedName,
873
900
  });
874
901
  }
875
- if (expose) {
902
+ if (expose && !isScenarioStep) {
876
903
  state.rpc.exposedMeta[name] = pikkuFuncId;
877
904
  state.rpc.exposedFiles.set(name, {
878
905
  path: node.getSourceFile().fileName,
@@ -881,11 +908,13 @@ export const addFunctions = (logger, node, checker, state, options) => {
881
908
  // Track exposed RPC function for service aggregation
882
909
  state.serviceAggregation.usedFunctions.add(pikkuFuncId);
883
910
  }
884
- // We add it to internal meta to allow autocomplete for everything
885
- state.rpc.internalMeta[name] = pikkuFuncId;
886
- if (version !== undefined) {
887
- state.rpc.internalMeta[pikkuFuncId] = pikkuFuncId;
888
- state.rpc.invokedFunctions.add(pikkuFuncId);
911
+ if (!isScenarioStep) {
912
+ // We add it to internal meta to allow autocomplete for everything
913
+ state.rpc.internalMeta[name] = pikkuFuncId;
914
+ if (version !== undefined) {
915
+ state.rpc.internalMeta[pikkuFuncId] = pikkuFuncId;
916
+ state.rpc.invokedFunctions.add(pikkuFuncId);
917
+ }
889
918
  }
890
919
  // But we only import the actual function if it's actually invoked to keep
891
920
  // bundle size down
@@ -86,6 +86,11 @@ export function collectInvokedRPCs(steps, rpcs) {
86
86
  rpcs.add(step.options.onError);
87
87
  }
88
88
  }
89
+ else if (step.type === 'scenarioStep' && step.stepFunc) {
90
+ // Steps are bundled like RPCs — the runner calls them through
91
+ // runPikkuFunc — but they are deliberately never RPC-registered.
92
+ rpcs.add(step.stepFunc);
93
+ }
89
94
  else if (step.type === 'branch') {
90
95
  for (const branch of step.branches) {
91
96
  collectInvokedRPCs(branch.steps, rpcs);
@@ -247,6 +252,7 @@ export const addWorkflow = (logger, node, checker, state) => {
247
252
  let description;
248
253
  let errors;
249
254
  let expose;
255
+ let skip;
250
256
  if (ts.isObjectLiteralExpression(firstArg)) {
251
257
  const metadata = getCommonWireMetaData(firstArg, 'Workflow', workflowName, logger, checker);
252
258
  if (metadata.disabled)
@@ -257,6 +263,7 @@ export const addWorkflow = (logger, node, checker, state) => {
257
263
  description = metadata.description;
258
264
  errors = metadata.errors;
259
265
  expose = getPropertyValue(firstArg, 'expose');
266
+ skip = getPropertyValue(firstArg, 'skip') ?? undefined;
260
267
  }
261
268
  // Validate that we got a valid function
262
269
  if (ts.isObjectLiteralExpression(firstArg) &&
@@ -324,6 +331,13 @@ export const addWorkflow = (logger, node, checker, state) => {
324
331
  logger.critical(ErrorCode.INVALID_DSL_WORKFLOW, `Workflow '${workflowName}' does not conform to DSL workflow rules:\n${result.reason || 'Unknown error'}`);
325
332
  return;
326
333
  }
334
+ else if (wrapperType === 'scenario') {
335
+ // The fallback below understands do/sleep but not step/given/when/then,
336
+ // so a scenario that lands here registers with zero steps. Silence would
337
+ // leave the whole scenario passing vacuously.
338
+ logger.critical(ErrorCode.SCENARIO_NOT_EXTRACTABLE, `Scenario '${workflowName}' could not be extracted, so none of its steps were recorded and it would run as an empty scenario:\n${result.reason || 'Unknown error'}`);
339
+ return;
340
+ }
327
341
  else {
328
342
  // For pikkuWorkflowComplexFunc, fall back to basic extraction
329
343
  logger.debug(`Workflow '${workflowName}' could not be extracted as DSL workflow: ${result.reason || 'Unknown error'}. Falling back to basic extraction.`);
@@ -358,36 +372,15 @@ export const addWorkflow = (logger, node, checker, state) => {
358
372
  errors,
359
373
  tags,
360
374
  expose,
375
+ skip,
361
376
  scenario: wrapperType === 'scenario' ? true : undefined,
362
377
  actors: actorNames.length > 0 ? actorNames : undefined,
363
378
  };
364
- // Scenarios are pure stories of remote RPCs (same rule as client-side CLI
365
- // renderers): the func may only destructure logger/config — everything else
366
- // must go through actor steps so the flow runs against the TARGET
367
- // environment, never local services.
368
- const funcMeta = state.functions.meta[pikkuFuncId];
369
- if (wrapperType === 'scenario' && funcMeta?.services) {
370
- const disallowed = funcMeta.services.services.filter((svc) => svc !== 'logger' && svc !== 'config');
371
- if (disallowed.length > 0) {
372
- logger.critical(ErrorCode.SCENARIO_HAS_SERVICES, `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
373
- `Scenarios may only use 'logger'/'config' — drive everything else through ` +
374
- `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
375
- `runs against the target environment.`);
376
- return;
377
- }
378
- }
379
- // Workflow functions require platform services that aren't visible
380
- // through parameter destructuring (they're accessed via workflow.do/sleep)
381
- if (funcMeta?.services) {
382
- for (const svc of [
383
- 'workflowService',
384
- 'workflowRunService',
385
- 'schedulerService',
386
- 'queueService',
387
- ]) {
388
- if (!funcMeta.services.services.includes(svc)) {
389
- funcMeta.services.services.push(svc);
390
- }
391
- }
392
- }
379
+ // The scenario "logger/config only" rule (PKU673) and the implicit
380
+ // workflow platform-service injection both used to live here, reading
381
+ // state.functions.meta[pikkuFuncId]. addWorkflow runs in the visitSetup
382
+ // pass, which completes before addFunctions populates that meta, so both
383
+ // were dead. The rule now lives in validateScenarioServices (post-process),
384
+ // and aggregateRequiredServices already adds the platform services globally
385
+ // whenever any workflow exists.
393
386
  };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The instrumentation RPCs `pikku scenario run` calls on the server it is
3
+ * testing: snapshot/reset live coverage, and reset/read recorded stub calls.
4
+ *
5
+ * They are pikku's own, not the application's. They used to be scaffolded into
6
+ * the project as ordinary `pikkuFunc`s, which made them indistinguishable from
7
+ * application code — so they were registered in the app bootstrap, listed in the
8
+ * app's function and RPC meta, and shipped, `expose: true`, in every deployed
9
+ * bundle. A deployment has no use for them: coverage and stub inspection are
10
+ * things you do to a development server.
11
+ *
12
+ * The inspector therefore ignores these names wherever it finds them, and
13
+ * `pikku dev` registers the implementations itself. Ignoring by name (rather
14
+ * than deleting the scaffold and requiring every project to regenerate) is what
15
+ * keeps an existing project's checked-in `scenarios.gen.ts` from deploying.
16
+ */
17
+ export declare const SCENARIO_INSTRUMENTATION_FUNCTIONS: readonly ["pikkuScenarioTakeLiveCoverage", "pikkuScenarioResetLiveCoverage", "pikkuScenarioResetStubs", "pikkuScenarioGetStubCalls"];
18
+ export type ScenarioInstrumentationFunction = (typeof SCENARIO_INSTRUMENTATION_FUNCTIONS)[number];
19
+ /**
20
+ * True for a scenario instrumentation function, by name. Versioned ids
21
+ * (`name@v2`) are matched on their base so a project that bumps one is still
22
+ * covered.
23
+ */
24
+ export declare const isScenarioInstrumentationFunction: (name: string | undefined) => boolean;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The instrumentation RPCs `pikku scenario run` calls on the server it is
3
+ * testing: snapshot/reset live coverage, and reset/read recorded stub calls.
4
+ *
5
+ * They are pikku's own, not the application's. They used to be scaffolded into
6
+ * the project as ordinary `pikkuFunc`s, which made them indistinguishable from
7
+ * application code — so they were registered in the app bootstrap, listed in the
8
+ * app's function and RPC meta, and shipped, `expose: true`, in every deployed
9
+ * bundle. A deployment has no use for them: coverage and stub inspection are
10
+ * things you do to a development server.
11
+ *
12
+ * The inspector therefore ignores these names wherever it finds them, and
13
+ * `pikku dev` registers the implementations itself. Ignoring by name (rather
14
+ * than deleting the scaffold and requiring every project to regenerate) is what
15
+ * keeps an existing project's checked-in `scenarios.gen.ts` from deploying.
16
+ */
17
+ export const SCENARIO_INSTRUMENTATION_FUNCTIONS = [
18
+ 'pikkuScenarioTakeLiveCoverage',
19
+ 'pikkuScenarioResetLiveCoverage',
20
+ 'pikkuScenarioResetStubs',
21
+ 'pikkuScenarioGetStubCalls',
22
+ ];
23
+ const names = new Set(SCENARIO_INSTRUMENTATION_FUNCTIONS);
24
+ /**
25
+ * True for a scenario instrumentation function, by name. Versioned ids
26
+ * (`name@v2`) are matched on their base so a project that bumps one is still
27
+ * covered.
28
+ */
29
+ export const isScenarioInstrumentationFunction = (name) => (name ? names.has(name.split('@')[0]) : false);
@@ -20,6 +20,9 @@ export declare enum ErrorCode {
20
20
  CLI_CLIENTSIDE_RENDERER_HAS_SERVICES = "PKU672",
21
21
  SCENARIO_HAS_SERVICES = "PKU673",
22
22
  EXPECT_EVENTUALLY_SCENARIO_ONLY = "PKU675",
23
+ SCENARIO_BROWSER_STEP_NEEDS_ACTOR = "PKU677",
24
+ SCENARIO_STEP_TARGET_NOT_STATIC = "PKU678",
25
+ SCENARIO_NOT_EXTRACTABLE = "PKU679",
23
26
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = "PKU600",
24
27
  INVALID_DSL_WORKFLOW = "PKU641",
25
28
  WORKFLOW_GRAPH_ADDON_NOT_WIRED = "PKU642",
@@ -22,6 +22,9 @@ export var ErrorCode;
22
22
  ErrorCode["CLI_CLIENTSIDE_RENDERER_HAS_SERVICES"] = "PKU672";
23
23
  ErrorCode["SCENARIO_HAS_SERVICES"] = "PKU673";
24
24
  ErrorCode["EXPECT_EVENTUALLY_SCENARIO_ONLY"] = "PKU675";
25
+ ErrorCode["SCENARIO_BROWSER_STEP_NEEDS_ACTOR"] = "PKU677";
26
+ ErrorCode["SCENARIO_STEP_TARGET_NOT_STATIC"] = "PKU678";
27
+ ErrorCode["SCENARIO_NOT_EXTRACTABLE"] = "PKU679";
25
28
  ErrorCode["WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED"] = "PKU600";
26
29
  ErrorCode["INVALID_DSL_WORKFLOW"] = "PKU641";
27
30
  ErrorCode["WORKFLOW_GRAPH_ADDON_NOT_WIRED"] = "PKU642";
package/dist/index.d.ts CHANGED
@@ -5,6 +5,8 @@ export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js';
5
5
  export { ErrorCode } from './error-codes.js';
6
6
  export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js';
7
7
  export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js';
8
+ export { SCENARIO_INSTRUMENTATION_FUNCTIONS, isScenarioInstrumentationFunction, } from './add/scenario-instrumentation.js';
9
+ export type { ScenarioInstrumentationFunction } from './add/scenario-instrumentation.js';
8
10
  export { serializeInspectorState, deserializeInspectorState, } from './utils/serialize-inspector-state.js';
9
11
  export type { SerializableInspectorState } from './utils/serialize-inspector-state.js';
10
12
  export { filterInspectorState } from './utils/filter-inspector-state.js';
@@ -12,6 +14,7 @@ export { resolveDeployTarget } from './utils/resolve-deploy-target.js';
12
14
  export { generateCustomTypes, sanitizeTypeName, } from './utils/custom-types-generator.js';
13
15
  export { createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
14
16
  export type { ContractEntry, VersionHashEntry, VersionValidateError, VersionManifest, VersionManifestEntry, } from './utils/contract-hashes.js';
17
+ export type { InspectorFeature, InspectorFeatureEntry } from './types.js';
15
18
  export { serializeMCPJson } from './utils/serialize-mcp-json.js';
16
19
  export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js';
17
20
  export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWorkflows, } from './utils/workflow/dsl/index.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { inspect, getInitialInspectorState } from './inspector.js';
2
2
  export { ErrorCode } from './error-codes.js';
3
3
  export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js';
4
+ export { SCENARIO_INSTRUMENTATION_FUNCTIONS, isScenarioInstrumentationFunction, } from './add/scenario-instrumentation.js';
4
5
  export { serializeInspectorState, deserializeInspectorState, } from './utils/serialize-inspector-state.js';
5
6
  export { filterInspectorState } from './utils/filter-inspector-state.js';
6
7
  export { resolveDeployTarget } from './utils/resolve-deploy-target.js';
package/dist/inspector.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import * as ts from 'typescript';
2
2
  import { performance } from 'perf_hooks';
3
- import { resolve } from 'path';
3
+ import { dirname, resolve } from 'path';
4
4
  import { visitSetup, visitFunctions, visitRoutes } from './visit.js';
5
5
  import { TypesMap } from './types-map.js';
6
6
  import { getFilesAndMethods } from './utils/get-files-and-methods.js';
7
7
  import { findCommonAncestor } from './utils/find-root-dir.js';
8
8
  import { createNestedProjectFilter } from './utils/nested-project-filter.js';
9
- import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, computeDiagnostics, validateSchemaWiringSeparation, validateWorkflowGraphAddons, } from './utils/post-process.js';
9
+ import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, computeDiagnostics, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateWorkflowGraphAddons, } from './utils/post-process.js';
10
10
  import { generateOpenAPISpec } from './utils/serialize-openapi-json.js';
11
11
  import { pikkuState } from '@pikku/core/internal';
12
12
  import { resolveLatestVersions } from './utils/resolve-versions.js';
@@ -86,6 +86,7 @@ export function getInitialInspectorState(rootDir) {
86
86
  files: new Map(),
87
87
  graphMeta: {},
88
88
  graphFiles: new Map(),
89
+ featureFiles: new Map(),
89
90
  invokedWorkflows: new Set(),
90
91
  },
91
92
  rpc: {
@@ -204,9 +205,43 @@ export function getInitialInspectorState(rootDir) {
204
205
  program: null,
205
206
  };
206
207
  }
208
+ /**
209
+ * The path mappings the inspector's program has to share with the project.
210
+ *
211
+ * The inspector builds its own program rather than reusing the project's, which
212
+ * is what keeps a cold run affordable — but a project that reaches its generated
213
+ * code through a tsconfig `paths` entry resolves that import through those
214
+ * mappings and nothing else. Without them the factory an inspected function is
215
+ * passed to resolves to `any`, so a function whose input type comes from the
216
+ * factory's contextual type (rather than an explicit generic or a named schema)
217
+ * silently records no input at all.
218
+ *
219
+ * Only resolution keys are copied. Type-inference options such as `strict` stay
220
+ * the inspector's own, because changing those would change the recorded types.
221
+ */
222
+ const readProjectPathMappings = (logger, tsconfig) => {
223
+ if (!tsconfig) {
224
+ return {};
225
+ }
226
+ const configPath = resolve(tsconfig);
227
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
228
+ if (configFile.error || !configFile.config) {
229
+ logger.debug(`Could not read ${configPath} for path mappings; imports that rely on them will not resolve`);
230
+ return {};
231
+ }
232
+ const { options: projectOptions } = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(configPath));
233
+ const { baseUrl, paths, rootDirs, pathsBasePath } = projectOptions;
234
+ return {
235
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
236
+ ...(paths !== undefined ? { paths } : {}),
237
+ ...(rootDirs !== undefined ? { rootDirs } : {}),
238
+ ...(pathsBasePath !== undefined ? { pathsBasePath } : {}),
239
+ };
240
+ };
207
241
  export const inspect = async (logger, routeFiles, options = {}) => {
208
242
  const normalizedRouteFiles = routeFiles.map((file) => resolve(file));
209
243
  const compilerOptions = {
244
+ ...readProjectPathMappings(logger, options.tsconfig),
210
245
  target: ts.ScriptTarget.ESNext,
211
246
  module: ts.ModuleKind.Node16,
212
247
  skipLibCheck: true,
@@ -310,6 +345,8 @@ export const inspect = async (logger, routeFiles, options = {}) => {
310
345
  computeDiagnostics(state);
311
346
  validateSchemaWiringSeparation(logger, state);
312
347
  validateWorkflowGraphAddons(logger, state);
348
+ validateScenarioServices(logger, state);
349
+ validateScenarioSteps(logger, state);
313
350
  if (options.openAPI) {
314
351
  state.openAPISpec = await generateOpenAPISpec(logger, state.functions.meta, state.http.meta, state.schemas, options.openAPI.additionalInfo, pikkuState(null, 'misc', 'errors'));
315
352
  }
package/dist/types.d.ts CHANGED
@@ -205,6 +205,12 @@ export type InspectorOptions = Partial<{
205
205
  rootDir: string;
206
206
  isAddon: boolean;
207
207
  sourceFile: ts.SourceFile;
208
+ /**
209
+ * The project's tsconfig, read for its path mappings only. The inspector's
210
+ * program needs them to resolve imports the project reaches through `paths`;
211
+ * unlike `schemaConfig.tsconfig` this is also needed on a setup-only run.
212
+ */
213
+ tsconfig: string;
208
214
  types: Partial<{
209
215
  configFileType: string;
210
216
  userSessionType: string;
@@ -346,6 +352,34 @@ export interface AuthDefinition {
346
352
  */
347
353
  services: FunctionServicesMeta;
348
354
  }
355
+ /** One statically resolved entry of a feature's `scenarios` array. */
356
+ export interface InspectorFeatureEntry {
357
+ /** The scenario's declared export name, with any import alias followed back. */
358
+ scenario: string;
359
+ /** A `{ scenario, data }` entry's literal data — gherkin's `Examples:`. */
360
+ data?: unknown;
361
+ }
362
+ /** A `pikkuFeature` export, as far as it can be read from the source. */
363
+ export interface InspectorFeature {
364
+ path: string;
365
+ exportedName: string;
366
+ name?: string;
367
+ description?: string;
368
+ tags?: string[];
369
+ entries: InspectorFeatureEntry[];
370
+ /**
371
+ * Entries that are not literal — a spread, a `.map()`, a call. Counted rather
372
+ * than dropped so the console can say the listing is partial instead of
373
+ * quietly showing a short feature.
374
+ */
375
+ unresolvedEntries: number;
376
+ /**
377
+ * Feature hooks run once around the whole group, never per scenario, and are
378
+ * runtime-only — so only their presence is recorded.
379
+ */
380
+ hasBefore: boolean;
381
+ hasAfter: boolean;
382
+ }
349
383
  export interface InspectorState {
350
384
  rootDir: string;
351
385
  singletonServicesTypeImportMap: PathToNameAndType;
@@ -395,6 +429,15 @@ export interface InspectorState {
395
429
  path: string;
396
430
  exportedName: string;
397
431
  }>;
432
+ /**
433
+ * `pikkuFeature` exports, keyed by export identifier.
434
+ *
435
+ * Everything the config states literally is extracted here, because a
436
+ * feature is the document structure the console renders. Entries built by
437
+ * an ordinary loop cannot be enumerated statically — those are counted in
438
+ * `unresolvedEntries` and resolved at runtime by object identity instead.
439
+ */
440
+ featureFiles: Map<string, InspectorFeature>;
398
441
  invokedWorkflows: Set<string>;
399
442
  };
400
443
  rpc: {
@@ -28,6 +28,32 @@ export function funcIdToTypeName(id) {
28
28
  .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
29
29
  .join('');
30
30
  }
31
+ /**
32
+ * `pikkuScenarioStep({ name })` is the one wrapper whose `name` is its identity:
33
+ * scenarios reference a step by that string, so it must become the pikkuFuncId
34
+ * (and hence the generated step map's key), not just a cosmetic label the way
35
+ * `pikkuMiddleware`/`pikkuAgent` use it.
36
+ */
37
+ function extractScenarioStepName(callExpr, result) {
38
+ if (!ts.isCallExpression(callExpr) ||
39
+ !ts.isIdentifier(callExpr.expression) ||
40
+ callExpr.expression.text !== 'pikkuScenarioStep') {
41
+ return;
42
+ }
43
+ const firstArg = callExpr.arguments[0];
44
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
45
+ return;
46
+ }
47
+ for (const prop of firstArg.properties) {
48
+ if (ts.isPropertyAssignment(prop) &&
49
+ ts.isIdentifier(prop.name) &&
50
+ prop.name.text === 'name' &&
51
+ ts.isStringLiteral(prop.initializer)) {
52
+ result.explicitName = prop.initializer.text;
53
+ return;
54
+ }
55
+ }
56
+ }
31
57
  export function extractFunctionName(callExpr, checker, rootDir) {
32
58
  const parent = callExpr.parent;
33
59
  // Initialize the result
@@ -40,6 +66,7 @@ export function extractFunctionName(callExpr, checker, rootDir) {
40
66
  isHelper: false,
41
67
  version: null,
42
68
  };
69
+ extractScenarioStepName(callExpr, result);
43
70
  const workflowHelpers = new Set([
44
71
  'workflow',
45
72
  'workflowStart',
@@ -50,6 +50,32 @@ export declare function computeMiddlewareGroupsMeta(state: InspectorState): void
50
50
  export declare function computePermissionsGroupsMeta(state: InspectorState): void;
51
51
  export declare function computeRequiredSchemas(state: InspectorState, options: InspectorOptions): void;
52
52
  export declare function validateAgentModels(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
53
+ /**
54
+ * Scenarios are pure stories of remote RPCs (same rule as client-side CLI
55
+ * renderers): the func may only destructure logger/config — everything else
56
+ * must go through actor steps so the flow runs against the TARGET
57
+ * environment, never local services.
58
+ *
59
+ * This runs in post-processing rather than in `addWorkflow` because
60
+ * `addWorkflow` runs in the `visitSetup` pass, which completes before
61
+ * `addFunctions` (`visitFunctions`) has populated `state.functions.meta` — so
62
+ * the check read `undefined` there and never fired.
63
+ */
64
+ export declare function validateScenarioServices(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
65
+ /**
66
+ * Walk every scenario step, wherever it is nested, and validate the two things
67
+ * that can only be known once both the workflow meta and the function meta
68
+ * exist:
69
+ *
70
+ * - the step target must be a static string literal (PKU678) — otherwise it
71
+ * can't be bundled, typed or drawn;
72
+ * - a `browser: true` step must be given an actor (PKU677), since the browser
73
+ * context signs in as that persona.
74
+ *
75
+ * It also marks the step's function as used, so `scenario run` boots the
76
+ * singletons the step actually needs.
77
+ */
78
+ export declare function validateScenarioSteps(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
53
79
  /**
54
80
  * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
55
81
  * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
@@ -4,6 +4,7 @@ import { extractTypeKeys } from './type-utils.js';
4
4
  import { ErrorCode } from '../error-codes.js';
5
5
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js';
6
6
  import { flattenScopeDefinitions } from '@pikku/core/scope';
7
+ import { DYNAMIC_SCENARIO_STEP_TARGET } from './workflow/dsl/patterns.js';
7
8
  /**
8
9
  * Stamp the inspected authorize/callbacks service set onto the generated auth
9
10
  * handler's function meta.
@@ -211,6 +212,13 @@ export function aggregateRequiredServices(state) {
211
212
  for (const [, graph] of Object.entries(state.workflows.graphMeta)) {
212
213
  if (!graph.nodes || !graph.name)
213
214
  continue;
215
+ // A scenario is a workflow, but it only ever runs in-process under
216
+ // `pikku scenario run` — no step of one is dispatched through a queue. The
217
+ // synthetic entries were pure leakage: they put a
218
+ // `wf-orchestrator-<scenario>` worker into the app's queue meta, which every
219
+ // bundle imports and a provider then creates as a real production queue.
220
+ if (graph.source === 'scenario')
221
+ continue;
214
222
  const toKebab = (s) => s
215
223
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
216
224
  .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
@@ -552,6 +560,90 @@ export function validateAgentModels(logger, state) {
552
560
  }
553
561
  }
554
562
  }
563
+ /**
564
+ * Scenarios are pure stories of remote RPCs (same rule as client-side CLI
565
+ * renderers): the func may only destructure logger/config — everything else
566
+ * must go through actor steps so the flow runs against the TARGET
567
+ * environment, never local services.
568
+ *
569
+ * This runs in post-processing rather than in `addWorkflow` because
570
+ * `addWorkflow` runs in the `visitSetup` pass, which completes before
571
+ * `addFunctions` (`visitFunctions`) has populated `state.functions.meta` — so
572
+ * the check read `undefined` there and never fired.
573
+ */
574
+ export function validateScenarioServices(logger, state) {
575
+ for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
576
+ if (!meta.scenario)
577
+ continue;
578
+ const funcMeta = state.functions.meta[meta.pikkuFuncId];
579
+ if (!funcMeta?.services)
580
+ continue;
581
+ const disallowed = funcMeta.services.services.filter((svc) => svc !== 'logger' && svc !== 'config');
582
+ if (disallowed.length > 0) {
583
+ logger.critical(ErrorCode.SCENARIO_HAS_SERVICES, `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
584
+ `Scenarios may only use 'logger'/'config' — drive everything else through ` +
585
+ `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
586
+ `runs against the target environment.`);
587
+ }
588
+ }
589
+ }
590
+ /**
591
+ * Walk every scenario step, wherever it is nested, and validate the two things
592
+ * that can only be known once both the workflow meta and the function meta
593
+ * exist:
594
+ *
595
+ * - the step target must be a static string literal (PKU678) — otherwise it
596
+ * can't be bundled, typed or drawn;
597
+ * - a `browser: true` step must be given an actor (PKU677), since the browser
598
+ * context signs in as that persona.
599
+ *
600
+ * It also marks the step's function as used, so `scenario run` boots the
601
+ * singletons the step actually needs.
602
+ */
603
+ export function validateScenarioSteps(logger, state) {
604
+ const visit = (workflowName, steps) => {
605
+ for (const step of steps) {
606
+ if (step.type === 'scenarioStep') {
607
+ if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
608
+ logger.critical(ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC, `Scenario '${workflowName}' calls step '${step.stepName}' with a target that isn't a string literal. ` +
609
+ `A step target must be statically resolvable so it can be bundled, typed and drawn — ` +
610
+ `loop over data sets instead of computing the step name.`);
611
+ continue;
612
+ }
613
+ state.serviceAggregation.usedFunctions.add(step.stepFunc);
614
+ const stepMeta = state.functions.meta[step.stepFunc];
615
+ if (stepMeta?.scenarioStepBrowser && !step.actor) {
616
+ logger.critical(ErrorCode.SCENARIO_BROWSER_STEP_NEEDS_ACTOR, `Scenario '${workflowName}' calls browser step '${step.stepFunc}' without an actor. ` +
617
+ `Pass { actor: actors.<name> } so the browser context signs in as that persona.`);
618
+ }
619
+ continue;
620
+ }
621
+ if (step.type === 'branch') {
622
+ for (const branch of step.branches)
623
+ visit(workflowName, branch.steps);
624
+ if (step.elseSteps)
625
+ visit(workflowName, step.elseSteps);
626
+ }
627
+ else if (step.type === 'switch') {
628
+ for (const c of step.cases ?? []) {
629
+ if (c.steps)
630
+ visit(workflowName, c.steps);
631
+ }
632
+ if (step.defaultSteps)
633
+ visit(workflowName, step.defaultSteps);
634
+ }
635
+ else if (step.type === 'fanout' && step.body) {
636
+ visit(workflowName, step.body);
637
+ }
638
+ else if (step.type === 'parallel' && step.children) {
639
+ visit(workflowName, step.children);
640
+ }
641
+ }
642
+ };
643
+ for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
644
+ visit(workflowName, meta.steps ?? []);
645
+ }
646
+ }
555
647
  /**
556
648
  * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
557
649
  * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
@@ -1,5 +1,5 @@
1
1
  import type { JSONValue } from '@pikku/core';
2
- import type { InspectorDiagnostic, InspectorState } from '../types.js';
2
+ import type { InspectorDiagnostic, InspectorFeature, InspectorState } from '../types.js';
3
3
  /**
4
4
  * Serializable version of InspectorState that can be saved to JSON
5
5
  * Omits typesLookup (contains non-serializable ts.Type objects) and converts Maps/Sets to arrays
@@ -160,6 +160,7 @@ export interface SerializableInspectorState {
160
160
  path: string;
161
161
  exportedName: string;
162
162
  }]>;
163
+ featureFiles: Array<[string, InspectorFeature]>;
163
164
  invokedWorkflows: string[];
164
165
  };
165
166
  rpc: {