@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
@@ -0,0 +1,306 @@
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 }: any) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ critical: (code: any, message: string) => {
21
+ criticals.push({ code, message })
22
+ },
23
+ hasCriticalErrors: () => criticals.length > 0,
24
+ } as InspectorLogger
25
+ }
26
+
27
+ const SCENARIOS = [
28
+ "import { pikkuScenario } from '@pikku/core/workflow'",
29
+ 'export const lazyLoadScenario = pikkuScenario({',
30
+ " title: 'A credential is loaded on first use',",
31
+ " tags: ['credential'],",
32
+ ' func: async () => ({ ok: true }),',
33
+ '})',
34
+ 'export const roundTripScenario = pikkuScenario({',
35
+ " title: 'A credential round-trips',",
36
+ ' func: async (_services, _data: { name: string }) => ({ ok: true }),',
37
+ '})',
38
+ ].join('\n')
39
+
40
+ async function run(featureSource: string) {
41
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-feature-'))
42
+ const scenarioFile = join(rootDir, 'credential.scenario.ts')
43
+ const featureFile = join(rootDir, 'credential.feature.ts')
44
+ await writeFile(scenarioFile, SCENARIOS)
45
+ await writeFile(featureFile, featureSource)
46
+ const criticals: Array<{ code: string; message: string }> = []
47
+ const state = await inspect(
48
+ makeLogger(criticals),
49
+ [scenarioFile, featureFile],
50
+ { rootDir }
51
+ )
52
+ return {
53
+ state,
54
+ criticals,
55
+ featureFile,
56
+ cleanup: () => rm(rootDir, { recursive: true, force: true }),
57
+ }
58
+ }
59
+
60
+ describe('addFeature', () => {
61
+ test('records where a feature is exported from', async () => {
62
+ const { state, criticals, featureFile, cleanup } = await run(
63
+ [
64
+ "import { pikkuFeature } from '@pikku/core/workflow'",
65
+ "import { lazyLoadScenario } from './credential.scenario.js'",
66
+ 'export const credentialFeature = pikkuFeature({',
67
+ " name: 'Credential API',",
68
+ " tags: ['credential'],",
69
+ ' scenarios: [lazyLoadScenario],',
70
+ '})',
71
+ ].join('\n')
72
+ )
73
+ try {
74
+ assert.deepEqual(criticals, [])
75
+ assert.deepEqual(
76
+ [...state.workflows.featureFiles.entries()],
77
+ [
78
+ [
79
+ 'credentialFeature',
80
+ {
81
+ path: featureFile,
82
+ exportedName: 'credentialFeature',
83
+ name: 'Credential API',
84
+ tags: ['credential'],
85
+ entries: [{ scenario: 'lazyLoadScenario' }],
86
+ unresolvedEntries: 0,
87
+ hasBefore: false,
88
+ hasAfter: false,
89
+ },
90
+ ],
91
+ ]
92
+ )
93
+ } finally {
94
+ await cleanup()
95
+ }
96
+ })
97
+
98
+ test('extracts the feature name, description and tags', async () => {
99
+ const { state, criticals, cleanup } = await run(
100
+ [
101
+ "import { pikkuFeature } from '@pikku/core/workflow'",
102
+ "import { lazyLoadScenario } from './credential.scenario.js'",
103
+ 'export const credentialFeature = pikkuFeature({',
104
+ " name: 'Credential API',",
105
+ " description: 'How credentials are loaded and rotated',",
106
+ " tags: ['credential', 'console'],",
107
+ ' scenarios: [lazyLoadScenario],',
108
+ '})',
109
+ ].join('\n')
110
+ )
111
+ try {
112
+ assert.deepEqual(criticals, [])
113
+ const feature = state.workflows.featureFiles.get('credentialFeature')!
114
+ assert.equal(feature.name, 'Credential API')
115
+ assert.equal(
116
+ feature.description,
117
+ 'How credentials are loaded and rotated'
118
+ )
119
+ assert.deepEqual(feature.tags, ['credential', 'console'])
120
+ } finally {
121
+ await cleanup()
122
+ }
123
+ })
124
+
125
+ test('resolves a literal scenarios array to scenario names, in order', async () => {
126
+ const { state, criticals, cleanup } = await run(
127
+ [
128
+ "import { pikkuFeature } from '@pikku/core/workflow'",
129
+ "import { lazyLoadScenario, roundTripScenario } from './credential.scenario.js'",
130
+ 'export const credentialFeature = pikkuFeature({',
131
+ " name: 'Credential API',",
132
+ ' scenarios: [roundTripScenario, lazyLoadScenario],',
133
+ '})',
134
+ ].join('\n')
135
+ )
136
+ try {
137
+ assert.deepEqual(criticals, [])
138
+ const feature = state.workflows.featureFiles.get('credentialFeature')!
139
+ assert.deepEqual(feature.entries, [
140
+ { scenario: 'roundTripScenario' },
141
+ { scenario: 'lazyLoadScenario' },
142
+ ])
143
+ assert.equal(feature.unresolvedEntries, 0)
144
+ } finally {
145
+ await cleanup()
146
+ }
147
+ })
148
+
149
+ test('resolves an aliased import back to the declared scenario name', async () => {
150
+ const { state, criticals, cleanup } = await run(
151
+ [
152
+ "import { pikkuFeature } from '@pikku/core/workflow'",
153
+ "import { lazyLoadScenario as lazy } from './credential.scenario.js'",
154
+ 'export const credentialFeature = pikkuFeature({',
155
+ " name: 'Credential API',",
156
+ ' scenarios: [lazy],',
157
+ '})',
158
+ ].join('\n')
159
+ )
160
+ try {
161
+ assert.deepEqual(criticals, [])
162
+ assert.deepEqual(
163
+ state.workflows.featureFiles.get('credentialFeature')!.entries,
164
+ [{ scenario: 'lazyLoadScenario' }]
165
+ )
166
+ } finally {
167
+ await cleanup()
168
+ }
169
+ })
170
+
171
+ test('resolves a { scenario, data } entry with its literal data', async () => {
172
+ const { state, criticals, cleanup } = await run(
173
+ [
174
+ "import { pikkuFeature } from '@pikku/core/workflow'",
175
+ "import { roundTripScenario } from './credential.scenario.js'",
176
+ 'export const credentialFeature = pikkuFeature({',
177
+ " name: 'Credential API',",
178
+ ' scenarios: [',
179
+ " { scenario: roundTripScenario, data: { name: 'stripe', retries: 2 } },",
180
+ " { scenario: roundTripScenario, data: { name: 'google', retries: 0 } },",
181
+ ' ],',
182
+ '})',
183
+ ].join('\n')
184
+ )
185
+ try {
186
+ assert.deepEqual(criticals, [])
187
+ assert.deepEqual(
188
+ state.workflows.featureFiles.get('credentialFeature')!.entries,
189
+ [
190
+ {
191
+ scenario: 'roundTripScenario',
192
+ data: { name: 'stripe', retries: 2 },
193
+ },
194
+ {
195
+ scenario: 'roundTripScenario',
196
+ data: { name: 'google', retries: 0 },
197
+ },
198
+ ]
199
+ )
200
+ } finally {
201
+ await cleanup()
202
+ }
203
+ })
204
+
205
+ test('records whether the feature has before/after hooks', async () => {
206
+ const { state, criticals, cleanup } = await run(
207
+ [
208
+ "import { pikkuFeature } from '@pikku/core/workflow'",
209
+ "import { lazyLoadScenario } from './credential.scenario.js'",
210
+ 'export const credentialFeature = pikkuFeature({',
211
+ " name: 'Credential API',",
212
+ ' before: async () => {},',
213
+ ' scenarios: [lazyLoadScenario],',
214
+ '})',
215
+ ].join('\n')
216
+ )
217
+ try {
218
+ assert.deepEqual(criticals, [])
219
+ const feature = state.workflows.featureFiles.get('credentialFeature')!
220
+ assert.equal(feature.hasBefore, true)
221
+ assert.equal(feature.hasAfter, false)
222
+ } finally {
223
+ await cleanup()
224
+ }
225
+ })
226
+
227
+ test('a feature built by a loop keeps its literal entries and counts the rest', async () => {
228
+ // A spread of `.map()` cannot be enumerated statically — membership for it
229
+ // is resolved at runtime by object identity. Everything literal alongside it
230
+ // is still extracted, and the dynamic portion is counted rather than lost.
231
+ const { state, criticals, cleanup } = await run(
232
+ [
233
+ "import { pikkuFeature } from '@pikku/core/workflow'",
234
+ "import { lazyLoadScenario, roundTripScenario } from './credential.scenario.js'",
235
+ 'export const credentialFeature = pikkuFeature({',
236
+ " name: 'Credential API',",
237
+ ' scenarios: [',
238
+ ' lazyLoadScenario,',
239
+ " ...['stripe', 'google'].map((name) => ({",
240
+ ' scenario: roundTripScenario,',
241
+ ' data: { name },',
242
+ ' })),',
243
+ ' ],',
244
+ '})',
245
+ ].join('\n')
246
+ )
247
+ try {
248
+ assert.deepEqual(criticals, [])
249
+ const feature = state.workflows.featureFiles.get('credentialFeature')!
250
+ assert.deepEqual(feature.entries, [{ scenario: 'lazyLoadScenario' }])
251
+ assert.equal(feature.unresolvedEntries, 1)
252
+ } finally {
253
+ await cleanup()
254
+ }
255
+ })
256
+
257
+ test('several features in one file are all recorded', async () => {
258
+ const { state, criticals, cleanup } = await run(
259
+ [
260
+ "import { pikkuFeature } from '@pikku/core/workflow'",
261
+ "import { lazyLoadScenario, roundTripScenario } from './credential.scenario.js'",
262
+ "export const readFeature = pikkuFeature({ name: 'Read', scenarios: [lazyLoadScenario] })",
263
+ "export const writeFeature = pikkuFeature({ name: 'Write', scenarios: [roundTripScenario] })",
264
+ ].join('\n')
265
+ )
266
+ try {
267
+ assert.deepEqual(criticals, [])
268
+ assert.deepEqual(
269
+ [...state.workflows.featureFiles.keys()],
270
+ ['readFeature', 'writeFeature']
271
+ )
272
+ } finally {
273
+ await cleanup()
274
+ }
275
+ })
276
+
277
+ test('a feature that is not exported is a critical error', async () => {
278
+ const { state, criticals, cleanup } = await run(
279
+ [
280
+ "import { pikkuFeature } from '@pikku/core/workflow'",
281
+ "import { lazyLoadScenario } from './credential.scenario.js'",
282
+ "const credentialFeature = pikkuFeature({ name: 'Credential API', scenarios: [lazyLoadScenario] })",
283
+ 'void credentialFeature',
284
+ ].join('\n')
285
+ )
286
+ try {
287
+ assert.equal(state.workflows.featureFiles.size, 0)
288
+ assert.equal(criticals.length, 1)
289
+ assert.match(criticals[0]!.message, /must be assigned to an export/)
290
+ } finally {
291
+ await cleanup()
292
+ }
293
+ })
294
+
295
+ test('a project with no features records none', async () => {
296
+ const { state, criticals, cleanup } = await run(
297
+ "export const nothing = 'here'\n"
298
+ )
299
+ try {
300
+ assert.deepEqual(criticals, [])
301
+ assert.equal(state.workflows.featureFiles.size, 0)
302
+ } finally {
303
+ await cleanup()
304
+ }
305
+ })
306
+ })
@@ -0,0 +1,253 @@
1
+ import * as ts from 'typescript'
2
+ import { extractFunctionName } from '../utils/extract-function-name.js'
3
+ import { ErrorCode } from '../error-codes.js'
4
+ import type {
5
+ AddWiring,
6
+ InspectorFeature,
7
+ InspectorFeatureEntry,
8
+ } from '../types.js'
9
+
10
+ /**
11
+ * Reads a fully literal expression into a plain value.
12
+ *
13
+ * Used for a `{ scenario, data }` entry's `data`, which is gherkin's
14
+ * `Examples:` row. Anything not literal yields `undefined` — a computed row
15
+ * belongs to the runtime-resolved portion, not to the extracted meta.
16
+ */
17
+ const literalValue = (node: ts.Expression): unknown => {
18
+ if (ts.isStringLiteralLike(node)) {
19
+ return node.text
20
+ }
21
+ if (ts.isNumericLiteral(node)) {
22
+ return Number(node.text)
23
+ }
24
+ if (node.kind === ts.SyntaxKind.TrueKeyword) {
25
+ return true
26
+ }
27
+ if (node.kind === ts.SyntaxKind.FalseKeyword) {
28
+ return false
29
+ }
30
+ if (node.kind === ts.SyntaxKind.NullKeyword) {
31
+ return null
32
+ }
33
+ if (
34
+ ts.isPrefixUnaryExpression(node) &&
35
+ node.operator === ts.SyntaxKind.MinusToken &&
36
+ ts.isNumericLiteral(node.operand)
37
+ ) {
38
+ return -Number(node.operand.text)
39
+ }
40
+ if (ts.isArrayLiteralExpression(node)) {
41
+ const values: unknown[] = []
42
+ for (const element of node.elements) {
43
+ const value = literalValue(element)
44
+ if (value === undefined) {
45
+ return undefined
46
+ }
47
+ values.push(value)
48
+ }
49
+ return values
50
+ }
51
+ if (ts.isObjectLiteralExpression(node)) {
52
+ const value: Record<string, unknown> = {}
53
+ for (const property of node.properties) {
54
+ if (!ts.isPropertyAssignment(property)) {
55
+ return undefined
56
+ }
57
+ const key =
58
+ ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)
59
+ ? property.name.text
60
+ : undefined
61
+ if (key === undefined) {
62
+ return undefined
63
+ }
64
+ const propertyValue = literalValue(property.initializer)
65
+ if (propertyValue === undefined) {
66
+ return undefined
67
+ }
68
+ value[key] = propertyValue
69
+ }
70
+ return value
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ /**
76
+ * Resolves a scenario reference to the name it is declared under, following an
77
+ * import alias back to the original export so `{ x as y }` records `x`.
78
+ */
79
+ const scenarioName = (
80
+ node: ts.Expression,
81
+ checker: ts.TypeChecker
82
+ ): string | undefined => {
83
+ if (!ts.isIdentifier(node)) {
84
+ return undefined
85
+ }
86
+ const symbol = checker.getSymbolAtLocation(node)
87
+ if (!symbol) {
88
+ return node.text
89
+ }
90
+ const resolved =
91
+ symbol.flags & ts.SymbolFlags.Alias
92
+ ? checker.getAliasedSymbol(symbol)
93
+ : symbol
94
+ return resolved.getName() || node.text
95
+ }
96
+
97
+ const getProperty = (
98
+ config: ts.ObjectLiteralExpression,
99
+ name: string
100
+ ): ts.Expression | undefined => {
101
+ const property = config.properties.find(
102
+ (p) =>
103
+ ts.isPropertyAssignment(p) &&
104
+ ts.isIdentifier(p.name) &&
105
+ p.name.text === name
106
+ )
107
+ return property && ts.isPropertyAssignment(property)
108
+ ? property.initializer
109
+ : undefined
110
+ }
111
+
112
+ const hasProperty = (
113
+ config: ts.ObjectLiteralExpression,
114
+ name: string
115
+ ): boolean =>
116
+ config.properties.some(
117
+ (p) =>
118
+ (ts.isPropertyAssignment(p) ||
119
+ ts.isMethodDeclaration(p) ||
120
+ ts.isShorthandPropertyAssignment(p)) &&
121
+ ts.isIdentifier(p.name) &&
122
+ p.name.text === name
123
+ )
124
+
125
+ const stringProperty = (
126
+ config: ts.ObjectLiteralExpression,
127
+ name: string
128
+ ): string | undefined => {
129
+ const value = getProperty(config, name)
130
+ return value && ts.isStringLiteralLike(value) ? value.text : undefined
131
+ }
132
+
133
+ const stringArrayProperty = (
134
+ config: ts.ObjectLiteralExpression,
135
+ name: string
136
+ ): string[] | undefined => {
137
+ const value = getProperty(config, name)
138
+ if (!value || !ts.isArrayLiteralExpression(value)) {
139
+ return undefined
140
+ }
141
+ const values = value.elements
142
+ .filter(ts.isStringLiteralLike)
143
+ .map((e) => e.text)
144
+ return values.length === value.elements.length ? values : undefined
145
+ }
146
+
147
+ const readEntry = (
148
+ element: ts.Expression,
149
+ checker: ts.TypeChecker
150
+ ): InspectorFeatureEntry | undefined => {
151
+ if (ts.isIdentifier(element)) {
152
+ const scenario = scenarioName(element, checker)
153
+ return scenario ? { scenario } : undefined
154
+ }
155
+
156
+ if (ts.isObjectLiteralExpression(element)) {
157
+ const reference = getProperty(element, 'scenario')
158
+ if (!reference) {
159
+ return undefined
160
+ }
161
+ const scenario = scenarioName(reference, checker)
162
+ if (!scenario) {
163
+ return undefined
164
+ }
165
+ const dataNode = getProperty(element, 'data')
166
+ if (!dataNode) {
167
+ return { scenario }
168
+ }
169
+ const data = literalValue(dataNode)
170
+ return data === undefined ? undefined : { scenario, data }
171
+ }
172
+
173
+ return undefined
174
+ }
175
+
176
+ /**
177
+ * Inspector for `pikkuFeature()` calls.
178
+ *
179
+ * A feature is the document structure the console renders — its name,
180
+ * description, tags and the order of its scenarios are what a reader sees — so
181
+ * everything the config states literally is extracted here.
182
+ *
183
+ * What cannot be extracted is a scenarios entry that is not literal:
184
+ * `...['stripe', 'google'].map((name) => ({ scenario, data: { name } }))` is
185
+ * ordinary TypeScript, and no AST walk can enumerate it. Those entries are
186
+ * counted in `unresolvedEntries` rather than dropped, and membership for them
187
+ * is resolved at runtime by object identity (`resolveFeatureScenarios`), where
188
+ * the array has actually been evaluated.
189
+ *
190
+ * The export location is recorded either way, which is what the CLI needs to
191
+ * emit its `addFeature(...)` wiring line.
192
+ */
193
+ export const addFeature: AddWiring = (logger, node, checker, state) => {
194
+ if (!ts.isCallExpression(node)) {
195
+ return
196
+ }
197
+
198
+ const expression = node.expression
199
+ if (!ts.isIdentifier(expression) || expression.text !== 'pikkuFeature') {
200
+ return
201
+ }
202
+
203
+ const { exportedName } = extractFunctionName(node, checker, state.rootDir)
204
+
205
+ if (!exportedName) {
206
+ logger.critical(
207
+ ErrorCode.MISSING_NAME,
208
+ `A pikkuFeature() must be assigned to an export — the export identifier is the feature's id.`
209
+ )
210
+ return
211
+ }
212
+
213
+ const feature: InspectorFeature = {
214
+ path: node.getSourceFile().fileName,
215
+ exportedName,
216
+ entries: [],
217
+ unresolvedEntries: 0,
218
+ hasBefore: false,
219
+ hasAfter: false,
220
+ }
221
+
222
+ const [config] = node.arguments
223
+ if (config && ts.isObjectLiteralExpression(config)) {
224
+ const name = stringProperty(config, 'name')
225
+ const description = stringProperty(config, 'description')
226
+ const tags = stringArrayProperty(config, 'tags')
227
+ if (name !== undefined) {
228
+ feature.name = name
229
+ }
230
+ if (description !== undefined) {
231
+ feature.description = description
232
+ }
233
+ if (tags !== undefined) {
234
+ feature.tags = tags
235
+ }
236
+ feature.hasBefore = hasProperty(config, 'before')
237
+ feature.hasAfter = hasProperty(config, 'after')
238
+
239
+ const scenarios = getProperty(config, 'scenarios')
240
+ if (scenarios && ts.isArrayLiteralExpression(scenarios)) {
241
+ for (const element of scenarios.elements) {
242
+ const entry = readEntry(element, checker)
243
+ if (entry) {
244
+ feature.entries.push(entry)
245
+ } else {
246
+ feature.unresolvedEntries += 1
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ state.workflows.featureFiles.set(exportedName, feature)
253
+ }
@@ -21,6 +21,7 @@ import { resolvePermissions } from '../utils/permissions.js'
21
21
  import { extractWireNames } from '../utils/post-process.js'
22
22
  import { ErrorCode } from '../error-codes.js'
23
23
  import { findPiiPaths } from '../utils/check-pii-output.js'
24
+ import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js'
24
25
  import type { NodeType } from '@pikku/core/node'
25
26
 
26
27
  const isValidVariableName = (name: string) => {
@@ -386,15 +387,27 @@ export const addFunctions: AddWiring = (
386
387
  }
387
388
 
388
389
  // Match identifiers that contain both "pikku" and "func" (case insensitive),
389
- // plus pikkuScenario (a workflow wrapper without "func" in its name)
390
+ // plus pikkuScenario and pikkuScenarioStep (workflow wrappers without "func"
391
+ // in their names)
390
392
  const pikkuFuncPattern = /pikku.*func/i
391
393
  if (
392
394
  !pikkuFuncPattern.test(expression.text) &&
393
- expression.text !== 'pikkuScenario'
395
+ expression.text !== 'pikkuScenario' &&
396
+ expression.text !== 'pikkuScenarioStep'
394
397
  ) {
395
398
  return
396
399
  }
397
400
 
401
+ // A scenario step is bundled like any invoked function, but is deliberately
402
+ // never RPC-registered: a step may drive a browser, so exposing it over the
403
+ // network would put a test harness on the public API surface.
404
+ const isScenarioStep = expression.text === 'pikkuScenarioStep'
405
+
406
+ // A scenario's own body. Marked so the codegen partitions and the deploy
407
+ // analyzer can tell it apart from an application workflow without having to
408
+ // reach into the workflow graph.
409
+ const isScenario = expression.text === 'pikkuScenario'
410
+
398
411
  // only handle calls like pikkuFunc(...)
399
412
  if (!ts.isIdentifier(expression) || !expression.text.startsWith('pikku')) {
400
413
  return
@@ -412,6 +425,17 @@ export const addFunctions: AddWiring = (
412
425
  return
413
426
  }
414
427
 
428
+ // pikku's own scenario instrumentation, which older projects still carry as a
429
+ // scaffolded file in their source. It is not application code and must not be
430
+ // registered, metadata'd or deployed as if it were — `pikku dev` registers the
431
+ // implementations itself, and that is the only server that should have them.
432
+ if (
433
+ isScenarioInstrumentationFunction(pikkuFuncId) ||
434
+ isScenarioInstrumentationFunction(name)
435
+ ) {
436
+ return
437
+ }
438
+
415
439
  let title: string | undefined
416
440
  let tags: string[] | undefined
417
441
  let summary: string | undefined
@@ -427,6 +451,8 @@ export const addFunctions: AddWiring = (
427
451
  let workflowQueued: boolean | undefined
428
452
  let workflowRetries: number | undefined
429
453
  let workflowTimeout: string | undefined
454
+ let scenarioStepBrowser: boolean | undefined
455
+ let scenarioStepTemplate: string | undefined
430
456
  let scopes: string[] | undefined
431
457
  let version: number | undefined
432
458
  let objectNode: ts.ObjectLiteralExpression | undefined
@@ -532,6 +558,12 @@ export const addFunctions: AddWiring = (
532
558
  workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout') as
533
559
  | string
534
560
  | undefined
561
+ scenarioStepBrowser = getPropertyValue(firstArg, 'browser') as
562
+ | boolean
563
+ | undefined
564
+ scenarioStepTemplate = getPropertyValue(firstArg, 'template') as
565
+ | string
566
+ | undefined
535
567
  scopes = getArrayPropertyValue(firstArg, 'scopes') ?? undefined
536
568
 
537
569
  // Extract approvalDescription identifier reference
@@ -1078,6 +1110,8 @@ export const addFunctions: AddWiring = (
1078
1110
  outputs: outputNames.filter((n) => n !== 'void') ?? null,
1079
1111
  expose: expose || undefined,
1080
1112
  remote: remote || undefined,
1113
+ scenarioStep: isScenarioStep || undefined,
1114
+ scenario: isScenario || undefined,
1081
1115
  mcp: mcpEnabled || undefined,
1082
1116
  readonly: readonly_ || undefined,
1083
1117
  deploy: deploy || undefined,
@@ -1086,6 +1120,8 @@ export const addFunctions: AddWiring = (
1086
1120
  workflowQueued: workflowQueued === true ? true : undefined,
1087
1121
  workflowRetries: workflowRetries ?? undefined,
1088
1122
  workflowTimeout: workflowTimeout ?? undefined,
1123
+ scenarioStepBrowser: scenarioStepBrowser === true ? true : undefined,
1124
+ scenarioStepTemplate: scenarioStepTemplate || undefined,
1089
1125
  scopes: scopes ?? undefined,
1090
1126
  implementationHash,
1091
1127
  version,
@@ -1182,7 +1218,7 @@ export const addFunctions: AddWiring = (
1182
1218
  return
1183
1219
  }
1184
1220
 
1185
- if (remote) {
1221
+ if (remote && !isScenarioStep) {
1186
1222
  state.rpc.invokedFunctions.add(pikkuFuncId)
1187
1223
  // The consumer-facing surface a wireRemoteAddon imports (mirrors exposedMeta)
1188
1224
  state.rpc.remoteMeta[name] = pikkuFuncId
@@ -1192,7 +1228,7 @@ export const addFunctions: AddWiring = (
1192
1228
  })
1193
1229
  }
1194
1230
 
1195
- if (expose) {
1231
+ if (expose && !isScenarioStep) {
1196
1232
  state.rpc.exposedMeta[name] = pikkuFuncId
1197
1233
  state.rpc.exposedFiles.set(name, {
1198
1234
  path: node.getSourceFile().fileName,
@@ -1202,12 +1238,14 @@ export const addFunctions: AddWiring = (
1202
1238
  state.serviceAggregation.usedFunctions.add(pikkuFuncId)
1203
1239
  }
1204
1240
 
1205
- // We add it to internal meta to allow autocomplete for everything
1206
- state.rpc.internalMeta[name] = pikkuFuncId
1241
+ if (!isScenarioStep) {
1242
+ // We add it to internal meta to allow autocomplete for everything
1243
+ state.rpc.internalMeta[name] = pikkuFuncId
1207
1244
 
1208
- if (version !== undefined) {
1209
- state.rpc.internalMeta[pikkuFuncId] = pikkuFuncId
1210
- state.rpc.invokedFunctions.add(pikkuFuncId)
1245
+ if (version !== undefined) {
1246
+ state.rpc.internalMeta[pikkuFuncId] = pikkuFuncId
1247
+ state.rpc.invokedFunctions.add(pikkuFuncId)
1248
+ }
1211
1249
  }
1212
1250
 
1213
1251
  // But we only import the actual function if it's actually invoked to keep