@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,188 @@
1
+ ## 0.12.48
2
+
3
+ ### Patch Changes
4
+
5
+ - 04b8607: Make the unmodellable-for-of diagnostic tell you what is actually wrong. It always blamed the iterable ("its iterable must be a data array"), which was false whenever the real cause was a `workflow.do` nested inside an `if`/`switch` — a DSL fanout body is a flat list of steps with no branch member, so the diagnostic now says so and points at `.filter` or `pikkuWorkflowComplexFunc`. Also stops the same diagnostic firing on a `for-of` that contains no workflow call at all: a loop that only massages locals has no step to lose, so erroring on it was a false positive.
6
+ - 7c8f015: Hard-error on a for-of a DSL workflow can't model, instead of silently dropping it.
7
+
8
+ A `pikkuWorkflowFunc` (DSL) whose body contained a computed/counting loop — e.g. `for (const i of [...Array(n).keys()])` — used to serialize with **zero steps**: the DSL extractor only models a for-of as a sequential fanout over a data-array identifier/field (`data.items`), so a non-path iterable made `extractSequentialFanout` return null and the extractor dropped the loop _and every `workflow.do` inside it_. The invoked functions then never entered `invokedFunctions`, so they got no `addFunction()` registration and threw `Function not found` at runtime — a silent codegen footgun that bricked every prod sandbox create.
9
+
10
+ Now the extractor pushes a validation error naming the offending for-of, so `pikkuWorkflowFunc` reports `INVALID_DSL_WORKFLOW` at codegen. A genuine control-flow/counting loop belongs in `pikkuWorkflowComplexFunc`, which falls back to the basic AST walk that _does_ register loop-invoked functions.
11
+
12
+ - 5962e51: Give the inspector's program the project's tsconfig path mappings.
13
+
14
+ The inspector builds its own TypeScript program rather than reusing the project's, which is what keeps a cold run affordable — but it hand-rolled its compiler options and so had no `paths`. An import the project resolves only through a `paths` entry therefore did not resolve inside the inspector, and the factory the inspected function was passed to came back as `any`. A function whose input type comes from that factory's contextual type — rather than an explicit generic or a named `input:` schema — then recorded no input at all, silently, with no diagnostic.
15
+
16
+ `inspect` takes a new `tsconfig` option (the CLI passes `config.tsconfig`, including on a setup-only run, where `schemaConfig` is absent). Only resolution keys are copied across — `baseUrl`, `paths`, `rootDirs`, `pathsBasePath`. Type-inference options such as `strict` deliberately stay the inspector's own, because changing those would change the types it records.
17
+
18
+ This was latent rather than live: the generic path reads type _nodes_, the schema path reads an identifier, output types come from the handler body, and JSON schemas are generated by a separate tsconfig-aware program — so nothing in the tree reached the contextual-type fallback. Regenerating the e2e project and the `functions` template produces byte-identical output.
19
+
20
+ - 5962e51: Add `pikkuFeature`, a grouping primitive for scenarios.
21
+
22
+ A feature groups scenarios the way gherkin's `Feature:` groups `Scenario:`, and gets `Examples:` for free as an ordinary loop:
23
+
24
+ ```ts
25
+ export const credentialFeature = pikkuFeature({
26
+ name: 'Credential API',
27
+ tags: ['credential'],
28
+ before: startsMockOAuthServer,
29
+ after: stopsMockOAuthServer,
30
+ scenarios: [
31
+ credentialLazyLoadScenario,
32
+ ...['stripe', 'google', 'hmac-key'].map((name) => ({
33
+ scenario: credentialRoundTripScenario,
34
+ data: { name },
35
+ })),
36
+ ],
37
+ })
38
+ ```
39
+
40
+ - Scenarios are referenced by **imported identifier**, not by string name, so a renamed or deleted scenario is a compile error rather than a silent skip. A `{ scenario, data }` entry's `data` is typed against that scenario's own input.
41
+ - Feature hooks run **once around the whole group** (`before → a → b → c → after`), not per scenario, and `after` runs in a `finally`. Per-scenario setup stays the scenario's own `before`; gherkin's `Background:` is deliberately not expressible.
42
+ - A scenario's effective tags are its own plus its feature's, so `--tags credential` selects through the feature.
43
+ - New `--features` selector on `pikku scenario run`, and `pikku scenario list` now prints features with their scenarios indented. Every filter narrows the same plan, so narrowing a feature to two of its five scenarios still runs its hooks exactly once around those two.
44
+ - The **feature is the run unit**: `--flows` on a scenario whose every feature entry carries `data` errors and names the features containing it, because the feature is what supplies that data. A scenario referenced bare anywhere, or in no feature at all, still runs standalone.
45
+
46
+ `pikkuFeature` infers its scenario list with a `const` generic, so `CoreFeature['scenarios']` is `readonly` — otherwise the emitted `addFeature(id, feature)` call does not typecheck.
47
+
48
+ Membership is resolved at runtime by object identity — `pikkuScenario` returns its config verbatim, so a feature holds the very object that was registered. That is what lets the scenario list be built by a loop, which no static analysis could enumerate. It also means a scenario constructed inline inside a feature is never registered, and is reported as unresolved rather than silently running as something else.
49
+
50
+ - 5962e51: Add `before` / `after` hooks to `pikkuScenario`, and make an unextractable scenario a hard error.
51
+
52
+ A scenario config now takes `before` and `after`. Both have the same signature as `func` — `(services, data, wire)` — with the return value discarded, so there is no new type to learn and a hook reaches the app the same way the body does, through `wire.actors`:
53
+
54
+ ```ts
55
+ export const credentialScenario = pikkuScenario({
56
+ title: 'A credential is loaded on first use',
57
+ tags: ['scenario', 'credential'],
58
+ before: resetsCredentials,
59
+ after: removesInstalledAddon,
60
+ func: async (services, data, { scenario, actors }) => { ... },
61
+ })
62
+ ```
63
+
64
+ - `before` throwing skips the body and fails the run, but `after` still runs.
65
+ - `after` always runs, in a `finally`. Throwing fails a run that would otherwise have passed; on an already-failed run it attaches as the `cause` and never replaces the original error.
66
+ - Neither runs when the run is suspended or waiting — teardown only fires at a terminal outcome.
67
+ - Hooks are not ladder rows: the runner records nothing for them, and a failure is labelled by phase via the new `ScenarioHookError`.
68
+ - Hooks are scenario-only. A `before`/`after` on a `pikkuWorkflowFunc` never runs — a workflow is durable and resumable, so a callback that reran on every replay would have no honest meaning.
69
+
70
+ Two fixes that scenarios needed to be safe to write:
71
+ - A closure in a complex-workflow or scenario body is no longer held to the DSL statement whitelist. A single `try`/`catch` inside any callback previously failed extraction, and the fallback path understands `do`/`sleep` but not `step`/`given`/`when`/`then` — so the scenario registered with **zero steps** and passed vacuously, with no diagnostic. Plain DSL workflows still descend into callbacks, which is what validates fanout bodies.
72
+ - New `PKU679`: a scenario that fails DSL extraction is now a critical error and refuses to register, instead of silently registering empty. A scenario that declares no input parameter at all is legitimate and still extracts.
73
+
74
+ - a436645: Redesign the console's scenarios screen as living documentation of a project's BDD features.
75
+
76
+ The inspector now statically extracts `pikkuFeature` declarations — name, description, tags, the scenarios each one groups (including `{ scenario, data }` examples), and whether it declares `before`/`after` — and the CLI writes them to `<outDir>/scenarios/features.gen.json`, which `MetaService.getFeaturesMeta()` reads and the console addon returns from `getAllMeta`.
77
+
78
+ The scenarios page reads that back as a document: features on the left, and on the right the selected feature's scenarios, each rendered as the given/when/then ladder of prose its author actually wrote, with repeats shown as `for each x in xs`, `Examples:` tables for parameterised entries, skip reasons stated rather than hidden, and each scenario's cast of personas inline. The Flows/Personas segmented control is gone; tags filter the document the same way `pikku scenario run --tags` filters a run.
79
+
80
+ - 47478a4: Let a scenario declare why it is held out of a default run.
81
+
82
+ `pikkuScenario({ skip: 'why' })` keeps the scenario in the plan and reports it as `SKIP <name> (<reason>)` on the ladder, instead of the alternatives available until now: deleting it, commenting it out, or leaving it red. Naming it directly with `--flows` clears the quarantine and runs it; selecting the feature it belongs to does not, because a feature is a group and running the group should not silently drag a quarantined member in.
83
+
84
+ The run report's `skipped` list now carries a reason per scenario rather than assuming `--no-browser`, so a browser scenario held back on a machine with no browser reads differently from one the project quarantined itself.
85
+
86
+ `@pikku/console` gains a test id on the addon detail page's Setup tab, which was previously only reachable through its translated label.
87
+
88
+ - 2434f1b: Fix two silent losses in scenario step extraction.
89
+
90
+ Destructuring a scenario step result (`const { threadId } = await scenario.given(...)`) dropped the
91
+ step from the graph without a diagnostic — the step stayed fully typed and present in the step map,
92
+ then failed at runtime with `Function not found`. It now reports PKU679, like every other step form
93
+ the DSL cannot model.
94
+
95
+ A constant referenced as step input (`{ resourceId: RESOURCE_ID }`) was serialized as
96
+ `{ $ref: 'trigger', path: 'RESOURCE_ID' }` — a read of a trigger field that does not exist. A `const`
97
+ with a literal initializer is now inlined as that literal.
98
+
99
+ - 5962e51: Add `template` to `pikkuScenarioStep`, so a step's reported prose names the values it was called with.
100
+
101
+ `description` documents what a step does, for the console and for whoever reads the source. `template` is what a reader of the report sees, with `{placeholders}` filled from the input the step was actually called with:
102
+
103
+ ```ts
104
+ export const seesAddonCard = pikkuScenarioStep<
105
+ { packageName: string; state?: 'installed' | 'available' },
106
+ { visible: true },
107
+ true
108
+ >({
109
+ name: 'seesAddonCard',
110
+ description: 'sees an addon in the gallery',
111
+ template: 'sees {state} addon {packageName}',
112
+ browser: true,
113
+ func: async (_services, { packageName, state }, { browser }) => { … },
114
+ })
115
+ ```
116
+
117
+ ```
118
+ Then the admin sees at least 10 addons on offer ✓ 3ms
119
+ When the admin searches for stripe ✓ 10ms
120
+ Then the admin sees available addon @pikku/addon-stripe ✓ 77ms
121
+ ```
122
+
123
+ Previously the only way to get that was a `description` at every call site, which meant writing the sentence once per call rather than once per step — and a call site that forgot it reported the same sentence three times in a row.
124
+ - A placeholder with no recorded value renders as nothing and the surrounding whitespace collapses, so an omitted optional input reads as a shorter sentence rather than leaking a literal `{state}` into the report. Type placeholder values so they read as words (`state?: 'installed' | 'available'`, not `installed?: boolean`).
125
+ - A call-site `description` still wins, the same way it already won over the step's `description`.
126
+ - `renderStepTemplate` is exported from `@pikku/core/workflow` alongside `composeStepProse`, so the CLI reporter and the console render identically.
127
+
128
+ Scenario steps now record their input on the run (`inlineStep` persisted `null` for every inline step, so there was nothing for a reporter to interpolate). This is what `getRunSteps` already exposes as `data` for RPC steps.
129
+
130
+ A step called from a loop gets its template too. Its durable name is built at runtime (`sees @pikku/addon-todos`) from a declaration the static meta records verbatim (`sees ${packageName}`), so the two can never match by name — it used to fall back to the bare name, with no keyword, actor or template:
131
+
132
+ ```
133
+ sees @pikku/addon-console ✓ 85ms
134
+ Then the admin sees installed addon @pikku/addon-console ✓ 92ms
135
+ ```
136
+
137
+ The join is by **step function**. A scenario step is dispatched by name exactly as an RPC is, so it now records that name in the run's existing `rpcName` slot — no new field, no schema change in any workflow store. Nothing dispatches off that value anywhere; step identity always comes from the code being replayed.
138
+
139
+ To keep the slot honest, a scenario step is now its own **kind of RPC**, alongside public / private / remote: `FunctionMeta.scenarioStep` marks it, and `rpcExposed` refuses it even if something marks it `expose: true`. Steps were already left out of the RPC registry; this makes "never network-callable" a property the runtime enforces rather than one the registration path happens to produce.
140
+
141
+ `collectScenarioStepProse` now returns `{ byStepName, byStepFunc }` rather than a bare `Map`, and `buildStepLadder` takes that. The step name still wins; the function index only decides steps recorded under a name no declaration carries, and a function called from several sites that disagree on their prose is left out rather than guessed at.
142
+
143
+ - 5962e51: Add `pikkuScenarioStep` — named, typed scenario steps whose body is an ordinary pikku function.
144
+
145
+ A scenario step is referenced by typed string name, the same way `workflow.do` references an RPC, and is checked against a generated `FlattenedScenarioStepMap`:
146
+
147
+ ```ts
148
+ export const buysAnApple = pikkuScenarioStep({
149
+ name: 'buysAnApple',
150
+ description: 'buys an apple',
151
+ func: async (services, data: { qty: number }) => { ... },
152
+ })
153
+
154
+ await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })
155
+ // renders: Given the shopper buys an apple
156
+ ```
157
+
158
+ - `given`/`when`/`then` are sugar over `step`, setting only the prose prefix. The runner renders a step ladder from the recorded run.
159
+ - Steps default to `retries: 0` — a failed assertion is not retried.
160
+ - Steps are deliberately **not** registered as RPCs, so a browser-driving step is never network-callable.
161
+ - `browser: true` steps receive a browser handle on the wire. `@pikku/playwright` is a new package providing the Playwright-backed provider, signing each actor's browser context in through the same actor path the HTTP actors use. Without a provider, `pikku scenario run --no-browser` **skips** browser scenarios instead of failing them.
162
+ - New diagnostics: PKU677 (a `browser: true` step called without an actor) and PKU678 (a step target that is not a static string literal).
163
+ - Fixes `--no-<flag>` boolean negation in the CLI command parser, which previously parsed as an unknown option.
164
+ - Fixes PKU673 (a scenario func destructuring services), which never fired because it ran before function meta existed; it now runs in post-processing.
165
+ - Fixes scenario/workflow steps nested in `for...of` and `Promise.all` being dropped from workflow meta.
166
+
167
+ - Updated dependencies [539ee0b]
168
+ - Updated dependencies [a1a6816]
169
+ - Updated dependencies [dc3e11e]
170
+ - Updated dependencies [24da616]
171
+ - Updated dependencies [04bfe3f]
172
+ - Updated dependencies [5962e51]
173
+ - Updated dependencies [5962e51]
174
+ - Updated dependencies [cd6453c]
175
+ - Updated dependencies [a436645]
176
+ - Updated dependencies [46cf63e]
177
+ - Updated dependencies [9e666bc]
178
+ - Updated dependencies [1c841d8]
179
+ - Updated dependencies [47478a4]
180
+ - Updated dependencies [9e666bc]
181
+ - Updated dependencies [5962e51]
182
+ - Updated dependencies [5962e51]
183
+ - Updated dependencies [61b9bf8]
184
+ - @pikku/core@0.12.70
185
+
1
186
  ## 0.12.47
