@onlineapps/conn-orch-validator 3.3.2 → 4.0.1
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 +358 -0
- package/README.md +46 -6
- package/TESTING_STRATEGY.md +1 -1
- package/docs/DESIGN.md +18 -11
- package/package.json +2 -8
- package/src/CookbookTestRunner.js +155 -26
- package/src/CookbookTestUtils.js +12 -4
- package/src/ServiceReadinessValidator.js +35 -18
- package/src/ValidationOrchestrator.js +223 -126
- package/src/cli/biz-ci-gate.js +356 -7
- package/src/helpers/README.md +3 -55
- package/src/helpers/createServiceReadinessTests.js +5 -1
- package/src/index.js +5 -2
- package/src/mocks/MockMQClient.js +100 -15
- package/src/utils/bizCiGateContract.js +110 -21
- package/src/utils/connectorContract.js +96 -0
- package/src/utils/cookbookFormat.js +95 -0
- package/src/utils/deployContract.js +488 -0
- package/src/utils/envContract.js +417 -0
- package/src/utils/installContract.js +142 -0
- package/src/utils/integrationRun.js +297 -0
- package/src/utils/libCompat.js +158 -0
- package/src/utils/preValidation.js +137 -0
- package/src/utils/setupDatabase.js +154 -0
- package/src/utils/stepFailure.js +73 -0
- package/src/utils/testNamespace.js +104 -0
- package/src/validators/ServiceStructureValidator.js +195 -48
- package/src/config.js +0 -32
- package/src/defaults.js +0 -11
- package/src/helpers/createPreValidationTests.js +0 -326
- package/test-mq-flow.js +0 -72
- package/test-orchestrator.js +0 -95
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Environment contract (F16) — one declaration, two consumers.
|
|
5
|
+
*
|
|
6
|
+
* The platform already had an ENFORCEMENT engine for environment variables
|
|
7
|
+
* (@onlineapps/runtime-config schemas throw on a missing required key) but no
|
|
8
|
+
* DECLARATION: nothing said which variables a service needs, so nothing could
|
|
9
|
+
* tell a missing one from a variable nobody reads any more. The 2026-08 audit
|
|
10
|
+
* measured 56 variables across 8 services; 21 were enforced by ConfigLoader,
|
|
11
|
+
* 5 by the connector contract, and the rest by nothing at all.
|
|
12
|
+
*
|
|
13
|
+
* So the service declares its environment in the file where it already declares
|
|
14
|
+
* its connectors and its database — `config/service/integration-contract.json`:
|
|
15
|
+
*
|
|
16
|
+
* "env": {
|
|
17
|
+
* "required": [{ "name": "SECRETS_MASTER_KEY", "why": "decrypts per-tenant secrets" }],
|
|
18
|
+
* "optional": [{ "name": "SES_REGION", "why": "AWS region when SES is live" }]
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* `why` is mandatory. A required declaration nobody can justify is exactly the
|
|
22
|
+
* one nobody dares delete (change-discipline.md § Removing something removes
|
|
23
|
+
* its declaration), and a gate must be able to say what the fix is
|
|
24
|
+
* (automation-gates.md §3).
|
|
25
|
+
*
|
|
26
|
+
* Two consumers read that one declaration:
|
|
27
|
+
*
|
|
28
|
+
* CI — COMPLETENESS: everything the repo reads is declared or covered
|
|
29
|
+
* (`verifyEnvCompleteness`, folded into `biz-ci-gate verify-contract`).
|
|
30
|
+
* boot — PRESENCE: everything declared required is actually set
|
|
31
|
+
* (`verifyEnvPresence`, Tier-1 phase 0.2, before any connector opens —
|
|
32
|
+
* the fix for SECRETS_MASTER_KEY failing after MQ registration).
|
|
33
|
+
*
|
|
34
|
+
* ## What the declaration must NOT repeat
|
|
35
|
+
*
|
|
36
|
+
* Two mechanisms already cover part of the environment, and duplicating them
|
|
37
|
+
* would create a second owner for the same fact:
|
|
38
|
+
*
|
|
39
|
+
* M1 `${VAR}` placeholders in `config/service/*.json` — ConfigLoader
|
|
40
|
+
* substitutes them and fails on a missing required key.
|
|
41
|
+
* M2 the endpoint variables of the connectors the contract declares
|
|
42
|
+
* required — checked by `utils/connectorContract.js`.
|
|
43
|
+
*
|
|
44
|
+
* A name covered by either is REJECTED in the env block.
|
|
45
|
+
*
|
|
46
|
+
* ## Measurability boundary — stated, not implied
|
|
47
|
+
*
|
|
48
|
+
* The completeness scan reads the service repository only, and only literal
|
|
49
|
+
* accesses. It cannot see:
|
|
50
|
+
*
|
|
51
|
+
* - dynamic access (`process.env[key]`) — counted and reported, never guessed;
|
|
52
|
+
* - environment read inside an installed library (`node_modules`), e.g.
|
|
53
|
+
* `SECRETS_MASTER_KEY` in @onlineapps/service-wrapper. Those names are
|
|
54
|
+
* declared by hand until libraries export their own env schemas (variant D
|
|
55
|
+
* of the F16 design, api/shared/TODO.md §5);
|
|
56
|
+
* - anything outside `src/`, `index.js`, `scripts/` and `config/service/*.json`.
|
|
57
|
+
*
|
|
58
|
+
* The scan therefore proves one direction only: what it CAN see is declared.
|
|
59
|
+
* It never claims the declaration is complete.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
const fs = require('fs');
|
|
63
|
+
const path = require('path');
|
|
64
|
+
const { CONNECTORS } = require('./connectorContract');
|
|
65
|
+
|
|
66
|
+
/** POSIX-ish environment variable name, as every platform template writes them. */
|
|
67
|
+
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
68
|
+
|
|
69
|
+
/** Directories and files the completeness scan reads, relative to the service root. */
|
|
70
|
+
const SCAN_ROOTS = ['src', 'index.js', 'scripts'];
|
|
71
|
+
|
|
72
|
+
/** Config files whose `${VAR}` placeholders are M1 coverage. */
|
|
73
|
+
const CONFIG_GLOB_DIR = path.join('config', 'service');
|
|
74
|
+
|
|
75
|
+
const SCANNED_EXTENSIONS = new Set(['.js', '.cjs', '.mjs']);
|
|
76
|
+
|
|
77
|
+
const ENV_SCAN_SCOPE = 'config/service/*.json ${VAR}; literal process.env.X / env.X / '
|
|
78
|
+
+ "requireEnv('X') in src/, index.js, scripts/";
|
|
79
|
+
|
|
80
|
+
const ENV_SCAN_BLIND_SPOTS = 'dynamic access (process.env[key]) and environment read inside '
|
|
81
|
+
+ 'installed libraries (node_modules)';
|
|
82
|
+
|
|
83
|
+
const ENV_LIST_KEYS = ['required', 'optional'];
|
|
84
|
+
const ENV_ITEM_KEYS = ['name', 'why'];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate and normalize the `env` block of an integration contract.
|
|
88
|
+
*
|
|
89
|
+
* @param {object|undefined|null} rawEnv the contract's `env` value
|
|
90
|
+
* @param {object} options
|
|
91
|
+
* @param {Map<string,string>} [options.coverage] name -> what already covers it (M1/M2).
|
|
92
|
+
* Mandatory whenever a block is present: without it redundancy cannot be
|
|
93
|
+
* decided, and a check that silently degrades to half of itself is a false
|
|
94
|
+
* guarantee (automation-gates.md §5).
|
|
95
|
+
* @returns {{required: Array<{name: string, why: string}>, optional: Array<{name: string, why: string}>}|null}
|
|
96
|
+
*/
|
|
97
|
+
function normalizeEnvDeclaration(rawEnv, options = {}) {
|
|
98
|
+
// Absent means "not adopted yet" — a legitimate state until every repo carries
|
|
99
|
+
// the block. Null rather than {} so callers can say so out loud instead of
|
|
100
|
+
// reporting an empty declaration as a satisfied one.
|
|
101
|
+
if (rawEnv === undefined || rawEnv === null) return null;
|
|
102
|
+
|
|
103
|
+
if (typeof rawEnv !== 'object' || Array.isArray(rawEnv)) {
|
|
104
|
+
throw new Error('[BizCiGate] Invalid env block - Expected an object with "required" and/or "optional" arrays. '
|
|
105
|
+
+ 'Fix: "env": { "required": [{ "name": "DB_TIMEOUT_MS", "why": "..." }] }, or omit the key entirely.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const coverage = options.coverage;
|
|
109
|
+
if (!(coverage instanceof Map)) {
|
|
110
|
+
throw new Error('[BizCiGate] Cannot validate the env block - no M1/M2 coverage set was supplied. '
|
|
111
|
+
+ 'Fix: load the contract through loadAndValidateIntegrationContract(serviceRoot), which collects '
|
|
112
|
+
+ 'the config placeholders and connector variables the declaration must not repeat.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const key of Object.keys(rawEnv)) {
|
|
116
|
+
if (!ENV_LIST_KEYS.includes(key)) {
|
|
117
|
+
throw new Error(`[BizCiGate] Unknown env key - "${key}" is not part of the env block. `
|
|
118
|
+
+ 'Fix: the block has exactly two keys, "required" and "optional".');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (rawEnv.required === undefined && rawEnv.optional === undefined) {
|
|
123
|
+
throw new Error('[BizCiGate] Empty env block - Neither env.required nor env.optional is declared. '
|
|
124
|
+
+ 'Fix: declare at least one variable, or omit the "env" key entirely — an empty block claims '
|
|
125
|
+
+ 'the service reads no environment, which the completeness check will contradict.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const declaredIn = new Map();
|
|
129
|
+
const normalized = { required: [], optional: [] };
|
|
130
|
+
|
|
131
|
+
for (const listKey of ENV_LIST_KEYS) {
|
|
132
|
+
const list = rawEnv[listKey];
|
|
133
|
+
if (list === undefined) continue;
|
|
134
|
+
|
|
135
|
+
if (!Array.isArray(list)) {
|
|
136
|
+
throw new Error(`[BizCiGate] Invalid env.${listKey} - Expected an array of `
|
|
137
|
+
+ '{ "name": "...", "why": "..." } items. Fix: use [] or omit the key.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
list.forEach((item, index) => {
|
|
141
|
+
const at = `env.${listKey}[${index}]`;
|
|
142
|
+
|
|
143
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
144
|
+
throw new Error(`[BizCiGate] Invalid ${at} - Expected {"name": "...", "why": "..."}. `
|
|
145
|
+
+ 'Fix: a bare string does not say why the variable exists.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const key of Object.keys(item)) {
|
|
149
|
+
if (!ENV_ITEM_KEYS.includes(key)) {
|
|
150
|
+
throw new Error(`[BizCiGate] Unknown key in ${at} - "${key}". `
|
|
151
|
+
+ 'Fix: an item has exactly two keys, "name" and "why".');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (typeof item.name !== 'string' || !ENV_NAME.test(item.name)) {
|
|
156
|
+
throw new Error(`[BizCiGate] Invalid ${at}.name - "${item.name}" is not a valid environment `
|
|
157
|
+
+ 'variable name. Fix: use SCREAMING_SNAKE_CASE exactly as the env file writes it, e.g. "DB_HOST".');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (typeof item.why !== 'string' || item.why.trim() === '') {
|
|
161
|
+
throw new Error(`[BizCiGate] Missing ${at}.why - "${item.name}" is declared without a reason. `
|
|
162
|
+
+ 'Fix: state in one sentence what the service does with it — a declaration nobody can justify '
|
|
163
|
+
+ 'is the one nobody dares delete.');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const previous = declaredIn.get(item.name);
|
|
167
|
+
if (previous) {
|
|
168
|
+
throw new Error(`[BizCiGate] Duplicate env declaration - "${item.name}" is declared twice `
|
|
169
|
+
+ `(env.${previous}, env.${listKey}). Fix: declare each variable exactly once.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const coveredBy = coverage.get(item.name);
|
|
173
|
+
if (coveredBy) {
|
|
174
|
+
throw new Error(`[BizCiGate] Redundant env declaration - "${item.name}" is already covered by `
|
|
175
|
+
+ `${coveredBy}, remove the duplicate declaration. Fix: the env block declares what NO other `
|
|
176
|
+
+ 'mechanism already enforces; two owners of the same fact drift apart.');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
declaredIn.set(item.name, listKey);
|
|
180
|
+
normalized[listKey].push({ name: item.name, why: item.why });
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return normalized;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Collect the names M1 (config placeholders) and M2 (required connectors)
|
|
189
|
+
* already cover — the set the env block must not repeat.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} args
|
|
192
|
+
* @param {string} args.serviceRoot
|
|
193
|
+
* @param {object} args.requiredConnectors db/redis/mq/minio booleans
|
|
194
|
+
* @returns {Map<string,string>} name -> what covers it
|
|
195
|
+
*/
|
|
196
|
+
function collectEnvCoverage({ serviceRoot, requiredConnectors }) {
|
|
197
|
+
const coverage = new Map();
|
|
198
|
+
|
|
199
|
+
for (const [file, names] of collectConfigPlaceholders(serviceRoot)) {
|
|
200
|
+
for (const name of names) {
|
|
201
|
+
if (!ENV_NAME.test(name)) continue; // e.g. ${npm_package_version} — not an env contract concern
|
|
202
|
+
if (!coverage.has(name)) coverage.set(name, `${file} (\${${name}})`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const [connector, spec] of Object.entries(CONNECTORS)) {
|
|
207
|
+
if (requiredConnectors?.[connector] !== true) continue;
|
|
208
|
+
for (const name of spec.env) {
|
|
209
|
+
if (!coverage.has(name)) coverage.set(name, `requiredConnectors.${connector}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return coverage;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** `${VAR}` placeholders per config/service/*.json file, as [relativePath, Set<name>]. */
|
|
217
|
+
function collectConfigPlaceholders(serviceRoot) {
|
|
218
|
+
const configDir = path.join(serviceRoot, CONFIG_GLOB_DIR);
|
|
219
|
+
if (!fs.existsSync(configDir)) return [];
|
|
220
|
+
|
|
221
|
+
const entries = [];
|
|
222
|
+
for (const fileName of fs.readdirSync(configDir).sort()) {
|
|
223
|
+
if (!fileName.endsWith('.json')) continue;
|
|
224
|
+
const filePath = path.join(configDir, fileName);
|
|
225
|
+
if (!fs.statSync(filePath).isFile()) continue;
|
|
226
|
+
|
|
227
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
228
|
+
const names = new Set();
|
|
229
|
+
for (const match of content.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
230
|
+
names.add(match[1]);
|
|
231
|
+
}
|
|
232
|
+
entries.push([path.posix.join('config', 'service', fileName), names]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return entries;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function listSourceFiles(serviceRoot) {
|
|
239
|
+
const files = [];
|
|
240
|
+
const stack = SCAN_ROOTS.map((entry) => path.join(serviceRoot, entry));
|
|
241
|
+
|
|
242
|
+
while (stack.length > 0) {
|
|
243
|
+
const current = stack.pop();
|
|
244
|
+
if (!fs.existsSync(current)) continue;
|
|
245
|
+
|
|
246
|
+
const stat = fs.statSync(current);
|
|
247
|
+
if (stat.isDirectory()) {
|
|
248
|
+
for (const entry of fs.readdirSync(current)) {
|
|
249
|
+
if (entry === 'node_modules' || entry === '.git') continue;
|
|
250
|
+
stack.push(path.join(current, entry));
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (stat.isFile() && SCANNED_EXTENSIONS.has(path.extname(current))) {
|
|
256
|
+
files.push(current);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return files.sort();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `process.env.X` / `env.X` where X is an environment-shaped name.
|
|
265
|
+
*
|
|
266
|
+
* `env.X` is deliberate, not sloppy: architecture principle 1 makes injected
|
|
267
|
+
* configuration the norm, so a service that obeys DI reads `env.SES_REGION`
|
|
268
|
+
* from an injected object and never writes `process.env` at all. Measured
|
|
269
|
+
* 2026-08-27 across the eight biz repos: `process.env.` finds 9 names,
|
|
270
|
+
* `env.` finds 22 — including all thirteen of the emailer provider variables
|
|
271
|
+
* whose absence is defect D1. Restricting the pattern to SCREAMING_SNAKE_CASE
|
|
272
|
+
* on a receiver literally named `env` produced no false positive in that run.
|
|
273
|
+
*/
|
|
274
|
+
const READ_PATTERN = /(?<![A-Za-z0-9_$.])(?:process\.)?env\??\.([A-Z][A-Z0-9_]*)/g;
|
|
275
|
+
const REQUIRE_ENV_PATTERN = /requireEnv\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/g;
|
|
276
|
+
const BRACKET_LITERAL_PATTERN = /(?:process\.)?env\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]/g;
|
|
277
|
+
const DYNAMIC_PATTERN = /(?:process\.)?env\[\s*(?!['"])/g;
|
|
278
|
+
|
|
279
|
+
/** A write (`process.env.X = …`) is not a read requirement. */
|
|
280
|
+
function isAssignment(content, matchEnd) {
|
|
281
|
+
const rest = content.slice(matchEnd, matchEnd + 4);
|
|
282
|
+
return /^\s*=(?!=)/.test(rest);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function lineOf(content, index) {
|
|
286
|
+
return content.slice(0, index).split('\n').length;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Scan the service repository for environment names it reads.
|
|
291
|
+
*
|
|
292
|
+
* @param {object} args
|
|
293
|
+
* @param {string} args.serviceRoot
|
|
294
|
+
* @returns {{reads: Map<string, string[]>, dynamicSites: Array<{file: string, line: number}>, filesScanned: number}}
|
|
295
|
+
*/
|
|
296
|
+
function collectEnvReads({ serviceRoot }) {
|
|
297
|
+
const reads = new Map();
|
|
298
|
+
const dynamicSites = [];
|
|
299
|
+
const files = listSourceFiles(serviceRoot);
|
|
300
|
+
|
|
301
|
+
const record = (name, location) => {
|
|
302
|
+
if (!ENV_NAME.test(name)) return;
|
|
303
|
+
const existing = reads.get(name);
|
|
304
|
+
if (existing) {
|
|
305
|
+
if (!existing.includes(location)) existing.push(location);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
reads.set(name, [location]);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
for (const filePath of files) {
|
|
312
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
313
|
+
const relative = path.relative(serviceRoot, filePath).split(path.sep).join('/');
|
|
314
|
+
|
|
315
|
+
for (const match of content.matchAll(READ_PATTERN)) {
|
|
316
|
+
if (isAssignment(content, match.index + match[0].length)) continue;
|
|
317
|
+
record(match[1], `${relative}:${lineOf(content, match.index)}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const pattern of [BRACKET_LITERAL_PATTERN, REQUIRE_ENV_PATTERN]) {
|
|
321
|
+
for (const match of content.matchAll(pattern)) {
|
|
322
|
+
record(match[1], `${relative}:${lineOf(content, match.index)}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const match of content.matchAll(DYNAMIC_PATTERN)) {
|
|
327
|
+
dynamicSites.push({ file: relative, line: lineOf(content, match.index) });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { reads, dynamicSites, filesScanned: files.length };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* COMPLETENESS: every environment name the repository visibly reads is either
|
|
336
|
+
* declared in the env block or covered by M1/M2.
|
|
337
|
+
*
|
|
338
|
+
* @param {object} args
|
|
339
|
+
* @param {string} args.serviceRoot
|
|
340
|
+
* @param {object} args.contract normalized integration contract
|
|
341
|
+
* @returns {{ok: boolean, violations: Array<{name: string, sources: string[]}>, declaredCount: number,
|
|
342
|
+
* coveredCount: number, readCount: number, filesScanned: number,
|
|
343
|
+
* dynamicSites: Array<{file: string, line: number}>}}
|
|
344
|
+
*/
|
|
345
|
+
function verifyEnvCompleteness({ serviceRoot, contract }) {
|
|
346
|
+
const coverage = collectEnvCoverage({
|
|
347
|
+
serviceRoot,
|
|
348
|
+
requiredConnectors: contract.requiredConnectors
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const declared = new Set();
|
|
352
|
+
for (const listKey of ENV_LIST_KEYS) {
|
|
353
|
+
for (const item of contract.env?.[listKey] ?? []) declared.add(item.name);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const { reads, dynamicSites, filesScanned } = collectEnvReads({ serviceRoot });
|
|
357
|
+
|
|
358
|
+
const violations = [];
|
|
359
|
+
for (const [name, sources] of reads) {
|
|
360
|
+
if (coverage.has(name) || declared.has(name)) continue;
|
|
361
|
+
violations.push({ name, sources });
|
|
362
|
+
}
|
|
363
|
+
violations.sort((a, b) => a.name.localeCompare(b.name));
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
ok: violations.length === 0,
|
|
367
|
+
violations,
|
|
368
|
+
declaredCount: declared.size,
|
|
369
|
+
coveredCount: coverage.size,
|
|
370
|
+
readCount: reads.size,
|
|
371
|
+
filesScanned,
|
|
372
|
+
dynamicSites
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* PRESENCE: every name declared required is set in the environment.
|
|
378
|
+
*
|
|
379
|
+
* Runs in Tier-1 phase 0.2, before the wrapper opens a connector — a missing
|
|
380
|
+
* key must fail where its name is known, not several layers deeper (defect D2:
|
|
381
|
+
* SECRETS_MASTER_KEY failed in ServiceWrapper.js:916, after MQ registration).
|
|
382
|
+
*
|
|
383
|
+
* @param {object} args
|
|
384
|
+
* @param {object|null} args.declaration normalized env declaration, null when absent
|
|
385
|
+
* @param {object} args.env environment to check against
|
|
386
|
+
* @returns {{valid: boolean, skipped?: boolean, checked: string[], errors: string[]}}
|
|
387
|
+
*/
|
|
388
|
+
function verifyEnvPresence({ declaration, env }) {
|
|
389
|
+
if (declaration === null || declaration === undefined) {
|
|
390
|
+
return { valid: true, skipped: true, checked: [], errors: [] };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const checked = [];
|
|
394
|
+
const errors = [];
|
|
395
|
+
|
|
396
|
+
for (const item of declaration.required) {
|
|
397
|
+
checked.push(item.name);
|
|
398
|
+
const value = env?.[item.name];
|
|
399
|
+
if (typeof value === 'string' && value.trim() !== '') continue;
|
|
400
|
+
|
|
401
|
+
errors.push(`[EnvContract] Missing environment variable - ${item.name} is required `
|
|
402
|
+
+ `(${item.why}). Fix: set ${item.name} in env-active/*.env`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return { valid: errors.length === 0, checked, errors };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
module.exports = {
|
|
409
|
+
ENV_NAME,
|
|
410
|
+
ENV_SCAN_SCOPE,
|
|
411
|
+
ENV_SCAN_BLIND_SPOTS,
|
|
412
|
+
normalizeEnvDeclaration,
|
|
413
|
+
collectEnvCoverage,
|
|
414
|
+
collectEnvReads,
|
|
415
|
+
verifyEnvCompleteness,
|
|
416
|
+
verifyEnvPresence
|
|
417
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Installation contract for a biz service repository.
|
|
5
|
+
*
|
|
6
|
+
* SETUP_DOCS every repo carries docs/setup/{INSTALL,PLATFORM_MATRIX,VALIDATION}.md
|
|
7
|
+
* SQL_PACKAGE a repo WITH a database carries the BASELINE / SEED SQL tree
|
|
8
|
+
* SQL_HEADERS every SQL package file declares its dataset class and safety
|
|
9
|
+
*
|
|
10
|
+
* Rules: ADR 0006 (api/docs/biz/80-decisions/) — database and test-data contract.
|
|
11
|
+
*
|
|
12
|
+
* Whether the SQL half applies is DERIVED from the service's own
|
|
13
|
+
* integration-contract.json database block, never declared a second time here.
|
|
14
|
+
* The eight shell copies this replaces each carried a hand-maintained
|
|
15
|
+
* REPO_HAS_DB literal, and three of them (converter, hello-service, ingest) said
|
|
16
|
+
* "false" while their contract declared a database — so the SQL half checked
|
|
17
|
+
* nothing in those repos while still printing PASS. One fact, one owner: the
|
|
18
|
+
* contract says whether there is a database, and this decides what that costs.
|
|
19
|
+
*
|
|
20
|
+
* Pure module: reads the repository, returns a structured result. It renders
|
|
21
|
+
* nothing and exits nothing — the CLI owns presentation, this owns the rules.
|
|
22
|
+
*
|
|
23
|
+
* Every violation is reported, not just the first, so a repo can be fixed in
|
|
24
|
+
* one pass.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const SETUP_DOCS = [
|
|
31
|
+
'docs/setup/INSTALL.md',
|
|
32
|
+
'docs/setup/PLATFORM_MATRIX.md',
|
|
33
|
+
'docs/setup/VALIDATION.md'
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const SQL_DIRECTORIES = [
|
|
37
|
+
'migrations/BASELINE',
|
|
38
|
+
'migrations/SEED/production_like',
|
|
39
|
+
'migrations/SEED/test_only'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const REQUIRED_HEADERS = [
|
|
43
|
+
'-- Dataset-Class:',
|
|
44
|
+
'-- Target-DB:',
|
|
45
|
+
'-- Safe-For-Production:',
|
|
46
|
+
'-- Idempotency:'
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const TEST_ONLY_DIRECTORY = 'migrations/SEED/test_only';
|
|
50
|
+
|
|
51
|
+
/** Sorted so the report order is the directory order, not the filesystem's. */
|
|
52
|
+
function listSqlFiles(absoluteDir) {
|
|
53
|
+
return fs.readdirSync(absoluteDir)
|
|
54
|
+
.filter((name) => name.endsWith('.sql'))
|
|
55
|
+
.sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function checkSetupDocs(serviceRoot, add) {
|
|
59
|
+
for (const relativePath of SETUP_DOCS) {
|
|
60
|
+
if (!fs.existsSync(path.join(serviceRoot, relativePath))) {
|
|
61
|
+
add('SETUP_DOCS', `Missing required file - ${relativePath}. `
|
|
62
|
+
+ `Fix: add ${relativePath} to the repository.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function checkSqlHeaders(serviceRoot, relativeDir, add) {
|
|
68
|
+
for (const name of listSqlFiles(path.join(serviceRoot, relativeDir))) {
|
|
69
|
+
const relativeFile = `${relativeDir}/${name}`;
|
|
70
|
+
const content = fs.readFileSync(path.join(serviceRoot, relativeFile), 'utf8');
|
|
71
|
+
const lines = content.split('\n');
|
|
72
|
+
|
|
73
|
+
for (const header of REQUIRED_HEADERS) {
|
|
74
|
+
if (!lines.some((line) => line.startsWith(header))) {
|
|
75
|
+
add('SQL_HEADERS', `Missing "${header}" header - ${relativeFile}. `
|
|
76
|
+
+ `Fix: add the "${header}" header line to the file.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (relativeDir !== TEST_ONLY_DIRECTORY) continue;
|
|
81
|
+
|
|
82
|
+
// Test data must be unmistakable at the file level: a TEST_ONLY package that
|
|
83
|
+
// reads as production-safe is the one mistake that reaches a real database.
|
|
84
|
+
if (!lines.some((line) => line.startsWith('-- Dataset-Class: TEST_ONLY'))) {
|
|
85
|
+
add('SQL_HEADERS', `Wrong Dataset-Class - ${relativeFile} must declare `
|
|
86
|
+
+ '"-- Dataset-Class: TEST_ONLY". Fix: correct the Dataset-Class header.');
|
|
87
|
+
}
|
|
88
|
+
if (!lines.some((line) => line.startsWith('-- Safe-For-Production: no'))) {
|
|
89
|
+
add('SQL_HEADERS', `Wrong Safe-For-Production - ${relativeFile} must declare `
|
|
90
|
+
+ '"-- Safe-For-Production: no". Fix: correct the Safe-For-Production header.');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkSqlPackage(serviceRoot, add) {
|
|
96
|
+
for (const relativeDir of SQL_DIRECTORIES) {
|
|
97
|
+
const absoluteDir = path.join(serviceRoot, relativeDir);
|
|
98
|
+
|
|
99
|
+
if (!fs.existsSync(absoluteDir)) {
|
|
100
|
+
add('SQL_PACKAGE', `Missing required directory - ${relativeDir}. `
|
|
101
|
+
+ `Fix: create ${relativeDir} with its SQL package files.`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// An existing but empty directory is the shape the shell version passed on:
|
|
106
|
+
// its `for sql in dir/*.sql` loop saw the unexpanded glob and moved on.
|
|
107
|
+
if (listSqlFiles(absoluteDir).length === 0) {
|
|
108
|
+
add('SQL_PACKAGE', `No SQL package files - ${relativeDir} contains no .sql file. `
|
|
109
|
+
+ 'Fix: add the SQL package files, or remove the database declaration from the contract.');
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
checkSqlHeaders(serviceRoot, relativeDir, add);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} serviceRoot repository root to inspect
|
|
119
|
+
* @param {object|null} database normalized contract database block; null means
|
|
120
|
+
* the service declares no database, and the SQL half does not apply
|
|
121
|
+
* @returns {{service: string, ok: boolean, databaseChecked: boolean, violations: Array<{requirement: string, message: string}>}}
|
|
122
|
+
*/
|
|
123
|
+
function verifyInstallContract(serviceRoot, database) {
|
|
124
|
+
const violations = [];
|
|
125
|
+
const add = (requirement, message) => violations.push({ requirement, message });
|
|
126
|
+
|
|
127
|
+
checkSetupDocs(serviceRoot, add);
|
|
128
|
+
|
|
129
|
+
const databaseChecked = database !== null && database !== undefined;
|
|
130
|
+
if (databaseChecked) {
|
|
131
|
+
checkSqlPackage(serviceRoot, add);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
service: path.basename(path.resolve(serviceRoot)),
|
|
136
|
+
ok: violations.length === 0,
|
|
137
|
+
databaseChecked,
|
|
138
|
+
violations
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { verifyInstallContract };
|