@onlineapps/conn-orch-validator 9.0.0 → 11.0.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/CHANGELOG.md +546 -0
- package/README.md +337 -19
- package/docs/DESIGN.md +32 -9
- package/manifests/biz-service.manifest.json +56 -6
- package/package.json +3 -2
- package/src/CookbookTestRunner.js +134 -22
- package/src/ValidationOrchestrator.js +312 -73
- package/src/cli/biz-ci-gate.js +191 -15
- package/src/cli/oa-sync-template.js +23 -8
- package/src/cli/oa-validate.js +70 -2
- package/src/index.js +21 -13
- package/src/lint/scripts/lintScripts.js +65 -18
- package/src/manifest/checks/composeRunnerBlock.js +37 -20
- package/src/manifest/checks/discoveryOrphan.js +2 -1
- package/src/manifest/checks/docsLintBridge.js +79 -21
- package/src/manifest/checks/gitTracked.js +14 -28
- package/src/manifest/checks/libraryPackage.js +3 -1
- package/src/manifest/checks/libraryWorkspace.js +18 -3
- package/src/manifest/checks/readmeRegion.js +9 -1
- package/src/manifest/checks/serviceConfig.js +29 -12
- package/src/manifest/checks/serviceDb.js +176 -7
- package/src/manifest/checks/serviceFiles.js +34 -7
- package/src/manifest/checks/serviceIdentityRows.js +3 -1
- package/src/manifest/checks/serviceRuntime.js +126 -1
- package/src/manifest/discovery.js +25 -7
- package/src/manifest/gitCheckout.js +84 -0
- package/src/manifest/runManifest.js +58 -7
- package/src/manifest/workspaceRoot.js +91 -5
- package/src/sync/serviceTemplate.js +76 -7
- package/src/sync/sharedEnv.js +11 -4
- package/src/sync/uniformFiles.js +91 -21
- package/src/utils/bizCiGateContract.js +25 -1
- package/src/utils/dbAccountGrants.js +126 -0
- package/src/utils/envContract.js +36 -6
- package/src/utils/envReads.js +102 -0
- package/src/utils/installContract.js +46 -5
- package/src/utils/libCompat.js +39 -19
- package/src/utils/preValidation.js +56 -11
- package/src/utils/stepFailure.js +106 -19
- package/src/utils/stepReferences.js +278 -0
- package/src/utils/testCoverageContract.js +60 -2
- package/src/utils/throwawaySchema.js +92 -7
- package/src/validatorIdentity.js +31 -0
- package/src/validators/ServiceStructureValidator.js +47 -15
- package/src/validators/ValidationProofGenerator.js +73 -34
- package/templates/business-service/.dockerignore +9 -1
- package/templates/business-service/.gitlab-ci.yml +199 -35
- package/templates/business-service/Dockerfile +49 -16
- package/templates/business-service/README.md +56 -9
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
- package/templates/business-service/config/env-templates/shared.env +8 -2
- package/templates/business-service/docker-compose.production.yml +9 -0
- package/templates/business-service/docker-compose.yml +17 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +1 -1
- package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
- package/templates/business-service/jest.config.js +9 -1
- package/templates/business-service/package.json.template +1 -1
- package/src/mocks/MockStorage.js +0 -188
package/src/utils/stepFailure.js
CHANGED
|
@@ -15,7 +15,27 @@
|
|
|
15
15
|
* `result.validationErrors` / `result.error` and was discarded at both ends,
|
|
16
16
|
* so a failed biz-hello boot could not be diagnosed from its own logs
|
|
17
17
|
* (automation-gates.md §5 — silence is a defect).
|
|
18
|
+
*
|
|
19
|
+
* `results.steps` holds TWO kinds of record and they are not both steps. A step
|
|
20
|
+
* result is one; the entry `CookbookTestRunner.runCookbooks` pushes for a file
|
|
21
|
+
* the format check rejected is the other — a whole cookbook that produced no
|
|
22
|
+
* step at all. Each says which it is in `kind`, written where the record is
|
|
23
|
+
* made, so nothing here infers it from a missing field
|
|
24
|
+
* (`.claude/rules/architecture-principles.md` §8, Explicit Over Implicit).
|
|
25
|
+
* Until d.516b the two were conflated, and a rejected file was reported as
|
|
26
|
+
* `cookbook "broken.json" step "step #1": …` — a step that does not exist, in
|
|
27
|
+
* the one line whose job is to find the case.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** A record that IS a cookbook step: a `step_id` names it, and one must. */
|
|
31
|
+
const KIND_STEP = 'step';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A record standing for a whole cookbook that never produced a step — the file
|
|
35
|
+
* could not be loaded, or the format check rejected it. It carries no
|
|
36
|
+
* `step_id`, because it is no step.
|
|
18
37
|
*/
|
|
38
|
+
const KIND_COOKBOOK_LOAD_FAILURE = 'cookbook-load-failure';
|
|
19
39
|
|
|
20
40
|
/**
|
|
21
41
|
* Which step this is — for a log line, an error list, anything a human reads.
|
|
@@ -26,29 +46,51 @@
|
|
|
26
46
|
* every conforming cookbook logged `Step undefined: FAILED …` — measured in the
|
|
27
47
|
* live biz-converter log on 2026-08-29 against a cookbook that is correct.
|
|
28
48
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
49
|
+
* **A step without `step_id` is a defect, not a step to be labelled some other
|
|
50
|
+
* way.** Until d.516b this fell back to the position, `step #3 (operation:
|
|
51
|
+
* convert)`, which was the right answer while the shape was still settling: the
|
|
52
|
+
* alternative on the day was `undefined`. It is the wrong answer now. Since
|
|
53
|
+
* `@onlineapps/cookbook-core` 5.0.0 the schema requires `step_id` on every task
|
|
54
|
+
* step (`schemas/cookbook.v2.schema.json` definitions.TaskStep.required lists
|
|
55
|
+
* step_id, type, service, operation), `readCookbookSteps` refuses a step
|
|
56
|
+
* spelling it `id`, `CookbookTestRunner.validateCookbook` refuses a cookbook
|
|
57
|
+
* whose step omits it, and the orchestrator will not run a task step that fails
|
|
58
|
+
* to name its operation either (`@onlineapps/conn-orch-orchestrator`
|
|
59
|
+
* § _requireStepOperation, d.460). So the only step that can still arrive here
|
|
60
|
+
* without one is a broken step, and inventing a name for it is the silent
|
|
61
|
+
* substitution `.claude/rules/architecture-principles.md` §3 forbids — it makes
|
|
62
|
+
* the diagnostics read as if the cookbook were fine.
|
|
63
|
+
*
|
|
64
|
+
* This is about STEPS. A cookbook that produced none is a different record and
|
|
65
|
+
* is never handed to this function (`describeStepFailureWithContext` below).
|
|
66
|
+
*
|
|
67
|
+
* The position is still in the message, because it is what finds the offending
|
|
68
|
+
* step in the file; it is now part of saying WHAT IS WRONG rather than a name
|
|
69
|
+
* standing in for one (§5, `[Context] Problem - Expected/Fix`).
|
|
33
70
|
*
|
|
34
71
|
* @param {Object} step - a cookbook step, or a step result carrying the same keys
|
|
35
72
|
* @param {number} index - zero-based position within the cookbook
|
|
36
|
-
* @returns {string} never empty, never "undefined"
|
|
73
|
+
* @returns {string} the step's own `step_id`; never empty, never "undefined"
|
|
74
|
+
* @throws {Error} when the step names no `step_id`
|
|
37
75
|
*/
|
|
38
76
|
function describeStepIdentity(step, index) {
|
|
39
77
|
if (!step || typeof step !== 'object') {
|
|
40
78
|
throw new Error('[stepFailure] step is required - Expected the cookbook step (or its result) to name in a message');
|
|
41
79
|
}
|
|
42
80
|
|
|
43
|
-
// `step_id` only. `
|
|
44
|
-
//
|
|
81
|
+
// `step_id` only. `readCookbookSteps` refuses a step spelling it `id`, so a
|
|
82
|
+
// step reaching here can no longer carry the retired name.
|
|
45
83
|
if (typeof step.step_id === 'string' && step.step_id.length > 0) return step.step_id;
|
|
46
84
|
|
|
47
85
|
const position = Number.isInteger(index) && index >= 0 ? index + 1 : 1;
|
|
48
86
|
const operation = typeof step.operation === 'string' && step.operation.length > 0
|
|
49
87
|
? ` (operation: ${step.operation})`
|
|
50
88
|
: '';
|
|
51
|
-
|
|
89
|
+
throw new Error(`[stepFailure] Step #${position}${operation} has no step_id - every step of a v2.1 `
|
|
90
|
+
+ 'cookbook is addressed by its own step_id, so there is no name to report this step under '
|
|
91
|
+
+ '(@onlineapps/cookbook-core schemas/cookbook.v2.schema.json definitions.TaskStep.required). '
|
|
92
|
+
+ `Fix: add "step_id" to step #${position} of the cookbook `
|
|
93
|
+
+ '(api/docs/biz/40-cookbooks/format.md § Required fields).');
|
|
52
94
|
}
|
|
53
95
|
|
|
54
96
|
/**
|
|
@@ -89,21 +131,66 @@ function describeStepFailure(stepResult) {
|
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* `results.
|
|
134
|
+
* How a failed record is NAMED, by what it is.
|
|
135
|
+
*
|
|
136
|
+
* `results.steps` carries two kinds and only one of them is a step (d.516b), so
|
|
137
|
+
* the name of a record is a decision about its `kind` — and one decision has one
|
|
138
|
+
* owner. Until d.523 it was made here for the orchestrator's error list and
|
|
139
|
+
* again, differently, in `utils/preValidation.js` for the CLI's, where the
|
|
140
|
+
* second copy did not make it at all: `src/cli/biz-ci-gate.js` printed
|
|
141
|
+
* `step_id` for both kinds, so a rejected cookbook reached the operator as
|
|
142
|
+
* `undefined` — the very shape d.516b removed from the other channel
|
|
143
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
144
|
+
*
|
|
145
|
+
* @param {Object} record a step result, or a cookbook-load failure
|
|
146
|
+
* @returns {string} `cookbook "<file>"` or `step "<step_id>"`
|
|
147
|
+
*/
|
|
148
|
+
function describeRecordIdentity(record) {
|
|
149
|
+
if (!record || typeof record !== 'object') {
|
|
150
|
+
throw new Error('[stepFailure] record is required - Expected a failed entry of results.steps to name '
|
|
151
|
+
+ 'in a message. Fix: pass the record, not its reason.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (record.kind === KIND_COOKBOOK_LOAD_FAILURE) {
|
|
155
|
+
return `cookbook "${record.cookbook || 'unknown cookbook'}"`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return `step "${describeStepIdentity(record, record.stepIndex)}"`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The same reason, prefixed with enough context to find the case. WHICH context
|
|
163
|
+
* depends on what the record is, and the record says so in `kind`:
|
|
95
164
|
*
|
|
96
|
-
*
|
|
165
|
+
* - a cookbook that never produced a step is named by the FILE and nothing
|
|
166
|
+
* else. There is no step in it, so there is no step to name;
|
|
167
|
+
* - a step failure is named by cookbook, step and operation, as before.
|
|
168
|
+
*
|
|
169
|
+
* This is what step 4 puts into `results.errors`.
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} record - a step result, or a cookbook-load failure, carrying
|
|
172
|
+
* `cookbook` and its own `kind`
|
|
97
173
|
* @returns {string}
|
|
98
174
|
*/
|
|
99
|
-
function describeStepFailureWithContext(
|
|
100
|
-
const reason = describeStepFailure(
|
|
101
|
-
const cookbook =
|
|
175
|
+
function describeStepFailureWithContext(record) {
|
|
176
|
+
const reason = describeStepFailure(record);
|
|
177
|
+
const cookbook = record.cookbook || 'unknown cookbook';
|
|
178
|
+
|
|
179
|
+
if (record.kind === KIND_COOKBOOK_LOAD_FAILURE) {
|
|
180
|
+
return `${describeRecordIdentity(record)} could not be run: ${reason}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
102
183
|
// One owner for "which step": `unnamed step` used to be the answer for every
|
|
103
184
|
// conforming cookbook, because the result carried neither identifier.
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
return `cookbook "${cookbook}" step "${stepId}"${operation}: ${reason}`;
|
|
185
|
+
const operation = record.operation ? ` (${record.operation})` : '';
|
|
186
|
+
return `cookbook "${cookbook}" ${describeRecordIdentity(record)}${operation}: ${reason}`;
|
|
107
187
|
}
|
|
108
188
|
|
|
109
|
-
module.exports = {
|
|
189
|
+
module.exports = {
|
|
190
|
+
describeStepIdentity,
|
|
191
|
+
describeRecordIdentity,
|
|
192
|
+
describeStepFailure,
|
|
193
|
+
describeStepFailureWithContext,
|
|
194
|
+
KIND_STEP,
|
|
195
|
+
KIND_COOKBOOK_LOAD_FAILURE
|
|
196
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolveReferencesWith, resolveReferencePath } = require('@onlineapps/cookbook-core');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The run context a Tier-1 cookbook resolves its `{{…}}` references against.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT EXISTS. Until 2026-09-17 the runner executed every step independently:
|
|
9
|
+
* `stepResults` only counted, and nothing a step returned could reach the next
|
|
10
|
+
* one. A recipe therefore could not build the data it needs, so biz-converter's
|
|
11
|
+
* boot recipe depended on a row only a TEST_ONLY seed supplies — and on
|
|
12
|
+
* production Tier-1 answered `NOT_CONVERTIBLE`, the boot exited 1 after ~18
|
|
13
|
+
* minutes of backoff and the container restarted in a loop (measured
|
|
14
|
+
* 2026-09-16). The owner's decision was that the recipe stays as written and the
|
|
15
|
+
* RUNNER learns what the orchestrator already does
|
|
16
|
+
* (`api/docs/governance/confirmations/converter-boot-cookbook.md` 001).
|
|
17
|
+
*
|
|
18
|
+
* ONE RAIL, AND IT IS NOT THIS PACKAGE'S. The syntax, the type rule for a whole
|
|
19
|
+
* expression, the ban on addressing a step by position and the fate of an
|
|
20
|
+
* unresolved reference all belong to the cookbook format —
|
|
21
|
+
* `api/docs/biz/40-cookbooks/variable-references.md`. The traversal of the value
|
|
22
|
+
* and the walk of one path belong to `@onlineapps/cookbook-core`:
|
|
23
|
+
* `resolveReferencesWith()` and `resolveReferencePath()`, the same two functions
|
|
24
|
+
* `WorkflowOrchestrator._resolveInputReferencesAsync` /
|
|
25
|
+
* `_resolveTemplateExpression` call. This module writes no parser; it supplies
|
|
26
|
+
* the SCOPE, which is the only part Tier-1 owns.
|
|
27
|
+
*
|
|
28
|
+
* THE SCOPE IS THE ORCHESTRATOR'S, SPELLED THE SAME WAY. A completed entry is
|
|
29
|
+
* "the step definition unchanged, plus two added fields: `output` and
|
|
30
|
+
* `_execution`" — the section "Runtime context (`context.steps`)" of
|
|
31
|
+
* api/docs/biz/40-cookbooks/format.md — and a reference addresses it by
|
|
32
|
+
* `step_id`, which is what
|
|
33
|
+
* `WorkflowOrchestrator.referenceScope()` puts under the root `steps` (its
|
|
34
|
+
* `steps_by_id` map). A second spelling here, such as a bare `status` beside
|
|
35
|
+
* `output`, would resolve at boot and stay literal in production: precisely the
|
|
36
|
+
* divergence class that broke the converter's boot.
|
|
37
|
+
*
|
|
38
|
+
* WHAT TIER-1 DELIBERATELY DOES NOT CARRY. The orchestrator's context also holds
|
|
39
|
+
* `api_input`, `delivery` and the tenancy keys. A Tier-1 run has no workflow
|
|
40
|
+
* input and no delivery at all, so those roots are absent and a reference to
|
|
41
|
+
* them stays literal — the norm's documented outcome for an unresolvable
|
|
42
|
+
* reference, not a silent substitution.
|
|
43
|
+
*
|
|
44
|
+
* @see api/docs/biz/40-cookbooks/variable-references.md
|
|
45
|
+
* @see api/docs/biz/40-cookbooks/format.md
|
|
46
|
+
* @module utils/stepReferences
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The context one cookbook run starts from: no step has produced anything yet.
|
|
51
|
+
*
|
|
52
|
+
* It is per COOKBOOK, never per runner — `runCookbooks()` executes many
|
|
53
|
+
* cookbooks through one runner and a step of one must not see a step of
|
|
54
|
+
* another.
|
|
55
|
+
*
|
|
56
|
+
* @returns {{steps: Object}} the scope `resolveStepInput` resolves against
|
|
57
|
+
*/
|
|
58
|
+
function createRunContext() {
|
|
59
|
+
return { steps: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the `{{…}}` references in ONE step's input.
|
|
64
|
+
*
|
|
65
|
+
* `input` is the only field expanded. The format names three expansion sites —
|
|
66
|
+
* `step.input`, a `foreach` iterator and a `switch` expression
|
|
67
|
+
* (`variable-references.md` § Limitations) — and a Tier-1 cookbook has only the
|
|
68
|
+
* first: `CookbookTestRunner.validateCookbook` requires `service` and
|
|
69
|
+
* `operation` of every step, so a `foreach` or `switch` step never reaches this
|
|
70
|
+
* runner. `expect` is not on that list and is therefore taken literally, the
|
|
71
|
+
* same as `service`, `operation` and `depends_on`.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} step - the cookbook step as written
|
|
74
|
+
* @param {{steps: Object}} runContext - what earlier steps produced
|
|
75
|
+
* @returns {Promise<*>} the input the handler is called with
|
|
76
|
+
*/
|
|
77
|
+
function resolveStepInput(step, runContext) {
|
|
78
|
+
return resolveReferencesWith(
|
|
79
|
+
step.input,
|
|
80
|
+
(expression) => resolveReferencePath(expression, runContext)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* WHAT A TIER-1 RECIPE MAY WRITE IN `input`, checked before the run starts.
|
|
86
|
+
*
|
|
87
|
+
* Tier-1 is the service's own startup proof, so a recipe it passes must be a
|
|
88
|
+
* recipe production would accept. Two shapes broke that symmetry, both by going
|
|
89
|
+
* QUIET where production is loud:
|
|
90
|
+
*
|
|
91
|
+
* 1. a helper call — `{{webalizeString(…)}}`, `{{string2file(…)}}`. The
|
|
92
|
+
* orchestrator resolves it through its helper registry and throws on a
|
|
93
|
+
* name the registry does not carry
|
|
94
|
+
* (`WorkflowOrchestrator._executeTemplateHelper`). This runner has no
|
|
95
|
+
* registry — deliberately: a helper reaches its own runtime (ContentResolver,
|
|
96
|
+
* files, storage), which is not what a boot probe may do. So the expression
|
|
97
|
+
* resolved to nothing and survived as literal text, and the step ran with
|
|
98
|
+
* `{{webalizeString(…)}}` where a value belonged.
|
|
99
|
+
* 2. a `{{steps.<id>}}` naming a step the recipe does not define. Production
|
|
100
|
+
* refuses such a cookbook at submission with 400 `Invalid cookbook
|
|
101
|
+
* references` (`infra/api_gateway/shared/cookbookReferences.js`,
|
|
102
|
+
* `api/docs/governance/confirmations/cookbook-validation-placement.md` 001
|
|
103
|
+
* — the receiver's own throw is the safety net for the paths that bypass
|
|
104
|
+
* the gateway, and a Tier-1 boot run is exactly such a path). Here it was a
|
|
105
|
+
* literal too.
|
|
106
|
+
*
|
|
107
|
+
* The literal outcome is right for a reference whose VALUE is missing at that
|
|
108
|
+
* moment — a step that has not run, or one that failed
|
|
109
|
+
* (`variable-references.md` § Unresolved references), and those two stay literal
|
|
110
|
+
* below. It is wrong for a name that does not exist in the recipe at all: no
|
|
111
|
+
* run can ever make it resolve.
|
|
112
|
+
*
|
|
113
|
+
* NO SECOND WALK. The traversal and the expression extraction are the same
|
|
114
|
+
* `resolveReferencesWith()` that resolves the input for real one moment later,
|
|
115
|
+
* so this check sees exactly the expressions the resolver will see. The
|
|
116
|
+
* evaluator returns `undefined` for everything: nothing is substituted here,
|
|
117
|
+
* only read.
|
|
118
|
+
*
|
|
119
|
+
* WHERE THE HELPER-CALL SHAPE OUGHT TO LIVE. `@onlineapps/cookbook-core` owns
|
|
120
|
+
* the format and exports the traversal and the path walk, but no definition of
|
|
121
|
+
* "this expression is a helper call" — the only one on the platform is the
|
|
122
|
+
* private regex in `WorkflowOrchestrator._resolveTemplateExpression`, and this
|
|
123
|
+
* module now holds a second copy of it. That is one concern on two rails
|
|
124
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern) and it belongs
|
|
125
|
+
* beside `resolveReferencePath()` in cookbook-core, where both callers could
|
|
126
|
+
* read it. Recorded for the owner rather than fixed here: cookbook-core is
|
|
127
|
+
* another package's manifest.
|
|
128
|
+
*
|
|
129
|
+
* SCOPE. `input` only, which is the single expansion site a Tier-1 cookbook has
|
|
130
|
+
* (`resolveStepInput` above says why), plus `depends_on`, whose values are step
|
|
131
|
+
* names by definition.
|
|
132
|
+
*
|
|
133
|
+
* @see api/docs/biz/40-cookbooks/variable-references.md
|
|
134
|
+
* @see api/docs/governance/confirmations/cookbook-validation-placement.md
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/** `helperName(...)` — the shape `WorkflowOrchestrator` routes to its registry. */
|
|
138
|
+
const HELPER_CALL = /^([a-zA-Z_][a-zA-Z0-9_]*)\(([\s\S]*)\)$/;
|
|
139
|
+
|
|
140
|
+
/** Root of the step namespace; the only root whose names a recipe can be checked against. */
|
|
141
|
+
const STEPS_ROOT = 'steps';
|
|
142
|
+
|
|
143
|
+
function helperCallProblem(stepId, helperName, expression) {
|
|
144
|
+
return `Step ${stepId} input calls the template helper "${helperName}" - `
|
|
145
|
+
+ `Tier-1 runs no helper registry, so "{{${expression}}}" would reach the handler as literal `
|
|
146
|
+
+ 'text while the production orchestrator evaluates it (and throws when the helper name is '
|
|
147
|
+
+ 'unknown). '
|
|
148
|
+
+ 'Fix: remove the helper call from the step input; a Tier-1 recipe builds its values from '
|
|
149
|
+
+ 'earlier steps (api/docs/biz/40-cookbooks/variable-references.md).';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function undefinedStepProblem(stepId, referencedId, expression, knownIds) {
|
|
153
|
+
return `Step ${stepId} references an undefined step - "{{${expression}}}" names "${referencedId}", `
|
|
154
|
+
+ 'which is not a step_id of this cookbook. '
|
|
155
|
+
+ `Expected one of: ${knownIds.join(', ')}. `
|
|
156
|
+
+ 'Fix: correct the step_id in the reference, or add the missing step '
|
|
157
|
+
+ '(api/docs/biz/40-cookbooks/variable-references.md § Unresolved references).';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function undefinedDependencyProblem(stepId, dependencyId, knownIds) {
|
|
161
|
+
return `Step ${stepId} depends on an undefined step - "depends_on" names "${dependencyId}", `
|
|
162
|
+
+ 'which is not a step_id of this cookbook. '
|
|
163
|
+
+ `Expected one of: ${knownIds.join(', ')}. `
|
|
164
|
+
+ 'Fix: correct the step_id in "depends_on", or add the missing step '
|
|
165
|
+
+ '(api/docs/biz/40-cookbooks/format.md § Step definition).';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* What ONE expression is, measured against the names this cookbook defines.
|
|
170
|
+
*
|
|
171
|
+
* A path whose root is not `steps` — `api_input.…`, `context.…`, `current.…` —
|
|
172
|
+
* is left alone: its value comes into being at run time and a recipe says
|
|
173
|
+
* nothing about whether it will. The BRACKET form `steps[0]` is not a step
|
|
174
|
+
* reference either, in this runner or at the gateway; the dotted `steps.0` is,
|
|
175
|
+
* and it names a step no recipe can define.
|
|
176
|
+
*
|
|
177
|
+
* @returns {string|null} the problem, or `null` when the expression is fine
|
|
178
|
+
*/
|
|
179
|
+
function checkExpression(expression, stepId, known, knownIds) {
|
|
180
|
+
const helperCall = expression.match(HELPER_CALL);
|
|
181
|
+
if (helperCall) {
|
|
182
|
+
return helperCallProblem(stepId, helperCall[1], expression);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const parts = expression.split('.');
|
|
186
|
+
if (parts[0] !== STEPS_ROOT || parts.length < 2) return null;
|
|
187
|
+
|
|
188
|
+
const referencedId = parts[1];
|
|
189
|
+
if (known.has(referencedId)) return null;
|
|
190
|
+
|
|
191
|
+
return undefinedStepProblem(stepId, referencedId, expression, knownIds);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The problem this cookbook's step inputs carry, or `null` when they carry none.
|
|
196
|
+
*
|
|
197
|
+
* Returns the FIRST problem rather than throwing, the way
|
|
198
|
+
* `utils/cookbookFormat.checkCookbookFormatVersion` does: the caller owns the
|
|
199
|
+
* `[Context]` of the message it throws, and the caller here is the runner
|
|
200
|
+
* (`.claude/rules/architecture-principles.md` §5).
|
|
201
|
+
*
|
|
202
|
+
* @param {Array<Object>} steps - the cookbook's steps, already proved to be the array shape
|
|
203
|
+
* @returns {Promise<string|null>} the problem text without its context prefix
|
|
204
|
+
*/
|
|
205
|
+
async function checkStepInputExpressions(steps) {
|
|
206
|
+
const knownIds = steps
|
|
207
|
+
.map((step) => step && step.step_id)
|
|
208
|
+
.filter((stepId) => typeof stepId === 'string' && stepId.length > 0);
|
|
209
|
+
const known = new Set(knownIds);
|
|
210
|
+
|
|
211
|
+
for (const step of steps) {
|
|
212
|
+
const dependencies = Array.isArray(step.depends_on) ? step.depends_on : [];
|
|
213
|
+
for (const dependency of dependencies) {
|
|
214
|
+
if (typeof dependency !== 'string' || known.has(dependency)) continue;
|
|
215
|
+
return undefinedDependencyProblem(step.step_id, dependency, knownIds);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let problem = null;
|
|
219
|
+
await resolveReferencesWith(step.input, (expression) => {
|
|
220
|
+
if (problem === null) {
|
|
221
|
+
problem = checkExpression(expression, step.step_id, known, knownIds);
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
});
|
|
225
|
+
if (problem !== null) return problem;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Write one finished step into the run context, under its own name.
|
|
233
|
+
*
|
|
234
|
+
* `output` appears ONLY on a step that passed — "an entry carries `output` and
|
|
235
|
+
* `_execution` once it has completed, not before"
|
|
236
|
+
* (the same "Runtime context (`context.steps`)" section). A step that failed is
|
|
237
|
+
* recorded with its status and no output, so a reference into it resolves to
|
|
238
|
+
* nothing and survives as literal text: the cookbook author sees
|
|
239
|
+
* `{{steps.x.output.id}}` sitting in the failing step's input, which names the
|
|
240
|
+
* real cause.
|
|
241
|
+
*
|
|
242
|
+
* The `step_id` is REQUIRED here rather than assumed. `validateCookbook`
|
|
243
|
+
* already refuses a cookbook whose step has none, so in a run this cannot
|
|
244
|
+
* happen; the check is at the entry of this function because the alternative is
|
|
245
|
+
* a silent `steps[undefined]` — a nameless step that a later reference could
|
|
246
|
+
* never address on purpose (architecture-principles.md §4).
|
|
247
|
+
*
|
|
248
|
+
* @param {{steps: Object}} runContext - the context to write into
|
|
249
|
+
* @param {Object} step - the cookbook step as written
|
|
250
|
+
* @param {{passed: boolean, actual: *}} result - the step's result record
|
|
251
|
+
* @returns {void}
|
|
252
|
+
*/
|
|
253
|
+
function recordStepOutcome(runContext, step, result) {
|
|
254
|
+
if (typeof step.step_id !== 'string' || step.step_id.length === 0) {
|
|
255
|
+
throw new Error('[stepReferences] A step with no step_id cannot enter the run context - Expected a '
|
|
256
|
+
+ 'non-empty "step_id"; a step is addressed by its name and never by its position '
|
|
257
|
+
+ '(api/docs/biz/40-cookbooks/variable-references.md § Rules). '
|
|
258
|
+
+ 'Fix: add "step_id" to the step.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const entry = {
|
|
262
|
+
...step,
|
|
263
|
+
_execution: { status: result.passed ? 'completed' : 'failed' }
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (result.passed) {
|
|
267
|
+
entry.output = result.actual;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
runContext.steps[step.step_id] = entry;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
createRunContext,
|
|
275
|
+
resolveStepInput,
|
|
276
|
+
recordStepOutcome,
|
|
277
|
+
checkStepInputExpressions
|
|
278
|
+
};
|
|
@@ -43,6 +43,23 @@
|
|
|
43
43
|
* why the jest binary is the SERVICE's own (resolved from its `node_modules`),
|
|
44
44
|
* not this package's: a different jest version resolves a different `testMatch`.
|
|
45
45
|
*
|
|
46
|
+
* ## Why an empty set is a finding rather than a clean run
|
|
47
|
+
*
|
|
48
|
+
* Every comparison here iterates a set, so over an empty one it finds no
|
|
49
|
+
* counter-example — and an empty match is jest's own exit 0 with no output.
|
|
50
|
+
* A repository whose jest matched nothing therefore passed, and the gate printed
|
|
51
|
+
* `0 file(s) matched, 0 run by the test:all chain` as if that were a
|
|
52
|
+
* measurement. The same held one level down: a `test:all` step aimed at an empty
|
|
53
|
+
* directory listed 0 files, exited 0, and contributed nothing to either side of
|
|
54
|
+
* any comparison (both measured 2026-09-15, finding d.205).
|
|
55
|
+
*
|
|
56
|
+
* So an empty T, and an empty answer from any jest invocation the chain reaches,
|
|
57
|
+
* are named: a gate that reports OK over something it never observed is the
|
|
58
|
+
* false guarantee automation-gates.md §5 calls a defect of the same severity as
|
|
59
|
+
* a wrong result. Each is reported only where it is the ROOT — an empty T
|
|
60
|
+
* silences the per-step check, and so does a file on disk that the config does
|
|
61
|
+
* not match, because there every empty step is that defect's shadow.
|
|
62
|
+
*
|
|
46
63
|
* ## Why a declared tier exists at all
|
|
47
64
|
*
|
|
48
65
|
* `tests/e2e/mq-invocation` needs a live consumer on the service workflow queue.
|
|
@@ -77,7 +94,8 @@ const { spawnSync } = require('child_process');
|
|
|
77
94
|
|
|
78
95
|
const TEST_COVERAGE_SCOPE = 'files the service jest config matches (jest --listTests) vs. the files '
|
|
79
96
|
+ 'the test:all chain runs, decomposed npm run → jest, plus the scripts declared in contract stackTiers; '
|
|
80
|
-
+ 'and that same matched set vs. every platform test file present on disk (**/tests/**/*.test.js)'
|
|
97
|
+
+ 'and that same matched set vs. every platform test file present on disk (**/tests/**/*.test.js); '
|
|
98
|
+
+ 'and that neither the matched set nor any jest invocation the test:all chain reaches is empty';
|
|
81
99
|
|
|
82
100
|
const TEST_COVERAGE_NOT_MEASURED = 'whether a declared stack tier is ever RUN — the declaration says '
|
|
83
101
|
+ 'why it cannot run in test:all, never that something else runs it';
|
|
@@ -437,10 +455,31 @@ function verifyTestCoverage({ serviceRoot, contract, spawn = spawnSync }) {
|
|
|
437
455
|
const relative = (absolutePath) => path.relative(serviceRoot, absolutePath);
|
|
438
456
|
const violations = [];
|
|
439
457
|
const add = (requirement, message) => violations.push({ requirement, message });
|
|
458
|
+
const has = (requirement) => violations.some((violation) => violation.requirement === requirement);
|
|
440
459
|
|
|
441
460
|
// T — everything the service's jest config matches, asked without a path filter.
|
|
442
461
|
const matched = new Set(ask([]));
|
|
443
462
|
|
|
463
|
+
// T = ∅ — the gate ran and measured nothing.
|
|
464
|
+
//
|
|
465
|
+
// An empty match is jest's own exit 0 with no output (§ listTests), so nothing
|
|
466
|
+
// in the run distinguishes it from a healthy one. And every comparison below
|
|
467
|
+
// iterates a set: over an empty T, T \ (A ∪ B) and D \ T find no
|
|
468
|
+
// counter-example for the same reason an empty sum is zero. The gate printed
|
|
469
|
+
// "OK test-coverage — 0 file(s) matched, 0 run by the test:all chain" and the
|
|
470
|
+
// reader took it for a measurement (measured 2026-09-15, finding d.205).
|
|
471
|
+
//
|
|
472
|
+
// Reported here and nowhere else: when the config matches nothing, every step
|
|
473
|
+
// of the chain lists nothing too, and one defect with one fix must not arrive
|
|
474
|
+
// as one violation per step.
|
|
475
|
+
if (matched.size === 0) {
|
|
476
|
+
add('TEST_SET_EMPTY', 'Nothing measured - the service jest config matches 0 test files, '
|
|
477
|
+
+ 'so "jest --listTests" (the service\'s own jest, no path filter) returns an empty set, every '
|
|
478
|
+
+ 'comparison this gate makes is vacuously satisfied, and the run prints OK over a service whose '
|
|
479
|
+
+ `coverage was never observed. Fix: widen testMatch in jest.config.js to the platform pattern `
|
|
480
|
+
+ `(${PLATFORM_TEST_FILE_PATTERN}), or add the test files the service is missing.`);
|
|
481
|
+
}
|
|
482
|
+
|
|
444
483
|
// D \ T — a suite that is on disk and in no jest answer. Checked FIRST because
|
|
445
484
|
// it is the only defect the other three cannot express: a file jest does not
|
|
446
485
|
// match is missing from both sides of every comparison below, so silence there
|
|
@@ -459,7 +498,26 @@ function verifyTestCoverage({ serviceRoot, contract, spawn = spawnSync }) {
|
|
|
459
498
|
for (const problem of chain.problems) add('TEST_ALL_CHAIN', problem.message);
|
|
460
499
|
const inTestAll = new Set();
|
|
461
500
|
for (const command of chain.commands) {
|
|
462
|
-
|
|
501
|
+
const files = ask(command.args);
|
|
502
|
+
|
|
503
|
+
// A step that lists nothing runs nothing — and says so with exit 0, so the
|
|
504
|
+
// script stays green in every pipeline while measuring nothing. It is what a
|
|
505
|
+
// narrowed pattern, a renamed directory or a moved suite leaves behind, and
|
|
506
|
+
// it is invisible to every other check here: a step contributing no file
|
|
507
|
+
// changes neither side of any comparison.
|
|
508
|
+
// Only where the matched set is the whole truth: T non-empty (checked above)
|
|
509
|
+
// and nothing on disk left outside it. A config that misses files on disk is
|
|
510
|
+
// already named, and every step aimed at those files lists 0 as a CONSEQUENCE
|
|
511
|
+
// of it — reporting the shadow beside the thing casting it buries the fix.
|
|
512
|
+
if (matched.size > 0 && !has('TEST_NOT_MATCHED') && files.length === 0) {
|
|
513
|
+
const invocation = ['jest', ...command.args].join(' ').trim();
|
|
514
|
+
add('TEST_SET_EMPTY', `Chain step runs no test - "${CHAIN_ENTRY_SCRIPT}" reaches `
|
|
515
|
+
+ `"${invocation}" (via ${command.viaScript}), and jest lists 0 files for it, so the step exits 0 `
|
|
516
|
+
+ `having measured nothing. Fix: point "${command.viaScript}" at the tests it is meant to run, `
|
|
517
|
+
+ `or remove the step from the ${CHAIN_ENTRY_SCRIPT} chain.`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const file of files) inTestAll.add(file);
|
|
463
521
|
}
|
|
464
522
|
|
|
465
523
|
// B — everything the declared stack tiers run.
|