2
187
 
3
188
  ### Patch Changes
package/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Pikku CLI
1
+ # @pikku/inspector
2
2
 
3
- This package is part of @pikku/mono and is responsible for all the intropsection and file generations.
3
+ The TypeScript inspector behind the Pikku CLI. Walks your source with the
4
+ TypeScript compiler API to discover functions, wirings and their types, and
5
+ produces the state the CLI generates code from.
6
+
7
+ Most users get this transitively via `@pikku/cli`; install it directly only if
8
+ you are building your own tooling on top of Pikku's introspection.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @pikku/inspector
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```typescript
19
+ import { inspect, getInitialInspectorState } from '@pikku/inspector'
20
+
21
+ const state = getInitialInspectorState()
22
+ await inspect(['src'], tsconfigPath, state)
23
+ ```
24
+
25
+ Diagnostics come back as `CodedDiagnostic` values with a stable `ErrorCode`.
26
+
27
+ ## Docs
28
+
29
+ https://pikku.dev/docs
@@ -0,0 +1,19 @@
1
+ import type { AddWiring } from '../types.js';
2
+ /**
3
+ * Inspector for `pikkuFeature()` calls.
4
+ *
5
+ * A feature is the document structure the console renders — its name,
6
+ * description, tags and the order of its scenarios are what a reader sees — so
7
+ * everything the config states literally is extracted here.
8
+ *
9
+ * What cannot be extracted is a scenarios entry that is not literal:
10
+ * `...['stripe', 'google'].map((name) => ({ scenario, data: { name } }))` is
11
+ * ordinary TypeScript, and no AST walk can enumerate it. Those entries are
12
+ * counted in `unresolvedEntries` rather than dropped, and membership for them
13
+ * is resolved at runtime by object identity (`resolveFeatureScenarios`), where
14
+ * the array has actually been evaluated.
15
+ *
16
+ * The export location is recorded either way, which is what the CLI needs to
17
+ * emit its `addFeature(...)` wiring line.
18
+ */
19
+ export declare const addFeature: AddWiring;
@@ -0,0 +1,200 @@
1
+ import * as ts from 'typescript';
2
+ import { extractFunctionName } from '../utils/extract-function-name.js';
3
+ import { ErrorCode } from '../error-codes.js';
4
+ /**
5
+ * Reads a fully literal expression into a plain value.
6
+ *
7
+ * Used for a `{ scenario, data }` entry's `data`, which is gherkin's
8
+ * `Examples:` row. Anything not literal yields `undefined` — a computed row
9
+ * belongs to the runtime-resolved portion, not to the extracted meta.
10
+ */
11
+ const literalValue = (node) => {
12
+ if (ts.isStringLiteralLike(node)) {
13
+ return node.text;
14
+ }
15
+ if (ts.isNumericLiteral(node)) {
16
+ return Number(node.text);
17
+ }
18
+ if (node.kind === ts.SyntaxKind.TrueKeyword) {
19
+ return true;
20
+ }
21
+ if (node.kind === ts.SyntaxKind.FalseKeyword) {
22
+ return false;
23
+ }
24
+ if (node.kind === ts.SyntaxKind.NullKeyword) {
25
+ return null;
26
+ }
27
+ if (ts.isPrefixUnaryExpression(node) &&
28
+ node.operator === ts.SyntaxKind.MinusToken &&
29
+ ts.isNumericLiteral(node.operand)) {
30
+ return -Number(node.operand.text);
31
+ }
32
+ if (ts.isArrayLiteralExpression(node)) {
33
+ const values = [];
34
+ for (const element of node.elements) {
35
+ const value = literalValue(element);
36
+ if (value === undefined) {
37
+ return undefined;
38
+ }
39
+ values.push(value);
40
+ }
41
+ return values;
42
+ }
43
+ if (ts.isObjectLiteralExpression(node)) {
44
+ const value = {};
45
+ for (const property of node.properties) {
46
+ if (!ts.isPropertyAssignment(property)) {
47
+ return undefined;
48
+ }
49
+ const key = ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)
50
+ ? property.name.text
51
+ : undefined;
52
+ if (key === undefined) {
53
+ return undefined;
54
+ }
55
+ const propertyValue = literalValue(property.initializer);
56
+ if (propertyValue === undefined) {
57
+ return undefined;
58
+ }
59
+ value[key] = propertyValue;
60
+ }
61
+ return value;
62
+ }
63
+ return undefined;
64
+ };
65
+ /**
66
+ * Resolves a scenario reference to the name it is declared under, following an
67
+ * import alias back to the original export so `{ x as y }` records `x`.
68
+ */
69
+ const scenarioName = (node, checker) => {
70
+ if (!ts.isIdentifier(node)) {
71
+ return undefined;
72
+ }
73
+ const symbol = checker.getSymbolAtLocation(node);
74
+ if (!symbol) {
75
+ return node.text;
76
+ }
77
+ const resolved = symbol.flags & ts.SymbolFlags.Alias
78
+ ? checker.getAliasedSymbol(symbol)
79
+ : symbol;
80
+ return resolved.getName() || node.text;
81
+ };
82
+ const getProperty = (config, name) => {
83
+ const property = config.properties.find((p) => ts.isPropertyAssignment(p) &&
84
+ ts.isIdentifier(p.name) &&
85
+ p.name.text === name);
86
+ return property && ts.isPropertyAssignment(property)
87
+ ? property.initializer
88
+ : undefined;
89
+ };
90
+ const hasProperty = (config, name) => config.properties.some((p) => (ts.isPropertyAssignment(p) ||
91
+ ts.isMethodDeclaration(p) ||
92
+ ts.isShorthandPropertyAssignment(p)) &&
93
+ ts.isIdentifier(p.name) &&
94
+ p.name.text === name);
95
+ const stringProperty = (config, name) => {
96
+ const value = getProperty(config, name);
97
+ return value && ts.isStringLiteralLike(value) ? value.text : undefined;
98
+ };
99
+ const stringArrayProperty = (config, name) => {
100
+ const value = getProperty(config, name);
101
+ if (!value || !ts.isArrayLiteralExpression(value)) {
102
+ return undefined;
103
+ }
104
+ const values = value.elements
105
+ .filter(ts.isStringLiteralLike)
106
+ .map((e) => e.text);
107
+ return values.length === value.elements.length ? values : undefined;
108
+ };
109
+ const readEntry = (element, checker) => {
110
+ if (ts.isIdentifier(element)) {
111
+ const scenario = scenarioName(element, checker);
112
+ return scenario ? { scenario } : undefined;
113
+ }
114
+ if (ts.isObjectLiteralExpression(element)) {
115
+ const reference = getProperty(element, 'scenario');
116
+ if (!reference) {
117
+ return undefined;
118
+ }
119
+ const scenario = scenarioName(reference, checker);
120
+ if (!scenario) {
121
+ return undefined;
122
+ }
123
+ const dataNode = getProperty(element, 'data');
124
+ if (!dataNode) {
125
+ return { scenario };
126
+ }
127
+ const data = literalValue(dataNode);
128
+ return data === undefined ? undefined : { scenario, data };
129
+ }
130
+ return undefined;
131
+ };
132
+ /**
133
+ * Inspector for `pikkuFeature()` calls.
134
+ *
135
+ * A feature is the document structure the console renders — its name,
136
+ * description, tags and the order of its scenarios are what a reader sees — so
137
+ * everything the config states literally is extracted here.
138
+ *
139
+ * What cannot be extracted is a scenarios entry that is not literal:
140
+ * `...['stripe', 'google'].map((name) => ({ scenario, data: { name } }))` is
141
+ * ordinary TypeScript, and no AST walk can enumerate it. Those entries are
142
+ * counted in `unresolvedEntries` rather than dropped, and membership for them
143
+ * is resolved at runtime by object identity (`resolveFeatureScenarios`), where
144
+ * the array has actually been evaluated.
145
+ *
146
+ * The export location is recorded either way, which is what the CLI needs to
147
+ * emit its `addFeature(...)` wiring line.
148
+ */
149
+ export const addFeature = (logger, node, checker, state) => {
150
+ if (!ts.isCallExpression(node)) {
151
+ return;
152
+ }
153
+ const expression = node.expression;
154
+ if (!ts.isIdentifier(expression) || expression.text !== 'pikkuFeature') {
155
+ return;
156
+ }
157
+ const { exportedName } = extractFunctionName(node, checker, state.rootDir);
158
+ if (!exportedName) {
159
+ logger.critical(ErrorCode.MISSING_NAME, `A pikkuFeature() must be assigned to an export — the export identifier is the feature's id.`);
160
+ return;
161
+ }
162
+ const feature = {
163
+ path: node.getSourceFile().fileName,
164
+ exportedName,
165
+ entries: [],
166
+ unresolvedEntries: 0,
167
+ hasBefore: false,
168
+ hasAfter: false,
169
+ };
170
+ const [config] = node.arguments;
171
+ if (config && ts.isObjectLiteralExpression(config)) {
172
+ const name = stringProperty(config, 'name');
173
+ const description = stringProperty(config, 'description');
174
+ const tags = stringArrayProperty(config, 'tags');
175
+ if (name !== undefined) {
176
+ feature.name = name;
177
+ }
178
+ if (description !== undefined) {
179
+ feature.description = description;
180
+ }
181
+ if (tags !== undefined) {
182
+ feature.tags = tags;
183
+ }
184
+ feature.hasBefore = hasProperty(config, 'before');
185
+ feature.hasAfter = hasProperty(config, 'after');
186
+ const scenarios = getProperty(config, 'scenarios');
187
+ if (scenarios && ts.isArrayLiteralExpression(scenarios)) {
188
+ for (const element of scenarios.elements) {
189
+ const entry = readEntry(element, checker);
190
+ if (entry) {
191
+ feature.entries.push(entry);
192
+ }
193
+ else {
194
+ feature.unresolvedEntries += 1;
195
+ }
196
+ }
197
+ }
198
+ }
199
+ state.workflows.featureFiles.set(exportedName, feature);
200
+ };
@@ -273,12 +273,18 @@ export const addFunctions = (logger, node, checker, state, options) => {
273
273
  return;
274
274
  }
