@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,351 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ONE service, ONE name.
5
+ *
6
+ * `api/docs/biz/60-templates/naming.md` is normative and derives every spelling
7
+ * of a biz service from a single short name: `service.name` is `biz-<shortname>`,
8
+ * the npm package is `biz-<shortname>`, the container is
9
+ * `api_service_<shortname>`, the env template is `<shortname>.env`, the database
10
+ * is `oagen_<shortname>`, the directory is `<shortname>` and the git repository
11
+ * ends in `biz-<shortname>`. The document says why the last of those matters in
12
+ * a way no other node does: `service_code` in the entitlement data and the
13
+ * service name in a cookbook step are compared by the gateway CHARACTER BY
14
+ * CHARACTER, so a second spelling of one service is not untidiness — it is a
15
+ * request that is refused.
16
+ *
17
+ * Measured 2026-09-10 over the eight live services: seven wear one name each,
18
+ * and `hello-service` wears four — directory `hello-service`, repository
19
+ * `biz-hello-service`, env template `hello-service.env`, everything else
20
+ * `hello`. Nothing said a word about it, and the standard's own § Current State
21
+ * recorded the divergence as a fact rather than as a defect.
22
+ *
23
+ * Two rows, because they answer to two different owners:
24
+ *
25
+ * - **`C-IDENTITY`** compares the spellings INSIDE the repository against the
26
+ * short name that repository declares. It needs nothing but its own root, so
27
+ * it answers in a container and in the service's own CI too.
28
+ * - **`U-IDENTITY`** compares the SSOT's row for this service — `directory`,
29
+ * `repo`, `container` in `api/config/services.json` — against the same short
30
+ * name. Those three are facts of the workspace, so the row is only ever
31
+ * asked where the workspace is.
32
+ *
33
+ * The short name is read the one way this package reads it, from
34
+ * `config/service/config.json` → `service.name` (`serviceIdentity.js`); the
35
+ * directory name is never the source, because inside the image every service
36
+ * lies in `/app`.
37
+ *
38
+ * @see api/docs/biz/60-templates/naming.md
39
+ */
40
+
41
+ const fs = require('fs');
42
+ const path = require('path');
43
+
44
+ const { whereOf } = require('./libraryContext');
45
+ const { readComposeServices } = require('./composeShape');
46
+ const { readIdentity, IDENTITY_FILE } = require('../serviceIdentity');
47
+ const { PLACEHOLDERS } = require('../../sync/serviceTemplate');
48
+ const { resolveFromMap } = require('../discovery');
49
+
50
+ /** Where a service declares the name npm knows it by. */
51
+ const PACKAGE_FILE = 'package.json';
52
+
53
+ /**
54
+ * Where a service declares its own name to the platform gates and its database,
55
+ * if it has one. `C-CONTRACT` owns the FILE — whether each declaration is there
56
+ * and well-formed; this row owns only whether the two names it carries are this
57
+ * service's.
58
+ */
59
+ const CONTRACT_FILE = 'config/service/integration-contract.json';
60
+
61
+ /** The directory every per-service env template lies in. */
62
+ const ENV_TEMPLATE_DIR = 'config/env-templates';
63
+
64
+ /** The env template every service shares; it is named after the platform, not the service. */
65
+ const SHARED_ENV = 'shared.env';
66
+
67
+ /** The prefix `naming.md` gives every biz database. */
68
+ const DATABASE_PREFIX = 'oagen_';
69
+
70
+ /** The profile marking the one-shot test runner, which declares no container name. */
71
+ const TEST_PROFILE = 'test';
72
+
73
+ /**
74
+ * Read a file of the repository under check.
75
+ *
76
+ * @param {string} serviceRoot repository root
77
+ * @param {string} relative repository-relative path, `/`-separated
78
+ * @returns {string|null} null when it is not there
79
+ */
80
+ function readOrNull(serviceRoot, relative) {
81
+ const target = path.join(serviceRoot, ...relative.split('/'));
82
+ return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
83
+ }
84
+
85
+ /**
86
+ * A JSON file of the repository, or null when it is absent or unreadable. A
87
+ * broken file is somebody else's row (`C-SERVICE`, `C-CONTRACT`), and this row
88
+ * saying so a second time would be a second owner of one defect.
89
+ *
90
+ * @param {string} serviceRoot repository root
91
+ * @param {string} relative repository-relative path
92
+ * @returns {object|null}
93
+ */
94
+ function readJson(serviceRoot, relative) {
95
+ const text = readOrNull(serviceRoot, relative);
96
+ if (text === null) return null;
97
+ try {
98
+ return JSON.parse(text);
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The env templates a repository carries, the shared one excluded — it is named
106
+ * after the platform and every service has the same copy (`G-SHARED-ENV`).
107
+ *
108
+ * @param {string} serviceRoot repository root
109
+ * @returns {string[]|null} file names, or null when the directory is absent
110
+ */
111
+ function envTemplates(serviceRoot) {
112
+ const dir = path.join(serviceRoot, ...ENV_TEMPLATE_DIR.split('/'));
113
+ if (!fs.existsSync(dir)) return null;
114
+ return fs.readdirSync(dir).filter((name) => name !== SHARED_ENV).sort();
115
+ }
116
+
117
+ /**
118
+ * Every `env_file` entry of every service block of a compose file, as written.
119
+ *
120
+ * @param {string} text the compose file
121
+ * @returns {string[]}
122
+ */
123
+ function envFileEntries(text) {
124
+ return [...readComposeServices(text).values()]
125
+ .flatMap((node) => node.sequences.get('env_file') || [])
126
+ .map((entry) => entry.trim());
127
+ }
128
+
129
+ /**
130
+ * The container names a compose file declares, the one-shot runner excluded: it
131
+ * declares none on purpose, and `F-RUNNER` owns that block.
132
+ *
133
+ * @param {string} text the compose file
134
+ * @returns {Array<[string, string]>} `[compose service, container_name]`
135
+ */
136
+ function containerNames(text) {
137
+ return [...readComposeServices(text).entries()]
138
+ .filter(([, node]) => !(node.sequences.get('profiles') || []).includes(TEST_PROFILE))
139
+ .map(([name, node]) => [name, node.scalars.get('container_name')])
140
+ .filter(([, container]) => typeof container === 'string' && container !== '');
141
+ }
142
+
143
+ const serviceIdentity = Object.freeze({
144
+ scope: 'bearer',
145
+ requires: Object.freeze(['path', 'production_path']),
146
+
147
+ // Nothing outside this repository is read, so the row answers wherever the
148
+ // repository is — in the container and in the service's own CI included.
149
+ needsWorkspace: () => false,
150
+
151
+ run({ row, serviceRoot, workspaceRoot }) {
152
+ const identity = readIdentity(serviceRoot);
153
+ const at = (relative) => whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative });
154
+
155
+ if (identity.problem !== undefined) {
156
+ if (identity.undecided === null) return [];
157
+ return [{ where: at(IDENTITY_FILE), what: `the spellings could not be compared — ${identity.undecided}` }];
158
+ }
159
+
160
+ const { service_name: shortName, container_name: container } = identity.params;
161
+ const findings = [];
162
+
163
+ // The npm name is compared against what `config/service/config.json`
164
+ // DECLARES, character for character — naming.md gives the two the same
165
+ // spelling, so one name is the whole question. Comparing it against the
166
+ // derived `biz-<shortname>` instead would smuggle in a rule about
167
+ // `service.name` itself that no row owns (`serviceIdentity.js` § REGISTERED_NAME
168
+ // says so in as many words); that is a change to the rules, not a side
169
+ // effect of this one (`truth-over-agreement.md` §6).
170
+ const declaredName = (readJson(serviceRoot, IDENTITY_FILE) || {}).service.name;
171
+ const pkg = readJson(serviceRoot, PACKAGE_FILE);
172
+ if (pkg !== null && pkg.name !== declaredName) {
173
+ findings.push({
174
+ where: at(PACKAGE_FILE),
175
+ what: `npm name is ${JSON.stringify(pkg.name)}, `
176
+ + `and ${IDENTITY_FILE} declares ${JSON.stringify(declaredName)}`
177
+ });
178
+ }
179
+
180
+ // The SIXTH spelling, and the one this row missed until d.218: the
181
+ // integration contract opens with `serviceName`, and it is the first file
182
+ // every platform gate reads. Compared against what `config/service/config.json`
183
+ // DECLARES, character for character, for the same reason the npm name is:
184
+ // whether `service.name` itself has to read `biz-<name>` is a question no row
185
+ // owns, and this one is not the place a rule appears as a side effect
186
+ // (`truth-over-agreement.md` §6). A contract that declares nothing here, or
187
+ // none at all, is `C-CONTRACT`'s finding — it calls the validator owning
188
+ // every rule of that file, and one defect has one owner
189
+ // (`change-discipline.md` § One rail per concern).
190
+ const contractName = (readJson(serviceRoot, CONTRACT_FILE) || {}).serviceName;
191
+ if (typeof contractName === 'string' && contractName !== declaredName) {
192
+ findings.push({
193
+ where: at(CONTRACT_FILE),
194
+ what: `serviceName is ${JSON.stringify(contractName)}, `
195
+ + `and ${IDENTITY_FILE} declares ${JSON.stringify(declaredName)}`
196
+ });
197
+ }
198
+
199
+ for (const relative of [row.path, row.production_path]) {
200
+ const text = readOrNull(serviceRoot, relative);
201
+ if (text === null) continue;
202
+
203
+ for (const [composeService, declared] of containerNames(text)) {
204
+ if (declared === container) continue;
205
+ findings.push({
206
+ where: at(relative),
207
+ what: `service ${composeService} is called ${JSON.stringify(declared)}, `
208
+ + `and the name derived from ${IDENTITY_FILE} is ${JSON.stringify(container)}`
209
+ });
210
+ }
211
+ }
212
+
213
+ const expectedEnv = `${shortName}.env`;
214
+ const templates = envTemplates(serviceRoot);
215
+ if (templates !== null && !templates.includes(expectedEnv)) {
216
+ findings.push({
217
+ where: at(ENV_TEMPLATE_DIR),
218
+ what: templates.length === 0
219
+ ? `carries no env template of its own — ${expectedEnv} is the one this service is named after`
220
+ : `carries ${templates.join(', ')}, and this service's env template is ${expectedEnv}`
221
+ });
222
+ }
223
+
224
+ for (const relative of [row.path, row.production_path]) {
225
+ const text = readOrNull(serviceRoot, relative);
226
+ if (text === null) continue;
227
+
228
+ for (const entry of envFileEntries(text)) {
229
+ const name = entry.split('/').pop();
230
+ if (name === SHARED_ENV || name === expectedEnv) continue;
231
+ findings.push({
232
+ where: at(relative),
233
+ what: `env_file loads ${JSON.stringify(entry)}, and this service's env template is ${expectedEnv}`
234
+ });
235
+ }
236
+ }
237
+
238
+ // Only where the repository DECLARES a database. A stateless service
239
+ // declares none, and demanding one would be this row inventing a rule
240
+ // `naming.md` does not state (`pdfgen` is the measured case).
241
+ const contract = readJson(serviceRoot, CONTRACT_FILE);
242
+ const schema = contract === null || contract.database === undefined || contract.database === null
243
+ ? null
244
+ : contract.database.schema;
245
+ if (typeof schema === 'string' && schema !== `${DATABASE_PREFIX}${shortName}`) {
246
+ findings.push({
247
+ where: at(CONTRACT_FILE),
248
+ what: `database.schema is ${JSON.stringify(schema)}, and the name derived from ${IDENTITY_FILE} `
249
+ + `is ${JSON.stringify(`${DATABASE_PREFIX}${shortName}`)}`
250
+ });
251
+ }
252
+
253
+ return findings;
254
+ }
255
+ });
256
+
257
+ /**
258
+ * The three fields of the SSOT row that spell this service's name, and what each
259
+ * has to read. `container` is the same derivation `C-IDENTITY` holds the compose
260
+ * files to, which is the point: the SSOT and the repository are two places
261
+ * saying one thing.
262
+ */
263
+ const SSOT_FIELDS = Object.freeze([
264
+ { field: 'directory', of: (params) => params.service_name, says: 'the directory this service lies in' },
265
+ { field: 'repo', of: (params) => params.repo_name, says: 'the last segment of the git repository path', last: true },
266
+ { field: 'container', of: (params) => params.container_name, says: 'the container this service runs as' }
267
+ ]);
268
+
269
+ const ssotIdentity = Object.freeze({
270
+ scope: 'bearer',
271
+ requires: Object.freeze([]),
272
+
273
+ describeNotRun({ block }) {
274
+ return `the workspace root is not reachable, so ${block.from.path} cannot be read`;
275
+ },
276
+
277
+ run({ block, serviceRoot, workspaceRoot }) {
278
+ const identity = readIdentity(serviceRoot);
279
+ const at = (relative) => whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative });
280
+
281
+ if (identity.problem !== undefined) {
282
+ if (identity.undecided === null) return [];
283
+ return [{ where: at(IDENTITY_FILE), what: `the SSOT row could not be found — ${identity.undecided}` }];
284
+ }
285
+
286
+ const registered = identity.params.registry_name;
287
+
288
+ // The TEMPLATE is a bearer of every FILE row and of none of the SSOT ones:
289
+ // it declares its identity as the placeholder (`serviceIdentity.js`), and
290
+ // `api/config/services.json` lists services, of which the template is not
291
+ // one — it is the shape they are made from. The question this row asks does
292
+ // not apply to it, the same way `appliesTo` decides a library duty by
293
+ // category. Not a silence about a service: a service always declares a name
294
+ // of its own, and the case below reports one the SSOT does not know.
295
+ if (registered === PLACEHOLDERS.registry_name) return [];
296
+
297
+ const entries = resolveFromMap({ from: block.from, workspaceRoot });
298
+ const rows = Array.isArray(entries) ? entries : [];
299
+ const declared = rows.filter((entry) => entry.name === registered);
300
+
301
+ if (declared.length === 0) {
302
+ // A bearer the SSOT knows under NO name and under NO directory is
303
+ // `U-ORPHAN`'s finding, and saying it twice would give one defect two
304
+ // owners (`change-discipline.md` § One rail per concern). What reaches
305
+ // here is the other case: the SSOT declares this directory, and calls the
306
+ // service living in it something else.
307
+ const directory = path.basename(path.resolve(serviceRoot));
308
+ if (!rows.some((entry) => entry[block.from.key] === directory)) return [];
309
+
310
+ return [{
311
+ where: block.from.path,
312
+ what: `declares directory ${JSON.stringify(directory)} under another name, and no `
313
+ + `${JSON.stringify(block.from.list)} entry is named ${JSON.stringify(registered)} — `
314
+ + `which is what ${IDENTITY_FILE} calls this service`
315
+ }];
316
+ }
317
+
318
+ const findings = [];
319
+ for (const entry of declared) {
320
+ for (const { field, of, says, last } of SSOT_FIELDS) {
321
+ const value = entry[field];
322
+ if (typeof value !== 'string' || value === '') {
323
+ findings.push({
324
+ where: block.from.path,
325
+ what: `${JSON.stringify(registered)} declares no ${JSON.stringify(field)} — it names ${says}`
326
+ });
327
+ continue;
328
+ }
329
+ const compared = last === true ? value.split('/').pop() : value;
330
+ const expected = of(identity.params);
331
+ if (compared === expected) continue;
332
+ findings.push({
333
+ where: block.from.path,
334
+ what: `${JSON.stringify(registered)} declares ${field} ${JSON.stringify(value)}, and the name `
335
+ + `derived from ${IDENTITY_FILE} is ${JSON.stringify(expected)} — it names ${says}`
336
+ });
337
+ }
338
+ }
339
+ return findings;
340
+ }
341
+ });
342
+
343
+ module.exports = {
344
+ checks: [
345
+ { name: 'service-identity', check: serviceIdentity },
346
+ { name: 'ssot-identity', check: ssotIdentity }
347
+ ],
348
+ DATABASE_PREFIX,
349
+ envFileEntries,
350
+ containerNames
351
+ };
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What a service is allowed to BE at run time: which Node major it runs, how
5
+ * much memory it may hold, which process it runs AS, and the ports it may not
6
+ * publish.
7
+ *
8
+ * Every row here is a measured gap the existing gates leave open, not a second
9
+ * copy of them (004 point 4):
10
+ *
11
+ * - `R3` of the deploy contract compares CI, Dockerfile and `engines.node`
12
+ * against EACH OTHER. Five of the eight services are internally consistent
13
+ * on an older major while the platform has moved, so R3 is green and the
14
+ * drift is invisible. `R-NODE` compares them against the platform major, and
15
+ * is the one row this uniform fills by itself.
16
+ * - `R4` refuses published ports in the PRODUCTION compose. `R-PORTS-DEV` is
17
+ * the dev compose, which R4 never reads.
18
+ * - the memory norm has no gate at all today: `biz-memory-limits.md` 001 is
19
+ * prose, and two services sit at 384M in dev and in production.
20
+ * - `biz-compose-pid1` 001 was prose too, and 002 says where it belongs: in
21
+ * the uniform. `R-PID1` is the row, and until it existed the decision
22
+ * travelled by being remembered.
23
+ *
24
+ * The Node major is REFERENCED, never restated. Until d.238 the reference was
25
+ * `api/.nvmrc`, the file that owns it — and that made the row unanswerable in
26
+ * the two places a service is actually built: inside its container and in its
27
+ * own CI checkout, neither of which has a workspace. It now reads the
28
+ * `engines.node` of THIS package, which the library uniform's `L-ENGINES` fails
29
+ * any package for disagreeing with `api/.nvmrc`. So it is the same fact, one
30
+ * checked hop further along, and it travels with the pin (confirmation
31
+ * `biz-service-manifest` 006 point 2).
32
+ *
33
+ * The two memory values are carried by the row, because no machine-readable
34
+ * owner for them exists yet and a row's `doc` naming the owner's decision is the
35
+ * honest second best.
36
+ *
37
+ * @see api/docs/governance/confirmations/biz-memory-limits.md
38
+ * @see api/docs/governance/confirmations/biz-compose-pid1.md
39
+ * @see api/docs/governance/confirmations/biz-service-manifest.md
40
+ */
41
+
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+
45
+ const { resolveFromValue, isPackageReference, referenceOwner } = require('../discovery');
46
+ const { whereOf } = require('./libraryContext');
47
+ const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
48
+
49
+ /** Where a service block states its own budget, in both compose files. */
50
+ const SERVICE_MEMORY_PATH = 'deploy.resources.limits.memory';
51
+
52
+ /** The profile that marks the test runner — its budget is F-RUNNER's row, not R-MEM's. */
53
+ const TEST_PROFILE = 'test';
54
+
55
+ const readOrNull = (serviceRoot, relative) => {
56
+ const target = path.join(serviceRoot, ...relative.split('/'));
57
+ return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
58
+ };
59
+
60
+ /** The major of a Node version however it is written: `24`, `24-alpine`, `>=24.0.0 <25`. */
61
+ function majorOf(text) {
62
+ const match = /(\d+)/.exec(String(text));
63
+ return match === null ? null : Number(match[1]);
64
+ }
65
+
66
+ /** The services of a compose file that are not the one-shot test runner. */
67
+ const runtimeServices = (text) => [...readComposeServices(text).entries()]
68
+ .filter(([, node]) => !(node.sequences.get('profiles') || []).includes(TEST_PROFILE));
69
+
70
+ /**
71
+ * The range the referenced `package.json` declares in `engines.node`.
72
+ *
73
+ * The reference names the FILE; which field carries the major is this check's
74
+ * business, and it is the same field the check compares every service against —
75
+ * one concept, asked twice (`change-discipline.md` § One rail per concern).
76
+ *
77
+ * @param {{ row: object, workspaceRoot: string|null }} params
78
+ * @returns {string} the range as the file writes it
79
+ */
80
+ function platformRange({ row, workspaceRoot }) {
81
+ const raw = resolveFromValue({ from: row.from, workspaceRoot });
82
+ const owner = referenceOwner(row.from);
83
+
84
+ let document;
85
+ try {
86
+ document = JSON.parse(raw);
87
+ } catch (cause) {
88
+ throw new Error(`[ManifestRuntime] Platform Node major unreadable - ${owner} is not valid JSON. `
89
+ + 'Fix: repair the file; its engines.node is the major every service is measured against.', { cause });
90
+ }
91
+
92
+ const declared = (document.engines || {}).node;
93
+ if (typeof declared !== 'string' || declared.length === 0) {
94
+ throw new Error(`[ManifestRuntime] Platform Node major unreadable - ${owner} declares no engines.node. `
95
+ + 'Fix: declare it; the library uniform\'s L-ENGINES keeps it equal to api/.nvmrc, and this row reads it.');
96
+ }
97
+ return declared;
98
+ }
99
+
100
+ const nodeMajor = Object.freeze({
101
+ scope: 'bearer',
102
+ requires: Object.freeze(['from']),
103
+
104
+ // A reference into this package travels with the pin, so the row answers in a
105
+ // container and in the service's own CI too (d.229 § describeFromProblem).
106
+ needsWorkspace: ({ row }) => !isPackageReference(row.from),
107
+
108
+ describeNotRun({ row }) {
109
+ return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
110
+ },
111
+
112
+ run({ row, serviceRoot, workspaceRoot }) {
113
+ const range = platformRange({ row, workspaceRoot });
114
+ const platform = majorOf(range);
115
+ if (platform === null) {
116
+ throw new Error('[ManifestRuntime] Platform Node major unreadable - '
117
+ + `${referenceOwner(row.from)} declares engines.node as ${JSON.stringify(range)}, which carries no number. `
118
+ + 'Fix: repair the file; it is what every service is measured against.');
119
+ }
120
+
121
+ const findings = [];
122
+ const dockerfile = readOrNull(serviceRoot, 'Dockerfile');
123
+ if (dockerfile === null) {
124
+ findings.push({
125
+ where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: 'Dockerfile' }),
126
+ what: 'absent — the image this service runs as is built from it'
127
+ });
128
+ } else {
129
+ const from = /^FROM\s+node:(\S+)/m.exec(dockerfile);
130
+ const major = from === null ? null : majorOf(from[1]);
131
+ if (major !== platform) {
132
+ findings.push({
133
+ where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: 'Dockerfile' }),
134
+ what: from === null
135
+ ? `does not build on a node: image, and the platform major is ${platform}`
136
+ : `builds on node:${from[1].split('-')[0]}, and the platform major is ${platform}`
137
+ });
138
+ }
139
+ }
140
+
141
+ const pkg = readOrNull(serviceRoot, 'package.json');
142
+ const declared = pkg === null ? null : (JSON.parse(pkg).engines || {}).node;
143
+ if (typeof declared !== 'string' || declared.length === 0) {
144
+ findings.push({
145
+ where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: 'package.json' }),
146
+ what: `declares no engines.node, and the platform major is ${platform}`
147
+ });
148
+ } else if (majorOf(declared) !== platform) {
149
+ findings.push({
150
+ where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: 'package.json' }),
151
+ what: `engines.node is ${JSON.stringify(declared)}, whose major is ${majorOf(declared)}, `
152
+ + `and the platform major is ${platform}`
153
+ });
154
+ }
155
+
156
+ return findings;
157
+ }
158
+ });
159
+
160
+ const memoryLimit = Object.freeze({
161
+ scope: 'service',
162
+ requires: Object.freeze(['path', 'production_path', 'service_memory']),
163
+
164
+ /**
165
+ * The same number in both files, because the norm names dev AND production
166
+ * together — production is not the place to discover a different limit.
167
+ */
168
+ run({ row, serviceRoot }) {
169
+ const findings = [];
170
+
171
+ for (const relative of [row.path, row.production_path]) {
172
+ const text = readOrNull(serviceRoot, relative);
173
+ if (text === null) {
174
+ findings.push({ where: relative, what: 'absent — the service is run from it' });
175
+ continue;
176
+ }
177
+
178
+ for (const [name, node] of runtimeServices(text)) {
179
+ const declared = scalarAt(node, SERVICE_MEMORY_PATH);
180
+ if (declared === row.service_memory) continue;
181
+ findings.push({
182
+ where: relative,
183
+ what: declared === null
184
+ ? `service ${name} declares no ${SERVICE_MEMORY_PATH} — the platform norm is ${row.service_memory}`
185
+ : `the service declares ${declared}, the platform norm is ${row.service_memory}`
186
+ });
187
+ }
188
+ }
189
+
190
+ return findings;
191
+ }
192
+ });
193
+
194
+ /**
195
+ * The argv a `command:` declaration means, whichever of the two shapes compose
196
+ * accepts it is written in — `["node", "index.js"]` or `node index.js`. Two
197
+ * spellings of one decision, and a row grading the spelling would report a
198
+ * service that is already right.
199
+ *
200
+ * @param {string} declared the declaration verbatim
201
+ * @returns {string[]|null} null when there is nothing there, or when the
202
+ * bracket form is not readable — an unreadable command is a finding, not a pass
203
+ */
204
+ function argvOf(declared) {
205
+ const text = String(declared).trim();
206
+ if (text === '') return null;
207
+ if (!text.startsWith('[')) return text.split(/\s+/);
208
+
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(text);
212
+ } catch {
213
+ return null;
214
+ }
215
+ return Array.isArray(parsed) ? parsed.map(String) : null;
216
+ }
217
+
218
+ /**
219
+ * `R-PID1` — the process the container runs AS.
220
+ *
221
+ * `command: ["npm","start"]` makes npm process 1, and npm does not forward
222
+ * SIGTERM: the stop is a ten-second wait followed by SIGKILL, so a service never
223
+ * gets to close its channels. Confirmation `biz-compose-pid1` 001 settled the
224
+ * substance ("Ano, srovnat na node") and 002 settled where it belongs: not a
225
+ * per-repo choice any more but part of the uniform biz service shape, which is
226
+ * this row.
227
+ *
228
+ * The one-shot test runner is left alone on purpose — it declares no command
229
+ * because `docker compose run` supplies one, and its shape is F-RUNNER's row.
230
+ *
231
+ * @see api/docs/governance/confirmations/biz-compose-pid1.md
232
+ */
233
+ const composeCommand = Object.freeze({
234
+ scope: 'service',
235
+ requires: Object.freeze(['path', 'service_command']),
236
+
237
+ run({ row, serviceRoot }) {
238
+ // The row states the command the way a person writes it; which of the two
239
+ // compose spellings a service happens to use is not the row's business.
240
+ const expected = argvOf(row.service_command);
241
+ if (expected === null) {
242
+ throw new Error('[ManifestRuntime] Platform command unreadable - row '
243
+ + `${row.id} declares service_command as ${JSON.stringify(row.service_command)}, which names no command. `
244
+ + 'Fix: state it as the argv a container runs, e.g. "node index.js".');
245
+ }
246
+ const wanted = JSON.stringify(expected);
247
+ const why = `the platform shape is ${wanted}, so PID 1 is node and a SIGTERM reaches it`;
248
+
249
+ const text = readOrNull(serviceRoot, row.path);
250
+ if (text === null) return [{ where: row.path, what: 'absent — the service is run from it' }];
251
+
252
+ const findings = [];
253
+ for (const [name, node] of runtimeServices(text)) {
254
+ const declared = declarationOf(node, 'command');
255
+ const argv = argvOf(declared);
256
+
257
+ if (argv === null && declared.trim() === '') {
258
+ findings.push({ where: row.path, what: `service ${name} declares no command — ${why}` });
259
+ continue;
260
+ }
261
+ if (argv !== null && argv.length === expected.length && argv.every((word, i) => word === expected[i])) continue;
262
+
263
+ findings.push({ where: row.path, what: `service ${name} runs as ${declared.trim()} — ${why}` });
264
+ }
265
+
266
+ return findings;
267
+ }
268
+ });
269
+
270
+ const composeNoPorts = Object.freeze({
271
+ scope: 'service',
272
+ requires: Object.freeze(['path']),
273
+
274
+ run({ row, serviceRoot }) {
275
+ const text = readOrNull(serviceRoot, row.path);
276
+ if (text === null) return [];
277
+
278
+ return [...readComposeServices(text).entries()]
279
+ .filter(([, node]) => declares(node, 'ports'))
280
+ .map(([name]) => ({
281
+ where: row.path,
282
+ what: `service ${name} publishes a port — a biz service binds nothing, and work arrives over MQ`
283
+ }));
284
+ }
285
+ });
286
+
287
+ module.exports = {
288
+ checks: [
289
+ { name: 'node-major', check: nodeMajor },
290
+ { name: 'memory-limit', check: memoryLimit },
291
+ { name: 'compose-command', check: composeCommand },
292
+ { name: 'compose-no-ports', check: composeNoPorts }
293
+ ],
294
+ majorOf
295
+ };