@onlineapps/conn-orch-validator 12.2.0 → 13.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 +597 -0
- package/README.md +126 -19
- package/manifests/biz-service.manifest.json +15 -2
- package/manifests/library.manifest.json +4 -4
- package/package.json +11 -3
- package/src/CookbookTestRunner.js +275 -105
- package/src/CookbookTestUtils.js +79 -68
- package/src/ServiceReadinessValidator.js +42 -52
- package/src/ValidationOrchestrator.js +65 -44
- package/src/cli/biz-ci-gate.js +2 -2
- package/src/cli/oa-sync-template.js +97 -47
- package/src/cli/oa-validate.js +44 -10
- package/src/helpers/README.md +6 -6
- package/src/helpers/createServiceReadinessTests.js +87 -33
- package/src/index.js +14 -5
- package/src/lint/scripts/lintScripts.js +11 -4
- package/src/manifest/checks/libraryContext.js +6 -3
- package/src/manifest/checks/libraryDocs.js +174 -4
- package/src/manifest/checks/libraryTests.js +200 -19
- package/src/manifest/checks/scriptHeaders.js +6 -13
- package/src/manifest/checks/serviceConfig.js +36 -16
- package/src/manifest/checks/serviceConnectors.js +180 -2
- package/src/manifest/checks/serviceDb.js +0 -3
- package/src/manifest/checks/serviceScripts.js +3 -20
- package/src/manifest/runManifest.js +90 -13
- package/src/manifest/workspaceRoot.js +133 -4
- package/src/mocks/MockMQClient.js +2 -2
- package/src/sync/docsRegion.js +2 -2
- package/src/sync/readmeFile.js +30 -0
- package/src/sync/readmeLocation.js +2 -12
- package/src/sync/readmePointer.js +10 -4
- package/src/sync/serviceTemplate.js +9 -11
- package/src/sync/sharedEnv.js +59 -3
- package/src/sync/uniformFiles.js +81 -8
- package/src/utils/bizCiGateContract.js +2 -2
- package/src/utils/connectorContract.js +54 -2
- package/src/utils/cookbookFormat.js +25 -115
- package/src/utils/dbAccountGrants.js +5 -3
- package/src/utils/deployContract.js +153 -28
- package/src/utils/envContract.js +2 -2
- package/src/utils/handlerRef.js +8 -10
- package/src/utils/integrationRun.js +1 -1
- package/src/utils/operationsDocumentRules.js +242 -0
- package/src/utils/operationsRules.js +157 -0
- package/src/utils/resolveHeaders.js +12 -1
- package/src/utils/setupDatabase.js +1 -1
- package/src/utils/stepFailure.js +3 -3
- package/src/utils/stepReferences.js +28 -87
- package/src/utils/throwawaySchema.js +1 -1
- package/src/utils/yamlTopLevel.js +105 -0
- package/src/validators/ServiceStructureValidator.js +67 -152
- package/templates/business-service/README.md +3 -2
- package/templates/business-service/config/env-templates/shared.env +1 -0
- package/templates/business-service/src/config/index.js +15 -0
- package/TESTING_STRATEGY.md +0 -92
- package/jest.config.js +0 -37
|
@@ -29,12 +29,10 @@ const path = require('path');
|
|
|
29
29
|
|
|
30
30
|
const { loadManifest, DEFAULT_MANIFEST_PATH, LIBRARY_MANIFEST_PATH } = require('../manifest/loadManifest');
|
|
31
31
|
const {
|
|
32
|
-
API_CHECKOUT_ROOT, PACKAGE_ROOT: OWN_ROOT,
|
|
32
|
+
API_CHECKOUT_ROOT, PACKAGE_ROOT: OWN_ROOT, API_PREFIX, resolveWorkspacePath
|
|
33
33
|
} = require('../manifest/workspaceRoot');
|
|
34
34
|
const { KINDS, renderUniformRegion } = require('./readmePointer');
|
|
35
|
-
|
|
36
|
-
/** The file the pointer is a region of. */
|
|
37
|
-
const README_FILE = 'README.md';
|
|
35
|
+
const { README_FILE } = require('./readmeFile');
|
|
38
36
|
|
|
39
37
|
/**
|
|
40
38
|
* This package, by the name a consumer installs it under — read when a link
|
|
@@ -74,13 +72,6 @@ function serviceManifestPath(serviceRoot) {
|
|
|
74
72
|
return path.join(serviceRoot, 'node_modules', ...selfName().split('/'), SERVICE_MANIFEST_IN_PACKAGE);
|
|
75
73
|
}
|
|
76
74
|
|
|
77
|
-
/**
|
|
78
|
-
* The conventional prefix a workspace-relative path uses for the api checkout,
|
|
79
|
-
* read off the marker that declares it rather than typed a second time here
|
|
80
|
-
* (`../manifest/workspaceRoot.js`, which owns both the prefix and the marker).
|
|
81
|
-
*/
|
|
82
|
-
const [API_PREFIX] = WORKSPACE_MARKER.split('/');
|
|
83
|
-
|
|
84
75
|
/**
|
|
85
76
|
* Where this package lies inside its own workspace, workspace-relative — or null
|
|
86
77
|
* when it lies in no checkout at all (an installed copy, a service container).
|
|
@@ -190,7 +181,6 @@ function libraryRegion({ packageDir, pkg, workspaceRoot, manifest }) {
|
|
|
190
181
|
}
|
|
191
182
|
|
|
192
183
|
module.exports = {
|
|
193
|
-
README_FILE,
|
|
194
184
|
selfName,
|
|
195
185
|
PACKAGE_ROOT,
|
|
196
186
|
PACKAGE_IN_WORKSPACE,
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
const path = require('path');
|
|
43
43
|
|
|
44
44
|
const { markersFor, replaceRegion, extractRegion: extractBetween, checkRegion } = require('./generatedRegion');
|
|
45
|
+
const { README_FILE } = require('./readmeFile');
|
|
45
46
|
|
|
46
47
|
/** The bearer kinds this package renders a pointer for. */
|
|
47
48
|
const KINDS = Object.freeze({ library: 'library', service: 'service' });
|
|
@@ -57,8 +58,13 @@ const ALL_CATEGORIES = '*';
|
|
|
57
58
|
*/
|
|
58
59
|
const DISCOVERY_SECTION = 'discovery';
|
|
59
60
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The file every error of this module is about; the prefix is
|
|
63
|
+
* generatedRegion.js's own. The name comes from `readmeFile.js`, which owns it
|
|
64
|
+
* for the whole package — this module renders the region and names no file of
|
|
65
|
+
* its own (d.739b).
|
|
66
|
+
*/
|
|
67
|
+
const WHERE = Object.freeze({ file: README_FILE });
|
|
62
68
|
|
|
63
69
|
const code = (value) => `\`${value}\``;
|
|
64
70
|
|
|
@@ -392,7 +398,7 @@ function headerEnd(lines, { required }) {
|
|
|
392
398
|
if (!required) return 0;
|
|
393
399
|
throw new Error('[ReadmePointer] README has no node header - the file must open with the '
|
|
394
400
|
+ '"> Status:" / "> Owns:" block, and the region is placed after it. '
|
|
395
|
-
+
|
|
401
|
+
+ `Fix: give ${README_FILE} the node header (api/docs/standards/INFRA-DOC-STANDARD.md).`);
|
|
396
402
|
}
|
|
397
403
|
|
|
398
404
|
let index = 0;
|
|
@@ -402,7 +408,7 @@ function headerEnd(lines, { required }) {
|
|
|
402
408
|
throw new Error('[ReadmePointer] Node header is not followed by a blank line - the region is placed '
|
|
403
409
|
+ `after it and the shape has to be unambiguous, but line ${index + 1} is `
|
|
404
410
|
+ `${JSON.stringify(lines[index] === undefined ? null : lines[index])}. `
|
|
405
|
-
+
|
|
411
|
+
+ `Fix: leave one blank line between the "> " block and the rest of ${README_FILE}.`);
|
|
406
412
|
}
|
|
407
413
|
return index + 1;
|
|
408
414
|
}
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
const fs = require('fs');
|
|
24
24
|
const path = require('path');
|
|
25
25
|
|
|
26
|
+
const { rewritableTopLevelKeys } = require('../utils/yamlTopLevel');
|
|
27
|
+
|
|
26
28
|
/** The template as it lies in this package. */
|
|
27
29
|
const TEMPLATE_ROOT = path.join(__dirname, '..', '..', 'templates', 'business-service');
|
|
28
30
|
|
|
@@ -550,9 +552,6 @@ function insertBlockAfterFunction({ text, fn, replacement }) {
|
|
|
550
552
|
return [...lines.slice(0, end + 1), '', ...replacement, ...lines.slice(end + 1)].join('\n');
|
|
551
553
|
}
|
|
552
554
|
|
|
553
|
-
/** A top-level mapping key: a line that starts in column 0 and ends its key with a colon. */
|
|
554
|
-
const TOP_LEVEL_KEY = /^([A-Za-z_][A-Za-z0-9_.-]*):/;
|
|
555
|
-
|
|
556
555
|
/**
|
|
557
556
|
* The top-level mapping keys a run of lines declares, in the order it declares
|
|
558
557
|
* them.
|
|
@@ -563,16 +562,17 @@ const TOP_LEVEL_KEY = /^([A-Za-z_][A-Za-z0-9_.-]*):/;
|
|
|
563
562
|
* (`.claude/rules/change-discipline.md` § One rail per concern), and the day the
|
|
564
563
|
* block gained a job the two would disagree about which file had drifted.
|
|
565
564
|
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
565
|
+
* What a top-level key IS has one owner, `src/utils/yamlTopLevel.js`, and this
|
|
566
|
+
* takes the REWRITABLE reading of it: hidden keys (`.oa-uniform:`) are left out,
|
|
567
|
+
* because the same list drives `replaceTopLevelKeys`, which DELETES what it
|
|
568
|
+
* matches, and a hidden key a service wrote itself is not the platform's to
|
|
569
|
+
* delete.
|
|
570
570
|
*
|
|
571
571
|
* @param {string[]} lines
|
|
572
572
|
* @returns {string[]}
|
|
573
573
|
*/
|
|
574
574
|
function topLevelKeys(lines) {
|
|
575
|
-
return lines.
|
|
575
|
+
return rewritableTopLevelKeys(lines.join('\n')).map((key) => key.name);
|
|
576
576
|
}
|
|
577
577
|
|
|
578
578
|
/**
|
|
@@ -597,9 +597,7 @@ function topLevelKeys(lines) {
|
|
|
597
597
|
*/
|
|
598
598
|
function replaceTopLevelKeys({ text, keys, replacement }) {
|
|
599
599
|
const lines = text.split('\n');
|
|
600
|
-
const starts =
|
|
601
|
-
.map((line, index) => ({ key: (TOP_LEVEL_KEY.exec(line) || [])[1], index }))
|
|
602
|
-
.filter((entry) => entry.key !== undefined);
|
|
600
|
+
const starts = rewritableTopLevelKeys(text).map(({ name, line }) => ({ key: name, index: line }));
|
|
603
601
|
|
|
604
602
|
const doomed = new Set();
|
|
605
603
|
for (let i = 0; i < starts.length; i += 1) {
|
package/src/sync/sharedEnv.js
CHANGED
|
@@ -26,6 +26,49 @@ const DIFF_HEADER = Object.freeze([
|
|
|
26
26
|
'+++ on disk'
|
|
27
27
|
]);
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The opening of the per-machine marker line, and the only field its
|
|
31
|
+
* declaration carries. A template says with it that the value of a key is a
|
|
32
|
+
* property of the box rather than of the platform, so the drift gate must not
|
|
33
|
+
* judge it.
|
|
34
|
+
*
|
|
35
|
+
* @see api/scripts/lib/env-template-markers.sh - the reader of this marker
|
|
36
|
+
* @see api/scripts/validate-env.sh - the gate that consults the reader (--check-drift)
|
|
37
|
+
*/
|
|
38
|
+
const PER_MACHINE_PREFIX = '# @per-machine — ';
|
|
39
|
+
const PER_MACHINE_FIELDS = Object.freeze(['reason']);
|
|
40
|
+
|
|
41
|
+
function assertPerMachine(name, perMachine) {
|
|
42
|
+
if (typeof perMachine !== 'object' || perMachine === null || Array.isArray(perMachine)) {
|
|
43
|
+
throw new Error(`[SharedEnv] Key ${name} has a "perMachine" that is not an object - expected `
|
|
44
|
+
+ '{ "reason": "<why this machine\'s value differs>" }, because a marker with no reason '
|
|
45
|
+
+ 'exempts a key from the drift gate without saying who decided it. Fix: write "perMachine": '
|
|
46
|
+
+ '{ "reason": "..." } for that key in api/config/shared-env.json.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Object.keys(perMachine).forEach((field) => {
|
|
50
|
+
if (!PER_MACHINE_FIELDS.includes(field)) {
|
|
51
|
+
throw new Error(`[SharedEnv] Key ${name} has an unknown "perMachine" field `
|
|
52
|
+
+ `${JSON.stringify(field)} - expected "reason" and nothing else, so a field nobody renders `
|
|
53
|
+
+ 'cannot look like a declaration. Fix: remove it from that key in '
|
|
54
|
+
+ 'api/config/shared-env.json.');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (typeof perMachine.reason !== 'string' || perMachine.reason.trim() === '') {
|
|
59
|
+
throw new Error(`[SharedEnv] Key ${name} has a "perMachine" with no "reason" - the marker line `
|
|
60
|
+
+ 'states why THIS machine differs, and the drift gate quotes it back to the operator. '
|
|
61
|
+
+ 'Fix: add "reason" to that key\'s "perMachine" in api/config/shared-env.json.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (/[\r\n]/.test(perMachine.reason)) {
|
|
65
|
+
throw new Error(`[SharedEnv] Key ${name} has a multi-line "perMachine.reason" - the generated `
|
|
66
|
+
+ 'file carries one marker line per key, and the reader holds a marker only inside the '
|
|
67
|
+
+ 'comment block immediately above the key. Fix: write it as a single sentence in '
|
|
68
|
+
+ 'api/config/shared-env.json.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
29
72
|
function assertManifest(manifest) {
|
|
30
73
|
const keys = manifest && manifest.keys;
|
|
31
74
|
if (!Array.isArray(keys) || keys.length === 0) {
|
|
@@ -46,7 +89,7 @@ function assertManifest(manifest) {
|
|
|
46
89
|
}
|
|
47
90
|
if (typeof key.why !== 'string' || key.why.trim() === '') {
|
|
48
91
|
throw new Error(`[SharedEnv] Key ${name} has no "why" - every shared key states why the platform `
|
|
49
|
-
+ 'shares it (docs/biz/70-contracts/env-contract.md). Fix: add "why" to that key in '
|
|
92
|
+
+ 'shares it (api/docs/biz/70-contracts/env-contract.md). Fix: add "why" to that key in '
|
|
50
93
|
+ 'api/config/shared-env.json.');
|
|
51
94
|
}
|
|
52
95
|
if (/[\r\n]/.test(key.why)) {
|
|
@@ -57,6 +100,9 @@ function assertManifest(manifest) {
|
|
|
57
100
|
throw new Error(`[SharedEnv] Key ${name} has a multi-line "value" - an env file carries one line `
|
|
58
101
|
+ 'per key. Fix: correct the value in api/config/shared-env.json.');
|
|
59
102
|
}
|
|
103
|
+
if (key.perMachine !== undefined) {
|
|
104
|
+
assertPerMachine(name, key.perMachine);
|
|
105
|
+
}
|
|
60
106
|
});
|
|
61
107
|
}
|
|
62
108
|
|
|
@@ -78,7 +124,13 @@ function assertManifest(manifest) {
|
|
|
78
124
|
* names a service's code reads against what its
|
|
79
125
|
* `config/service/integration-contract.json` declares.
|
|
80
126
|
*
|
|
81
|
-
*
|
|
127
|
+
* A key that declares `perMachine` renders a second comment line under its
|
|
128
|
+
* `why`, immediately above `KEY=`: the marker of
|
|
129
|
+
* `api/scripts/lib/env-template-markers.sh`, which holds only inside the
|
|
130
|
+
* contiguous comment block above the key it belongs to.
|
|
131
|
+
*
|
|
132
|
+
* @param {{keys: Array<{name: string, value: string, why: string,
|
|
133
|
+
* perMachine?: {reason: string}}>}} manifest
|
|
82
134
|
* @returns {string}
|
|
83
135
|
*/
|
|
84
136
|
function renderSharedEnv(manifest) {
|
|
@@ -86,7 +138,11 @@ function renderSharedEnv(manifest) {
|
|
|
86
138
|
|
|
87
139
|
const lines = [...HEADER];
|
|
88
140
|
manifest.keys.forEach((key) => {
|
|
89
|
-
lines.push('', `# ${key.why}
|
|
141
|
+
lines.push('', `# ${key.why}`);
|
|
142
|
+
if (key.perMachine !== undefined) {
|
|
143
|
+
lines.push(`${PER_MACHINE_PREFIX}${key.perMachine.reason}`);
|
|
144
|
+
}
|
|
145
|
+
lines.push(`${key.name}=${key.value}`);
|
|
90
146
|
});
|
|
91
147
|
|
|
92
148
|
return `${lines.join('\n')}\n`;
|
package/src/sync/uniformFiles.js
CHANGED
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
const fs = require('fs');
|
|
39
39
|
const path = require('path');
|
|
40
40
|
|
|
41
|
-
const {
|
|
41
|
+
const {
|
|
42
|
+
readReferencedFile, isPackageReference, referenceOwner, resolveFromDocument
|
|
43
|
+
} = require('../manifest/discovery');
|
|
44
|
+
const { renderSharedEnv } = require('./sharedEnv');
|
|
45
|
+
const { collectRows } = require('../manifest/walk');
|
|
42
46
|
const { readComposeServices } = require('../manifest/checks/composeShape');
|
|
43
47
|
const { renderRunnerBlock, replaceServiceNode } = require('../manifest/checks/composeRunnerBlock');
|
|
44
48
|
const {
|
|
@@ -175,6 +179,24 @@ function renderReadme({ row, current, serviceRoot }) {
|
|
|
175
179
|
return applyUniformRegion(current, serviceRegion(serviceRoot), { kind: KINDS.service });
|
|
176
180
|
}
|
|
177
181
|
|
|
182
|
+
/**
|
|
183
|
+
* `config/env-templates/shared.env` from the platform env manifest.
|
|
184
|
+
*
|
|
185
|
+
* The same expression the row's own check evaluates
|
|
186
|
+
* (`../manifest/checks/serviceConfig.js` § sharedEnvGenerated): the referenced
|
|
187
|
+
* document, handed to the module that owns the shared key set. Nothing here
|
|
188
|
+
* knows a key, a format or a path to the SSOT — the row names the owner and
|
|
189
|
+
* `sharedEnv.js` renders it, which is why the `shared-env` subcommand and this
|
|
190
|
+
* run cannot produce two different files (`.claude/rules/change-discipline.md`
|
|
191
|
+
* § One rail per concern).
|
|
192
|
+
*
|
|
193
|
+
* @param {{row: object, workspaceRoot: string}} args
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
function renderSharedEnvFile({ row, workspaceRoot }) {
|
|
197
|
+
return renderSharedEnv(resolveFromDocument({ from: row.from, workspaceRoot }));
|
|
198
|
+
}
|
|
199
|
+
|
|
178
200
|
/**
|
|
179
201
|
* A whole file rendered for THIS repository: the template with the one parameter
|
|
180
202
|
* a service has in it, its declared name.
|
|
@@ -325,7 +347,8 @@ const RENDERERS = Object.freeze({
|
|
|
325
347
|
'template-render': renderRendered,
|
|
326
348
|
'delimited-block-render': renderDelimitedBlock,
|
|
327
349
|
'template-skeleton': renderSkeleton,
|
|
328
|
-
'readme-uniform-current': renderReadme
|
|
350
|
+
'readme-uniform-current': renderReadme,
|
|
351
|
+
'shared-env-generated': renderSharedEnvFile
|
|
329
352
|
});
|
|
330
353
|
|
|
331
354
|
/**
|
|
@@ -337,7 +360,28 @@ const SYNCABLE_CLASSES = Object.freeze(['identical', 'generated', 'contains']);
|
|
|
337
360
|
/**
|
|
338
361
|
* The manifest rows of the classes the generator owns, in manifest order.
|
|
339
362
|
*
|
|
340
|
-
*
|
|
363
|
+
* Two ways in, and the second one is why this is not one flatMap:
|
|
364
|
+
*
|
|
365
|
+
* * a row of `files.<class>` wears the class of the array it sits in;
|
|
366
|
+
* * a row ANYWHERE else wears the class it DECLARES, in a `class` field of
|
|
367
|
+
* its own.
|
|
368
|
+
*
|
|
369
|
+
* The second exists because a generated file is not always reported under
|
|
370
|
+
* `files`. `G-SHARED-ENV` is filed under `config`, where the manifest reports
|
|
371
|
+
* about a service's configuration, and until d.710 that placement decided
|
|
372
|
+
* something it was never meant to decide: the sync's row set was the `files`
|
|
373
|
+
* section, so `oa-validate` measured `config/env-templates/shared.env` and the
|
|
374
|
+
* sync wrote nothing — instruction 3 of the cascade ("sync every row") was four
|
|
375
|
+
* commands each of the eight repositories assembled for itself, and BIZ-ingest
|
|
376
|
+
* escalated that the bare run does not include that row. A section is a place
|
|
377
|
+
* to report from; what a file IS belongs to the row.
|
|
378
|
+
*
|
|
379
|
+
* A row joins by SAYING so, never by being in a section somebody read as
|
|
380
|
+
* generated: `files.own` and `files.forbidden` cannot drift into this run, and
|
|
381
|
+
* neither can a future row of any other section (`automation-gates.md` §1
|
|
382
|
+
* requirement 1 — no dependence on state nobody declared).
|
|
383
|
+
*
|
|
384
|
+
* Not every row here is a row this run renders — see `syncRows`. This is the
|
|
341
385
|
* full declaration, and the CLI needs it to tell a path the uniform declares as
|
|
342
386
|
* a REQUIREMENT from a path it has never heard of.
|
|
343
387
|
*
|
|
@@ -346,12 +390,18 @@ const SYNCABLE_CLASSES = Object.freeze(['identical', 'generated', 'contains']);
|
|
|
346
390
|
*/
|
|
347
391
|
function uniformRows(manifest) {
|
|
348
392
|
const files = (manifest && manifest.files) || {};
|
|
349
|
-
|
|
393
|
+
const inFiles = SYNCABLE_CLASSES.flatMap((className) => files[className] || []);
|
|
394
|
+
|
|
395
|
+
const declared = collectRows(manifest || {})
|
|
396
|
+
.map((entry) => entry.row)
|
|
397
|
+
.filter((row) => SYNCABLE_CLASSES.includes(row.class) && !inFiles.includes(row));
|
|
398
|
+
|
|
399
|
+
return [...inFiles, ...declared];
|
|
350
400
|
}
|
|
351
401
|
|
|
352
402
|
/**
|
|
353
|
-
* Whether the sync is defined for this row: does it name what its
|
|
354
|
-
* from?
|
|
403
|
+
* Whether the sync is defined for this row: does it name a file, and what its
|
|
404
|
+
* content comes from?
|
|
355
405
|
*
|
|
356
406
|
* The predicate is the manifest's own distinction, not a list of ids: a row with
|
|
357
407
|
* a `from:` reference is a file rendered from that reference, a row without one
|
|
@@ -359,11 +409,16 @@ function uniformRows(manifest) {
|
|
|
359
409
|
* `desiredContent` would read. Judging the reference rather than the id is why a
|
|
360
410
|
* new requirement row needs no change here.
|
|
361
411
|
*
|
|
412
|
+
* The `path` is the other half, and it is not a formality: `R-NODE` names a
|
|
413
|
+
* reference and no path, because what it compares is `engines.node` — a FIELD of
|
|
414
|
+
* a file the service owns. There is no file to write from that reference, so a
|
|
415
|
+
* reference alone does not make a row renderable.
|
|
416
|
+
*
|
|
362
417
|
* @param {object} row
|
|
363
418
|
* @returns {boolean}
|
|
364
419
|
*/
|
|
365
420
|
function isSyncRow(row) {
|
|
366
|
-
if (!row || !row.from) return false;
|
|
421
|
+
if (!row || !row.from || typeof row.path !== 'string') return false;
|
|
367
422
|
return typeof row.from.path === 'string' || isPackageReference(row.from);
|
|
368
423
|
}
|
|
369
424
|
|
|
@@ -461,9 +516,24 @@ function desiredContent({ row, current, serviceRoot, workspaceRoot }) {
|
|
|
461
516
|
/**
|
|
462
517
|
* One row's plan.
|
|
463
518
|
*
|
|
519
|
+
* `block` is what this entry actually compared, and it is why a reader can
|
|
520
|
+
* trust the word `unchanged`. For a block row over a file that EXISTS, the
|
|
521
|
+
* desired text is the file's own with the markers spliced (`desiredContent`
|
|
522
|
+
* below), so the two sides can differ only inside them — everything else is
|
|
523
|
+
* this service's own and was never looked at. A line reporting that as a fact
|
|
524
|
+
* about the FILE is the false guarantee `.claude/rules/automation-gates.md` §5
|
|
525
|
+
* calls a defect, and it was measured: a `find` over node_modules with an
|
|
526
|
+
* `rm -rf` loop sat below `oa-deps-guard v1` in api_biz/hello for months while
|
|
527
|
+
* every run printed `unchanged init.sh` (d.42, hello `7d14d07`).
|
|
528
|
+
*
|
|
529
|
+
* It is absent where no splice happened — a whole-file row, and a block row
|
|
530
|
+
* whose file is not there, which is created entire. The name comes from the
|
|
531
|
+
* manifest row and from nowhere else, so this module still holds no list of
|
|
532
|
+
* blocks of its own.
|
|
533
|
+
*
|
|
464
534
|
* @param {{row: object, serviceRoot: string, workspaceRoot: string}} args
|
|
465
535
|
* @returns {{id: string, path: string, outcome: 'unchanged'|'change'|'not-run'|'blocked',
|
|
466
|
-
* current: string|null, desired?: string, detail?: string, reason?: string}}
|
|
536
|
+
* current: string|null, block?: string, desired?: string, detail?: string, reason?: string}}
|
|
467
537
|
*/
|
|
468
538
|
function planRow({ row, serviceRoot, workspaceRoot }) {
|
|
469
539
|
// Fail-fast rather than a NOT RUN line: `planSync` hands over only the rows
|
|
@@ -477,6 +547,9 @@ function planRow({ row, serviceRoot, workspaceRoot }) {
|
|
|
477
547
|
|
|
478
548
|
const current = readServiceFile(serviceRoot, row.path);
|
|
479
549
|
const base = { id: row.id, path: row.path, current };
|
|
550
|
+
// The same predicate `desiredContent` decides the splice by, asked once here
|
|
551
|
+
// so the reported scope and the rendered scope cannot drift apart.
|
|
552
|
+
if (typeof row.block === 'string' && current !== null) base.block = row.block;
|
|
480
553
|
|
|
481
554
|
// The same question the manifest run asks of the same row, answered by the
|
|
482
555
|
// same predicate: does THIS row need the workspace? Since d.229 most of them
|
|
@@ -41,7 +41,7 @@ function assertBoolean(value, fieldName) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// The service declares WHAT its database is; the library implements HOW it is
|
|
44
|
-
// built (see docs/biz/00-model/uniformity-principle.md). Everything the six
|
|
44
|
+
// built (see api/docs/biz/00-model/uniformity-principle.md). Everything the six
|
|
45
45
|
// per-repo ci-setup-db.js scripts used to decide for themselves — client,
|
|
46
46
|
// ordering, foreign-key handling — is a platform property and lives in the
|
|
47
47
|
// library; only these four values legitimately differ per service.
|
|
@@ -51,7 +51,7 @@ const ENGINE_DECLARATION = /^(mariadb|mysql):[0-9][0-9.]*$/;
|
|
|
51
51
|
/**
|
|
52
52
|
* The collation a service declares its schema is created with — owner decision
|
|
53
53
|
* 2026-09-14, `db-collation-declaration` 001, written into
|
|
54
|
-
* `docs/biz/70-contracts/database-contract.md` §1.
|
|
54
|
+
* `api/docs/biz/70-contracts/database-contract.md` §1.
|
|
55
55
|
*
|
|
56
56
|
* Which utf8mb4 collation is the SERVICE's choice and this file sets none;
|
|
57
57
|
* what it holds is that the value is one, because it is interpolated into
|
|
@@ -38,6 +38,49 @@
|
|
|
38
38
|
* services: an assumed wrapper.storage section for MinIO exists nowhere, and
|
|
39
39
|
* guessing it would have failed emailer and pdfgen for a configuration neither
|
|
40
40
|
* was ever meant to have.
|
|
41
|
+
*
|
|
42
|
+
* ## What `env` IS — and the three questions asked of it
|
|
43
|
+
*
|
|
44
|
+
* `env` is the set of environment names the connector's own library resolves
|
|
45
|
+
* WITHOUT a default: the names that decide whether the connector opens at all.
|
|
46
|
+
* It is measured against that library's runtime-config schema, never guessed:
|
|
47
|
+
*
|
|
48
|
+
* db — no shared library resolves a `DB_*` name; a service reaches its
|
|
49
|
+
* schema itself, so `DB_HOST` is the endpoint of that reach
|
|
50
|
+
* redis — `@onlineapps/conn-base-cache/src/config.js` `REDIS_URL`. The
|
|
51
|
+
* `REDIS_HOST`/`REDIS_PORT` of `conn-base-state` are NOT here: the
|
|
52
|
+
* wrapper passes both explicitly, parsed out of `REDIS_URL`
|
|
53
|
+
* (`ServiceWrapper.js` `stateConfig?.host || parsed.host`), so an
|
|
54
|
+
* environment without them is no gap
|
|
55
|
+
* mq — `@onlineapps/mq-client-core/src/config.js` `RABBITMQ_URL`
|
|
56
|
+
* minio — `@onlineapps/conn-base-storage/src/config.js`, the five keys its
|
|
57
|
+
* schema marks `required: true` with no default. `MINIO_ACTUAL_HOST`
|
|
58
|
+
* is not among them: it is the OPTIONAL proxy-host override
|
|
59
|
+
* (`config.js` `actualHost`, no `required`), so a row demanding it
|
|
60
|
+
* would be this module inventing a requirement, and the services that
|
|
61
|
+
* do route through a proxy declare it where they read it — the
|
|
62
|
+
* `${MINIO_ACTUAL_HOST}` placeholder of `config/service/config.json`,
|
|
63
|
+
* which is M1 coverage (`utils/envContract.js`)
|
|
64
|
+
*
|
|
65
|
+
* Three mechanisms read this set, and they ask three different questions of it —
|
|
66
|
+
* said out loud, because a list read three ways is where a silent drift starts:
|
|
67
|
+
*
|
|
68
|
+
* 1. `verifyConnectorContract` below, at boot: is at least ONE of them set —
|
|
69
|
+
* evidence that the environment knows this connector at all. Deliberately
|
|
70
|
+
* not "all of them": the check runs in phase 0.2 of every start, it is a
|
|
71
|
+
* warning and not a stop, and the complete answer is question 3's;
|
|
72
|
+
* 2. `collectEnvCoverage` (`utils/envContract.js`), M2: these names are covered
|
|
73
|
+
* by the connector declaration, so the `env` block of the integration
|
|
74
|
+
* contract must not declare them a second time, and `--env-reads` lists them;
|
|
75
|
+
* 3. the uniform row `C-CI-CONNECTOR-ENV`
|
|
76
|
+
* (`manifest/checks/serviceConnectors.js`): EVERY one of them is set in the
|
|
77
|
+
* `variables:` of the job that runs the suite. That is the question the
|
|
78
|
+
* other two never asked, and the gap it closes was measured on 2026-09-18 —
|
|
79
|
+
* `MINIO_USE_SSL` missing from one CI job, failing three emailer tests on a
|
|
80
|
+
* name no line of the service reads.
|
|
81
|
+
*
|
|
82
|
+
* The minio set grew from two names to these five on the owner's decision
|
|
83
|
+
* (`api/docs/governance/confirmations/biz-service-manifest.md` 013).
|
|
41
84
|
*/
|
|
42
85
|
const CONNECTORS = {
|
|
43
86
|
db: { configSections: [], env: ['DB_HOST'] },
|
|
@@ -47,7 +90,10 @@ const CONNECTORS = {
|
|
|
47
90
|
// @onlineapps/conn-base-storage directly. Verified 2026-08-22 — no service on
|
|
48
91
|
// the platform declares wrapper.storage. So it is declared by the contract and
|
|
49
92
|
// evidenced only by the environment.
|
|
50
|
-
minio: {
|
|
93
|
+
minio: {
|
|
94
|
+
configSections: [],
|
|
95
|
+
env: ['MINIO_ENDPOINT', 'MINIO_PORT', 'MINIO_USE_SSL', 'MINIO_ACCESS_KEY', 'MINIO_SECRET_KEY']
|
|
96
|
+
}
|
|
51
97
|
};
|
|
52
98
|
|
|
53
99
|
/** Where each of the two declarations lives, relative to the service root. */
|
|
@@ -165,9 +211,15 @@ function verifyConnectorContract({ config, requiredConnectors, env }) {
|
|
|
165
211
|
+ ` Fix: configure it, or set requiredConnectors.${name} to false if the service does not use it.`);
|
|
166
212
|
}
|
|
167
213
|
|
|
214
|
+
// ONE of them, deliberately — question 1 of the three the header names.
|
|
215
|
+
// The message says which question it asked, so a reader does not take the
|
|
216
|
+
// silence of this step for the complete answer (`automation-gates.md` §5).
|
|
168
217
|
const satisfied = spec.env.some((key) => env?.[key]);
|
|
169
218
|
if (!satisfied) {
|
|
170
|
-
|
|
219
|
+
const names = spec.env.length === 1
|
|
220
|
+
? `${spec.env[0]} is not set`
|
|
221
|
+
: `none of ${spec.env.join(' / ')} is set`;
|
|
222
|
+
errors.push(`Connector "${name}" is required but ${names}.\n`
|
|
171
223
|
+ ' Fix: set it in the CI job or config/env-active/*.env — a required connector without its '
|
|
172
224
|
+ 'endpoint fails later, inside the connector, where the cause is harder to see.');
|
|
173
225
|
}
|