@onlineapps/conn-orch-validator 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +2582 -2
  2. package/README.md +1038 -4
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +140 -9
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +2 -1
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +409 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +22 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +123 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. 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 };