@onlineapps/conn-orch-validator 7.0.0 → 8.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 +2558 -2
- package/README.md +1038 -4
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +408 -101
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +10 -35
- package/src/ValidationOrchestrator.js +219 -71
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +2 -1
- package/src/helpers/createServiceReadinessTests.js +60 -4
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +140 -9
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +2 -1
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- package/src/WorkflowTestRunner.js +0 -402
|
@@ -6,6 +6,7 @@ const net = require('net');
|
|
|
6
6
|
const { execSync } = require('child_process');
|
|
7
7
|
const { readIntegrationRunArtefact } = require('./integrationRun');
|
|
8
8
|
const { normalizeEnvDeclaration, collectEnvCoverage } = require('./envContract');
|
|
9
|
+
const { normalizeStackTiersDeclaration } = require('./testCoverageContract');
|
|
9
10
|
|
|
10
11
|
const CONNECTOR_KEYS = ['db', 'redis', 'mq', 'minio'];
|
|
11
12
|
const DEFAULT_CONTRACT_RELATIVE_PATH = path.join('config', 'service', 'integration-contract.json');
|
|
@@ -47,6 +48,25 @@ function assertBoolean(value, fieldName) {
|
|
|
47
48
|
const SCHEMA_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
48
49
|
const ENGINE_DECLARATION = /^(mariadb|mysql):[0-9][0-9.]*$/;
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The collation a service declares its schema is created with — owner decision
|
|
53
|
+
* 2026-09-14, `db-collation-declaration` 001, written into
|
|
54
|
+
* `docs/biz/70-contracts/database-contract.md` §1.
|
|
55
|
+
*
|
|
56
|
+
* Which utf8mb4 collation is the SERVICE's choice and this file sets none;
|
|
57
|
+
* what it holds is that the value is one, because it is interpolated into
|
|
58
|
+
* `CREATE DATABASE … COLLATE` the way `schema` is interpolated into the name,
|
|
59
|
+
* and because the platform speaks utf8mb4 end to end — the client is invoked
|
|
60
|
+
* with `--default-character-set=utf8mb4` (§3) and every permanent table of the
|
|
61
|
+
* eight repositories declares a utf8mb4 collation (measured 2026-09-14: 122 of
|
|
62
|
+
* 123, the exception being a table whose own thread has the finding).
|
|
63
|
+
*
|
|
64
|
+
* The key's PRESENCE is not this rail's question: a repository that declares
|
|
65
|
+
* none is reported by uniform row `D-DB-COLLATION`, and refused by
|
|
66
|
+
* `setupDatabase.buildSchema` before it builds anything.
|
|
67
|
+
*/
|
|
68
|
+
const COLLATION_DECLARATION = /^utf8mb4_[a-z0-9_]+$/;
|
|
69
|
+
|
|
50
70
|
/** A declared path must stay inside the repository — it is resolved against the service root. */
|
|
51
71
|
function assertRepoRelativePath(value, fieldName) {
|
|
52
72
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
@@ -85,6 +105,15 @@ function normalizeDatabaseDeclaration(database, connectors) {
|
|
|
85
105
|
+ 'Fix: use the real schema name, e.g. "oagen_emailer"; it is interpolated into CREATE DATABASE and USE.');
|
|
86
106
|
}
|
|
87
107
|
|
|
108
|
+
if (database.collation !== undefined && !COLLATION_DECLARATION.test(
|
|
109
|
+
typeof database.collation === 'string' ? database.collation : ''
|
|
110
|
+
)) {
|
|
111
|
+
throw new Error(`[BizCiGate] Invalid database.collation - ${JSON.stringify(database.collation)} is not a `
|
|
112
|
+
+ 'utf8mb4 collation name. Fix: declare the collation this service\'s schema is created with, e.g. '
|
|
113
|
+
+ '"utf8mb4_bin" — the value is interpolated into CREATE DATABASE … COLLATE, and the platform speaks '
|
|
114
|
+
+ 'utf8mb4 (api/docs/biz/70-contracts/database-contract.md §1).');
|
|
115
|
+
}
|
|
116
|
+
|
|
88
117
|
assertRepoRelativePath(database.migrations, 'database.migrations');
|
|
89
118
|
|
|
90
119
|
const seeds = database.seeds ?? [];
|
|
@@ -97,6 +126,7 @@ function normalizeDatabaseDeclaration(database, connectors) {
|
|
|
97
126
|
return {
|
|
98
127
|
engine: database.engine,
|
|
99
128
|
schema: database.schema,
|
|
129
|
+
collation: database.collation,
|
|
100
130
|
migrations: database.migrations,
|
|
101
131
|
seeds
|
|
102
132
|
};
|
|
@@ -139,6 +169,10 @@ function normalizeIntegrationContract(rawContract, contractPath, options = {}) {
|
|
|
139
169
|
|
|
140
170
|
const database = normalizeDatabaseDeclaration(rawContract.database, normalizedConnectors);
|
|
141
171
|
const env = normalizeEnvDeclaration(rawContract.env, { coverage: options.envCoverage });
|
|
172
|
+
// Which suites legitimately stand outside test:all, and why. The rules that
|
|
173
|
+
// read it live in utils/testCoverageContract.js; only the shape is decided here,
|
|
174
|
+
// beside the other declarations, so one file still answers "what may a contract say".
|
|
175
|
+
const stackTiers = normalizeStackTiersDeclaration(rawContract.stackTiers);
|
|
142
176
|
|
|
143
177
|
return {
|
|
144
178
|
serviceName: rawContract.serviceName || null,
|
|
@@ -148,6 +182,7 @@ function normalizeIntegrationContract(rawContract, contractPath, options = {}) {
|
|
|
148
182
|
},
|
|
149
183
|
database,
|
|
150
184
|
env,
|
|
185
|
+
stackTiers,
|
|
151
186
|
setup: rawContract.setup || {},
|
|
152
187
|
raw: rawContract,
|
|
153
188
|
};
|
|
@@ -240,9 +275,12 @@ function validatePackageScriptsForIntegrationMinimum(serviceRoot) {
|
|
|
240
275
|
throw new Error('[BizCiGate] Missing package script - test:all is required');
|
|
241
276
|
}
|
|
242
277
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
278
|
+
// Whether test:all actually REACHES the integration tests is not asked here.
|
|
279
|
+
// It used to be, as a substring match on the script text — a rule about a name,
|
|
280
|
+
// which passed for `echo test:integration` and failed for a chain reaching the
|
|
281
|
+
// same files through another script. utils/testCoverageContract.js measures the
|
|
282
|
+
// fact instead: every file the jest config matches must be run by the test:all
|
|
283
|
+
// chain or declared as a stack tier (change-discipline.md § One rail per concern).
|
|
246
284
|
|
|
247
285
|
return {
|
|
248
286
|
packageJsonPath,
|
|
@@ -300,18 +338,100 @@ function formatEnvironmentOutput(env, format) {
|
|
|
300
338
|
throw new Error(`[BizCiGate] Unsupported output format - ${format}`);
|
|
301
339
|
}
|
|
302
340
|
|
|
341
|
+
/**
|
|
342
|
+
* What the artefact says when a run declares a failure and names no step.
|
|
343
|
+
*
|
|
344
|
+
* It is not a filler string: it is the whole point of the change that put it
|
|
345
|
+
* here. "I do not know which step failed" is a true sentence somebody can act
|
|
346
|
+
* on; "integration minimum satisfied" beside a failing verdict is neither
|
|
347
|
+
* (`.claude/rules/truth-over-agreement.md` §4 — uncertainty is not certainty).
|
|
348
|
+
*/
|
|
349
|
+
const UNKNOWN_STEP_REASON = '[BizCiGate] unknown step failed - the run declared a failing verdict and '
|
|
350
|
+
+ 'named no step, and the integration minimum (the only check this summary runs itself) is satisfied, '
|
|
351
|
+
+ 'so nothing here can say what failed. Fix: pass the step from the CI job - '
|
|
352
|
+
+ 'npm run ci:gate:summary -- --gate-verdict fail --gate-reason "<the step that exited non-zero>".';
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* What the artefact says when a run declares `pass` over a check this summary
|
|
356
|
+
* measured as failing.
|
|
357
|
+
*
|
|
358
|
+
* It names the refusal rather than quietly correcting the verdict: the CI job
|
|
359
|
+
* asked for something it may not have, and whoever wrote that job is the only
|
|
360
|
+
* person who can stop asking. Format per
|
|
361
|
+
* `.claude/rules/architecture-principles.md` §5 - `[Context] Problem - Fix`.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} measuredFailure the failing check's own message
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
function refusedOverrideReason(measuredFailure) {
|
|
367
|
+
return '[BizCiGate] A measured failure cannot be declared passing - the integration minimum failed '
|
|
368
|
+
+ `(${measuredFailure}) and the run passed --gate-verdict pass. The verdict stays fail: the override `
|
|
369
|
+
+ 'exists to report a failure this summary cannot see for itself, never to withdraw one it can. '
|
|
370
|
+
+ 'Fix: drop --gate-verdict pass from the ci:gate:summary call and make the integration minimum pass, '
|
|
371
|
+
+ 'or correct integrationMinimum.minTestFiles in the integration contract if the requirement is wrong.';
|
|
372
|
+
}
|
|
373
|
+
|
|
303
374
|
function buildIntegrationSignalSummary(options) {
|
|
304
375
|
const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
|
|
305
376
|
const unitTestFiles = getUnitTestFiles(contractInfo.serviceRoot);
|
|
306
377
|
const integrationTestFiles = getIntegrationTestFiles(contractInfo.serviceRoot);
|
|
307
378
|
|
|
308
|
-
|
|
309
|
-
|
|
379
|
+
// The reason comes from the step that FAILED, or it admits it does not know
|
|
380
|
+
// which one did. Three sources, in this order, and no fourth:
|
|
381
|
+
//
|
|
382
|
+
// 1. the caller — the CI job watched the chain of ci:gate:* steps run and is
|
|
383
|
+
// the only party that knows which of them exited non-zero;
|
|
384
|
+
// 2. the integration minimum, the one step this function runs itself, when
|
|
385
|
+
// that is what failed;
|
|
386
|
+
// 3. nothing — and then the artefact says so.
|
|
387
|
+
//
|
|
388
|
+
// What it may never do is attribute somebody else's failure to the last check
|
|
389
|
+
// in the row. A job declaring `--gate-verdict fail` without a reason used to
|
|
390
|
+
// get "integration minimum satisfied" written beside a failing verdict, which
|
|
391
|
+
// is both untrue about the cause and an outright contradiction of the verdict
|
|
392
|
+
// beside it (reported twice by BIZ-ingest on 2026-09-08; the failing step had
|
|
393
|
+
// been setup-db both times).
|
|
394
|
+
// What this summary can establish ITSELF, before anybody declares anything.
|
|
395
|
+
let minimumFailure = null;
|
|
310
396
|
try {
|
|
311
397
|
verifyIntegrationMinimum(contractInfo.serviceRoot, contractInfo.contractPath);
|
|
312
398
|
} catch (error) {
|
|
313
|
-
|
|
314
|
-
|
|
399
|
+
minimumFailure = error.message;
|
|
400
|
+
}
|
|
401
|
+
const measuredVerdict = minimumFailure === null ? 'pass' : 'fail';
|
|
402
|
+
|
|
403
|
+
// `--gate-verdict` may only make the verdict WORSE. It exists so a job can
|
|
404
|
+
// report a failure this summary cannot see for itself - a step further up the
|
|
405
|
+
// chain that exited non-zero. Read the other way it is a bypass, and a gate
|
|
406
|
+
// has exactly one path and no flag that turns it off
|
|
407
|
+
// (`.claude/rules/automation-gates.md` §1 requirement 5).
|
|
408
|
+
const overrideRefused = options.gateVerdict === 'pass' && measuredVerdict === 'fail';
|
|
409
|
+
const gateVerdict = measuredVerdict === 'fail' || options.gateVerdict === 'fail' ? 'fail' : 'pass';
|
|
410
|
+
|
|
411
|
+
// The reason comes from the step that FAILED, or it admits it does not know
|
|
412
|
+
// which one did. Four sources, in this order, and no fifth:
|
|
413
|
+
//
|
|
414
|
+
// 0. the refusal above - a declared pass the measurement contradicts is
|
|
415
|
+
// itself the thing the reader has to see, and neither the caller's own
|
|
416
|
+
// reason nor the failure's message may stand in its place: "fail" beside
|
|
417
|
+
// "test:all passed" is the same contradiction one step further on;
|
|
418
|
+
// 1. the caller - the CI job watched the chain of ci:gate:* steps run and is
|
|
419
|
+
// the only party that knows which of them exited non-zero;
|
|
420
|
+
// 2. the integration minimum, the one step this function runs itself, when
|
|
421
|
+
// that is what failed;
|
|
422
|
+
// 3. nothing - and then the artefact says so.
|
|
423
|
+
//
|
|
424
|
+
// What it may never do is attribute somebody else's failure to the last check
|
|
425
|
+
// in the row. A job declaring `--gate-verdict fail` without a reason used to
|
|
426
|
+
// get "integration minimum satisfied" written beside a failing verdict, which
|
|
427
|
+
// is both untrue about the cause and an outright contradiction of the verdict
|
|
428
|
+
// beside it (reported twice by BIZ-ingest on 2026-09-08; the failing step had
|
|
429
|
+
// been setup-db both times).
|
|
430
|
+
let gateReason = overrideRefused
|
|
431
|
+
? refusedOverrideReason(minimumFailure)
|
|
432
|
+
: options.gateReason || minimumFailure;
|
|
433
|
+
if (!gateReason) {
|
|
434
|
+
gateReason = gateVerdict === 'pass' ? 'integration minimum satisfied' : UNKNOWN_STEP_REASON;
|
|
315
435
|
}
|
|
316
436
|
|
|
317
437
|
// Executed figures come from the run artefact or from nowhere. The previous
|
|
@@ -507,6 +627,10 @@ module.exports = {
|
|
|
507
627
|
DEFAULT_CONTRACT_RELATIVE_PATH,
|
|
508
628
|
loadAndValidateIntegrationContract,
|
|
509
629
|
normalizeIntegrationContract,
|
|
630
|
+
// Exported so the consumers of a normalized `database` block can VERIFY they
|
|
631
|
+
// were handed one, against this definition and not a second copy of the shape
|
|
632
|
+
// (utils/installContract.js).
|
|
633
|
+
normalizeDatabaseDeclaration,
|
|
510
634
|
getIntegrationTestFiles,
|
|
511
635
|
getUnitTestFiles,
|
|
512
636
|
verifyIntegrationMinimum,
|
|
@@ -50,6 +50,96 @@ const CONNECTORS = {
|
|
|
50
50
|
minio: { configSections: [], env: ['MINIO_ENDPOINT', 'MINIO_ACTUAL_HOST'] }
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
/** Where each of the two declarations lives, relative to the service root. */
|
|
54
|
+
const CONFIG_PATH = 'config/service/config.json';
|
|
55
|
+
const CONTRACT_PATH = 'config/service/integration-contract.json';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Do the two declarations agree? — the half of the contract a CHECKOUT can
|
|
59
|
+
* answer, and therefore the half the uniform row `C-CONNECTORS` asks
|
|
60
|
+
* (confirmation `connector-contract-check` 001).
|
|
61
|
+
*
|
|
62
|
+
* It is separated from the environment half below for one reason: an
|
|
63
|
+
* environment is a property of a running container, not of a repository. The
|
|
64
|
+
* row runs from the api checkout and in CI, where no service's `REDIS_URL` is
|
|
65
|
+
* set and none should be — asking that question there would fail all eight
|
|
66
|
+
* services for a configuration none of them is running in, which is a gate
|
|
67
|
+
* whose violations have no fix (`automation-gates.md` §3).
|
|
68
|
+
*
|
|
69
|
+
* The RULE is stated once, in `mismatches()` below; this function and
|
|
70
|
+
* `verifyConnectorContract` are two phrasings of the same answer, for two
|
|
71
|
+
* surfaces (`change-discipline.md` § One rail per concern).
|
|
72
|
+
*
|
|
73
|
+
* @param {object} args
|
|
74
|
+
* @param {object} args.config parsed config/service/config.json
|
|
75
|
+
* @param {object} args.requiredConnectors from the integration contract
|
|
76
|
+
* @returns {{valid: boolean, checked: string[], findings: Array<{connector: string, where: string, what: string}>}}
|
|
77
|
+
* `where` is the file that must change, `what` the contradiction —
|
|
78
|
+
* the shape a manifest row reports, whose `fix` the row itself carries.
|
|
79
|
+
*/
|
|
80
|
+
function verifyConnectorDeclarations({ config, requiredConnectors }) {
|
|
81
|
+
const findings = mismatches({ config, requiredConnectors }).map((mismatch) => ({
|
|
82
|
+
connector: mismatch.connector,
|
|
83
|
+
where: mismatch.kind === 'required-not-configured' ? CONFIG_PATH : CONTRACT_PATH,
|
|
84
|
+
what: mismatch.kind === 'required-not-configured'
|
|
85
|
+
? `requiredConnectors.${mismatch.connector} is true and no `
|
|
86
|
+
+ `${mismatch.sections.map((section) => `wrapper.${section}`).join('/')} section configures it `
|
|
87
|
+
+ '— the two declarations describe the same fact and one of them is wrong'
|
|
88
|
+
: `${CONFIG_PATH} configures wrapper.${mismatch.section} and requiredConnectors.${mismatch.connector} `
|
|
89
|
+
+ 'is false — while they disagree, ci:gate:wait does not wait for this connector and the suite races it'
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
valid: findings.length === 0,
|
|
94
|
+
checked: requiredNames(requiredConnectors),
|
|
95
|
+
findings
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The names the contract declares as required — what either half reports as
|
|
101
|
+
* "checked", so the two can never count differently.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} requiredConnectors
|
|
104
|
+
* @returns {string[]}
|
|
105
|
+
*/
|
|
106
|
+
function requiredNames(requiredConnectors) {
|
|
107
|
+
return Object.keys(CONNECTORS).filter((name) => requiredConnectors?.[name] === true);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* THE RULE, stated once: where the two declarations contradict each other.
|
|
112
|
+
*
|
|
113
|
+
* A connector with no `configSections` (db, minio) can contradict nothing here —
|
|
114
|
+
* no service on the platform declares a wrapper section for either, so both
|
|
115
|
+
* directions would be this module inventing a requirement. Their evidence is the
|
|
116
|
+
* environment, and that is the other half.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} args
|
|
119
|
+
* @param {object} args.config parsed config/service/config.json
|
|
120
|
+
* @param {object} args.requiredConnectors from the integration contract
|
|
121
|
+
* @returns {Array<{connector: string, kind: string, sections?: string[], section?: string}>}
|
|
122
|
+
*/
|
|
123
|
+
function mismatches({ config, requiredConnectors }) {
|
|
124
|
+
const wrapper = config?.wrapper ?? {};
|
|
125
|
+
const found = [];
|
|
126
|
+
|
|
127
|
+
for (const [name, spec] of Object.entries(CONNECTORS)) {
|
|
128
|
+
if (spec.configSections.length === 0) continue;
|
|
129
|
+
|
|
130
|
+
const required = requiredConnectors?.[name] === true;
|
|
131
|
+
const section = spec.configSections.find((candidate) => wrapper[candidate] !== undefined);
|
|
132
|
+
|
|
133
|
+
if (required && section === undefined) {
|
|
134
|
+
found.push({ connector: name, kind: 'required-not-configured', sections: spec.configSections });
|
|
135
|
+
} else if (!required && section !== undefined) {
|
|
136
|
+
found.push({ connector: name, kind: 'configured-not-required', section });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
|
|
53
143
|
/**
|
|
54
144
|
* @param {object} args
|
|
55
145
|
* @param {object} args.config parsed config/service/config.json
|
|
@@ -60,18 +150,18 @@ const CONNECTORS = {
|
|
|
60
150
|
function verifyConnectorContract({ config, requiredConnectors, env }) {
|
|
61
151
|
const errors = [];
|
|
62
152
|
const checked = [];
|
|
63
|
-
const
|
|
153
|
+
const byConnector = new Map(mismatches({ config, requiredConnectors }).map((m) => [m.connector, m]));
|
|
64
154
|
|
|
65
155
|
for (const [name, spec] of Object.entries(CONNECTORS)) {
|
|
66
156
|
const required = requiredConnectors?.[name] === true;
|
|
67
|
-
const
|
|
157
|
+
const mismatch = byConnector.get(name);
|
|
68
158
|
|
|
69
159
|
if (required) {
|
|
70
160
|
checked.push(name);
|
|
71
161
|
|
|
72
|
-
if (
|
|
162
|
+
if (mismatch !== undefined) {
|
|
73
163
|
errors.push(`Connector "${name}" is required by the integration contract but no `
|
|
74
|
-
+ `wrapper.${spec.configSections.join('/wrapper.')} section configures it in
|
|
164
|
+
+ `wrapper.${spec.configSections.join('/wrapper.')} section configures it in ${CONFIG_PATH}.\n`
|
|
75
165
|
+ ` Fix: configure it, or set requiredConnectors.${name} to false if the service does not use it.`);
|
|
76
166
|
}
|
|
77
167
|
|
|
@@ -81,8 +171,8 @@ function verifyConnectorContract({ config, requiredConnectors, env }) {
|
|
|
81
171
|
+ ' Fix: set it in the CI job or config/env-active/*.env — a required connector without its '
|
|
82
172
|
+ 'endpoint fails later, inside the connector, where the cause is harder to see.');
|
|
83
173
|
}
|
|
84
|
-
} else if (
|
|
85
|
-
errors.push(
|
|
174
|
+
} else if (mismatch !== undefined) {
|
|
175
|
+
errors.push(`${CONFIG_PATH} configures wrapper.${mismatch.section} `
|
|
86
176
|
+ `but the integration contract says "${name}" is not required.\n`
|
|
87
177
|
+ ' The two declarations describe the same fact and must agree; while they disagree, '
|
|
88
178
|
+ 'ci:gate:wait does not wait for this connector and the suite races it.\n'
|
|
@@ -93,4 +183,4 @@ function verifyConnectorContract({ config, requiredConnectors, env }) {
|
|
|
93
183
|
return { valid: errors.length === 0, checked, errors };
|
|
94
184
|
}
|
|
95
185
|
|
|
96
|
-
module.exports = { verifyConnectorContract, CONNECTORS };
|
|
186
|
+
module.exports = { verifyConnectorContract, verifyConnectorDeclarations, CONNECTORS, CONFIG_PATH, CONTRACT_PATH };
|
|
@@ -26,11 +26,32 @@
|
|
|
26
26
|
/** Minimum accepted cookbook format version — format.md § Required fields. */
|
|
27
27
|
const MIN_COOKBOOK_FORMAT_VERSION = '2.1.0';
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* `major.minor.patch`, digits only, all three components required.
|
|
31
|
+
*
|
|
32
|
+
* The machine SSOT is `shared/cookbook/cookbook-core/schemas/cookbook.v2.schema.json:16`
|
|
33
|
+
* — `"pattern": "^2\\.\\d+\\.\\d+$"`. Until 2026-09-05 this gate accepted 1 to 3
|
|
34
|
+
* components, so `"version": "2.1"` passed Tier-1 here and failed schema
|
|
35
|
+
* validation there: two rails, two answers, the offline one lenient. The major
|
|
36
|
+
* is deliberately NOT pinned to 2 here — that ceiling belongs to the schema; the
|
|
37
|
+
* floor this module owns is `MIN_COOKBOOK_FORMAT_VERSION`.
|
|
38
|
+
*/
|
|
39
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
31
40
|
|
|
32
41
|
const DOC_REFERENCE = 'api/docs/biz/40-cookbooks/format.md § Required fields';
|
|
33
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The same node, § The array is the only accepted shape — the section that owns
|
|
45
|
+
* both halves of the rule below: `steps` is an array, and a step identifies
|
|
46
|
+
* itself with `step_id`. The node has no section called `Steps`; this constant
|
|
47
|
+
* named one until 2026-09-14, and `L008` never noticed, because it checks the
|
|
48
|
+
* document and not the section behind `§`.
|
|
49
|
+
*/
|
|
50
|
+
const STEPS_DOC_REFERENCE = 'api/docs/biz/40-cookbooks/format.md § The array is the only accepted shape';
|
|
51
|
+
|
|
52
|
+
/** `2.1` — how the format version is spoken about in prose, derived, never typed twice. */
|
|
53
|
+
const MIN_FORMAT_MAJOR_MINOR = MIN_COOKBOOK_FORMAT_VERSION.split('.').slice(0, 2).join('.');
|
|
54
|
+
|
|
34
55
|
/**
|
|
35
56
|
* @param {string} version numeric version string, already pattern-checked
|
|
36
57
|
* @returns {number[]} exactly three components, missing ones are 0
|
|
@@ -89,65 +110,86 @@ function checkCookbookFormatVersion(version) {
|
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
113
|
+
* @param {*} steps a `steps` value that is neither an array nor absent
|
|
114
|
+
* @returns {string} the shape, worded for the refusal message
|
|
115
|
+
*/
|
|
116
|
+
function describeStepsShape(steps) {
|
|
117
|
+
return typeof steps === 'object' ? 'an object' : `a ${typeof steps}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read a cookbook's `steps` in the ONE shape the format allows — an array of
|
|
122
|
+
* step objects, each carrying `step_id` — and refuse everything else.
|
|
95
123
|
*
|
|
96
|
-
*
|
|
97
|
-
* bans `id` outright. Accepting both is how a tree ends up with two names for
|
|
98
|
-
* one thing: measured 2026-08-30, 101 steps in 77 files carried `step_id` and
|
|
99
|
-
* six carried `id`, and every reader had to know about both. The V2 object
|
|
100
|
-
* shape keys the same steps by their `step_id`, so normalising it writes that
|
|
101
|
-
* key back onto the step — and nothing else.
|
|
124
|
+
* Two refusals, one rule each:
|
|
102
125
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
126
|
+
* 1. **Any shape but an array.** Owner decision 2026-09-05, fully authoritative
|
|
127
|
+
* (`api/docs/governance/confirmations/cookbook-steps-shape.md` 001): `steps`
|
|
128
|
+
* is an ARRAY, the only allowed shape, and the object variant "must not be
|
|
129
|
+
* permitted or used anywhere". It supersedes the 2026-08-17 object
|
|
130
|
+
* directive, which never landed in any runtime. The reason is order:
|
|
131
|
+
* JavaScript reorders numeric object keys and Postgres `jsonb` does not
|
|
132
|
+
* preserve key order either (measured M9,
|
|
133
|
+
* `infra/api_monitoring/src/consumer/cookbookSplit.js:32-37`), so an
|
|
134
|
+
* object-keyed cookbook has no defined execution order at all. Arrays keep
|
|
135
|
+
* theirs, because a reordered step list IS a different cookbook.
|
|
106
136
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
137
|
+
* Until 2026-09-05 this function converted the object into an array. That
|
|
138
|
+
* was a FALSE GREEN and the reason the tolerance is gone: the offline Tier-1
|
|
139
|
+
* gate accepted a cookbook the production WorkflowOrchestrator — which only
|
|
140
|
+
* ever read arrays — threw on.
|
|
141
|
+
*
|
|
142
|
+
* 2. **A step spelling its identifier `id`.** The node bans it outright
|
|
143
|
+
* (format.md § The array is the only accepted shape). Silently reading `id`
|
|
144
|
+
* is the fallback
|
|
145
|
+
* `architecture-principles.md` §3 forbids, and it hides an authoring mistake
|
|
146
|
+
* until something downstream reports `Step undefined`. There is no `id`
|
|
147
|
+
* anywhere any more: the adapter that wrote `id = step_id` went with
|
|
148
|
+
* `@onlineapps/cookbook-executor` (deleted 2026-08-31), and
|
|
149
|
+
* WorkflowOrchestrator — the single owner of cookbook execution — reads
|
|
150
|
+
* `step_id`.
|
|
151
|
+
*
|
|
152
|
+
* An absent `steps` is NOT a shape problem: the caller owns that message,
|
|
153
|
+
* because only it knows whether it is reading a file or an object handed to it.
|
|
113
154
|
*
|
|
114
155
|
* @param {*} steps the raw value of the cookbook's top-level `steps`
|
|
115
|
-
* @returns {object[]} the steps
|
|
116
|
-
* @throws {Error} when
|
|
156
|
+
* @returns {object[]} the steps, unchanged; `[]` when `steps` is absent
|
|
157
|
+
* @throws {Error} when `steps` is present but not an array, or a step carries `id`
|
|
117
158
|
*/
|
|
118
|
-
function
|
|
119
|
-
if (
|
|
159
|
+
function readCookbookSteps(steps) {
|
|
160
|
+
if (steps === undefined || steps === null) {
|
|
120
161
|
return [];
|
|
121
162
|
}
|
|
122
163
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
164
|
+
if (!Array.isArray(steps)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`[CookbookFormat] cookbook "steps" is ${describeStepsShape(steps)}, not an array - `
|
|
167
|
+
+ 'Expected an ARRAY of step objects, each carrying its own "step_id" '
|
|
168
|
+
+ `(${STEPS_DOC_REFERENCE}, cookbook format v${MIN_FORMAT_MAJOR_MINOR}; `
|
|
169
|
+
+ 'owner confirmation cookbook-steps-shape 001 of 2026-09-05: the array is the only '
|
|
170
|
+
+ 'allowed shape, because neither JS nor Postgres jsonb preserves object key order). '
|
|
171
|
+
+ 'Fix: rewrite {"fetch": {...}, "send": {...}} as '
|
|
172
|
+
+ '[{"step_id": "fetch", ...}, {"step_id": "send", ...}] — each key becomes that step\'s '
|
|
173
|
+
+ '"step_id" and the array order becomes the execution order.'
|
|
174
|
+
);
|
|
133
175
|
}
|
|
134
176
|
|
|
135
|
-
|
|
177
|
+
steps.forEach((step, index) => {
|
|
136
178
|
if (step && typeof step === 'object' && 'id' in step) {
|
|
137
179
|
const label = stepIdentityOf(step) || `#${index + 1}`;
|
|
138
180
|
throw new Error(
|
|
139
181
|
`[CookbookFormat] step "${label}" carries "id" - Expected: "step_id" only `
|
|
140
|
-
+ `(${
|
|
182
|
+
+ `(${STEPS_DOC_REFERENCE}). Fix: rename "id" to "step_id"`
|
|
141
183
|
);
|
|
142
184
|
}
|
|
143
185
|
});
|
|
144
186
|
|
|
145
|
-
return
|
|
187
|
+
return steps;
|
|
146
188
|
}
|
|
147
189
|
|
|
148
190
|
/**
|
|
149
191
|
* The identifier a step declares. `step_id` is the ONLY spelling the format
|
|
150
|
-
* allows; `
|
|
192
|
+
* allows; `readCookbookSteps` refuses a step that carries `id`, so nothing
|
|
151
193
|
* downstream needs to know that name ever existed.
|
|
152
194
|
*
|
|
153
195
|
* @param {object} step one normalised step
|
|
@@ -166,7 +208,6 @@ function stepIdentityOf(step) {
|
|
|
166
208
|
module.exports = {
|
|
167
209
|
MIN_COOKBOOK_FORMAT_VERSION,
|
|
168
210
|
checkCookbookFormatVersion,
|
|
169
|
-
|
|
170
|
-
normalizeCookbookSteps,
|
|
211
|
+
readCookbookSteps,
|
|
171
212
|
stepIdentityOf
|
|
172
213
|
};
|