275
275
  // Match identifiers that contain both "pikku" and "func" (case insensitive),
276
- // plus pikkuScenario (a workflow wrapper without "func" in its name)
276
+ // plus pikkuScenario and pikkuScenarioStep (workflow wrappers without "func"
277
+ // in their names)
277
278
  const pikkuFuncPattern = /pikku.*func/i;
278
279
  if (!pikkuFuncPattern.test(expression.text) &&
279
- expression.text !== 'pikkuScenario') {
280
+ expression.text !== 'pikkuScenario' &&
281
+ expression.text !== 'pikkuScenarioStep') {
280
282
  return;
281
283
  }
284
+ // A scenario step is bundled like any invoked function, but is deliberately
285
+ // never RPC-registered: a step may drive a browser, so exposing it over the
286
+ // network would put a test harness on the public API surface.
287
+ const isScenarioStep = expression.text === 'pikkuScenarioStep';
282
288
  // only handle calls like pikkuFunc(...)
283
289
  if (!ts.isIdentifier(expression) || !expression.text.startsWith('pikku')) {
284
290
  return;
@@ -304,6 +310,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
304
310
  let workflowQueued;
305
311
  let workflowRetries;
306
312
  let workflowTimeout;
313
+ let scenarioStepBrowser;
314
+ let scenarioStepTemplate;
307
315
  let scopes;
308
316
  let version;
309
317
  let objectNode;
@@ -375,6 +383,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
375
383
  workflowQueued = getPropertyValue(firstArg, 'workflowQueued');
376
384
  workflowRetries = getPropertyValue(firstArg, 'workflowRetries');
377
385
  workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout');
386
+ scenarioStepBrowser = getPropertyValue(firstArg, 'browser');
387
+ scenarioStepTemplate = getPropertyValue(firstArg, 'template');
378
388
  scopes = getArrayPropertyValue(firstArg, 'scopes') ?? undefined;
379
389
  // Extract approvalDescription identifier reference
380
390
  for (const prop of firstArg.properties) {
@@ -773,6 +783,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
773
783
  outputs: outputNames.filter((n) => n !== 'void') ?? null,
774
784
  expose: expose || undefined,
775
785
  remote: remote || undefined,
786
+ scenarioStep: isScenarioStep || undefined,
776
787
  mcp: mcpEnabled || undefined,
777
788
  readonly: readonly_ || undefined,
778
789
  deploy: deploy || undefined,
@@ -781,6 +792,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
781
792
  workflowQueued: workflowQueued === true ? true : undefined,
782
793
  workflowRetries: workflowRetries ?? undefined,
783
794
  workflowTimeout: workflowTimeout ?? undefined,
795
+ scenarioStepBrowser: scenarioStepBrowser === true ? true : undefined,
796
+ scenarioStepTemplate: scenarioStepTemplate || undefined,
784
797
  scopes: scopes ?? undefined,
785
798
  implementationHash,
786
799
  version,
@@ -863,7 +876,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
863
876
  logger.error(`• Function with explicit name '${name}' is not exported, this is not allowed.`);
864
877
  return;
865
878
  }
866
- if (remote) {
879
+ if (remote && !isScenarioStep) {
867
880
  state.rpc.invokedFunctions.add(pikkuFuncId);
868
881
  // The consumer-facing surface a wireRemoteAddon imports (mirrors exposedMeta)
869
882
  state.rpc.remoteMeta[name] = pikkuFuncId;
@@ -872,7 +885,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
872
885
  exportedName,
873
886
  });
874
887
  }
