@onlineapps/conn-orch-validator 6.0.1 → 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 +2591 -2
- package/README.md +1075 -7
- 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 +422 -104
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +78 -42
- package/src/ValidationOrchestrator.js +298 -75
- 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 +12 -2
- package/src/helpers/createServiceReadinessTests.js +75 -6
- 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 +213 -13
- 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 +21 -20
- 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
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The files a service is configured by.
|
|
5
|
+
*
|
|
6
|
+
* Two of these rows overlap deliberately with the boot validation: step 2 of
|
|
7
|
+
* `ValidationOrchestrator` already refuses a `config.json` without
|
|
8
|
+
* `service.name` and an `operations.json` with no operations, and it does that
|
|
9
|
+
* INSIDE a boot. The rows exist because `npx oa-validate` is asked the same
|
|
10
|
+
* question outside one — in CI, in the image build, in a repository nobody has
|
|
11
|
+
* started — and because the manifest is where a rule of service shape is
|
|
12
|
+
* declared (003 §20: an `Enforced by` cell names a row id). The honest end state
|
|
13
|
+
* is one implementation: step 2 extracting its rule into a pure module that both
|
|
14
|
+
* call. That refactor touches `ValidationOrchestrator.js`, which d.210a does not
|
|
15
|
+
* own, so it is recorded for the lead rather than done here.
|
|
16
|
+
*
|
|
17
|
+
* `G-SHARED-ENV` is a different kind: `shared.env` is GENERATED from the
|
|
18
|
+
* platform manifest `api/config/shared-env.json` (003 §18), so the row does not
|
|
19
|
+
* describe the file at all — it calls the renderer that owns it
|
|
20
|
+
* (`src/sync/sharedEnv.js`, d.217a) and reports the difference.
|
|
21
|
+
*
|
|
22
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md §2
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
|
|
28
|
+
const { readReferencedFile, isPackageReference, referenceOwner } = require('../discovery');
|
|
29
|
+
const { whereOf } = require('./libraryContext');
|
|
30
|
+
const { diffAgainst } = require('../../sync/sharedEnv');
|
|
31
|
+
const { loadAndValidateIntegrationContract } = require('../../utils/bizCiGateContract');
|
|
32
|
+
|
|
33
|
+
/** The generated platform file every service carries a copy of. */
|
|
34
|
+
const SHARED_ENV = 'shared.env';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} serviceRoot repository root
|
|
38
|
+
* @param {string} relative repository-relative path
|
|
39
|
+
* @returns {string|null}
|
|
40
|
+
*/
|
|
41
|
+
function readFileOrNull(serviceRoot, relative) {
|
|
42
|
+
const target = path.join(serviceRoot, ...relative.split('/'));
|
|
43
|
+
if (!fs.existsSync(target)) return null;
|
|
44
|
+
return fs.readFileSync(target, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read a JSON file of the repository. A malformed one is a finding, not a
|
|
49
|
+
* throw: the run reports every row before it stops.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} serviceRoot repository root
|
|
52
|
+
* @param {string} relative repository-relative path
|
|
53
|
+
* @returns {{ json: object }|{ absent: true }|{ broken: string }}
|
|
54
|
+
*/
|
|
55
|
+
function readJson(serviceRoot, relative) {
|
|
56
|
+
const text = readFileOrNull(serviceRoot, relative);
|
|
57
|
+
if (text === null) return { absent: true };
|
|
58
|
+
try {
|
|
59
|
+
return { json: JSON.parse(text) };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { broken: error.message };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The keys of `config/service/config.json` a reader demands, and the reader that
|
|
67
|
+
* demands each.
|
|
68
|
+
*
|
|
69
|
+
* The list is MEASURED, not designed: a key is here because grep finds the code
|
|
70
|
+
* that reads it and fails without it, and the keys the same measurement found
|
|
71
|
+
* unread are refused by `configDeadKeys` below rather than tolerated. What each
|
|
72
|
+
* reader does with the value is its own business — this row only settles that
|
|
73
|
+
* the value is there and has the type the reader assumes.
|
|
74
|
+
*
|
|
75
|
+
* service.name ConfigLoader.js (throws), ServiceWrapper.js
|
|
76
|
+
* (the registration identity)
|
|
77
|
+
* service.workspaceScoped ServiceWrapper._validateNamingAndWorkspaceScoped —
|
|
78
|
+
* an explicit boolean, no default, and it decides
|
|
79
|
+
* whether `list-workspaces` must exist
|
|
80
|
+
* service.specificationEndpoint ServiceWrapper.js, published in the service
|
|
81
|
+
* specification
|
|
82
|
+
*/
|
|
83
|
+
const REQUIRED_SERVICE_KEYS = Object.freeze([
|
|
84
|
+
Object.freeze({
|
|
85
|
+
key: 'name',
|
|
86
|
+
reader: 'the wrapper registers under that name',
|
|
87
|
+
ok: (value) => typeof value === 'string' && value.length > 0
|
|
88
|
+
}),
|
|
89
|
+
Object.freeze({
|
|
90
|
+
key: 'workspaceScoped',
|
|
91
|
+
reader: 'the wrapper refuses to boot without an explicit boolean',
|
|
92
|
+
ok: (value) => typeof value === 'boolean'
|
|
93
|
+
}),
|
|
94
|
+
Object.freeze({
|
|
95
|
+
key: 'specificationEndpoint',
|
|
96
|
+
reader: 'the wrapper publishes it in the service specification',
|
|
97
|
+
ok: (value) => typeof value === 'string' && value.length > 0
|
|
98
|
+
})
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const configService = Object.freeze({
|
|
102
|
+
scope: 'service',
|
|
103
|
+
requires: Object.freeze(['path']),
|
|
104
|
+
|
|
105
|
+
run({ row, serviceRoot }) {
|
|
106
|
+
const read = readJson(serviceRoot, row.path);
|
|
107
|
+
if (read.absent) return [{ where: row.path, what: 'absent — this uniform requires it' }];
|
|
108
|
+
if (read.broken) return [{ where: row.path, what: `is not valid JSON — ${read.broken}` }];
|
|
109
|
+
|
|
110
|
+
const service = read.json.service && typeof read.json.service === 'object' ? read.json.service : {};
|
|
111
|
+
|
|
112
|
+
// Absent and wrong-typed are separate sentences on purpose: "declares no X"
|
|
113
|
+
// sends the reader to the template, "declares X as <value>" sends them to
|
|
114
|
+
// the line they already wrote (`architecture-principles.md` §5).
|
|
115
|
+
return REQUIRED_SERVICE_KEYS
|
|
116
|
+
.filter((expected) => !expected.ok(service[expected.key]))
|
|
117
|
+
.map((expected) => ({
|
|
118
|
+
where: row.path,
|
|
119
|
+
what: service[expected.key] === undefined
|
|
120
|
+
? `declares no service.${expected.key} — ${expected.reader}`
|
|
121
|
+
: `declares service.${expected.key} as ${JSON.stringify(service[expected.key])} — ${expected.reader}`
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** Where every service declares what it integrates with. */
|
|
127
|
+
const CONTRACT_PATH = 'config/service/integration-contract.json';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The keys MEASURED to have no reader at all (d.217b, 2026-09-09).
|
|
131
|
+
*
|
|
132
|
+
* A declaration nothing reads is not harmless: it tells the next reader that
|
|
133
|
+
* something depends on it, and every new service copies it forward
|
|
134
|
+
* (`change-discipline.md` § Removing something removes its declaration). Each
|
|
135
|
+
* entry carries the evidence of its own deadness, because a ban whose reason is
|
|
136
|
+
* not written down is one nobody can re-check.
|
|
137
|
+
*/
|
|
138
|
+
const DEAD_KEYS = Object.freeze([
|
|
139
|
+
Object.freeze({
|
|
140
|
+
file: 'config/service/config.json',
|
|
141
|
+
path: Object.freeze(['service', 'version']),
|
|
142
|
+
label: 'service.version',
|
|
143
|
+
why: 'ConfigLoader assigns it from package.json on every load, so this value is never read'
|
|
144
|
+
}),
|
|
145
|
+
Object.freeze({
|
|
146
|
+
file: 'config/service/config.json',
|
|
147
|
+
path: Object.freeze(['service', 'port']),
|
|
148
|
+
label: 'service.port',
|
|
149
|
+
why: 'nothing reads it; a biz service binds no port'
|
|
150
|
+
}),
|
|
151
|
+
Object.freeze({
|
|
152
|
+
// Dated on purpose. The guard left the SOURCE in c7ee2256, but the artefact
|
|
153
|
+
// every service is pinned to today is the PUBLISHED 7.0.0, which still
|
|
154
|
+
// throws `Missing configuration - service.url is required` (measured
|
|
155
|
+
// 2026-09-14 in api_biz/invoicing/node_modules/@onlineapps/service-wrapper).
|
|
156
|
+
// A sentence saying "nothing reads it" would tell an author to delete a key
|
|
157
|
+
// their own boot demands (BIZ-invoicing 2026-09-11 point 1); the key travels
|
|
158
|
+
// WITH the pin, which is what confirmation biz-service-port-url 001 already
|
|
159
|
+
// said ("v rámci pin kaskády"). The row stays `deploy`: it names the work
|
|
160
|
+
// the cascade carries, it does not stop a boot.
|
|
161
|
+
file: 'config/service/config.json',
|
|
162
|
+
path: Object.freeze(['service', 'url']),
|
|
163
|
+
label: 'service.url',
|
|
164
|
+
why: 'dead from wrapper 8.0.0; the published 7.0.0 still refuses to boot without it '
|
|
165
|
+
+ '("Missing configuration - service.url is required"), so the key goes WITH the pin, '
|
|
166
|
+
+ 'never before it (confirmation biz-service-port-url 001)'
|
|
167
|
+
}),
|
|
168
|
+
Object.freeze({
|
|
169
|
+
file: 'config/service/config.json',
|
|
170
|
+
path: Object.freeze(['wrapper', 'registry', 'url']),
|
|
171
|
+
label: 'wrapper.registry.url',
|
|
172
|
+
why: 'nothing reads it; registration travels over MQ to registry.register '
|
|
173
|
+
+ 'and discovery reads the Redis projection (confirmation biz-discovery-redis 002)'
|
|
174
|
+
}),
|
|
175
|
+
Object.freeze({
|
|
176
|
+
file: 'config/service/config.json',
|
|
177
|
+
path: Object.freeze(['wrapper', 'tenantContext']),
|
|
178
|
+
label: 'wrapper.tenantContext',
|
|
179
|
+
// `service-shape-v11-retirement` 001 retired the validator LEVEL that demanded
|
|
180
|
+
// the key EXIST; it never sanctioned deleting it. What killed the key is
|
|
181
|
+
// `wrapper-tenant-middleware` 001 — its only consumer and the runtime default
|
|
182
|
+
// both went in b5e36431 (controller finding 21, BIZ-converter 2026-09-14).
|
|
183
|
+
why: 'nothing reads it; its only consumer, createTenantContextMiddleware, was deleted '
|
|
184
|
+
+ 'together with the runtime default in b5e36431 '
|
|
185
|
+
+ '(confirmation wrapper-tenant-middleware 001)'
|
|
186
|
+
}),
|
|
187
|
+
Object.freeze({
|
|
188
|
+
file: CONTRACT_PATH,
|
|
189
|
+
path: Object.freeze(['contractVersion']),
|
|
190
|
+
label: 'contractVersion',
|
|
191
|
+
why: 'nothing reads it'
|
|
192
|
+
})
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {object} json parsed document
|
|
197
|
+
* @param {ReadonlyArray<string>} keyPath the key to look up
|
|
198
|
+
* @returns {boolean} whether the document declares it at all
|
|
199
|
+
*/
|
|
200
|
+
function declares(json, keyPath) {
|
|
201
|
+
let node = json;
|
|
202
|
+
for (const segment of keyPath) {
|
|
203
|
+
if (node === null || typeof node !== 'object' || !(segment in node)) return false;
|
|
204
|
+
node = node[segment];
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const configDeadKeys = Object.freeze({
|
|
210
|
+
scope: 'service',
|
|
211
|
+
requires: Object.freeze([]),
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A document this row cannot parse raises nothing: `C-SERVICE` and
|
|
215
|
+
* `C-CONTRACT` already name a broken file with their own fix, and a second
|
|
216
|
+
* sentence about it would send the reader to two places for one defect.
|
|
217
|
+
*/
|
|
218
|
+
run({ serviceRoot }) {
|
|
219
|
+
const parsed = new Map();
|
|
220
|
+
const findings = [];
|
|
221
|
+
|
|
222
|
+
for (const dead of DEAD_KEYS) {
|
|
223
|
+
if (!parsed.has(dead.file)) parsed.set(dead.file, readJson(serviceRoot, dead.file));
|
|
224
|
+
const read = parsed.get(dead.file);
|
|
225
|
+
if (read.absent || read.broken) continue;
|
|
226
|
+
if (!declares(read.json, dead.path)) continue;
|
|
227
|
+
|
|
228
|
+
findings.push({ where: dead.file, what: `declares ${dead.label} — ${dead.why}` });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return findings;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The integration contract, decided by the module that already owns its rules.
|
|
237
|
+
*
|
|
238
|
+
* `utils/bizCiGateContract.js` validates this document for the CI gate —
|
|
239
|
+
* connector booleans, the integration minimum, the database block and its
|
|
240
|
+
* agreement with `requiredConnectors.db`, the env block with a `why` per name.
|
|
241
|
+
* That validator IS the schema, so this row calls it instead of restating it in
|
|
242
|
+
* a second notation (lead decision, d.217b; `change-discipline.md` § One rail
|
|
243
|
+
* per concern). Confirmation `biz-service-manifest` 003 §18 asks for a shipped
|
|
244
|
+
* schema; the divergence between its wording and one rail is on the lead's
|
|
245
|
+
* table, not settled here.
|
|
246
|
+
*/
|
|
247
|
+
const configContract = Object.freeze({
|
|
248
|
+
scope: 'service',
|
|
249
|
+
requires: Object.freeze(['path']),
|
|
250
|
+
|
|
251
|
+
run({ row, serviceRoot }) {
|
|
252
|
+
const target = path.join(serviceRoot, ...row.path.split('/'));
|
|
253
|
+
if (!fs.existsSync(target)) {
|
|
254
|
+
return [{
|
|
255
|
+
where: row.path,
|
|
256
|
+
what: 'absent — every platform gate reads it: the connectors, the database, '
|
|
257
|
+
+ 'the env declaration and the integration minimum'
|
|
258
|
+
}];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
loadAndValidateIntegrationContract(serviceRoot);
|
|
263
|
+
return [];
|
|
264
|
+
} catch (error) {
|
|
265
|
+
// The validator's own first line, without its `Fix:` sentence: the row
|
|
266
|
+
// carries a fix of its own, and two of them in one cell read as two
|
|
267
|
+
// defects (contractBridge.js summarise()).
|
|
268
|
+
const firstLine = String(error.message).split('\n')[0];
|
|
269
|
+
return [{ where: row.path, what: firstLine.split(' Fix:')[0].trim() }];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const configOperations = Object.freeze({
|
|
275
|
+
scope: 'service',
|
|
276
|
+
requires: Object.freeze(['path']),
|
|
277
|
+
|
|
278
|
+
run({ row, serviceRoot }) {
|
|
279
|
+
const read = readJson(serviceRoot, row.path);
|
|
280
|
+
if (read.absent) return [{ where: row.path, what: 'absent — this uniform requires it' }];
|
|
281
|
+
if (read.broken) return [{ where: row.path, what: `is not valid JSON — ${read.broken}` }];
|
|
282
|
+
|
|
283
|
+
const operations = read.json.operations;
|
|
284
|
+
const declared = operations && typeof operations === 'object' ? Object.keys(operations) : [];
|
|
285
|
+
if (declared.length > 0) return [];
|
|
286
|
+
return [{
|
|
287
|
+
where: row.path,
|
|
288
|
+
what: 'declares no operations — a service that dispatches nothing has no reason to boot'
|
|
289
|
+
}];
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const envTemplates = Object.freeze({
|
|
294
|
+
scope: 'service',
|
|
295
|
+
requires: Object.freeze(['path']),
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* One shared template and exactly one of the service's own.
|
|
299
|
+
*
|
|
300
|
+
* What the file is CALLED is not this row's business: the scaffold renames it
|
|
301
|
+
* to `<directory>.env` and `api/tests/scripts/add-service.bats` is the test of
|
|
302
|
+
* that rename (lead decision `api/shared/TODO.md` §0.2b-9 point 3). What the
|
|
303
|
+
* uniform fixes is that the service's own keys have exactly one home.
|
|
304
|
+
*/
|
|
305
|
+
run({ row, serviceRoot }) {
|
|
306
|
+
const dir = path.join(serviceRoot, ...row.path.split('/'));
|
|
307
|
+
if (!fs.existsSync(dir)) {
|
|
308
|
+
return [{ where: `${row.path}/`, what: 'absent — the service is started from the templates in it' }];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const files = fs.readdirSync(dir).filter((name) => name.endsWith('.env')).sort();
|
|
312
|
+
const findings = [];
|
|
313
|
+
|
|
314
|
+
if (!files.includes(SHARED_ENV)) {
|
|
315
|
+
findings.push({ where: `${row.path}/`, what: `declares no ${SHARED_ENV} — the platform keys have nowhere to live` });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const own = files.filter((name) => name !== SHARED_ENV);
|
|
319
|
+
if (own.length === 0) {
|
|
320
|
+
findings.push({
|
|
321
|
+
where: `${row.path}/`,
|
|
322
|
+
what: `declares no service env template beside ${SHARED_ENV} — the service's own keys have nowhere to live`
|
|
323
|
+
});
|
|
324
|
+
} else if (own.length > 1) {
|
|
325
|
+
findings.push({
|
|
326
|
+
where: `${row.path}/`,
|
|
327
|
+
what: `declares ${own.length} service env templates (${own.join(', ')}) — the service's own keys have one home`
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return findings;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* `shared.env` is rendered from `api/config/shared-env.json`, and this row reads
|
|
337
|
+
* the RENDER the package carries (d.229): the platform manifest stays the owner
|
|
338
|
+
* of the key set, `templates/business-service/config/env-templates/shared.env`
|
|
339
|
+
* is its output, and `sharedEnvTemplate.test.js` is what keeps the two equal.
|
|
340
|
+
* Reading the render rather than the manifest is what lets the row run inside a
|
|
341
|
+
* service container, where `api/config/` is not there at all — and that is the
|
|
342
|
+
* only place a service's own `shared.env` is ever wrong for a reader who cannot
|
|
343
|
+
* fix it from the workspace.
|
|
344
|
+
*/
|
|
345
|
+
const sharedEnvGenerated = Object.freeze({
|
|
346
|
+
scope: 'bearer',
|
|
347
|
+
requires: Object.freeze(['path', 'from']),
|
|
348
|
+
|
|
349
|
+
needsWorkspace: ({ row }) => !isPackageReference(row.from),
|
|
350
|
+
|
|
351
|
+
describeNotRun({ row }) {
|
|
352
|
+
return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
356
|
+
const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
357
|
+
const text = readFileOrNull(serviceRoot, row.path);
|
|
358
|
+
const owner = referenceOwner(row.from);
|
|
359
|
+
if (text === null) {
|
|
360
|
+
return [{ where, what: `absent — it is rendered from ${owner}` }];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const rendered = readReferencedFile({ from: row.from, workspaceRoot });
|
|
364
|
+
if (rendered === text) return [];
|
|
365
|
+
|
|
366
|
+
const differing = diffAgainst(rendered, text).split('\n').filter((line) => /^[-+]/.test(line)).length - 2;
|
|
367
|
+
return [{
|
|
368
|
+
where,
|
|
369
|
+
what: `differs from ${owner} — this file is generated, not written; `
|
|
370
|
+
+ `${differing} line(s) differ`
|
|
371
|
+
}];
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
module.exports = {
|
|
376
|
+
// Exported for one reader: the test holding the row's `why` to the keys this
|
|
377
|
+
// list actually carries. The row enumerates them for a human, so the two drift
|
|
378
|
+
// the moment a key is added — measured 2026-09-11, `wrapper.registry.url`
|
|
379
|
+
// joined the list in d.244 and the row said nothing about it
|
|
380
|
+
// (`doc-code-binding.md` §1: a hand-written enumeration needs a mechanism).
|
|
381
|
+
DEAD_KEYS,
|
|
382
|
+
checks: [
|
|
383
|
+
{ name: 'config-service', check: configService },
|
|
384
|
+
{ name: 'config-dead-keys', check: configDeadKeys },
|
|
385
|
+
{ name: 'config-contract', check: configContract },
|
|
386
|
+
{ name: 'config-operations', check: configOperations },
|
|
387
|
+
{ name: 'env-templates', check: envTemplates },
|
|
388
|
+
{ name: 'shared-env-generated', check: sharedEnvGenerated }
|
|
389
|
+
]
|
|
390
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `C-CONNECTORS` — a service's two declarations about its connections must
|
|
5
|
+
* agree.
|
|
6
|
+
*
|
|
7
|
+
* The rule is not new and it does not live here: `utils/connectorContract.js`
|
|
8
|
+
* has compared `config/service/config.json` → `wrapper.<connector>` against
|
|
9
|
+
* `config/service/integration-contract.json` → `requiredConnectors` since F12.
|
|
10
|
+
* What was missing was a consequence. At boot the contradiction goes into
|
|
11
|
+
* `results.warnings` and the service starts anyway, and the check ran in no CI
|
|
12
|
+
* job at all — a mechanism nothing acts on produces the feeling of a guarantee
|
|
13
|
+
* without the guarantee (`automation-gates.md` §5).
|
|
14
|
+
*
|
|
15
|
+
* Owner confirmation `connector-contract-check` 001 (2026-09-14) chose the
|
|
16
|
+
* uniform row over failing the boot: severity `deploy`, so the contradiction is
|
|
17
|
+
* caught before push and in CI, on one rail with the rest of the uniform, while
|
|
18
|
+
* the boot keeps reporting and keeps starting. "Fail the boot" may be proposed
|
|
19
|
+
* again only once all eight services measure clean on this row.
|
|
20
|
+
*
|
|
21
|
+
* The row asks only the DECLARATION half. The environment half of the boot check
|
|
22
|
+
* — is `REDIS_URL` set for a service that requires Redis — stays at boot, where
|
|
23
|
+
* an environment exists: asked from the api checkout it would fail all eight
|
|
24
|
+
* services for a configuration none of them is running in, and a gate whose
|
|
25
|
+
* violations have no fix is the grandfathering `automation-gates.md` §3 forbids.
|
|
26
|
+
*
|
|
27
|
+
* @see api/docs/governance/confirmations/connector-contract-check.md § Confirmation 20260914-0635-connector-contract-check-001
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
const { verifyConnectorDeclarations, CONFIG_PATH, CONTRACT_PATH } = require('../../utils/connectorContract');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The two declarations, or the reason they could not be read.
|
|
37
|
+
*
|
|
38
|
+
* An absent or malformed file is `C-CONTRACT`'s and `C-SERVICE`'s finding — both
|
|
39
|
+
* call the validators that own those documents. This row says nothing about a
|
|
40
|
+
* fact it could not read rather than guessing which way it went, exactly as
|
|
41
|
+
* `D-DB-CONSISTENT` does.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} serviceRoot repository root
|
|
44
|
+
* @returns {{config: object, requiredConnectors: object}|null}
|
|
45
|
+
*/
|
|
46
|
+
function readDeclarations(serviceRoot) {
|
|
47
|
+
const read = (relative) => {
|
|
48
|
+
const file = path.join(serviceRoot, ...relative.split('/'));
|
|
49
|
+
if (!fs.existsSync(file)) return null;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const config = read(CONFIG_PATH);
|
|
58
|
+
const contract = read(CONTRACT_PATH);
|
|
59
|
+
if (config === null || contract === null) return null;
|
|
60
|
+
|
|
61
|
+
const requiredConnectors = contract.requiredConnectors;
|
|
62
|
+
if (requiredConnectors === null || typeof requiredConnectors !== 'object') return null;
|
|
63
|
+
|
|
64
|
+
return { config, requiredConnectors };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const connectorContract = Object.freeze({
|
|
68
|
+
scope: 'service',
|
|
69
|
+
requires: Object.freeze([]),
|
|
70
|
+
|
|
71
|
+
run({ serviceRoot }) {
|
|
72
|
+
const declarations = readDeclarations(serviceRoot);
|
|
73
|
+
if (declarations === null) return [];
|
|
74
|
+
|
|
75
|
+
return verifyConnectorDeclarations(declarations)
|
|
76
|
+
.findings
|
|
77
|
+
.map(({ where, what }) => ({ where, what }));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
module.exports = { name: 'connector-contract', check: connectorContract };
|