@speqkit/plugin-use 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 speqkit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # @speqkit/plugin-use
2
+
3
+ Composition: calling something declared somewhere else.
4
+
5
+ ```yaml
6
+ # speq.yaml
7
+ plugins:
8
+ - use
9
+
10
+ use:
11
+ modulesDir: modules # defaults, all relative to the project root
12
+ sharedDir: shared
13
+ fixturesDir: fixtures
14
+ ```
15
+
16
+ One step type, three forms — a shared block, a module action, a fixture. They
17
+ differ only in what they hand back, which is why they are one plugin and one
18
+ keyword: a tester should not have to learn where our filing cabinet has a
19
+ divider.
20
+
21
+ ```yaml
22
+ steps:
23
+ - id: setup
24
+ type: use
25
+ ref: register-tenant # shared/register-tenant.yaml
26
+
27
+ - id: category
28
+ type: use
29
+ action: menu.createCategory # modules/menu.yaml, action `createCategory`
30
+ properties:
31
+ accessToken: "${setup.token}"
32
+ name: "starters"
33
+
34
+ - id: item
35
+ type: use
36
+ fixture: menu-item # fixtures/menu-item.yaml
37
+ overrides:
38
+ name: "speq-item"
39
+
40
+ - type: http
41
+ method: POST
42
+ url: "/categories/${category.id}/items"
43
+ body: "${item}"
44
+ assert:
45
+ - type: status
46
+ expected: 201
47
+ ```
48
+
49
+ ## What each form hands back
50
+
51
+ A `use` step is a step: it binds by `id` like every other one, and `${id.…}`
52
+ reads what it published.
53
+
54
+ **A shared block** is a file of steps, pulled into the tests that need the same
55
+ world built. Without `returns` it publishes its own steps by id; with one, it
56
+ publishes exactly what it says and nothing else.
57
+
58
+ ```yaml
59
+ # shared/register-tenant.yaml
60
+ steps:
61
+ - id: tenant
62
+ type: http
63
+ method: POST
64
+ url: /auth/register
65
+ body: { slug: "${tenantSlug}" }
66
+ assert:
67
+ - type: status
68
+ expected: 201
69
+
70
+ returns:
71
+ token: "${tenant.body.access_token}"
72
+ restaurantId: "${tenant.body.restaurant.id}"
73
+ ```
74
+
75
+ `returns` is worth adding the moment a block is used more than twice. Without
76
+ it every caller reaches through the block's internals — `${setup.tenant.body.
77
+ access_token}` — and renaming a step inside the block breaks fifty tests that
78
+ never mentioned it.
79
+
80
+ **A module action** is the same thing with parameters. `properties` are the
81
+ variables its steps see, and they are declared so a caller that forgets one is
82
+ told before the run rather than during it.
83
+
84
+ ```yaml
85
+ # modules/menu.yaml
86
+ actions:
87
+ createCategory:
88
+ properties: [accessToken, name]
89
+ steps:
90
+ - id: created
91
+ type: http
92
+ method: POST
93
+ url: /categories
94
+ headers: { authorization: "Bearer ${accessToken}" }
95
+ body: { name: "${name}" }
96
+ returns:
97
+ id: "${created.body.id}"
98
+ ```
99
+
100
+ An action's steps run in a child scope, so `${created…}` is invisible to the
101
+ test that called it. That is the point: what escapes an action is its
102
+ `returns`, which makes the action's insides free to change.
103
+
104
+ **A fixture** is a call whose result is data rather than an effect — a body
105
+ built somewhere else so the test can stay about what it is proving.
106
+ `overrides` merges at the top level and beats what the fixture built, so a test
107
+ pins the one field it means to assert on and leaves the rest generated.
108
+
109
+ ```yaml
110
+ # fixtures/menu-item.yaml
111
+ fixture:
112
+ build:
113
+ name: "${gen:string}"
114
+ description: "${gen:string}"
115
+ ```
116
+
117
+ ## Where files are looked up
118
+
119
+ Paths are relative to the **project root**, or bare inside the directory for
120
+ their kind: `ref: register-tenant` is `shared/register-tenant.yaml`, and
121
+ `ref: blocks/other.yaml` is `<root>/blocks/other.yaml`. The `.yaml` is
122
+ optional.
123
+
124
+ Deliberately not relative to the test file. A plugin is never told which file
125
+ the step it is running came from, so `../../../shared/x.yaml` could only be
126
+ resolved against the wrong directory — and how deep a suite tree happens to be
127
+ is not something a shared block should have an opinion about. A path written
128
+ the v1 way is refused by `speq validate`, with the fix in the hint.
129
+
130
+ ## What it checks before anything runs
131
+
132
+ `speq validate` catches all of it in milliseconds, naming the file and the step:
133
+
134
+ - the block, module or fixture file is not on disk
135
+ - the module has no such action — and which ones it has
136
+ - an action was called without a property it declares
137
+ - two of `ref` / `action` / `fixture` on one step
138
+ - `as:`, which is the v1 spelling for naming a result — write `id:`
139
+
140
+ ## The one thing it cannot say
141
+
142
+ A step type can return a result or throw; the contract gives it no way to
143
+ report `failed`. So when a step *inside* a block fails an assertion, the `use`
144
+ step errors rather than failing, carrying the inner step's id and message. The
145
+ difference between "the system was wrong" and "we could not ask" is lost at
146
+ that boundary. It is written down here rather than papered over — the fix
147
+ belongs in the contract, not in a workaround.
148
+
149
+ ## Migrating from speq v1
150
+
151
+ | v1 | here |
152
+ | --- | --- |
153
+ | `type: use` + `ref: "../../shared/x.yaml"` | `ref: x` — root-relative or bare |
154
+ | `as: parentCategory` | `id: parentCategory` |
155
+ | `{{tenant.response.body.token}}` | `${setup.tenant.body.token}`, or a `returns` on the block |
156
+ | `bodyFromFixture: { ref, overrides }` on a step | a `use` step with `fixture:`, then `body: "${item}"` |
157
+
158
+ `speq migrate` does the mechanical part. Adding `returns` to a block that
159
+ outgrew publishing its internals is the part worth doing by hand.
@@ -0,0 +1,3 @@
1
+ declare const _default: import("@speqkit/plugin-api").PluginSpec;
2
+ export default _default;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAiDA,wBA0JE"}
package/dist/index.js ADDED
@@ -0,0 +1,187 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { definePlugin } from '@speqkit/plugin-api';
5
+ /** The step the plugin appends to a block to read its `returns` out. */
6
+ const CAPTURE = 'use.capture';
7
+ export default definePlugin({
8
+ name: '@speqkit/plugin-use',
9
+ configSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ modulesDir: { type: 'string' },
13
+ sharedDir: { type: 'string' },
14
+ fixturesDir: { type: 'string' }
15
+ },
16
+ additionalProperties: false
17
+ },
18
+ setup(ctx) {
19
+ const root = ctx.host.root;
20
+ const config = ctx.config();
21
+ const dirs = {
22
+ modules: config.modulesDir ?? 'modules',
23
+ shared: config.sharedDir ?? 'shared',
24
+ fixtures: config.fixturesDir ?? 'fixtures'
25
+ };
26
+ // Read once per run: a file cannot change under a suite that is already
27
+ // executing, and a shared block pulled into fifty tests would otherwise be
28
+ // parsed fifty times.
29
+ const files = new Map();
30
+ const read = (path) => {
31
+ if (!files.has(path))
32
+ files.set(path, parseYaml(readFileSync(path, 'utf8')) ?? {});
33
+ return files.get(path);
34
+ };
35
+ const locate = (spec, dir) => {
36
+ const named = spec.endsWith('.yaml') || spec.endsWith('.yml') ? spec : `${spec}.yaml`;
37
+ if (isAbsolute(named))
38
+ return named;
39
+ // Root-relative on purpose. A plugin is not told which file the step it
40
+ // is running came from, so `../../../shared/x.yaml` could only ever be
41
+ // resolved against the wrong directory — and the depth of a suite tree
42
+ // is not something a shared block should have an opinion about.
43
+ return named.includes('/') ? join(root, named) : join(root, dir, named);
44
+ };
45
+ /**
46
+ * `use.capture` exists so that `returns` is resolved by the kernel in the
47
+ * scope the block ran in, rather than by a second `${...}` implementation
48
+ * living in this plugin and drifting from the first.
49
+ */
50
+ ctx.defineStepType(CAPTURE, {
51
+ schema: { type: 'object', properties: { values: {} }, additionalProperties: false },
52
+ execute: (_exec, input) => (input.values ?? {})
53
+ });
54
+ ctx.defineStepType('use', {
55
+ schema: {
56
+ type: 'object',
57
+ properties: {
58
+ ref: { type: 'string' },
59
+ action: { type: 'string' },
60
+ fixture: { type: 'string' },
61
+ properties: { type: 'object' },
62
+ overrides: { type: 'object' }
63
+ },
64
+ additionalProperties: false
65
+ },
66
+ /**
67
+ * Everything checkable without running anything: that exactly one of the
68
+ * three forms was written, that the file is on disk, that the action
69
+ * exists in the module and got the properties it declares. All of it
70
+ * used to be found out mid-run, from a step that could not name the file
71
+ * it was reading.
72
+ */
73
+ validate(step) {
74
+ const problems = [];
75
+ const forms = ['ref', 'action', 'fixture'].filter((k) => typeof step[k] === 'string');
76
+ if (step.as !== undefined) {
77
+ problems.push({
78
+ path: 'as',
79
+ message: "'as' is the v1 spelling",
80
+ hint: "name a result with 'id', the way every other step does"
81
+ });
82
+ }
83
+ if (forms.length === 0)
84
+ return [...problems, "a 'use' step needs one of 'ref', 'action' or 'fixture'"];
85
+ if (forms.length > 1)
86
+ return [...problems, `'${forms.join("' and '")}' exclude each other`];
87
+ if (typeof step.ref === 'string') {
88
+ if (step.ref.startsWith('.')) {
89
+ problems.push({
90
+ path: 'ref',
91
+ message: `'${step.ref}' is relative to the test file`,
92
+ hint: `paths are relative to the project root, or bare inside ${dirs.shared}/`
93
+ });
94
+ return problems;
95
+ }
96
+ const path = locate(step.ref, dirs.shared);
97
+ if (!existsSync(path))
98
+ return [...problems, { path: 'ref', message: `no such block: ${path}` }];
99
+ const block = read(path);
100
+ if (!Array.isArray(block.steps) || block.steps.length === 0) {
101
+ problems.push({ path: 'ref', message: `${step.ref} has no steps` });
102
+ }
103
+ return problems;
104
+ }
105
+ if (typeof step.fixture === 'string') {
106
+ const path = locate(step.fixture, dirs.fixtures);
107
+ if (!existsSync(path))
108
+ return [...problems, { path: 'fixture', message: `no such fixture: ${path}` }];
109
+ const built = read(path).fixture;
110
+ if (!built || typeof built.build !== 'object') {
111
+ problems.push({ path: 'fixture', message: `${step.fixture} has no 'fixture.build' block` });
112
+ }
113
+ return problems;
114
+ }
115
+ const [moduleName, actionName, ...rest] = String(step.action).split('.');
116
+ if (!moduleName || !actionName || rest.length > 0) {
117
+ return [...problems, { path: 'action', message: `'${String(step.action)}' is not '<module>.<action>'` }];
118
+ }
119
+ const path = locate(moduleName, dirs.modules);
120
+ if (!existsSync(path))
121
+ return [...problems, { path: 'action', message: `no such module: ${path}` }];
122
+ const actions = read(path).actions ?? {};
123
+ const action = actions[actionName];
124
+ if (!action) {
125
+ const known = Object.keys(actions).sort().join(', ') || '(none)';
126
+ return [...problems, { path: 'action', message: `module '${moduleName}' has no action '${actionName}'; it has: ${known}` }];
127
+ }
128
+ const given = new Set(Object.keys(step.properties ?? {}));
129
+ for (const required of action.properties ?? []) {
130
+ if (!given.has(required)) {
131
+ problems.push({ path: 'properties', message: `action '${String(step.action)}' needs '${required}'` });
132
+ }
133
+ }
134
+ return problems;
135
+ },
136
+ async execute(exec, input) {
137
+ if (typeof input.fixture === 'string') {
138
+ const built = read(locate(input.fixture, dirs.fixtures)).fixture?.build ?? {};
139
+ // Top-level merge, and the override wins: a test that pins one field
140
+ // to assert on it should not have to restate the other five.
141
+ return exec.resolveDeep({ ...built, ...(input.overrides ?? {}) });
142
+ }
143
+ if (typeof input.ref === 'string') {
144
+ const block = read(locate(input.ref, dirs.shared));
145
+ return runBlock(exec, block, {}, input.ref);
146
+ }
147
+ const [moduleName, actionName] = String(input.action).split('.');
148
+ const action = (read(locate(moduleName, dirs.modules)).actions ?? {})[actionName];
149
+ const properties = input.properties ?? {};
150
+ return runBlock(exec, action, properties, String(input.action));
151
+ }
152
+ });
153
+ }
154
+ });
155
+ /**
156
+ * Runs a block's steps in a child scope and hands back what it publishes.
157
+ *
158
+ * A block with `returns` publishes exactly that. Without one, the caller gets
159
+ * the block's steps by id — convenient for a shared setup that was written
160
+ * before anyone thought about its interface, and the reason `returns` is worth
161
+ * adding to one that outgrows it.
162
+ */
163
+ async function runBlock(exec, block, vars, label) {
164
+ const steps = (block.steps ?? []);
165
+ const returns = block.returns;
166
+ const body = returns ? [...steps, { id: CAPTURE, type: CAPTURE, values: returns }] : steps;
167
+ const records = await exec.runSteps(body, { vars, label });
168
+ const broken = records.find((r) => r.status !== 'passed');
169
+ if (broken) {
170
+ // A step type has no way to report `failed`, so an inner failure surfaces
171
+ // as this step erroring. The message carries the inner one, which is what
172
+ // a reader needs; the distinction between "the system was wrong" and "we
173
+ // could not ask" is lost at this boundary, and that is written down rather
174
+ // than papered over.
175
+ throw new Error(`${label}: step ${broken.id ? `'${broken.id}' ` : ''}(${broken.type}) ${broken.status}` +
176
+ (broken.message ? ` — ${broken.message}` : ''));
177
+ }
178
+ if (returns)
179
+ return (records.at(-1)?.result ?? {});
180
+ const published = {};
181
+ for (const record of records) {
182
+ if (record.id)
183
+ published[record.id] = record.result;
184
+ }
185
+ return published;
186
+ }
187
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@speqkit/plugin-use",
3
+ "version": "0.1.0",
4
+ "description": "Composition as a plugin: shared blocks, module actions and fixtures, called from a test with `use`.",
5
+ "license": "MIT",
6
+ "author": "Stepan Kaziatko",
7
+ "homepage": "https://github.com/speqkit/speqkit#readme",
8
+ "bugs": "https://github.com/speqkit/speqkit/issues",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/speqkit/speqkit.git",
12
+ "directory": "packages/plugin-use"
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "keywords": [
22
+ "speqkit-plugin"
23
+ ],
24
+ "dependencies": {
25
+ "yaml": "^2.6.1"
26
+ },
27
+ "peerDependencies": {
28
+ "@speqkit/plugin-api": "^0.9.0"
29
+ },
30
+ "devDependencies": {
31
+ "@speqkit/plugin-api": "0.9.0",
32
+ "@speqkit/test-kit": "0.1.0",
33
+ "speqkit": "0.2.0"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "engines": {
40
+ "node": ">=20.0.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,251 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { isAbsolute, join } from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+ import { definePlugin, type StepDef, type StepRecord, type ValidationProblem } from '@speqkit/plugin-api'
5
+
6
+ /**
7
+ * Composition: calling something declared somewhere else.
8
+ *
9
+ * It is the most used step of a real suite by a wide margin — in the corpus
10
+ * this plugin was written against, `use` outnumbers the HTTP step it composes
11
+ * — and it is one step type rather than three plugins because a shared block,
12
+ * a module action and a fixture differ only in what they hand back. Splitting
13
+ * them would mean explaining to a tester what an "action" is as opposed to a
14
+ * "block", which is our filing system, not their problem.
15
+ *
16
+ * Nothing here needs the kernel to grow: nested steps go through
17
+ * `ctx.runSteps`, and the child scope it opens is exactly what keeps an
18
+ * action's internals from leaking into the test that called it.
19
+ */
20
+
21
+ interface UseConfig {
22
+ /** Where module files live, relative to the project root. */
23
+ modulesDir?: string
24
+ /** Where shared blocks live. */
25
+ sharedDir?: string
26
+ /** Where fixtures live. */
27
+ fixturesDir?: string
28
+ }
29
+
30
+ interface Block {
31
+ steps?: StepDef[]
32
+ returns?: Record<string, unknown>
33
+ }
34
+
35
+ interface Action extends Block {
36
+ properties?: string[]
37
+ }
38
+
39
+ interface Module {
40
+ actions?: Record<string, Action>
41
+ }
42
+
43
+ interface Fixture {
44
+ fixture?: { build?: Record<string, unknown>; schemaRef?: string }
45
+ }
46
+
47
+ /** The step the plugin appends to a block to read its `returns` out. */
48
+ const CAPTURE = 'use.capture'
49
+
50
+ export default definePlugin({
51
+ name: '@speqkit/plugin-use',
52
+ configSchema: {
53
+ type: 'object',
54
+ properties: {
55
+ modulesDir: { type: 'string' },
56
+ sharedDir: { type: 'string' },
57
+ fixturesDir: { type: 'string' }
58
+ },
59
+ additionalProperties: false
60
+ },
61
+
62
+ setup(ctx) {
63
+ const root = ctx.host.root
64
+ const config = ctx.config<UseConfig>()
65
+ const dirs = {
66
+ modules: config.modulesDir ?? 'modules',
67
+ shared: config.sharedDir ?? 'shared',
68
+ fixtures: config.fixturesDir ?? 'fixtures'
69
+ }
70
+
71
+ // Read once per run: a file cannot change under a suite that is already
72
+ // executing, and a shared block pulled into fifty tests would otherwise be
73
+ // parsed fifty times.
74
+ const files = new Map<string, unknown>()
75
+ const read = (path: string): unknown => {
76
+ if (!files.has(path)) files.set(path, parseYaml(readFileSync(path, 'utf8')) ?? {})
77
+ return files.get(path)
78
+ }
79
+
80
+ const locate = (spec: string, dir: string): string => {
81
+ const named = spec.endsWith('.yaml') || spec.endsWith('.yml') ? spec : `${spec}.yaml`
82
+ if (isAbsolute(named)) return named
83
+ // Root-relative on purpose. A plugin is not told which file the step it
84
+ // is running came from, so `../../../shared/x.yaml` could only ever be
85
+ // resolved against the wrong directory — and the depth of a suite tree
86
+ // is not something a shared block should have an opinion about.
87
+ return named.includes('/') ? join(root, named) : join(root, dir, named)
88
+ }
89
+
90
+ /**
91
+ * `use.capture` exists so that `returns` is resolved by the kernel in the
92
+ * scope the block ran in, rather than by a second `${...}` implementation
93
+ * living in this plugin and drifting from the first.
94
+ */
95
+ ctx.defineStepType(CAPTURE, {
96
+ schema: { type: 'object', properties: { values: {} }, additionalProperties: false },
97
+ execute: (_exec, input) => (input.values ?? {}) as Record<string, unknown>
98
+ })
99
+
100
+ ctx.defineStepType('use', {
101
+ schema: {
102
+ type: 'object',
103
+ properties: {
104
+ ref: { type: 'string' },
105
+ action: { type: 'string' },
106
+ fixture: { type: 'string' },
107
+ properties: { type: 'object' },
108
+ overrides: { type: 'object' }
109
+ },
110
+ additionalProperties: false
111
+ },
112
+
113
+ /**
114
+ * Everything checkable without running anything: that exactly one of the
115
+ * three forms was written, that the file is on disk, that the action
116
+ * exists in the module and got the properties it declares. All of it
117
+ * used to be found out mid-run, from a step that could not name the file
118
+ * it was reading.
119
+ */
120
+ validate(step) {
121
+ const problems: (string | ValidationProblem)[] = []
122
+ const forms = (['ref', 'action', 'fixture'] as const).filter((k) => typeof step[k] === 'string')
123
+
124
+ if (step.as !== undefined) {
125
+ problems.push({
126
+ path: 'as',
127
+ message: "'as' is the v1 spelling",
128
+ hint: "name a result with 'id', the way every other step does"
129
+ })
130
+ }
131
+ if (forms.length === 0) return [...problems, "a 'use' step needs one of 'ref', 'action' or 'fixture'"]
132
+ if (forms.length > 1) return [...problems, `'${forms.join("' and '")}' exclude each other`]
133
+
134
+ if (typeof step.ref === 'string') {
135
+ if (step.ref.startsWith('.')) {
136
+ problems.push({
137
+ path: 'ref',
138
+ message: `'${step.ref}' is relative to the test file`,
139
+ hint: `paths are relative to the project root, or bare inside ${dirs.shared}/`
140
+ })
141
+ return problems
142
+ }
143
+ const path = locate(step.ref, dirs.shared)
144
+ if (!existsSync(path)) return [...problems, { path: 'ref', message: `no such block: ${path}` }]
145
+ const block = read(path) as Block
146
+ if (!Array.isArray(block.steps) || block.steps.length === 0) {
147
+ problems.push({ path: 'ref', message: `${step.ref} has no steps` })
148
+ }
149
+ return problems
150
+ }
151
+
152
+ if (typeof step.fixture === 'string') {
153
+ const path = locate(step.fixture, dirs.fixtures)
154
+ if (!existsSync(path)) return [...problems, { path: 'fixture', message: `no such fixture: ${path}` }]
155
+ const built = (read(path) as Fixture).fixture
156
+ if (!built || typeof built.build !== 'object') {
157
+ problems.push({ path: 'fixture', message: `${step.fixture} has no 'fixture.build' block` })
158
+ }
159
+ return problems
160
+ }
161
+
162
+ const [moduleName, actionName, ...rest] = String(step.action).split('.')
163
+ if (!moduleName || !actionName || rest.length > 0) {
164
+ return [...problems, { path: 'action', message: `'${String(step.action)}' is not '<module>.<action>'` }]
165
+ }
166
+ const path = locate(moduleName, dirs.modules)
167
+ if (!existsSync(path)) return [...problems, { path: 'action', message: `no such module: ${path}` }]
168
+
169
+ const actions = (read(path) as Module).actions ?? {}
170
+ const action = actions[actionName]
171
+ if (!action) {
172
+ const known = Object.keys(actions).sort().join(', ') || '(none)'
173
+ return [...problems, { path: 'action', message: `module '${moduleName}' has no action '${actionName}'; it has: ${known}` }]
174
+ }
175
+ const given = new Set(Object.keys((step.properties as Record<string, unknown>) ?? {}))
176
+ for (const required of action.properties ?? []) {
177
+ if (!given.has(required)) {
178
+ problems.push({ path: 'properties', message: `action '${String(step.action)}' needs '${required}'` })
179
+ }
180
+ }
181
+ return problems
182
+ },
183
+
184
+ async execute(exec, input) {
185
+ if (typeof input.fixture === 'string') {
186
+ const built = (read(locate(input.fixture, dirs.fixtures)) as Fixture).fixture?.build ?? {}
187
+ // Top-level merge, and the override wins: a test that pins one field
188
+ // to assert on it should not have to restate the other five.
189
+ return exec.resolveDeep({ ...built, ...((input.overrides as Record<string, unknown>) ?? {}) })
190
+ }
191
+
192
+ if (typeof input.ref === 'string') {
193
+ const block = read(locate(input.ref, dirs.shared)) as Block
194
+ return runBlock(exec, block, {}, input.ref)
195
+ }
196
+
197
+ const [moduleName, actionName] = String(input.action).split('.')
198
+ const action = ((read(locate(moduleName!, dirs.modules)) as Module).actions ?? {})[actionName!]!
199
+ const properties = (input.properties as Record<string, unknown>) ?? {}
200
+ return runBlock(exec, action, properties, String(input.action))
201
+ }
202
+ })
203
+ }
204
+ })
205
+
206
+ /**
207
+ * Runs a block's steps in a child scope and hands back what it publishes.
208
+ *
209
+ * A block with `returns` publishes exactly that. Without one, the caller gets
210
+ * the block's steps by id — convenient for a shared setup that was written
211
+ * before anyone thought about its interface, and the reason `returns` is worth
212
+ * adding to one that outgrows it.
213
+ */
214
+ async function runBlock(
215
+ exec: ExecLike,
216
+ block: Block,
217
+ vars: Record<string, unknown>,
218
+ label: string
219
+ ): Promise<Record<string, unknown>> {
220
+ const steps = (block.steps ?? []) as StepDef[]
221
+ const returns = block.returns
222
+ const body = returns ? [...steps, { id: CAPTURE, type: CAPTURE, values: returns }] : steps
223
+
224
+ const records = await exec.runSteps(body, { vars, label })
225
+ const broken = records.find((r) => r.status !== 'passed')
226
+ if (broken) {
227
+ // A step type has no way to report `failed`, so an inner failure surfaces
228
+ // as this step erroring. The message carries the inner one, which is what
229
+ // a reader needs; the distinction between "the system was wrong" and "we
230
+ // could not ask" is lost at this boundary, and that is written down rather
231
+ // than papered over.
232
+ throw new Error(
233
+ `${label}: step ${broken.id ? `'${broken.id}' ` : ''}(${broken.type}) ${broken.status}` +
234
+ (broken.message ? ` — ${broken.message}` : '')
235
+ )
236
+ }
237
+
238
+ if (returns) return (records.at(-1)?.result ?? {}) as Record<string, unknown>
239
+
240
+ const published: Record<string, unknown> = {}
241
+ for (const record of records) {
242
+ if (record.id) published[record.id] = record.result
243
+ }
244
+ return published
245
+ }
246
+
247
+ /** The slice of `ExecContext` a block needs. Kept local so the plugin depends on no import of the kernel. */
248
+ interface ExecLike {
249
+ runSteps(steps: StepDef[], options?: { vars?: Record<string, unknown>; label?: string }): Promise<StepRecord[]>
250
+ resolveDeep<T>(value: T): T
251
+ }