@pikku/inspector 0.12.47 → 0.12.48

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 (50) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/README.md +28 -2
  3. package/dist/add/add-feature.d.ts +19 -0
  4. package/dist/add/add-feature.js +200 -0
  5. package/dist/add/add-functions.js +24 -9
  6. package/dist/add/add-workflow.js +22 -29
  7. package/dist/error-codes.d.ts +3 -0
  8. package/dist/error-codes.js +3 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/inspector.js +39 -2
  11. package/dist/types.d.ts +43 -0
  12. package/dist/utils/extract-function-name.js +27 -0
  13. package/dist/utils/post-process.d.ts +26 -0
  14. package/dist/utils/post-process.js +85 -0
  15. package/dist/utils/serialize-inspector-state.d.ts +2 -1
  16. package/dist/utils/serialize-inspector-state.js +2 -0
  17. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +203 -19
  18. package/dist/utils/workflow/dsl/patterns.d.ts +21 -0
  19. package/dist/utils/workflow/dsl/patterns.js +30 -0
  20. package/dist/utils/workflow/dsl/validation.d.ts +5 -0
  21. package/dist/utils/workflow/dsl/validation.js +9 -0
  22. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +36 -0
  23. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -1
  24. package/dist/visit.js +2 -0
  25. package/package.json +4 -3
  26. package/src/add/add-feature.test.ts +306 -0
  27. package/src/add/add-feature.ts +253 -0
  28. package/src/add/add-functions.ts +29 -9
  29. package/src/add/add-scenario-step.test.ts +311 -0
  30. package/src/add/add-workflow-silent-drops.test.ts +98 -0
  31. package/src/add/add-workflow.ts +25 -37
  32. package/src/add/scenario-not-extractable.test.ts +159 -0
  33. package/src/add/scenario-services.test.ts +103 -0
  34. package/src/add/scenario-skip.test.ts +83 -0
  35. package/src/error-codes.ts +3 -0
  36. package/src/index.ts +1 -0
  37. package/src/inspector.ts +50 -1
  38. package/src/tsconfig-paths.test.ts +90 -0
  39. package/src/types.ts +45 -0
  40. package/src/utils/extract-function-name.ts +36 -0
  41. package/src/utils/post-process.ts +97 -0
  42. package/src/utils/serialize-inspector-state.ts +8 -1
  43. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
  44. package/src/utils/workflow/dsl/patterns.ts +38 -0
  45. package/src/utils/workflow/dsl/validation.ts +12 -0
  46. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
  47. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
  48. package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
  49. package/src/visit.ts +2 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,103 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { inspect } from '../inspector.js'
