@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,242 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Where the `from:` references of the manifest are resolved.
5
+ *
6
+ * Two facts, and they are not the same one:
7
+ *
8
+ * - the **api checkout** — the repository this package is part of, which owns
9
+ * the platform SSOT (`config/services.json`, `.nvmrc`, `config/libraries.json`);
10
+ * - the **workspace** — the directory holding that checkout and its siblings
11
+ * (`api_biz/*`, `fe_adminui`), which exist in local development and in
12
+ * nothing else.
13
+ *
14
+ * `api/` at the head of a manifest path is a CONVENTION of the declaring text,
15
+ * never the name of a directory on disk. GitLab CI checks the repository out
16
+ * under the project name (`infra-mono`), so joining the literal string onto the
17
+ * workspace resolves to a directory that is not there: measured 2026-09-09,
18
+ * `--library --all --workspace "$CI_PROJECT_DIR/.."` died with
19
+ * `[ManifestWorkspace] Workspace root does not carry api/config/services.json`,
20
+ * and every workspace row was reported NOT RUN — a green job that had checked
21
+ * nothing. `scripts/ci/lint-biz-docs.mjs` (§ resolveWorkspaceRelative) already
22
+ * answers this for the doc tree by deriving its `API_ROOT` from the script's own
23
+ * location; the rule here is the same one, for the same reason.
24
+ *
25
+ * So the api checkout is derived from THIS FILE'S location — the directory that
26
+ * carries this package as `shared/connector/<package>` and carries the marker —
27
+ * and the workspace is its parent. An explicit `--workspace` still wins, and
28
+ * fails fast when the root it names holds no api checkout. `oa-validate` asks
29
+ * for nothing else: which SSOT it reads no longer depends on where the shell
30
+ * happened to stand, nor on what the checkout is named (`automation-gates.md`
31
+ * §1.1, predictable).
32
+ *
33
+ * The upward search survives as `workspaceAbove`, for the callers whose subject
34
+ * IS a tree rather than the platform — `oa-sync-template --target <service>`,
35
+ * which writes into that tree, and the in-service `ValidationOrchestrator`,
36
+ * whose subject is the service it was constructed with. Those callers pass
37
+ * `startDir` and get the workspace THAT TREE belongs to. It is a different
38
+ * question, asked explicitly, never a fallback of the other one.
39
+ *
40
+ * An INSTALLED copy (`<service>/node_modules/@onlineapps/conn-orch-validator`)
41
+ * lies in no checkout, so it resolves nothing and the rows that need the SSOT
42
+ * are reported NOT RUN — loudly, never as a pass (`automation-gates.md` §5).
43
+ * That is what a service container has, and it is unchanged by this rule.
44
+ */
45
+
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+
49
+ /** The prefix every manifest path uses for the api checkout. */
50
+ const API_PREFIX = 'api';
51
+
52
+ /** What identifies an api checkout, whatever the checkout is named. */
53
+ const API_MARKER = 'config/services.json';
54
+
55
+ /**
56
+ * The same marker as a workspace-relative path — the shape the manifest and the
57
+ * CLIs write it in. Kept as the exported name it has always had.
58
+ */
59
+ const WORKSPACE_MARKER = `${API_PREFIX}/${API_MARKER}`;
60
+
61
+ /** Where this package sits inside an api checkout, from the checkout down. */
62
+ const PACKAGE_LOCATION = ['shared', 'connector'];
63
+
64
+ /** This package's root: `<checkout>/shared/connector/<package>`. */
65
+ const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
66
+
67
+ const carriesMarker = (dir) => fs.existsSync(path.join(dir, ...API_MARKER.split('/')));
68
+
69
+ /**
70
+ * One directory, one name: the path with every symlink resolved.
71
+ *
72
+ * Two roots reach a run by two different routes. The workspace root is derived
73
+ * from THIS FILE'S location, and node resolves a module's own path through its
74
+ * symlinks; the service or package root is an argument, and `path.resolve()`
75
+ * keeps the spelling the caller typed. On macOS `/var` is a symlink to
76
+ * `/private/var`, so a run over a directory below the system temp gets one root
77
+ * in each spelling — and "is the service inside the workspace", which is a
78
+ * string prefix, then answers no about a service that is plainly inside it.
79
+ *
80
+ * Measured 2026-09-09 over the pre-push export of this repository
81
+ * (`api/tests/scripts/library-uniform-hook.bats`, "over an export of this
82
+ * repository the sibling rows are NOT RUN"): the package root came back
83
+ * `/var/folders/…/export/api/shared/mq-client-core`, the workspace root
84
+ * `/private/var/folders/…/export`, and all six rows were reported `service root
85
+ * is outside the workspace root` — a sentence that was false, over a run whose
86
+ * real findings were never raised. A NOT RUN nobody can act on is the false
87
+ * guarantee of `automation-gates.md` §5.
88
+ *
89
+ * So every root passes through here BEFORE it is compared or printed, and it is
90
+ * one function rather than a `realpathSync` at each call site, because the
91
+ * fourth call site is the one that would forget (`change-discipline.md` § One
92
+ * rail per concern).
93
+ *
94
+ * A path that does not exist is NOT canonicalised into something else: the
95
+ * caller that owns the message about a missing root says it in its own words,
96
+ * so this refuses rather than guesses (`architecture-principles.md` §3).
97
+ *
98
+ * @param {string} dir an absolute or relative path to an existing directory
99
+ * @returns {string} the same directory, with no symlink left in the path
100
+ */
101
+ function canonicalRoot(dir) {
102
+ if (typeof dir !== 'string' || dir.length === 0) {
103
+ throw new Error(`[ManifestWorkspace] Root path is required - canonicalRoot() got ${JSON.stringify(dir)}. `
104
+ + 'Fix: pass the directory to canonicalise.');
105
+ }
106
+
107
+ try {
108
+ return fs.realpathSync(path.resolve(dir));
109
+ } catch (cause) {
110
+ throw new Error(`[ManifestWorkspace] Root does not exist - ${path.resolve(dir)} cannot be resolved, `
111
+ + 'so it cannot be compared with the other roots of this run. '
112
+ + 'Fix: pass an existing directory.', { cause });
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The api checkout this package is part of, or `null` when it is part of none.
118
+ *
119
+ * Both conditions must hold, and neither is guessed at: the package lies where a
120
+ * checkout puts it, and the directory above carries the marker. An installed
121
+ * copy fails the first, a stray copy fails the second.
122
+ *
123
+ * @param {string} packageRoot
124
+ * @returns {string|null}
125
+ */
126
+ function checkoutCarrying(packageRoot) {
127
+ const segments = path.resolve(packageRoot).split(path.sep);
128
+ const parents = segments.slice(-1 - PACKAGE_LOCATION.length, -1);
129
+ if (parents.join('/') !== PACKAGE_LOCATION.join('/')) return null;
130
+
131
+ const candidate = path.resolve(packageRoot, ...PACKAGE_LOCATION.map(() => '..'), '..');
132
+ return carriesMarker(candidate) ? candidate : null;
133
+ }
134
+
135
+ /** The api checkout this run speaks for, decided once, from this file's location. */
136
+ const API_CHECKOUT_ROOT = checkoutCarrying(PACKAGE_ROOT);
137
+
138
+ /**
139
+ * The api checkout of a given workspace: this package's own when the workspace
140
+ * is the one it lives in, and otherwise the checkout the prefix names literally
141
+ * — a fixture workspace, or any tree a `--workspace` points at.
142
+ *
143
+ * @param {string} workspaceRoot
144
+ * @returns {string|null}
145
+ */
146
+ function apiCheckoutOf(workspaceRoot) {
147
+ const root = path.resolve(workspaceRoot);
148
+ if (API_CHECKOUT_ROOT !== null && path.dirname(API_CHECKOUT_ROOT) === root) return API_CHECKOUT_ROOT;
149
+
150
+ const named = path.join(root, API_PREFIX);
151
+ return carriesMarker(named) ? named : null;
152
+ }
153
+
154
+ /**
155
+ * Resolve a manifest path — `api/config/services.json`, `api_biz/*&#47;package.json`
156
+ * — to an absolute path under `workspaceRoot`.
157
+ *
158
+ * @param {string} workspaceRoot
159
+ * @param {string} relative a `/`-separated workspace-relative path
160
+ * @returns {string}
161
+ */
162
+ function resolveWorkspacePath(workspaceRoot, relative) {
163
+ if (typeof workspaceRoot !== 'string' || workspaceRoot.length === 0) {
164
+ throw new Error('[ManifestWorkspace] Workspace root is required - resolveWorkspacePath() got '
165
+ + `${JSON.stringify(workspaceRoot)}. Fix: resolve it first (resolveWorkspaceRoot).`);
166
+ }
167
+
168
+ const segments = String(relative).split('/').filter((segment) => segment.length > 0);
169
+ if (segments[0] !== API_PREFIX) return path.join(workspaceRoot, ...segments);
170
+
171
+ const apiRoot = apiCheckoutOf(workspaceRoot);
172
+ if (apiRoot === null) {
173
+ throw new Error(`[ManifestWorkspace] Workspace root holds no api checkout - ${path.resolve(workspaceRoot)} `
174
+ + `carries no directory with ${API_MARKER}, so "${relative}" resolves to nothing. `
175
+ + 'Fix: pass --workspace <root> holding the api checkout (under any name).');
176
+ }
177
+ return path.join(apiRoot, ...segments.slice(1));
178
+ }
179
+
180
+ /**
181
+ * The workspace a given tree belongs to: the nearest ancestor carrying the
182
+ * marker under the name the prefix writes. This answers a DIFFERENT question
183
+ * from the one above — not "which SSOT does this package speak for" but "which
184
+ * workspace is this tree part of" — and it is the question a run that writes
185
+ * INTO a tree has to ask (`oa-sync-template --target <service>`, and the
186
+ * in-service `ValidationOrchestrator` step, whose subject is the service it was
187
+ * constructed with). A caller states which question it is asking by passing
188
+ * `startDir` or leaving it out; neither is a default of the other.
189
+ *
190
+ * @param {string} startDir
191
+ * @returns {string|null}
192
+ */
193
+ function workspaceAbove(startDir) {
194
+ let current = path.resolve(startDir);
195
+ for (;;) {
196
+ if (fs.existsSync(path.join(current, ...WORKSPACE_MARKER.split('/')))) return current;
197
+ const parent = path.dirname(current);
198
+ if (parent === current) return null;
199
+ current = parent;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * @param {{ explicit?: string|null, startDir?: string|null }} params
205
+ * @returns {string|null} absolute workspace root, or null when neither the named
206
+ * tree nor this package lies in one
207
+ */
208
+ function resolveWorkspaceRoot({ explicit = null, startDir = null } = {}) {
209
+ if (explicit !== null && explicit !== undefined) {
210
+ const resolved = path.resolve(explicit);
211
+ if (apiCheckoutOf(resolved) === null) {
212
+ throw new Error(`[ManifestWorkspace] Workspace root does not carry ${WORKSPACE_MARKER} - ${resolved}, `
213
+ + 'and this package\'s own checkout is not a directory under it. '
214
+ + `Fix: pass --workspace <root> holding the api checkout — under any name, carrying ${API_MARKER}.`);
215
+ }
216
+ return canonicalRoot(resolved);
217
+ }
218
+
219
+ if (startDir !== null && startDir !== undefined) {
220
+ if (typeof startDir !== 'string' || startDir.length === 0) {
221
+ throw new Error('[ManifestWorkspace] Start directory is required - resolveWorkspaceRoot({ startDir }) got '
222
+ + `${JSON.stringify(startDir)}. Fix: pass the tree the run is about, or omit it to use this `
223
+ + 'package\'s own checkout.');
224
+ }
225
+ const above = workspaceAbove(startDir);
226
+ return above === null ? null : canonicalRoot(above);
227
+ }
228
+
229
+ return API_CHECKOUT_ROOT === null ? null : canonicalRoot(path.dirname(API_CHECKOUT_ROOT));
230
+ }
231
+
232
+ module.exports = {
233
+ resolveWorkspaceRoot,
234
+ canonicalRoot,
235
+ workspaceAbove,
236
+ resolveWorkspacePath,
237
+ apiCheckoutOf,
238
+ API_CHECKOUT_ROOT,
239
+ API_MARKER,
240
+ PACKAGE_ROOT,
241
+ WORKSPACE_MARKER
242
+ };
@@ -61,8 +61,10 @@ class MockMQClient {
61
61
  */
62
62
  async publish(queue, message, options = {}) {
63
63
  if (!this._connected) {
64
- // Wording per BaseClient.publish (ConnectionError).
65
- throw new Error('Cannot publish: client is not connected');
64
+ // The mock states the same refusal as BaseClient.publish (ConnectionError) under
65
+ // its own context — it does not copy that text, so the two can never be mistaken
66
+ // for one another in a log.
67
+ throw new Error('[MockMQClient] Cannot publish - client is not connected. Fix: call connect() first');
66
68
  }
67
69
 
68
70
  if (!this.queues[queue]) {
@@ -79,32 +81,13 @@ class MockMQClient {
79
81
  this.queues[queue].push(messageWrapper);
80
82
  this.publishedMessages.push({ queue, message, options, timestamp: Date.now() });
81
83
 
82
- // Auto-reply simulation for workflow task requests:
83
- // When a task is published to service.*.request, publish a deterministic response
84
- // to workflow.<workflow_id>.response so WorkflowTestRunner unit tests can run
85
- // end-to-end with mocked MQ.
86
- if (
87
- typeof queue === 'string' &&
88
- queue.startsWith('service.') &&
89
- queue.endsWith('.request') &&
90
- message &&
91
- typeof message === 'object' &&
92
- message.workflow_id &&
93
- message.step_id
94
- ) {
95
- const responseQueue = `workflow.${message.workflow_id}.response`;
96
- const response = {
97
- step_id: message.step_id,
98
- result: {
99
- processed: true,
100
- operation: message.operation,
101
- input: message.input,
102
- output: { mocked: true }
103
- }
104
- };
105
- // Enqueue response (does not recurse into auto-reply because queue prefix differs)
106
- await this.publish(responseQueue, response);
107
- }
84
+ // The auto-reply that used to sit here — publish to service.*.request, get a
85
+ // synthetic `workflow.<workflow_id>.response` back — existed for exactly one
86
+ // reader, and said so in its own comment: WorkflowTestRunner's unit tests.
87
+ // That rail was deleted on 2026-09-05, and nothing else in the workspace
88
+ // publishes or consumes those queues through this mock (measured: api,
89
+ // api_biz/*, fe_adminui). A mock that invents traffic nobody reads is not
90
+ // harmless — it makes a test look end-to-end when nothing answered.
108
91
 
109
92
  // Trigger consumers if any
110
93
  if (this.consumers[queue]) {
@@ -122,8 +105,8 @@ class MockMQClient {
122
105
  */
123
106
  async consume(queue, callback, options = {}) {
124
107
  if (!this._connected) {
125
- // Wording per BaseClient.consume (ConnectionError).
126
- throw new Error('Cannot consume: client is not connected');
108
+ // Same shape as publish() above, and deliberately not a copy of BaseClient.consume.
109
+ throw new Error('[MockMQClient] Cannot consume - client is not connected. Fix: call connect() first');
127
110
  }
128
111
 
129
112
  this.consumers[queue] = {
@@ -18,7 +18,8 @@ class MockRegistry {
18
18
  const { name, version, url, healthCheck, openapi } = serviceData;
19
19
 
20
20
  if (!name) {
21
- throw new Error('Service name is required');
21
+ throw new Error('[MockRegistry] Service name is required - Expected serviceData.name to be a '
22
+ + 'non-empty string. Fix: pass { name } to register().');
22
23
  }
23
24
 
24
25
  this.services[name] = {
@@ -60,7 +61,8 @@ class MockRegistry {
60
61
  */
61
62
  async heartbeat(serviceName) {
62
63
  if (!this.services[serviceName]) {
63
- throw new Error(`Service ${serviceName} not registered`);
64
+ throw new Error(`[MockRegistry] Service ${serviceName} not registered - Expected a prior `
65
+ + 'register() call for that name. Fix: register the service before sending a heartbeat.');
64
66
  }
65
67
 
66
68
  this.heartbeats[serviceName] = {
@@ -59,7 +59,8 @@ class MockStorage {
59
59
  */
60
60
  async get(bucket, key) {
61
61
  if (!this.buckets[bucket] || !this.buckets[bucket][key]) {
62
- throw new Error(`Object not found: ${bucket}/${key}`);
62
+ throw new Error(`[MockStorage] Object not found: ${bucket}/${key} - Expected the object to have `
63
+ + 'been stored first. Fix: put(bucket, key, content) in the test setup before reading it.');
63
64
  }
64
65
 
65
66
  const obj = this.buckets[bucket][key];
@@ -128,7 +129,8 @@ class MockStorage {
128
129
  */
129
130
  async getMetadata(bucket, key) {
130
131
  if (!this.metadata[bucket] || !this.metadata[bucket][key]) {
131
- throw new Error(`Metadata not found: ${bucket}/${key}`);
132
+ throw new Error(`[MockStorage] Metadata not found: ${bucket}/${key} - Expected metadata written `
133
+ + 'by put(). Fix: store the object with put(bucket, key, content) before reading its metadata.');
132
134
  }
133
135
  return this.metadata[bucket][key];
134
136
  }