@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +2591 -2
  2. package/README.md +1075 -7
  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 +422 -104
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +78 -42
  10. package/src/ValidationOrchestrator.js +298 -75
  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 +12 -2
  16. package/src/helpers/createServiceReadinessTests.js +75 -6
  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 +213 -13
  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 +21 -20
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +290 -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 +4 -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 +101 -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,388 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Whether this service has a database — and whether the repository agrees with
5
+ * what it declared.
6
+ *
7
+ * The fact has an owner already: `config/service/integration-contract.json` →
8
+ * `requiredConnectors.db`, which `utils/installContract.js` derives the whole
9
+ * SQL half of its contract from. So this uniform introduces NO second
10
+ * declaration (no `oa.db` field): confirmation 005 point 3 asks for a
11
+ * declaration only where the fact cannot be derived, and here it is already
12
+ * declared, once (lead decision `api/shared/TODO.md` §0.2b-9 point 1, reported
13
+ * to the owner as this reading of 005 under 004).
14
+ *
15
+ * Whether the declaration is THERE and is a boolean is decided by `C-CONTRACT`,
16
+ * which calls the contract validator that owns every rule of this file
17
+ * (`utils/bizCiGateContract.js`): a missing or non-boolean `requiredConnectors.db`
18
+ * is its `[BizCiGate] Invalid contract field` message. This uniform once carried
19
+ * a `D-DB-DECL` row saying the same thing in its own words; two rows for one
20
+ * rule is the duplicate `change-discipline.md` § One rail per concern forbids,
21
+ * and it was removed when `C-CONTRACT` began calling the validator (d.217b, lead
22
+ * decision).
23
+ *
24
+ * What the row below adds is the comparison the contract cannot make on its own:
25
+ * `false` against a repository that has migrations or a database client anyway,
26
+ * and `true` against a repository with nothing to install. Both directions,
27
+ * because a contract that lies in either direction sends the installer the
28
+ * wrong way — measured precedent: three repositories carried `REPO_HAS_DB=false`
29
+ * while their contract declared a database, and the SQL half checked nothing
30
+ * while printing PASS.
31
+ *
32
+ * @see api/docs/governance/confirmations/biz-service-manifest.md § Confirmation 20260909-biz-service-manifest-005
33
+ */
34
+
35
+ const fs = require('fs');
36
+ const path = require('path');
37
+
38
+ const { readIdentity, IDENTITY_FILE } = require('../serviceIdentity');
39
+ const { DATABASE_PREFIX } = require('./serviceIdentityRows');
40
+
41
+ /** Where the fact lives, and the key that carries it. */
42
+ const CONTRACT_PATH = 'config/service/integration-contract.json';
43
+ const MIGRATIONS_DIR = 'migrations';
44
+
45
+ /** The document an operator runs the SQL package from; the contract §3 requires it. */
46
+ const MIGRATIONS_README = 'migrations/README.md';
47
+
48
+ /** Where a service keeps its env templates, and the one that is not its own. */
49
+ const ENV_TEMPLATE_DIR = 'config/env-templates';
50
+ const SHARED_ENV = 'shared.env';
51
+
52
+ /** The key an env template declares the database account under. */
53
+ const ACCOUNT_KEY = 'DB_USER';
54
+
55
+ /**
56
+ * @param {string} serviceRoot repository root
57
+ * @returns {{ db: boolean }|{ problem: string }}
58
+ */
59
+ function declaredDb(serviceRoot) {
60
+ const file = path.join(serviceRoot, ...CONTRACT_PATH.split('/'));
61
+ if (!fs.existsSync(file)) return { problem: 'the contract is absent' };
62
+
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
66
+ } catch (error) {
67
+ return { problem: `the contract is not valid JSON — ${error.message}` };
68
+ }
69
+
70
+ const value = (parsed.requiredConnectors || {}).db;
71
+ if (typeof value !== 'boolean') return { problem: 'requiredConnectors.db is missing' };
72
+ return { db: value };
73
+ }
74
+
75
+ const hasMigrations = (serviceRoot) => fs.existsSync(path.join(serviceRoot, MIGRATIONS_DIR));
76
+
77
+ /**
78
+ * @param {string} serviceRoot repository root
79
+ * @param {ReadonlyArray<string>} packages the database clients the row names
80
+ * @returns {string[]} the ones this repository depends on
81
+ */
82
+ function databaseClients(serviceRoot, packages) {
83
+ const file = path.join(serviceRoot, 'package.json');
84
+ if (!fs.existsSync(file)) return [];
85
+ const dependencies = JSON.parse(fs.readFileSync(file, 'utf8')).dependencies || {};
86
+ return packages.filter((name) => dependencies[name] !== undefined);
87
+ }
88
+
89
+ const dbConsistent = Object.freeze({
90
+ scope: 'service',
91
+ requires: Object.freeze(['forbidden_packages']),
92
+
93
+ run({ row, serviceRoot }) {
94
+ const declaration = declaredDb(serviceRoot);
95
+ // The declaration itself is C-CONTRACT's finding; this row says nothing
96
+ // about a fact it could not read, rather than guessing which way it went.
97
+ if (declaration.problem !== undefined) return [];
98
+
99
+ if (declaration.db === true) {
100
+ if (hasMigrations(serviceRoot)) return [];
101
+ return [{
102
+ where: CONTRACT_PATH,
103
+ what: 'requiredConnectors.db is true and there is no migrations/ directory — '
104
+ + 'a fresh install of this service has nothing to run'
105
+ }];
106
+ }
107
+
108
+ const findings = [];
109
+ if (hasMigrations(serviceRoot)) {
110
+ findings.push({
111
+ where: CONTRACT_PATH,
112
+ what: 'requiredConnectors.db is false and migrations/ exists — one of the two is wrong'
113
+ });
114
+ }
115
+ for (const client of databaseClients(serviceRoot, row.forbidden_packages)) {
116
+ findings.push({
117
+ where: 'package.json',
118
+ what: `requiredConnectors.db is false and ${JSON.stringify(client)} is a dependency — one of the two is wrong`
119
+ });
120
+ }
121
+ return findings;
122
+ }
123
+ });
124
+
125
+
126
+ /**
127
+ * The migration files of ONE level: `migrations/*.sql`, sorted.
128
+ *
129
+ * One level and no deeper, because that is exactly as far as the convention
130
+ * reaches. `BASELINE/` and `SEED/**` are the SQL package the installation
131
+ * contract owns, and what it says about them is what they must CONTAIN (§4, the
132
+ * four header fields — `D-DB-HEADERS`) and never what they are called;
133
+ * `archive/` and `superseded/` are history a repository keeps. Writing a regex
134
+ * over any of the three would be this row inventing a rule
135
+ * (`.claude/rules/truth-over-agreement.md` §6).
136
+ *
137
+ * @param {string} serviceRoot repository root
138
+ * @returns {string[]} file names
139
+ */
140
+ function migrationFiles(serviceRoot, relative = MIGRATIONS_DIR) {
141
+ const dir = path.join(serviceRoot, ...relative.replace(/\/$/, '').split('/'));
142
+ if (!fs.existsSync(dir)) return [];
143
+ return fs.readdirSync(dir, { withFileTypes: true })
144
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
145
+ .map((entry) => entry.name)
146
+ .sort();
147
+ }
148
+
149
+ /**
150
+ * The short name every per-service name is built from, or null when the
151
+ * repository has not said who it is — which is `C-SERVICE`'s finding, not this
152
+ * section's (`change-discipline.md` § One rail per concern).
153
+ *
154
+ * @param {string} serviceRoot repository root
155
+ * @returns {string|null}
156
+ */
157
+ function shortNameOf(serviceRoot) {
158
+ const identity = readIdentity(serviceRoot);
159
+ return identity.problem === undefined ? identity.params.service_name : null;
160
+ }
161
+
162
+ /**
163
+ * The value one env template declares for a key, or null.
164
+ *
165
+ * Read as the file is written — `KEY=value`, the first declaration winning, the
166
+ * way a shell reading it would. A commented-out line declares nothing, which is
167
+ * the case `D-DB-ACCOUNT` reports rather than passing over.
168
+ *
169
+ * @param {string} text the template
170
+ * @param {string} key the name
171
+ * @returns {string|null}
172
+ */
173
+ function declaredValue(text, key) {
174
+ for (const line of text.split('\n')) {
175
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
176
+ if (match !== null && match[1] === key) return match[2].trim();
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * The env template a service owns — the one named after it. `C-ENV` decides
183
+ * whether it is there and whether there is exactly one, and `C-IDENTITY`
184
+ * whether it carries this service's name; this function only finds it.
185
+ *
186
+ * @param {string} serviceRoot repository root
187
+ * @param {string} shortName
188
+ * @returns {{relative: string, text: string}|null}
189
+ */
190
+ function ownEnvTemplate(serviceRoot, shortName) {
191
+ const relative = `${ENV_TEMPLATE_DIR}/${shortName}.env`;
192
+ const file = path.join(serviceRoot, ...relative.split('/'));
193
+ if (fs.existsSync(file)) return { relative, text: fs.readFileSync(file, 'utf8') };
194
+
195
+ const dir = path.join(serviceRoot, ...ENV_TEMPLATE_DIR.split('/'));
196
+ if (!fs.existsSync(dir)) return null;
197
+ const others = fs.readdirSync(dir).filter((name) => name !== SHARED_ENV && name.endsWith('.env')).sort();
198
+ if (others.length !== 1) return null;
199
+ const only = `${ENV_TEMPLATE_DIR}/${others[0]}`;
200
+ return { relative: only, text: fs.readFileSync(path.join(serviceRoot, ...only.split('/')), 'utf8') };
201
+ }
202
+
203
+ /**
204
+ * The three rows below ask their question only of a repository that DECLARES a
205
+ * database, and say nothing about a declaration they could not read — that one
206
+ * is `C-CONTRACT`'s, which calls the validator owning every rule of that file.
207
+ *
208
+ * @param {string} serviceRoot repository root
209
+ * @returns {boolean}
210
+ */
211
+ const hasDatabase = (serviceRoot) => declaredDb(serviceRoot).db === true;
212
+
213
+ /**
214
+ * `D-DB-NAMING` — what a migration is CALLED is the only thing a reader has
215
+ * before opening it, and the only thing an ordering depends on.
216
+ *
217
+ * The shape is not this row's to invent: it is read back from the node that
218
+ * owns it, `api/docs/biz/70-contracts/database-contract.md` §5 — three digits,
219
+ * an underscore, a lowercase description. No second word is demanded, which is
220
+ * why `000_baseline.sql` passes: §5 prints it as an example and §8 makes a
221
+ * regenerated baseline an applied migration. No leading verb is demanded
222
+ * either, and the reason is measured rather than assumed —
223
+ * `converter/migrations/009_cnv_batch_drop_pending_status.sql` opens with a
224
+ * table prefix and is right. The three digits are not decoration: `999a_…`
225
+ * sorts after `999_…` only by accident of the byte after the digits, and a
226
+ * package installed in the wrong order is the failure the whole SQL contract
227
+ * exists to prevent.
228
+ *
229
+ * @see api/docs/biz/70-contracts/database-contract.md
230
+ */
231
+ const dbMigrationNaming = Object.freeze({
232
+ scope: 'service',
233
+ requires: Object.freeze(['path', 'naming']),
234
+
235
+ run({ row, serviceRoot }) {
236
+ if (!hasDatabase(serviceRoot)) return [];
237
+
238
+ const shape = new RegExp(row.naming);
239
+ const dir = row.path.replace(/\/$/, '');
240
+ return migrationFiles(serviceRoot, dir)
241
+ .filter((name) => !shape.test(name))
242
+ .map((name) => ({
243
+ where: `${dir}/${name}`,
244
+ what: 'the name does not read NNN_snake_case.sql — three digits, an underscore, then a '
245
+ + `lowercase description (${row.naming})`
246
+ }));
247
+ }
248
+ });
249
+
250
+ /**
251
+ * `D-DB-README` — the document that says in which order the package is run.
252
+ *
253
+ * `D-DB-PACKAGE` calls the installation contract's `SQL_PACKAGE` requirement,
254
+ * and that requirement checks the three directories and that each holds a `.sql`
255
+ * file. The contract §3 asks for a fourth thing it does not check:
256
+ * `migrations/README.md`. So the row is not a second owner of `SQL_PACKAGE` —
257
+ * it is the half of §3 nothing was looking at.
258
+ */
259
+ const dbReadme = Object.freeze({
260
+ scope: 'service',
261
+ requires: Object.freeze(['path']),
262
+
263
+ run({ row, serviceRoot }) {
264
+ if (!hasDatabase(serviceRoot)) return [];
265
+ if (fs.existsSync(path.join(serviceRoot, ...row.path.split('/')))) return [];
266
+
267
+ return [{
268
+ where: row.path,
269
+ what: 'the SQL package carries no README — the documented execution order that reproduces a working '
270
+ + 'instance is the fourth thing the installation contract §3 requires, and the only one nothing '
271
+ + 'else checks'
272
+ }];
273
+ }
274
+ });
275
+
276
+ /**
277
+ * `D-DB-ACCOUNT` — the service reaches its schema as ITSELF.
278
+ *
279
+ * Confirmation `db-accounts-per-service` 001: every service has its own account
280
+ * with grants on its own schemas, and root stops being an operational identity.
281
+ * The name is the same derivation `C-IDENTITY` holds `database.schema` to, read
282
+ * from the same file and built with the same prefix, so the account and the
283
+ * schema cannot drift into two spellings of one service.
284
+ *
285
+ * A repository that declares a database and no `DB_USER` is a FINDING, not a
286
+ * silence: an account nobody declared is one somebody will invent at install
287
+ * time, and that is how root became the identity of nine containers.
288
+ */
289
+ const dbAccount = Object.freeze({
290
+ scope: 'service',
291
+ requires: Object.freeze(['path', 'key']),
292
+
293
+ run({ row, serviceRoot }) {
294
+ if (!hasDatabase(serviceRoot)) return [];
295
+
296
+ const shortName = shortNameOf(serviceRoot);
297
+ if (shortName === null) return [];
298
+
299
+ const expected = `${DATABASE_PREFIX}${shortName}`;
300
+ const template = ownEnvTemplate(serviceRoot, shortName);
301
+ if (template === null) {
302
+ return [{
303
+ where: row.path,
304
+ what: `no env template of this service's own declares ${row.key}, and this service's account is `
305
+ + `${JSON.stringify(expected)}`
306
+ }];
307
+ }
308
+
309
+ const declared = declaredValue(template.text, row.key);
310
+ if (declared === null) {
311
+ return [{
312
+ where: template.relative,
313
+ what: `declares no ${row.key}, and this service's account is ${JSON.stringify(expected)}`
314
+ }];
315
+ }
316
+ if (declared === expected) return [];
317
+
318
+ return [{
319
+ where: template.relative,
320
+ what: `${row.key} is ${JSON.stringify(declared)}, and the account derived from ${IDENTITY_FILE} `
321
+ + `is ${JSON.stringify(expected)}`
322
+ }];
323
+ }
324
+ });
325
+
326
+ /**
327
+ * `D-DB-COLLATION` — the schema is created with a collation this service CHOSE.
328
+ *
329
+ * Owner decision 2026-09-14 (`db-collation-declaration` 001): the collation of a
330
+ * biz schema is the service's own declaration, one key in the `database` block,
331
+ * and the CI gate and the production runbook both read it. Without it each of
332
+ * them invents one — which is how six schemas came to exist with the image
333
+ * default `utf8mb4_general_ci` while every table inside them declares
334
+ * `utf8mb4_bin` or `utf8mb4_unicode_ci` (measured 2026-09-14: 122 of 123
335
+ * permanent tables declare their collation explicitly).
336
+ *
337
+ * This row asks ONE question — is the key there — and deliberately not the other
338
+ * two. Whether the VALUE is a collation name is the contract validator's rule,
339
+ * which `C-CONTRACT` calls; refusing to BUILD without it is
340
+ * `utils/setupDatabase.js`, at the moment it would have to interpolate the
341
+ * value. A row restating either would be the duplicate that already cost
342
+ * `D-DB-DECL` its place (see the head of this file).
343
+ *
344
+ * It reads the `database` BLOCK rather than `requiredConnectors.db`, because the
345
+ * block is what the gate builds from: a contract without one has no schema to
346
+ * create, and `biz-ci-gate setup-db` says NOT APPLICABLE over it.
347
+ *
348
+ * @see api/docs/governance/confirmations/db-collation-declaration.md
349
+ */
350
+ const dbCollation = Object.freeze({
351
+ scope: 'service',
352
+ requires: Object.freeze(['path', 'key']),
353
+
354
+ run({ row, serviceRoot }) {
355
+ const file = path.join(serviceRoot, ...row.path.split('/'));
356
+ if (!fs.existsSync(file)) return [];
357
+
358
+ let parsed;
359
+ try {
360
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
361
+ } catch (error) {
362
+ // An unreadable contract is `C-CONTRACT`'s finding, with its own fix.
363
+ return [];
364
+ }
365
+
366
+ const database = parsed.database;
367
+ if (database === undefined || database === null || typeof database !== 'object') return [];
368
+
369
+ const declared = database[row.key];
370
+ if (typeof declared === 'string' && declared.trim() !== '') return [];
371
+
372
+ return [{
373
+ where: row.path,
374
+ what: `the database block declares no database.${row.key} — the CI gate and the production runbook both create `
375
+ + 'this schema with the collation the service declares, and there is no default'
376
+ }];
377
+ }
378
+ });
379
+
380
+ module.exports = {
381
+ checks: [
382
+ { name: 'db-consistent', check: dbConsistent },
383
+ { name: 'db-collation', check: dbCollation },
384
+ { name: 'db-migration-naming', check: dbMigrationNaming },
385
+ { name: 'db-readme', check: dbReadme },
386
+ { name: 'db-account', check: dbAccount }
387
+ ]
388
+ };