7
+ import type { InspectorLogger } from '../types.js'
8
+
9
+ function makeLogger(
10
+ criticals: Array<{ code: string; message: string }>
11
+ ): InspectorLogger {
12
+ return {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ diagnostic: ({ code, message }) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ critical: (code: any, message: string) => {
21
+ criticals.push({ code, message })
22
+ },
23
+ hasCriticalErrors: () => criticals.length > 0,
24
+ }
25
+ }
26
+
27
+ const stepFile = (dir: string) => join(dir, 'my.steps.ts')
28
+ const stepSource = [
29
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
30
+ 'export const getTodos = pikkuSessionlessFunc({',
31
+ ' func: async ({ logger }) => [{ done: true }],',
32
+ '})',
33
+ ].join('\n')
34
+
35
+ describe('scenarios may only destructure logger/config', () => {
36
+ test('a scenario destructuring a real service is a PKU673 critical', async () => {
37
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-sc-svc-'))
38
+ const scenarioFile = join(rootDir, 'my.scenario.ts')
39
+ await writeFile(stepFile(rootDir), stepSource)
40
+ await writeFile(
41
+ scenarioFile,
42
+ [
43
+ "import { pikkuScenario } from '@pikku/core/workflow'",
44
+ 'declare const actors: Record<string, any>',
45
+ 'export const badFlow = pikkuScenario(async ({ kysely }: any, _input, { workflow }) => {',
46
+ " await workflow.do('Read todos', 'getTodos', {}, { actor: actors.pm })",
47
+ ' return { ok: true }',
48
+ '})',
49
+ ].join('\n')
50
+ )
51
+
52
+ const criticals: Array<{ code: string; message: string }> = []
53
+ try {
54
+ await inspect(makeLogger(criticals), [stepFile(rootDir), scenarioFile], {
55
+ rootDir,
56
+ })
57
+ const pku673 = criticals.find((c) => c.code === 'PKU673')
58
+ assert.ok(
59
+ pku673,
60
+ `expected a PKU673 critical for a scenario destructuring 'kysely', got: ${JSON.stringify(criticals)}`
61
+ )
62
+ assert.ok(
63
+ pku673.message.includes('kysely'),
64
+ `PKU673 should name the offending service, got: ${pku673.message}`
65
+ )
66
+ } finally {
67
+ await rm(rootDir, { recursive: true, force: true })
68
+ }
69
+ })
70
+
71
+ test('a scenario destructuring only logger is allowed', async () => {
72
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-sc-ok-'))
73
+ const scenarioFile = join(rootDir, 'my.scenario.ts')
74
+ await writeFile(stepFile(rootDir), stepSource)
75
+ await writeFile(
76
+ scenarioFile,
77
+ [
78
+ "import { pikkuScenario } from '@pikku/core/workflow'",
79
+ 'declare const actors: Record<string, any>',
80
+ 'export const goodFlow = pikkuScenario(async ({ logger }, _input, { workflow }) => {',
81
+ " await workflow.do('Read todos', 'getTodos', {}, { actor: actors.pm })",
82
+ ' return { ok: true }',
83
+ '})',
84
+ ].join('\n')
85
+ )
86
+
87
+ const criticals: Array<{ code: string; message: string }> = []
88
+ try {
89
+ const state = await inspect(
90
+ makeLogger(criticals),
91
+ [stepFile(rootDir), scenarioFile],
92
+ { rootDir }
93
+ )
94
+ assert.ok(
95
+ !criticals.some((c) => c.code === 'PKU673'),
96
+ `logger is allowed in a scenario; got: ${JSON.stringify(criticals)}`
97
+ )
98
+ assert.equal((state.workflows.meta as any).goodFlow?.scenario, true)
99
+ } finally {
100
+ await rm(rootDir, { recursive: true, force: true })
101
+ }
102
+ })
103
+ })
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A scenario can declare why it is held out of a default run. The reason has to
3
+ * reach the meta, because the runner reports skips from there — if it were
4
+ * dropped in extraction the scenario would silently run anyway, which is the
5
+ * opposite of what the author asked for.
6
+ */
7
+ import { strict as assert } from 'assert'
8
+ import { describe, test } from 'node:test'
9
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { inspect } from '../inspector.js'
13
+ import type { InspectorLogger } from '../types.js'
14
+
15
+ const logger = {
16
+ debug: () => {},
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ diagnostic: () => {},
21
+ critical: () => {},
22
+ hasCriticalErrors: () => false,
23
+ } as unknown as InspectorLogger
24
+
25
+ const STEPS = [
26
+ "import { pikkuScenarioStep } from '@pikku/core'",
27
+ 'export const buysAnApple = pikkuScenarioStep({',
28
+ " name: 'buysAnApple',",
29
+ " description: 'buys an apple',",
30
+ ' func: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
31
+ '})',
32
+ ].join('\n')
33
+
34
+ const scenarioSource = (properties: string[]) =>
35
+ [
36
+ "import { pikkuScenario } from '@pikku/core/workflow'",
37
+ 'export const shopFlow = pikkuScenario({',
38
+ ...properties.map((line) => ` ${line}`),
39
+ ' func: async (_services, _input, { scenario }: any) => {',
40
+ " await scenario.when('buys an apple', 'buysAnApple', { qty: 1 })",
41
+ ' return { ok: true }',
42
+ ' },',
43
+ '})',
44
+ ].join('\n')
45
+
46
+ async function inspectScenario(properties: string[]) {
47
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-skip-'))
48
+ const stepsFile = join(rootDir, 'shop.steps.ts')
49
+ const scenarioFile = join(rootDir, 'shop.scenario.ts')
50
+ await writeFile(stepsFile, STEPS)
51
+ await writeFile(scenarioFile, scenarioSource(properties))
52
+ const state = await inspect(logger, [stepsFile, scenarioFile], { rootDir })
53
+ return {
54
+ meta: state.workflows.meta['shopFlow'] as any,
55
+ cleanup: () => rm(rootDir, { recursive: true, force: true }),
56
+ }
57
+ }
58
+
59
+ describe('scenario skip', () => {
60
+ test('a declared skip reason reaches the meta', async () => {
61
+ const { meta, cleanup } = await inspectScenario([
62
+ "title: 'Installs an addon',",
63
+ "skip: 'mutates the project — needs a fresh server',",
64
+ ])
65
+ try {
66
+ assert.equal(meta.skip, 'mutates the project — needs a fresh server')
67
+ } finally {
68
+ await cleanup()
69
+ }
70
+ })
71
+
72
+ test('a scenario that declares none carries none', async () => {
73
+ const { meta, cleanup } = await inspectScenario([
74
+ "title: 'Installs an addon',",
75
+ ])
76
+ try {
77
+ assert.equal(meta.skip, undefined)
78
+ assert.equal(meta.scenario, true)
79
+ } finally {
80
+ await cleanup()
81
+ }
82
+ })
83
+ })
@@ -22,6 +22,9 @@ export enum ErrorCode {
22
22
  CLI_CLIENTSIDE_RENDERER_HAS_SERVICES = 'PKU672',
23
23
  SCENARIO_HAS_SERVICES = 'PKU673',
24
24
  EXPECT_EVENTUALLY_SCENARIO_ONLY = 'PKU675',
25
+ SCENARIO_BROWSER_STEP_NEEDS_ACTOR = 'PKU677',
26
+ SCENARIO_STEP_TARGET_NOT_STATIC = 'PKU678',
27
+ SCENARIO_NOT_EXTRACTABLE = 'PKU679',
25
28
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
26
29
  INVALID_DSL_WORKFLOW = 'PKU641',
27
30
  WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export type {
27
27
  VersionManifest,
28
28
  VersionManifestEntry,
29
29
  } from './utils/contract-hashes.js'
30
+ export type { InspectorFeature, InspectorFeatureEntry } from './types.js'
30
31
  export { serializeMCPJson } from './utils/serialize-mcp-json.js'
31
32
  export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
32
33
  export {
package/src/inspector.ts CHANGED
@@ -1,6 +1,6 @@
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 type {
@@ -27,6 +27,8 @@ import {
27
27
  computeRequiredSchemas,
28
28
  computeDiagnostics,
29
29
  validateSchemaWiringSeparation,
30
+ validateScenarioServices,
31
+ validateScenarioSteps,
30
32
  validateWorkflowGraphAddons,
31
33
  } from './utils/post-process.js'
32
34
  import { generateOpenAPISpec } from './utils/serialize-openapi-json.js'
@@ -121,6 +123,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
121
123
  files: new Map(),
122
124
  graphMeta: {},
123
125
  graphFiles: new Map(),
126
+ featureFiles: new Map(),
124
127
  invokedWorkflows: new Set(),
125
128
  },
126
129
  rpc: {
@@ -240,6 +243,49 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
240
243
  }
241
244
  }
242
245
 
246
+ /**
247
+ * The path mappings the inspector's program has to share with the project.
248
+ *
249
+ * The inspector builds its own program rather than reusing the project's, which
250
+ * is what keeps a cold run affordable — but a project that reaches its generated
251
+ * code through a tsconfig `paths` entry resolves that import through those
252
+ * mappings and nothing else. Without them the factory an inspected function is
253
+ * passed to resolves to `any`, so a function whose input type comes from the
254
+ * factory's contextual type (rather than an explicit generic or a named schema)
255
+ * silently records no input at all.
256
+ *
257
+ * Only resolution keys are copied. Type-inference options such as `strict` stay
258
+ * the inspector's own, because changing those would change the recorded types.
259
+ */
260
+ const readProjectPathMappings = (
261
+ logger: InspectorLogger,
262
+ tsconfig: string | undefined
263
+ ): ts.CompilerOptions => {
264
+ if (!tsconfig) {
265
+ return {}
266
+ }
267
+ const configPath = resolve(tsconfig)
268
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
269
+ if (configFile.error || !configFile.config) {
270
+ logger.debug(
271
+ `Could not read ${configPath} for path mappings; imports that rely on them will not resolve`
272
+ )
273
+ return {}
274
+ }
275
+ const { options: projectOptions } = ts.parseJsonConfigFileContent(
276
+ configFile.config,
277
+ ts.sys,
278
+ dirname(configPath)
279
+ )
280
+ const { baseUrl, paths, rootDirs, pathsBasePath } = projectOptions
281
+ return {
282
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
283
+ ...(paths !== undefined ? { paths } : {}),
284
+ ...(rootDirs !== undefined ? { rootDirs } : {}),
285
+ ...(pathsBasePath !== undefined ? { pathsBasePath } : {}),
286
+ }
287
+ }
288
+
243
289
  export const inspect = async (
244
290
  logger: InspectorLogger,
245
291
  routeFiles: string[],
@@ -247,6 +293,7 @@ export const inspect = async (
247
293
  ): Promise<InspectorState> => {
248
294
  const normalizedRouteFiles = routeFiles.map((file) => resolve(file))
249
295
  const compilerOptions: ts.CompilerOptions = {
296
+ ...readProjectPathMappings(logger, options.tsconfig),
250
297
  target: ts.ScriptTarget.ESNext,
251
298
  module: ts.ModuleKind.Node16,
252
299
  skipLibCheck: true,
@@ -407,6 +454,8 @@ export const inspect = async (
407
454
  computeDiagnostics(state)
408
455
  validateSchemaWiringSeparation(logger, state)
409
456
  validateWorkflowGraphAddons(logger, state)
457
+ validateScenarioServices(logger, state)
458
+ validateScenarioSteps(logger, state)
410
459
 
411
460
  if (options.openAPI) {
412
461
  state.openAPISpec = await generateOpenAPISpec(
@@ -0,0 +1,90 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { inspect } from './inspector.js'
7
+ import type { InspectorLogger } from './types.js'
8
+
9
+ const logger: InspectorLogger = {
10
+ debug: () => {},
11
+ info: () => {},
12
+ warn: () => {},
13
+ error: () => {},
14
+ diagnostic: () => {},
15
+ critical: () => {},
16
+ hasCriticalErrors: () => false,
17
+ }
18
+
19
+ const FACTORY = [
20
+ 'export type Config = {',
21
+ ' name: string',
22
+ ' func: (services: any, data: { qty: number }) => Promise<{ ok: boolean }>',
23
+ '}',
24
+ 'export const pikkuSessionlessFunc = (config: Config): Config => config',
25
+ ].join('\n')
26
+
27
+ // The parameter is deliberately unannotated: its type can only come from the
28
+ // factory's contextual type, which is exactly what an unresolved import loses.
29
+ const funcSource = (specifier: string) =>
30
+ [
31
+ `import { pikkuSessionlessFunc } from '${specifier}'`,
32
+ 'export const buysAnApple = pikkuSessionlessFunc({',
33
+ " name: 'buysAnApple',",
34
+ ' func: async (_services, data) => ({ ok: data.qty > 0 }),',
35
+ '})',
36
+ ].join('\n')
37
+
38
+ const makeProject = async (specifier: string) => {
39
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-tsconfig-paths-'))
40
+ await mkdir(join(rootDir, 'generated'))
41
+ await mkdir(join(rootDir, 'src'))
42
+ await writeFile(
43
+ join(rootDir, 'tsconfig.json'),
44
+ JSON.stringify({
45
+ compilerOptions: {
46
+ module: 'NodeNext',
47
+ moduleResolution: 'NodeNext',
48
+ paths: { '#gen/*': ['./generated/*'] },
49
+ },
50
+ })
51
+ )
52
+ await writeFile(
53
+ join(rootDir, 'package.json'),
54
+ JSON.stringify({ type: 'module' })
55
+ )
56
+ await writeFile(join(rootDir, 'generated', 'factory.ts'), FACTORY)
57
+ const funcFile = join(rootDir, 'src', 'apple.func.ts')
58
+ await writeFile(funcFile, funcSource(specifier))
59
+ return { rootDir, funcFile, tsconfig: join(rootDir, 'tsconfig.json') }
60
+ }
61
+
62
+ describe('tsconfig path mappings', () => {
63
+ test('a relative import gives the function its input type', async () => {
64
+ const { rootDir, funcFile } = await makeProject('../generated/factory.js')
65
+ try {
66
+ const state = await inspect(logger, [funcFile], { rootDir })
67
+ const meta = state.functions.meta['buysAnApple']
68
+ assert.ok(meta, 'buysAnApple meta should exist')
69
+ assert.deepStrictEqual(meta!.inputs, ['BuysAnAppleInput'])
70
+ } finally {
71
+ await rm(rootDir, { recursive: true, force: true })
72
+ }
73
+ })
74
+
75
+ test('an import that only resolves through tsconfig paths keeps its input type', async () => {
76
+ const { rootDir, funcFile, tsconfig } = await makeProject('#gen/factory.js')
77
+ try {
78
+ const state = await inspect(logger, [funcFile], { rootDir, tsconfig })
79
+ const meta = state.functions.meta['buysAnApple']
80
+ assert.ok(meta, 'buysAnApple meta should exist')
81
+ assert.deepStrictEqual(
82
+ meta!.inputs,
83
+ ['BuysAnAppleInput'],
84
+ 'without the project path mappings the factory resolves to `any`, and the input is silently dropped'
85
+ )
86
+ } finally {
87
+ await rm(rootDir, { recursive: true, force: true })
88
+ }
89
+ })
90
+ })
package/src/types.ts CHANGED
@@ -273,6 +273,12 @@ export type InspectorOptions = Partial<{
273
273
  rootDir: string
274
274
  isAddon: boolean
275
275
  sourceFile: ts.SourceFile
276
+ /**
277
+ * The project's tsconfig, read for its path mappings only. The inspector's
278
+ * program needs them to resolve imports the project reaches through `paths`;
279
+ * unlike `schemaConfig.tsconfig` this is also needed on a setup-only run.
280
+ */
281
+ tsconfig: string
276
282
  types: Partial<{
277
283
  configFileType: string
278
284
  userSessionType: string
@@ -423,6 +429,36 @@ export interface AuthDefinition {
423
429
  services: FunctionServicesMeta
424
430
  }
425
431
 
432
+ /** One statically resolved entry of a feature's `scenarios` array. */
433
+ export interface InspectorFeatureEntry {
434
+ /** The scenario's declared export name, with any import alias followed back. */
435
+ scenario: string
436
+ /** A `{ scenario, data }` entry's literal data — gherkin's `Examples:`. */
437
+ data?: unknown
438
+ }
439
+
440
+ /** A `pikkuFeature` export, as far as it can be read from the source. */
441
+ export interface InspectorFeature {
442
+ path: string
443
+ exportedName: string
444
+ name?: string
445
+ description?: string
446
+ tags?: string[]
447
+ entries: InspectorFeatureEntry[]
448
+ /**
449
+ * Entries that are not literal — a spread, a `.map()`, a call. Counted rather
450
+ * than dropped so the console can say the listing is partial instead of
451
+ * quietly showing a short feature.
452
+ */
453
+ unresolvedEntries: number
454
+ /**
455
+ * Feature hooks run once around the whole group, never per scenario, and are
456
+ * runtime-only — so only their presence is recorded.
457
+ */
458
+ hasBefore: boolean
459
+ hasAfter: boolean
460
+ }
461
+
426
462
  export interface InspectorState {
427
463
  rootDir: string // Root directory inferred from source files
428
464
  singletonServicesTypeImportMap: PathToNameAndType
@@ -466,6 +502,15 @@ export interface InspectorState {
466
502
  files: Map<string, { path: string; exportedName: string }>
467
503
  graphMeta: SerializedWorkflowGraphs
468
504
  graphFiles: Map<string, { path: string; exportedName: string }>
505
+ /**
506
+ * `pikkuFeature` exports, keyed by export identifier.
507
+ *
508
+ * Everything the config states literally is extracted here, because a
509
+ * feature is the document structure the console renders. Entries built by
510
+ * an ordinary loop cannot be enumerated statically — those are counted in
511
+ * `unresolvedEntries` and resolved at runtime by object identity instead.
512
+ */
513
+ featureFiles: Map<string, InspectorFeature>
469
514
  invokedWorkflows: Set<string>
470
515
  }
471
516
  rpc: {
@@ -45,6 +45,40 @@ export function funcIdToTypeName(id: string): string {
45
45
  .join('')
46
46
  }
47
47
 
48
+ /**
49
+ * `pikkuScenarioStep({ name })` is the one wrapper whose `name` is its identity:
50
+ * scenarios reference a step by that string, so it must become the pikkuFuncId
51
+ * (and hence the generated step map's key), not just a cosmetic label the way
52
+ * `pikkuMiddleware`/`pikkuAgent` use it.
53
+ */
54
+ function extractScenarioStepName(
55
+ callExpr: ts.Node,
56
+ result: ExtractedFunctionName
57
+ ): void {
58
+ if (
59
+ !ts.isCallExpression(callExpr) ||
60
+ !ts.isIdentifier(callExpr.expression) ||
61
+ callExpr.expression.text !== 'pikkuScenarioStep'
62
+ ) {
63
+ return
64
+ }
65
+ const firstArg = callExpr.arguments[0]
66
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
67
+ return
68
+ }
69
+ for (const prop of firstArg.properties) {
70
+ if (
71
+ ts.isPropertyAssignment(prop) &&
72
+ ts.isIdentifier(prop.name) &&
73
+ prop.name.text === 'name' &&
74
+ ts.isStringLiteral(prop.initializer)
75
+ ) {
76
+ result.explicitName = prop.initializer.text
77
+ return
78
+ }
79
+ }
80
+ }
81
+
48
82
  export function extractFunctionName(
49
83
  callExpr: ts.Node,
50
84
  checker: ts.TypeChecker,
@@ -63,6 +97,8 @@ export function extractFunctionName(
63
97
  version: null,
64
98
  }
65
99
 
100
+ extractScenarioStepName(callExpr, result)
101
+
66
102
  const workflowHelpers = new Set([
67
103
  'workflow',
68
104
  'workflowStart',
@@ -16,6 +16,8 @@ import { extractTypeKeys } from './type-utils.js'
16
16
  import { ErrorCode } from '../error-codes.js'
17
17
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
18
18
  import { flattenScopeDefinitions } from '@pikku/core/scope'
19
+ import type { WorkflowStepMeta } from '@pikku/core/workflow'
20
+ import { DYNAMIC_SCENARIO_STEP_TARGET } from './workflow/dsl/patterns.js'
19
21
 
20
22
  /**
21
23
  * Stamp the inspected authorize/callbacks service set onto the generated auth
@@ -696,6 +698,101 @@ export function validateAgentModels(
696
698
  }
697
699
  }
698
700
 
701
+ /**
702
+ * Scenarios are pure stories of remote RPCs (same rule as client-side CLI
703
+ * renderers): the func may only destructure logger/config — everything else
704
+ * must go through actor steps so the flow runs against the TARGET
705
+ * environment, never local services.
706
+ *
707
+ * This runs in post-processing rather than in `addWorkflow` because
708
+ * `addWorkflow` runs in the `visitSetup` pass, which completes before
709
+ * `addFunctions` (`visitFunctions`) has populated `state.functions.meta` — so
710
+ * the check read `undefined` there and never fired.
711
+ */
712
+ export function validateScenarioServices(
713
+ logger: InspectorLogger,
714
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
715
+ ): void {
716
+ for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
717
+ if (!meta.scenario) continue
718
+ const funcMeta = state.functions.meta[meta.pikkuFuncId]
719
+ if (!funcMeta?.services) continue
720
+ const disallowed = funcMeta.services.services.filter(
721
+ (svc) => svc !== 'logger' && svc !== 'config'
722
+ )
723
+ if (disallowed.length > 0) {
724
+ logger.critical(
725
+ ErrorCode.SCENARIO_HAS_SERVICES,
726
+ `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
727
+ `Scenarios may only use 'logger'/'config' — drive everything else through ` +
728
+ `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
729
+ `runs against the target environment.`
730
+ )
731
+ }
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Walk every scenario step, wherever it is nested, and validate the two things
737
+ * that can only be known once both the workflow meta and the function meta
738
+ * exist:
739
+ *
740
+ * - the step target must be a static string literal (PKU678) — otherwise it
741
+ * can't be bundled, typed or drawn;
742
+ * - a `browser: true` step must be given an actor (PKU677), since the browser
743
+ * context signs in as that persona.
744
+ *
745
+ * It also marks the step's function as used, so `scenario run` boots the
746
+ * singletons the step actually needs.
747
+ */
748
+ export function validateScenarioSteps(
749
+ logger: InspectorLogger,
750
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
751
+ ): void {
752
+ const visit = (workflowName: string, steps: WorkflowStepMeta[]): void => {
753
+ for (const step of steps) {
754
+ if (step.type === 'scenarioStep') {
755
+ if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
756
+ logger.critical(
757
+ ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC,
758
+ `Scenario '${workflowName}' calls step '${step.stepName}' with a target that isn't a string literal. ` +
759
+ `A step target must be statically resolvable so it can be bundled, typed and drawn — ` +
760
+ `loop over data sets instead of computing the step name.`
761
+ )
762
+ continue
763
+ }
764
+ state.serviceAggregation.usedFunctions.add(step.stepFunc)
765
+ const stepMeta = state.functions.meta[step.stepFunc]
766
+ if (stepMeta?.scenarioStepBrowser && !step.actor) {
767
+ logger.critical(
768
+ ErrorCode.SCENARIO_BROWSER_STEP_NEEDS_ACTOR,
769
+ `Scenario '${workflowName}' calls browser step '${step.stepFunc}' without an actor. ` +
770
+ `Pass { actor: actors.<name> } so the browser context signs in as that persona.`
771
+ )
772
+ }
773
+ continue
774
+ }
775
+ if (step.type === 'branch') {
776
+ for (const branch of step.branches) visit(workflowName, branch.steps)
777
+ if (step.elseSteps) visit(workflowName, step.elseSteps)
778
+ } else if (step.type === 'switch') {
779
+ for (const c of step.cases ?? []) {
780
+ if (c.steps) visit(workflowName, c.steps)
781
+ }
782
+ if (step.defaultSteps) visit(workflowName, step.defaultSteps)
783
+ } else if (step.type === 'fanout' && step.body) {
784
+ visit(workflowName, step.body as WorkflowStepMeta[])
785
+ } else if (step.type === 'parallel' && step.children) {
786
+ visit(workflowName, step.children as WorkflowStepMeta[])
787
+ }
788
+ }
789
+ }
790
+
791
+ for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
792
+ visit(workflowName, meta.steps ?? [])
793
+ }
794
+ }
795
+
699
796
  /**
700
797
  * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
701
798
  * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
@@ -1,5 +1,9 @@
1
1
  import type { JSONValue } from '@pikku/core'
2
- import type { InspectorDiagnostic, InspectorState } from '../types.js'
2
+ import type {
3
+ InspectorDiagnostic,
4
+ InspectorFeature,
5
+ InspectorState,
6
+ } from '../types.js'
3
7
  import { TypesMap } from '../types-map.js'
4
8
 
5
9
  /**
@@ -139,6 +143,7 @@ export interface SerializableInspectorState {
139
143
  files: Array<[string, { path: string; exportedName: string }]>
140
144
  graphMeta: InspectorState['workflows']['graphMeta']
141
145
  graphFiles: Array<[string, { path: string; exportedName: string }]>
146
+ featureFiles: Array<[string, InspectorFeature]>
142
147
  invokedWorkflows: string[]
143
148
  }
144
149
  rpc: {
@@ -362,6 +367,7 @@ export function serializeInspectorState(
362
367
  files: Array.from(state.workflows.files.entries()),
363
368
  graphMeta: state.workflows.graphMeta,
364
369
  graphFiles: Array.from(state.workflows.graphFiles.entries()),
370
+ featureFiles: Array.from(state.workflows.featureFiles.entries()),
365
371
  invokedWorkflows: Array.from(state.workflows.invokedWorkflows),
366
372
  },
367
373
  rpc: {
@@ -558,6 +564,7 @@ export function deserializeInspectorState(
558
564
  files: new Map(data.workflows.files),
559
565
  graphMeta: data.workflows.graphMeta || {},
560
566
  graphFiles: new Map(data.workflows.graphFiles || []),
567
+ featureFiles: new Map(data.workflows.featureFiles || []),
561
568
  invokedWorkflows: new Set(data.workflows.invokedWorkflows || []),
562
569
  },
563
570
  rpc: {