@onlineapps/conn-orch-validator 3.3.2 → 4.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.
@@ -3,12 +3,22 @@
3
3
  /**
4
4
  * MockMQClient - Simulates RabbitMQ for testing
5
5
  * Provides in-memory message queue simulation
6
+ *
7
+ * The surface mirrors `@onlineapps/mq-client-core` `BaseClient` — that is the
8
+ * client every service actually holds, so a divergence here does not merely make
9
+ * a test unrealistic: it teaches whoever codes against this mock to write the
10
+ * wrong call. Method names, argument shapes, defaults and error wording are kept
11
+ * in step with `shared/mq-client-core/src/BaseClient.js` and the RabbitMQ
12
+ * transport it delegates to.
13
+ *
14
+ * @see ../../../../mq-client-core/src/BaseClient.js
15
+ * @see ../../../../mq-client-core/src/transports/rabbitmqClient.js
6
16
  */
7
17
  class MockMQClient {
8
18
  constructor() {
9
19
  this.queues = {};
10
20
  this.consumers = {};
11
- this.isConnected = false;
21
+ this._connected = false;
12
22
  this.publishedMessages = [];
13
23
  this.acknowledgedMessages = [];
14
24
  this.rejectedMessages = [];
@@ -18,7 +28,7 @@ class MockMQClient {
18
28
  * Simulate connection
19
29
  */
20
30
  async connect() {
21
- this.isConnected = true;
31
+ this._connected = true;
22
32
  return Promise.resolve();
23
33
  }
24
34
 
@@ -26,17 +36,33 @@ class MockMQClient {
26
36
  * Simulate disconnection
27
37
  */
28
38
  async disconnect() {
29
- this.isConnected = false;
39
+ this._connected = false;
30
40
  this.consumers = {};
31
41
  return Promise.resolve();
32
42
  }
33
43
 
44
+ /**
45
+ * Connection status.
46
+ *
47
+ * A METHOD, matching `BaseClient.isConnected()`. It used to be a boolean
48
+ * property of the same name, which is the more dangerous kind of mismatch:
49
+ * `if (client.isConnected)` written against that mock reads a function object
50
+ * on the real client — always truthy — so the guard never fires and nothing
51
+ * fails loudly.
52
+ *
53
+ * @returns {boolean}
54
+ */
55
+ isConnected() {
56
+ return this._connected === true;
57
+ }
58
+
34
59
  /**
35
60
  * Publish message to queue
36
61
  */
37
62
  async publish(queue, message, options = {}) {
38
- if (!this.isConnected) {
39
- throw new Error('Not connected to MQ');
63
+ if (!this._connected) {
64
+ // Wording per BaseClient.publish (ConnectionError).
65
+ throw new Error('Cannot publish: client is not connected');
40
66
  }
41
67
 
42
68
  if (!this.queues[queue]) {
@@ -95,8 +121,9 @@ class MockMQClient {
95
121
  * Consume messages from queue
96
122
  */
97
123
  async consume(queue, callback, options = {}) {
98
- if (!this.isConnected) {
99
- throw new Error('Not connected to MQ');
124
+ if (!this._connected) {
125
+ // Wording per BaseClient.consume (ConnectionError).
126
+ throw new Error('Cannot consume: client is not connected');
100
127
  }
101
128
 
102
129
  this.consumers[queue] = {
@@ -118,36 +145,94 @@ class MockMQClient {
118
145
  }
119
146
 
120
147
  /**
121
- * Acknowledge message
148
+ * Acknowledge message.
149
+ *
150
+ * Signature and idempotence per `BaseClient.ack(msg)` → transport
151
+ * `rabbitmqClient.js:2286`: a delivery already settled (marked
152
+ * `_mqProcessed`) is silently skipped rather than settled twice.
153
+ *
154
+ * @param {Object} message - Broker message object
155
+ * @returns {Promise<void>}
122
156
  */
123
157
  async ack(message) {
158
+ if (message && message._mqProcessed) {
159
+ return; // Already acked/nacked — idempotent, as in the real transport
160
+ }
161
+
124
162
  this.acknowledgedMessages.push({
125
163
  message,
126
164
  timestamp: Date.now()
127
165
  });
128
- return Promise.resolve();
166
+
167
+ if (message) {
168
+ message._mqProcessed = true;
169
+ }
129
170
  }
130
171
 
131
172
  /**
132
- * Reject message
173
+ * Negative-acknowledge a message.
174
+ *
175
+ * Signature and semantics per `BaseClient.nack(msg, options)` → transport
176
+ * `rabbitmqClient.js:2313`:
177
+ *
178
+ * const requeue = options.requeue !== undefined ? options.requeue : true;
179
+ *
180
+ * `requeue` therefore defaults to TRUE. This mock previously carried the raw
181
+ * amqplib positional form `nack(message, allUpTo, requeue)` with `requeue`
182
+ * defaulting to FALSE — the exact opposite — so code written against it
183
+ * either dropped messages it meant to retry or retried forever what it meant
184
+ * to drop. Neither `BaseClient` nor the transport exposes `allUpTo`; it is
185
+ * pinned to `false` inside the transport and is not part of the contract.
186
+ *
187
+ * @param {Object} message - Broker message object
188
+ * @param {Object} [options] - { requeue: boolean } — requeue defaults to true
189
+ * @returns {Promise<void>}
133
190
  */
134
- async nack(message, allUpTo = false, requeue = false) {
191
+ async nack(message, options = {}) {
192
+ if (message && message._mqProcessed) {
193
+ return; // Already acked/nacked — idempotent, as in the real transport
194
+ }
195
+
196
+ const requeue = options.requeue !== undefined ? options.requeue : true;
197
+
198
+ if (requeue && !(message && message.fields && message.fields.routingKey)) {
199
+ throw new Error(
200
+ '[MockMQClient] Cannot requeue: message has no fields.routingKey - ' +
201
+ 'a broker delivery always carries one. Fix: nack a message obtained from ' +
202
+ 'consume(), or pass { requeue: false } to discard it.'
203
+ );
204
+ }
205
+
135
206
  this.rejectedMessages.push({
136
207
  message,
137
- allUpTo,
138
208
  requeue,
139
209
  timestamp: Date.now()
140
210
  });
141
211
 
142
- if (requeue && message.fields) {
212
+ if (message) {
213
+ message._mqProcessed = true;
214
+ }
215
+
216
+ if (requeue) {
143
217
  const queue = message.fields.routingKey;
144
218
  if (!this.queues[queue]) {
145
219
  this.queues[queue] = [];
146
220
  }
221
+ // A requeued message comes back as a NEW delivery with a new delivery tag,
222
+ // so it is settleable again. Keeping it marked would cap the retry loop at
223
+ // one round and hide the very behaviour this mock has to reproduce.
224
+ message._mqProcessed = false;
147
225
  this.queues[queue].push(message);
148
- }
149
226
 
150
- return Promise.resolve();
227
+ // The broker hands that redelivery straight to the live consumer. Skipping
228
+ // it is what would hide an always-failing handler's infinite retry loop.
229
+ if (this.consumers[queue]) {
230
+ const consumer = this.consumers[queue];
231
+ setImmediate(() => {
232
+ consumer.callback(message);
233
+ });
234
+ }
235
+ }
151
236
  }
152
237
 
153
238
  /**
@@ -4,6 +4,8 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const net = require('net');
6
6
  const { execSync } = require('child_process');
7
+ const { readIntegrationRunArtefact } = require('./integrationRun');
8
+ const { normalizeEnvDeclaration, collectEnvCoverage } = require('./envContract');
7
9
 
8
10
  const CONNECTOR_KEYS = ['db', 'redis', 'mq', 'minio'];
9
11
  const DEFAULT_CONTRACT_RELATIVE_PATH = path.join('config', 'service', 'integration-contract.json');
@@ -37,7 +39,78 @@ function assertBoolean(value, fieldName) {
37
39
  }
38
40
  }
39
41
 
40
- function normalizeIntegrationContract(rawContract, contractPath) {
42
+ // The service declares WHAT its database is; the library implements HOW it is
43
+ // built (see docs/biz/00-model/uniformity-principle.md). Everything the six
44
+ // per-repo ci-setup-db.js scripts used to decide for themselves — client,
45
+ // ordering, foreign-key handling — is a platform property and lives in the
46
+ // library; only these four values legitimately differ per service.
47
+ const SCHEMA_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
48
+ const ENGINE_DECLARATION = /^(mariadb|mysql):[0-9][0-9.]*$/;
49
+
50
+ /** A declared path must stay inside the repository — it is resolved against the service root. */
51
+ function assertRepoRelativePath(value, fieldName) {
52
+ if (typeof value !== 'string' || value.trim() === '') {
53
+ throw new Error(`[BizCiGate] Invalid ${fieldName} - Expected a non-empty path relative to the service root. `
54
+ + 'Fix: e.g. "migrations/*.sql".');
55
+ }
56
+ if (path.isAbsolute(value) || value.split('/').includes('..')) {
57
+ throw new Error(`[BizCiGate] Invalid ${fieldName} - "${value}" points outside the service root. `
58
+ + 'Fix: use a path relative to the repository, without ".." or a leading "/".');
59
+ }
60
+ }
61
+
62
+ function normalizeDatabaseDeclaration(database, connectors) {
63
+ // Absent means "this service has no database". Null rather than {} so callers
64
+ // distinguish that from an empty declaration and skip the steps explicitly.
65
+ if (database === undefined || database === null) return null;
66
+
67
+ if (typeof database !== 'object' || Array.isArray(database)) {
68
+ throw new Error('[BizCiGate] Invalid database - Expected an object with engine, schema and migrations. '
69
+ + 'Fix: omit the key entirely if the service has no database.');
70
+ }
71
+
72
+ if (!connectors.db) {
73
+ throw new Error('[BizCiGate] Contradictory contract - database is declared but requiredConnectors.db is false. '
74
+ + 'Fix: set requiredConnectors.db to true, or remove the database declaration.');
75
+ }
76
+
77
+ if (!ENGINE_DECLARATION.test(database.engine ?? '')) {
78
+ throw new Error(`[BizCiGate] Invalid database.engine - "${database.engine}" is not <mariadb|mysql>:<version>. `
79
+ + 'Fix: declare the engine AND its version, e.g. "mariadb:10.5" — CI runs exactly this image, '
80
+ + 'and testing on a different one proves nothing about the service SQL.');
81
+ }
82
+
83
+ if (!SCHEMA_IDENTIFIER.test(database.schema ?? '')) {
84
+ throw new Error(`[BizCiGate] Invalid database.schema - "${database.schema}" is not a plain identifier. `
85
+ + 'Fix: use the real schema name, e.g. "oagen_emailer"; it is interpolated into CREATE DATABASE and USE.');
86
+ }
87
+
88
+ assertRepoRelativePath(database.migrations, 'database.migrations');
89
+
90
+ const seeds = database.seeds ?? [];
91
+ if (!Array.isArray(seeds)) {
92
+ throw new Error('[BizCiGate] Invalid database.seeds - Expected an array of paths. '
93
+ + 'Fix: use ["scripts/seed/system.seed.sql"], or omit the key.');
94
+ }
95
+ seeds.forEach((seed, i) => assertRepoRelativePath(seed, `database.seeds[${i}]`));
96
+
97
+ return {
98
+ engine: database.engine,
99
+ schema: database.schema,
100
+ migrations: database.migrations,
101
+ seeds
102
+ };
103
+ }
104
+
105
+ /**
106
+ * @param {object} rawContract parsed integration-contract.json
107
+ * @param {string} contractPath for error messages
108
+ * @param {object} [options]
109
+ * @param {Map<string,string>} [options.envCoverage] what M1/M2 already cover; supplied by
110
+ * loadAndValidateIntegrationContract, which is the caller that can read the repository.
111
+ * Mandatory only when the contract carries an env block — see utils/envContract.js.
112
+ */
113
+ function normalizeIntegrationContract(rawContract, contractPath, options = {}) {
41
114
  if (!rawContract || typeof rawContract !== 'object' || Array.isArray(rawContract)) {
42
115
  throw new Error(`[BizCiGate] Invalid contract root - Expected object in ${contractPath}`);
43
116
  }
@@ -64,12 +137,17 @@ function normalizeIntegrationContract(rawContract, contractPath) {
64
137
  throw new Error('[BizCiGate] Invalid integrationMinimum.minTestFiles - Expected integer >= 1');
65
138
  }
66
139
 
140
+ const database = normalizeDatabaseDeclaration(rawContract.database, normalizedConnectors);
141
+ const env = normalizeEnvDeclaration(rawContract.env, { coverage: options.envCoverage });
142
+
67
143
  return {
68
144
  serviceName: rawContract.serviceName || null,
69
145
  requiredConnectors: normalizedConnectors,
70
146
  integrationMinimum: {
71
147
  minTestFiles,
72
148
  },
149
+ database,
150
+ env,
73
151
  setup: rawContract.setup || {},
74
152
  raw: rawContract,
75
153
  };
@@ -79,7 +157,15 @@ function loadAndValidateIntegrationContract(serviceRoot, contractPath) {
79
157
  const resolvedServiceRoot = resolveServiceRoot(serviceRoot);
80
158
  const resolvedContractPath = resolveContractPath(resolvedServiceRoot, contractPath);
81
159
  const rawContract = readJsonFile(resolvedContractPath);
82
- const normalizedContract = normalizeIntegrationContract(rawContract, resolvedContractPath);
160
+
161
+ // Coverage is collected from the repository, so only this entry point can
162
+ // supply it. The env block is rejected outright without it rather than
163
+ // validated halfway (automation-gates.md §5).
164
+ const envCoverage = collectEnvCoverage({
165
+ serviceRoot: resolvedServiceRoot,
166
+ requiredConnectors: rawContract?.requiredConnectors
167
+ });
168
+ const normalizedContract = normalizeIntegrationContract(rawContract, resolvedContractPath, { envCoverage });
83
169
 
84
170
  return {
85
171
  serviceRoot: resolvedServiceRoot,
@@ -214,17 +300,6 @@ function formatEnvironmentOutput(env, format) {
214
300
  throw new Error(`[BizCiGate] Unsupported output format - ${format}`);
215
301
  }
216
302
 
217
- function parseIntegerOrNull(value) {
218
- if (value === undefined || value === null || value === '') {
219
- return null;
220
- }
221
- const parsed = Number.parseInt(String(value), 10);
222
- if (!Number.isFinite(parsed)) {
223
- return null;
224
- }
225
- return parsed;
226
- }
227
-
228
303
  function buildIntegrationSignalSummary(options) {
229
304
  const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
230
305
  const unitTestFiles = getUnitTestFiles(contractInfo.serviceRoot);
@@ -239,10 +314,27 @@ function buildIntegrationSignalSummary(options) {
239
314
  gateReason = options.gateReason || error.message;
240
315
  }
241
316
 
242
- const unitExecuted = parseIntegerOrNull(options.unitExecuted || process.env.OA_CI_UNIT_EXECUTED);
243
- const integrationExecuted = parseIntegerOrNull(options.integrationExecuted || process.env.OA_CI_INTEGRATION_EXECUTED);
244
- const resolvedUnitExecuted = unitExecuted === null ? unitTestFiles.length : unitExecuted;
245
- const resolvedIntegrationExecuted = integrationExecuted === null ? integrationTestFiles.length : integrationExecuted;
317
+ // Executed figures come from the run artefact or from nowhere. The previous
318
+ // version fell back to the number of test FILES on disk, which reported tests
319
+ // as executed that had never run the exact fake-green this gate exists to
320
+ // stop. No repo ever supplied the override it fell back from, so the artefact
321
+ // has always stated a fabricated number.
322
+ const runArtefact = readIntegrationRunArtefact(contractInfo.serviceRoot);
323
+ const executed = runArtefact
324
+ ? {
325
+ source: 'runner',
326
+ integrationTests: runArtefact.artefact.executedTests ?? null,
327
+ notExecutedTests: runArtefact.artefact.notExecutedTests ?? null,
328
+ verdict: runArtefact.artefact.verdict ?? null,
329
+ artefactPath: runArtefact.artefactPath,
330
+ }
331
+ : {
332
+ source: 'unknown',
333
+ integrationTests: null,
334
+ notExecutedTests: null,
335
+ verdict: null,
336
+ artefactPath: null,
337
+ };
246
338
 
247
339
  return {
248
340
  generatedAt: new Date().toISOString(),
@@ -255,10 +347,7 @@ function buildIntegrationSignalSummary(options) {
255
347
  unitTestFiles: unitTestFiles.length,
256
348
  integrationTestFiles: integrationTestFiles.length,
257
349
  },
258
- executed: {
259
- unitSuites: resolvedUnitExecuted,
260
- integrationSuites: resolvedIntegrationExecuted,
261
- },
350
+ executed,
262
351
  gate: {
263
352
  verdict: gateVerdict,
264
353
  reason: gateReason,
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Connector contract check — validation step "connectors".
5
+ *
6
+ * Replaces a step that printed "PASS (via cookbook tests)" and returned
7
+ * valid:true without evaluating anything. A step reporting a guarantee it never
8
+ * checked is worse than no step, because the log then reads as evidence.
9
+ *
10
+ * Tier-1 runs in phase 0.2, before the wrapper opens any connector, so nothing
11
+ * live can be probed here. What can be checked is that the service's two
12
+ * declarations of the same fact agree, and that the environment backs them:
13
+ *
14
+ * config/service/config.json wrapper.<connector> sections
15
+ * config/service/integration-contract.json requiredConnectors
16
+ *
17
+ * biz-converter shipped with requiredConnectors.db=false while owning a schema —
18
+ * six migrations, DB_HOST set, a database service in CI. Nothing caught it: the
19
+ * only consequence was that ci:gate:wait never waited for the database, so the
20
+ * suite raced it. It was found by hand during the F4 switchover. This step
21
+ * exists so that class of contradiction is found by the machine instead.
22
+ *
23
+ * Pure: takes the two declarations and an environment, returns a result. No
24
+ * filesystem, no network, no process exit.
25
+ */
26
+
27
+ /**
28
+ * How a required connector shows up in config.json's wrapper section, and which
29
+ * environment variable has to back it.
30
+ *
31
+ * `configSections` lists the wrapper keys that imply the connector, empty when
32
+ * none does. `cache` and `state` are both Redis-backed, so either satisfies
33
+ * redis. `db` and `minio` have no wrapper section — services reach their schema
34
+ * and object storage directly — so they are declared by the contract and
35
+ * evidenced only by the environment.
36
+ *
37
+ * The mapping was corrected on 2026-08-22 by running it against all eight
38
+ * services: an assumed wrapper.storage section for MinIO exists nowhere, and
39
+ * guessing it would have failed emailer and pdfgen for a configuration neither
40
+ * was ever meant to have.
41
+ */
42
+ const CONNECTORS = {
43
+ db: { configSections: [], env: ['DB_HOST'] },
44
+ redis: { configSections: ['cache', 'state'], env: ['REDIS_URL'] },
45
+ mq: { configSections: ['mq'], env: ['RABBITMQ_URL'] },
46
+ // MinIO, like the database, has no wrapper section: services reach it through
47
+ // @onlineapps/conn-base-storage directly. Verified 2026-08-22 — no service on
48
+ // the platform declares wrapper.storage. So it is declared by the contract and
49
+ // evidenced only by the environment.
50
+ minio: { configSections: [], env: ['MINIO_ENDPOINT', 'MINIO_ACTUAL_HOST'] }
51
+ };
52
+
53
+ /**
54
+ * @param {object} args
55
+ * @param {object} args.config parsed config/service/config.json
56
+ * @param {object} args.requiredConnectors from the integration contract
57
+ * @param {object} args.env environment to check against
58
+ * @returns {{valid: boolean, checked: string[], errors: string[]}}
59
+ */
60
+ function verifyConnectorContract({ config, requiredConnectors, env }) {
61
+ const errors = [];
62
+ const checked = [];
63
+ const wrapper = config?.wrapper ?? {};
64
+
65
+ for (const [name, spec] of Object.entries(CONNECTORS)) {
66
+ const required = requiredConnectors?.[name] === true;
67
+ const configured = spec.configSections.some((section) => wrapper[section] !== undefined);
68
+
69
+ if (required) {
70
+ checked.push(name);
71
+
72
+ if (spec.configSections.length > 0 && !configured) {
73
+ errors.push(`Connector "${name}" is required by the integration contract but no `
74
+ + `wrapper.${spec.configSections.join('/wrapper.')} section configures it in config/service/config.json.\n`
75
+ + ` Fix: configure it, or set requiredConnectors.${name} to false if the service does not use it.`);
76
+ }
77
+
78
+ const satisfied = spec.env.some((key) => env?.[key]);
79
+ if (!satisfied) {
80
+ errors.push(`Connector "${name}" is required but ${spec.env.join(' / ')} is not set.\n`
81
+ + ' Fix: set it in the CI job or config/env-active/*.env — a required connector without its '
82
+ + 'endpoint fails later, inside the connector, where the cause is harder to see.');
83
+ }
84
+ } else if (configured) {
85
+ errors.push(`config/service/config.json configures wrapper.${spec.configSections.find((s) => wrapper[s] !== undefined)} `
86
+ + `but the integration contract says "${name}" is not required.\n`
87
+ + ' The two declarations describe the same fact and must agree; while they disagree, '
88
+ + 'ci:gate:wait does not wait for this connector and the suite races it.\n'
89
+ + ` Fix: set requiredConnectors.${name} to true, or remove the wrapper section.`);
90
+ }
91
+ }
92
+
93
+ return { valid: errors.length === 0, checked, errors };
94
+ }
95
+
96
+ module.exports = { verifyConnectorContract, CONNECTORS };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cookbook format rule — the single place in this package that decides which
5
+ * cookbook format version the platform accepts.
6
+ *
7
+ * The rule itself is owned by the biz doc tree, not by this file:
8
+ * api/docs/biz/40-cookbooks/format.md § "Required fields" — `version` is a
9
+ * REQUIRED string, `"2.1.0"` or higher. This module only implements that
10
+ * sentence so both entry points enforce the same thing:
11
+ *
12
+ * - `CookbookTestRunner.validateCookbook()` — the Tier-1 gate every biz
13
+ * service runs at boot (mandatory, no per-repo opt-in);
14
+ * - `CookbookTestUtils.validateCookbook()` — the readiness-wrapper path
15
+ * three repos still call from `tests/bootstrap/service-readiness.test.js`.
16
+ *
17
+ * Returned messages carry the `Problem - Expected/Fix` part of the error
18
+ * format (architecture-principles §5); the `[Context]` prefix belongs to the
19
+ * caller, which is the one that knows whether it read a file or was handed an
20
+ * object.
21
+ *
22
+ * @see api/docs/biz/40-cookbooks/format.md
23
+ * @see api/docs/biz/40-cookbooks/test-runner-flow.md
24
+ */
25
+
26
+ /** Minimum accepted cookbook format version — format.md § Required fields. */
27
+ const MIN_COOKBOOK_FORMAT_VERSION = '2.1.0';
28
+
29
+ /** `major[.minor[.patch]]`, digits only — the shape format.md documents. */
30
+ const VERSION_PATTERN = /^\d+(\.\d+){0,2}$/;
31
+
32
+ const DOC_REFERENCE = 'api/docs/biz/40-cookbooks/format.md § Required fields';
33
+
34
+ /**
35
+ * @param {string} version numeric version string, already pattern-checked
36
+ * @returns {number[]} exactly three components, missing ones are 0
37
+ */
38
+ function toComponents(version) {
39
+ const parts = version.split('.').map((part) => parseInt(part, 10));
40
+ while (parts.length < 3) {
41
+ parts.push(0);
42
+ }
43
+ return parts;
44
+ }
45
+
46
+ /**
47
+ * @param {string} a numeric version string
48
+ * @param {string} b numeric version string
49
+ * @returns {number} negative when a < b, 0 when equal, positive when a > b
50
+ */
51
+ function compareVersions(a, b) {
52
+ const left = toComponents(a);
53
+ const right = toComponents(b);
54
+
55
+ for (let i = 0; i < 3; i++) {
56
+ if (left[i] !== right[i]) {
57
+ return left[i] - right[i];
58
+ }
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ /**
64
+ * Check a cookbook's `version` field against the documented format rule.
65
+ *
66
+ * @param {*} version the raw value of the cookbook's top-level `version`
67
+ * @returns {string|null} `null` when the version satisfies the rule, otherwise
68
+ * the problem in `Problem - Expected/Fix` form
69
+ */
70
+ function checkCookbookFormatVersion(version) {
71
+ if (version === undefined || version === null || version === '') {
72
+ return `cookbook format version is missing - Expected top-level "version": "${MIN_COOKBOOK_FORMAT_VERSION}" or higher. `
73
+ + `Fix: add "version": "${MIN_COOKBOOK_FORMAT_VERSION}" to the cookbook (${DOC_REFERENCE}).`;
74
+ }
75
+
76
+ if (typeof version !== 'string' || !VERSION_PATTERN.test(version)) {
77
+ return `cookbook format version ${JSON.stringify(version)} is not a version string - `
78
+ + `Expected numeric "<major>.<minor>.<patch>", "${MIN_COOKBOOK_FORMAT_VERSION}" or higher. `
79
+ + `Fix: write the version as "${MIN_COOKBOOK_FORMAT_VERSION}" (${DOC_REFERENCE}).`;
80
+ }
81
+
82
+ if (compareVersions(version, MIN_COOKBOOK_FORMAT_VERSION) < 0) {
83
+ return `cookbook format version "${version}" is below the required minimum ${MIN_COOKBOOK_FORMAT_VERSION} - `
84
+ + `Expected "${MIN_COOKBOOK_FORMAT_VERSION}" or higher. `
85
+ + `Fix: migrate the cookbook to the v2.1 format and set "version": "${MIN_COOKBOOK_FORMAT_VERSION}" (${DOC_REFERENCE}).`;
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ module.exports = {
92
+ MIN_COOKBOOK_FORMAT_VERSION,
93
+ checkCookbookFormatVersion,
94
+ compareVersions
95
+ };