875
- if (expose) {
888
+ if (expose && !isScenarioStep) {
876
889
  state.rpc.exposedMeta[name] = pikkuFuncId;
877
890
  state.rpc.exposedFiles.set(name, {
878
891
  path: node.getSourceFile().fileName,
@@ -881,11 +894,13 @@ export const addFunctions = (logger, node, checker, state, options) => {
881
894
  // Track exposed RPC function for service aggregation
882
895
  state.serviceAggregation.usedFunctions.add(pikkuFuncId);
883
896
  }
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);
897
+ if (!isScenarioStep) {
898
+ // We add it to internal meta to allow autocomplete for everything
899
+ state.rpc.internalMeta[name] = pikkuFuncId;
900
+ if (version !== undefined) {
901
+ state.rpc.internalMeta[pikkuFuncId] = pikkuFuncId;
902
+ state.rpc.invokedFunctions.add(pikkuFuncId);
903
+ }
889
904
  }
890
905
  // But we only import the actual function if it's actually invoked to keep
891
906
  // 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
  };
@@ -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
@@ -12,6 +12,7 @@ export { resolveDeployTarget } from './utils/resolve-deploy-target.js';
12
12
  export { generateCustomTypes, sanitizeTypeName, } from './utils/custom-types-generator.js';
13
13
  export { createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
14
14
  export type { ContractEntry, VersionHashEntry, VersionValidateError, VersionManifest, VersionManifestEntry, } from './utils/contract-hashes.js';
15
+ export type { InspectorFeature, InspectorFeatureEntry } from './types.js';
15
16
  export { serializeMCPJson } from './utils/serialize-mcp-json.js';
16
17
  export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js';
17
18
  export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWorkflows, } from './utils/workflow/dsl/index.js';