@onlineapps/conn-orch-validator 7.0.0 → 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 +2558 -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 +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,474 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * `npx oa-validate [serviceRoot]` — the uniform of a biz service, and
6
+ * `npx oa-validate --library [packageDir]` — the uniform of a shared library,
7
+ * checked at any moment in any repository (confirmation `biz-service-manifest`
8
+ * 001 §3.1, 002 §13.1). One table, exit 0 or 1.
9
+ *
10
+ * There is deliberately NO flag that points the run at another manifest and no
11
+ * flag that skips a row: the manifest ships inside this package and its version
12
+ * is the pin (`.claude/rules/automation-gates.md` §1 requirement 5 — one path,
13
+ * unbypassable). `--workspace` is not such a flag: it says WHERE the SSOTs the
14
+ * manifest references live, and where they cannot be found the rows that need
15
+ * them are printed NOT RUN, never passed.
16
+ *
17
+ * A service run also RECORDS its verdict, in `<serviceRoot>/ci/deployability.json`
18
+ * — the machine half a deploy gate reads (`deployable && complete`). Until d.232
19
+ * the only writer was step 7 of the boot validation, which runs inside the
20
+ * service container, where the workspace SSOTs are absent: every signal that
21
+ * existed therefore said `complete: false`, so the gate could never see a proven
22
+ * tree, while the run that IS complete — this CLI, from the api checkout — left
23
+ * nothing behind. Both callers share one builder and one writer
24
+ * (`manifest/deployabilitySignal.js`): one file shape, not two.
25
+ *
26
+ * @see api/docs/governance/confirmations/biz-service-manifest.md §3, §4
27
+ */
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+
32
+ const { loadManifest, DEFAULT_MANIFEST_PATH, LIBRARY_MANIFEST_PATH } = require('../manifest/loadManifest');
33
+ const { runManifest } = require('../manifest/runManifest');
34
+ const {
35
+ resolveWorkspaceRoot, canonicalRoot, API_MARKER, PACKAGE_ROOT
36
+ } = require('../manifest/workspaceRoot');
37
+ const {
38
+ renderReport, renderJson, exitCodeFor, blockingOf, verdictOf, describeClearOutcome
39
+ } = require('../manifest/report');
40
+ const { buildDeployabilitySignal, writeDeployabilitySignal } = require('../manifest/deployabilitySignal');
41
+ const { discoverBearers } = require('../manifest/discovery');
42
+ const { collectRows } = require('../manifest/walk');
43
+ const { CHECK_REGISTRY } = require('../manifest/checks');
44
+ const { declaredCategory, readPackage } = require('../manifest/checks/libraryContext');
45
+
46
+ /** Exit code for a run that could not start at all — distinct from a finding. */
47
+ const USAGE_EXIT = 2;
48
+
49
+ /**
50
+ * This package's own version — the manifest ships inside it, so the manifest
51
+ * version IS this version (confirmation 004 § point 2).
52
+ *
53
+ * Read when the signal is written, not when the module loads: a `require` at the
54
+ * top would turn a copy of the engine planted without its `package.json` into a
55
+ * MODULE_NOT_FOUND stack trace before a single row ran, and the reader would be
56
+ * told nothing about what is missing (`architecture-principles.md` §5). Same
57
+ * shape, same reason, as `sync/readmeLocation.js` § selfName.
58
+ *
59
+ * @returns {string} the version string
60
+ */
61
+ function validatorVersion() {
62
+ const pkgPath = path.join(__dirname, '..', '..', 'package.json');
63
+ if (!fs.existsSync(pkgPath)) {
64
+ throw new Error(`[oa-validate] This copy of the engine carries no package.json - ${pkgPath} is missing, `
65
+ + 'so the deployability signal cannot say which manifest version measured the service. '
66
+ + 'Fix: run the engine from an installed package or a full checkout of '
67
+ + 'api/shared/connector/conn-orch-validator.');
68
+ }
69
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
70
+ }
71
+
72
+ /** The discovery block of the library uniform — one uniform, one block. */
73
+ const LIBRARY_BLOCK = 'library';
74
+
75
+ /** The one scope whose `where` is read from the package root rather than the workspace. */
76
+ const PACKAGE_RELATIVE_SCOPE = 'service';
77
+
78
+ /** What the category column shows for a package that declares none. */
79
+ const NO_CATEGORY = '-';
80
+
81
+ const USAGE = `
82
+ Usage:
83
+ oa-validate [serviceRoot] [--workspace <root>] [--json]
84
+ oa-validate --workspace <root> [--json]
85
+ oa-validate --library <packageDir> [--workspace <root>] [--json]
86
+ oa-validate --library --all [--workspace <root>] [--json]
87
+
88
+ Checks a repository against the uniform manifest shipped in
89
+ @onlineapps/conn-orch-validator. The manifest version IS this package's
90
+ version, so the pin decides which shape the bearer is measured against.
91
+
92
+ Three modes, decided by what the run is pointed at:
93
+ service — a service root (given, or the current directory) is checked
94
+ against the biz-service uniform. Rows that look at the whole
95
+ workspace report only what lies under that service; a
96
+ neighbour's finding belongs in the neighbour's table.
97
+ workspace — NO service root and --workspace <root>: the workspace-wide rows
98
+ report in full, and every per-service row is printed NOT RUN,
99
+ because there is no repository to look in.
100
+ library — --library: the library uniform instead, over one package
101
+ directory or, with --all, over every package its discovery
102
+ pattern finds.
103
+
104
+ Arguments:
105
+ serviceRoot Biz service repository to check. Default: the current
106
+ directory, unless --workspace is given without a service
107
+ root, which selects the workspace mode above.
108
+
109
+ Options:
110
+ --library [packageDir] Check a shared library against the library uniform instead.
111
+ Give exactly one of: a package directory, or --all.
112
+ --all With --library: every package the uniform's discovery
113
+ pattern finds under the workspace root.
114
+ --workspace <root> Directory holding the api checkout and its siblings
115
+ (api_biz/*), where the manifest's "from" references are
116
+ resolved. Default: the parent of the checkout THIS package
117
+ lies in — the checkout is found from the package's own
118
+ location and carries ${API_MARKER}, whatever it is named.
119
+ No such checkout (an installed copy, a service container)
120
+ → the rows that need it are reported NOT RUN, never as
121
+ passing.
122
+ --json Print the run as JSON instead of the table.
123
+ --help Print this and exit 0.
124
+
125
+ Output:
126
+ A service run writes <serviceRoot>/ci/deployability.json — the verdict of the
127
+ table above in the shape a deploy gate reads (deployable && complete). The
128
+ workspace and library modes write no such file: the signal is a verdict about
129
+ one service tree, and neither run has one.
130
+
131
+ Exit codes:
132
+ 0 no blocking finding (service: boot, deploy | library: publish)
133
+ 1 at least one such finding — the service is not deployable, the library is not publishable
134
+ 2 the run could not start (bad path, broken manifest, no target), or a service
135
+ run could not record its verdict
136
+ `;
137
+
138
+ function parseArgs(argv) {
139
+ const parsed = { serviceRoot: null, workspace: null, json: false, help: false, library: false, all: false };
140
+ const args = [...argv];
141
+
142
+ while (args.length > 0) {
143
+ const token = args.shift();
144
+ if (token === '--help' || token === '-h') {
145
+ parsed.help = true;
146
+ } else if (token === '--json') {
147
+ parsed.json = true;
148
+ } else if (token === '--library') {
149
+ parsed.library = true;
150
+ } else if (token === '--all') {
151
+ parsed.all = true;
152
+ } else if (token === '--workspace') {
153
+ const value = args.shift();
154
+ if (!value || value.startsWith('--')) {
155
+ throw new Error('[oa-validate] Option --workspace has no value - it names the directory holding '
156
+ + 'api/ and api_biz/. Fix: oa-validate [serviceRoot] --workspace <root>');
157
+ }
158
+ parsed.workspace = value;
159
+ } else if (token.startsWith('--')) {
160
+ throw new Error(`[oa-validate] Unknown option - ${token}. Fix: run oa-validate --help for the options.`);
161
+ } else if (parsed.serviceRoot === null) {
162
+ parsed.serviceRoot = token;
163
+ } else {
164
+ throw new Error(`[oa-validate] Unexpected argument - ${token}. `
165
+ + 'Fix: exactly one service root is checked per run.');
166
+ }
167
+ }
168
+
169
+ if (parsed.all && !parsed.library) {
170
+ throw new Error('[oa-validate] Option --all needs --library - a service is checked one repository at a time. '
171
+ + 'Fix: oa-validate --library --all');
172
+ }
173
+ if (parsed.library && parsed.all && parsed.serviceRoot !== null) {
174
+ throw new Error(`[oa-validate] --library --all takes no package directory - got ${parsed.serviceRoot}. `
175
+ + 'Fix: drop the argument, or run oa-validate --library <packageDir> for that one package.');
176
+ }
177
+ if (parsed.library && !parsed.all && parsed.serviceRoot === null && !parsed.help) {
178
+ throw new Error('[oa-validate] --library needs a target - a package directory, or --all for every package '
179
+ + 'the uniform finds. Fix: oa-validate --library <packageDir> | oa-validate --library --all');
180
+ }
181
+
182
+ return parsed;
183
+ }
184
+
185
+ /**
186
+ * Which rows report a path relative to the PACKAGE root, by row id.
187
+ *
188
+ * Of the three scopes (`manifestShape.js` § CHECK_SCOPES) only `service` does:
189
+ * a `scope: workspace` or `scope: bearer` check already writes the path from the
190
+ * workspace root, precisely so that two packages failing the same row are told
191
+ * apart (`libraryContext.js` § whereOf). The aggregate run below therefore puts
192
+ * the package back in front of a `service` row's path and leaves the other two
193
+ * alone. Asking the registry rather than listing ids keeps this true when a row
194
+ * changes scope — `L-CONSUMER` and `L-TOOLING` became `bearer` in d.223, and
195
+ * nothing here had to move.
196
+ *
197
+ * @param {object} manifest parsed manifest
198
+ * @returns {Set<string>} the ids whose `where` is package-relative
199
+ */
200
+ function packageRelativeRowIds(manifest) {
201
+ const ids = new Set();
202
+ for (const { row } of collectRows(manifest)) {
203
+ if (CHECK_REGISTRY[row.check].scope === PACKAGE_RELATIVE_SCOPE) ids.add(row.id);
204
+ }
205
+ return ids;
206
+ }
207
+
208
+ /**
209
+ * One package, checked. `runManifest` already places every finding for this
210
+ * package: a `service` row's path is package-relative, a `bearer` row runs
211
+ * against this package alone, and a `workspace` row's answer is filtered down to
212
+ * what lies under this package's root. Nothing is filtered here.
213
+ *
214
+ * @param {{ manifest: object, packageRoot: string, workspaceRoot: string|null }} params
215
+ * @returns {{ findings: Array<object>, notRun: Array<object> }}
216
+ */
217
+ function checkOnePackage({ manifest, packageRoot, workspaceRoot }) {
218
+ const result = runManifest({ manifest, serviceRoot: packageRoot, workspaceRoot });
219
+ const category = declaredCategory(readPackage(packageRoot).json) || NO_CATEGORY;
220
+
221
+ return {
222
+ findings: result.findings.map((finding) => ({ ...finding, category })),
223
+ notRun: result.notRun
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Every package of the library uniform, checked. Two kinds of run, because the
229
+ * manifest asks two kinds of question:
230
+ *
231
+ * - one run PER PACKAGE answers the per-package rows, and its `scope: service`
232
+ * findings get the package put back in front of their path;
233
+ * - one WHOLE-SET run answers the rows that are about the set — `U-ORPHAN`
234
+ * reports a name in the SSOT with no package on disk, and that finding
235
+ * belongs to no package's root, so no package's run would ever show it.
236
+ *
237
+ * The two overlap on the whole-set rows, which every package's run answers
238
+ * identically; a finding is kept once, keyed by its full identity, so nothing
239
+ * but an exact repetition collapses. The whole-set run goes last so that a
240
+ * finding a package already claimed keeps that package's category.
241
+ *
242
+ * Discovery is the manifest's, never a second walk: the same pattern and the
243
+ * same exclusions that decide who wears the uniform decide who is measured
244
+ * against it.
245
+ *
246
+ * @param {{ manifest: object, workspaceRoot: string }} params
247
+ * @returns {{ findings: Array<object>, notRun: Array<object>, packages: number }}
248
+ */
249
+ function checkEveryPackage({ manifest, workspaceRoot }) {
250
+ const block = manifest.discovery[LIBRARY_BLOCK];
251
+ const { bearers } = discoverBearers({ block, workspaceRoot });
252
+ const packageRelative = packageRelativeRowIds(manifest);
253
+
254
+ const findings = [];
255
+ const seen = new Set();
256
+ const notRun = [];
257
+
258
+ const keep = (finding) => {
259
+ const key = `${finding.id}|${finding.where}|${finding.what}`;
260
+ if (seen.has(key)) return;
261
+ seen.add(key);
262
+ findings.push(finding);
263
+ };
264
+
265
+ for (const bearer of bearers) {
266
+ const one = checkOnePackage({ manifest, packageRoot: bearer.dir, workspaceRoot });
267
+ for (const finding of one.findings) {
268
+ const where = packageRelative.has(finding.id)
269
+ ? `${bearer.relativeDir}/${finding.where}`
270
+ : finding.where;
271
+ keep({ ...finding, where });
272
+ }
273
+ for (const skipped of one.notRun) {
274
+ if (!notRun.some((already) => already.id === skipped.id)) notRun.push(skipped);
275
+ }
276
+ }
277
+
278
+ // The whole-set run has no package root, so its `scope: service` rows are NOT
279
+ // RUN by construction — an artefact of this aggregate, not a gap in coverage
280
+ // (every bearer above ran them). Its `bearer` rows repeat, package by package,
281
+ // exactly what the loop above already collected, and `keep` drops the
282
+ // repetition. Only its findings are taken.
283
+ for (const finding of runManifest({ manifest, workspaceRoot }).findings) {
284
+ keep({ ...finding, category: NO_CATEGORY });
285
+ }
286
+
287
+ return { findings, notRun, packages: bearers.length };
288
+ }
289
+
290
+ /** The columns of the library table (002 §13.1). */
291
+ const LIBRARY_COLUMNS = Object.freeze(['id', 'category', 'where', 'what', 'fix']);
292
+
293
+ function renderLibraryTable(findings) {
294
+ const widths = LIBRARY_COLUMNS.map((column) => Math.max(
295
+ column.length,
296
+ ...findings.map((finding) => String(finding[column]).length)
297
+ ));
298
+ const line = (cells) => cells
299
+ .map((cell, index) => (index === cells.length - 1 ? String(cell) : String(cell).padEnd(widths[index])))
300
+ .join(' ')
301
+ .trimEnd();
302
+
303
+ return [line(LIBRARY_COLUMNS), ...findings.map((f) => line(LIBRARY_COLUMNS.map((column) => f[column])))];
304
+ }
305
+
306
+ /**
307
+ * The library report. Which severities block and which two words the verdict is
308
+ * printed with come from the LIBRARY manifest, exactly as the service report
309
+ * takes them from the service one: a library has no boot and no deploy of its
310
+ * own (002 §10), and a second list in this file was the duplicate that made a
311
+ * service verdict offer `publish` (`api/shared/TODO.md` §0.2b-15).
312
+ *
313
+ * @param {object} run what checkOnePackage or checkEveryPackage returned, plus its heading,
314
+ * the uniform's blockingSeverities and its verdict words
315
+ * @returns {string} the whole report, newline-terminated
316
+ */
317
+ function renderLibraryReport(run) {
318
+ const blocking = blockingOf(run);
319
+ const verdict = verdictOf(run);
320
+
321
+ const lines = [`Library conformance — uniform ${run.uniform}`, ...run.heading, ''];
322
+
323
+ for (const skipped of run.notRun) lines.push(`NOT RUN ${skipped.id} — ${skipped.reason}`);
324
+ if (run.notRun.length > 0) lines.push('');
325
+
326
+ if (run.findings.length > 0) {
327
+ lines.push(...renderLibraryTable(run.findings), '');
328
+ lines.push('Doc:');
329
+ const seen = new Set();
330
+ for (const finding of run.findings) {
331
+ if (seen.has(finding.id)) continue;
332
+ seen.add(finding.id);
333
+ lines.push(` ${finding.id} ${finding.doc}`);
334
+ }
335
+ lines.push('');
336
+ }
337
+
338
+ if (blocking.length > 0) {
339
+ lines.push(`${verdict.blocked} — ${blocking.length} finding(s) of severity `
340
+ + `${run.blockingSeverities.join('|')}`);
341
+ } else {
342
+ // The same sentence the service report prints, from the same owner: a clean
343
+ // verdict names what did not run (`report.js` § describeClearOutcome).
344
+ lines.push(`${verdict.clear} — ${describeClearOutcome(run)}`);
345
+ }
346
+
347
+ return `${lines.join('\n')}\n`;
348
+ }
349
+
350
+ /**
351
+ * The library modes. Returns the exit code; printing happens here because the
352
+ * library table has its own columns (002 §13.1) and the service report has its
353
+ * own owner.
354
+ *
355
+ * @param {object} options parsed arguments
356
+ * @returns {number} exit code
357
+ */
358
+ function runLibrary(options) {
359
+ const manifest = loadManifest(LIBRARY_MANIFEST_PATH);
360
+
361
+ if (options.all) {
362
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace });
363
+ if (workspaceRoot === null) {
364
+ throw new Error(`[oa-validate] --library --all needs a workspace root - this package lies at ${PACKAGE_ROOT}, `
365
+ + `which is not shared/connector/<package> of a checkout carrying ${API_MARKER}. `
366
+ + 'Fix: pass --workspace <root>.');
367
+ }
368
+ const run = checkEveryPackage({ manifest, workspaceRoot });
369
+ const report = {
370
+ uniform: manifest.uniform,
371
+ blockingSeverities: manifest.blocking_severities,
372
+ verdict: manifest.verdict,
373
+ heading: [`Workspace root: ${workspaceRoot}`, `Packages: ${run.packages}`],
374
+ ...run
375
+ };
376
+ process.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderLibraryReport(report));
377
+ return blockingOf(report).length > 0 ? 1 : 0;
378
+ }
379
+
380
+ const given = path.resolve(options.serviceRoot);
381
+ if (!fs.existsSync(given) || !fs.statSync(given).isDirectory()) {
382
+ throw new Error(`[oa-validate] Package directory not found - ${given}. `
383
+ + 'Fix: pass an existing package directory.');
384
+ }
385
+ // The root the run REPORTS is the canonical one, the same spelling the
386
+ // workspace root is resolved in — so the heading and the engine's own
387
+ // comparisons speak about one directory (`workspaceRoot.js` § canonicalRoot).
388
+ const packageRoot = canonicalRoot(given);
389
+
390
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace });
391
+ const run = checkOnePackage({ manifest, packageRoot, workspaceRoot });
392
+ const report = {
393
+ uniform: manifest.uniform,
394
+ blockingSeverities: manifest.blocking_severities,
395
+ verdict: manifest.verdict,
396
+ heading: [
397
+ `Package root: ${packageRoot}`,
398
+ `Workspace root: ${workspaceRoot === null ? 'NOT RESOLVED' : workspaceRoot}`
399
+ ],
400
+ ...run
401
+ };
402
+ process.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderLibraryReport(report));
403
+ return blockingOf(report).length > 0 ? 1 : 0;
404
+ }
405
+
406
+ function main(argv) {
407
+ const options = parseArgs(argv);
408
+
409
+ if (options.help) {
410
+ process.stdout.write(`${USAGE.trim()}\n`);
411
+ return 0;
412
+ }
413
+
414
+ if (options.library) return runLibrary(options);
415
+
416
+ const manifest = loadManifest(DEFAULT_MANIFEST_PATH);
417
+
418
+ // `--workspace` with no service root IS the workspace mode (lead decision,
419
+ // api/shared/TODO.md §0.2b-8 point 2): the home of the workspace-wide rows,
420
+ // the run that sees every orphan rather than only this service's own.
421
+ if (options.serviceRoot === null && options.workspace !== null) {
422
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace });
423
+ const workspaceResult = runManifest({ manifest, workspaceRoot });
424
+
425
+ process.stdout.write(options.json ? renderJson(workspaceResult) : renderReport(workspaceResult));
426
+ return exitCodeFor(workspaceResult);
427
+ }
428
+
429
+ const given = path.resolve(options.serviceRoot === null ? process.cwd() : options.serviceRoot);
430
+ if (!fs.existsSync(given) || !fs.statSync(given).isDirectory()) {
431
+ throw new Error(`[oa-validate] Service root not found - ${given}. `
432
+ + 'Fix: pass an existing repository directory, or run the command inside the service repository.');
433
+ }
434
+ const serviceRoot = canonicalRoot(given);
435
+
436
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace });
437
+ const result = runManifest({ manifest, serviceRoot, workspaceRoot });
438
+
439
+ process.stdout.write(options.json ? renderJson(result) : renderReport(result));
440
+
441
+ // Order as in step 7 (`ValidationOrchestrator.js` § validateManifestConformance):
442
+ // the report is printed BEFORE the file is written, so a service root that
443
+ // cannot be written to still leaves the human-readable verdict behind. The
444
+ // write has no switch — the run that produces the verdict is the run that
445
+ // records it (`automation-gates.md` §1 requirement 5) — so an unwritable root
446
+ // ends the run with the writer's own message and the usage exit code, never
447
+ // with an exit 0 whose signal nobody wrote.
448
+ writeDeployabilitySignal({
449
+ serviceRoot,
450
+ signal: buildDeployabilitySignal({ result, validatorVersion: validatorVersion() })
451
+ });
452
+
453
+ return exitCodeFor(result);
454
+ }
455
+
456
+ if (require.main === module) {
457
+ try {
458
+ process.exitCode = main(process.argv.slice(2));
459
+ } catch (error) {
460
+ process.stderr.write(`${error.message}\n`);
461
+ process.exitCode = USAGE_EXIT;
462
+ }
463
+ }
464
+
465
+ module.exports = {
466
+ main,
467
+ parseArgs,
468
+ runLibrary,
469
+ checkOnePackage,
470
+ checkEveryPackage,
471
+ renderLibraryReport,
472
+ LIBRARY_COLUMNS,
473
+ USAGE_EXIT
474
+ };
@@ -173,7 +173,8 @@ function createMyTests(testsDir, options = {}) {
173
173
  console.log(ServiceStructureValidator.formatResult(result));
174
174
 
175
175
  if (!result.valid) {
176
- throw new Error('Structure validation failed');
176
+ throw new Error('[createMyTests] Service structure validation failed - Expected the layout '
177
+ + 'ServiceStructureValidator requires. Fix: correct the errors printed above, then rerun.');
177
178
  }
178
179
 
179
180
  // 2. Load config
@@ -32,6 +32,48 @@ const ServiceReadinessValidator = require('../ServiceReadinessValidator');
32
32
  const MockRegistry = require('../mocks/MockRegistry');
33
33
  const { ServiceStructureValidator } = require('../validators/ServiceStructureValidator');
34
34
  const { MIN_COOKBOOK_FORMAT_VERSION } = require('../utils/cookbookFormat');
35
+ const { HANDLER_REF_PATTERN, parseHandlerRef, resolveHandlerModule } = require('../utils/handlerRef');
36
+
37
+ /**
38
+ * Resolve every handler an `operations.json` declares, the way the runtime does.
39
+ *
40
+ * The readiness suite used to match the ref against a regex and stop there, so
41
+ * an operation naming a module nobody shipped — or an export that does not
42
+ * exist — passed readiness in CI and failed at boot, where
43
+ * `HandlerRegistry.validate()` resolves every declared operation and fail-fasts
44
+ * (api/docs/biz/10-invocation/handler-dispatch.md § `HandlerRegistry` —
45
+ * resolution rules). The verdict was right in the end; it arrived one
46
+ * environment too late.
47
+ *
48
+ * Fail-fast on the first unusable handler, naming the operation: a service with
49
+ * six operations must not report "something is wrong" and leave the reader to
50
+ * find which.
51
+ *
52
+ * @param {string} serviceRoot - absolute path to the service root (holds `src/`)
53
+ * @param {Object} operationsFlat - the flat `{ name: spec }` map
54
+ * @returns {Array<{operation: string, modulePath: string, exportName: string, handler: Function}>}
55
+ * @throws {Error} naming the operation and the reason its handler is unusable
56
+ */
57
+ function resolveDeclaredHandlers(serviceRoot, operationsFlat) {
58
+ const resolved = [];
59
+
60
+ for (const [operation, spec] of Object.entries(operationsFlat || {})) {
61
+ try {
62
+ parseHandlerRef(spec && spec.handler);
63
+ const { modulePath, exportName, handler } = resolveHandlerModule({
64
+ serviceRoot,
65
+ handlerRef: spec.handler
66
+ });
67
+ resolved.push({ operation, modulePath, exportName, handler });
68
+ } catch (error) {
69
+ throw new Error(
70
+ `[ServiceReadiness] Operation '${operation}' has an unusable handler - ${error.message}`
71
+ );
72
+ }
73
+ }
74
+
75
+ return resolved;
76
+ }
35
77
 
36
78
  /**
37
79
  * Create service readiness integration test suite
@@ -71,7 +113,8 @@ function createServiceReadinessTests(testsDir, options = {}) {
71
113
  // FAIL FAST if structure is invalid
72
114
  if (!structureResult.valid) {
73
115
  throw new Error(
74
- `Service structure validation failed. Fix errors above before running tests.`
116
+ '[createServiceReadinessTests] Service structure validation failed - Expected the layout '
117
+ + 'ServiceStructureValidator requires. Fix: correct the errors it printed above, then rerun.'
75
118
  );
76
119
  }
77
120
 
@@ -188,17 +231,30 @@ function createServiceReadinessTests(testsDir, options = {}) {
188
231
  expect(Object.keys(operationsFlat).length).toBeGreaterThan(0);
189
232
 
190
233
  const validScopes = ['platform', 'tenant', 'workspace'];
191
- const handlerPattern = /^handlers\/[a-zA-Z0-9_/-]+#[a-zA-Z_][a-zA-Z0-9_]*$/;
192
234
 
193
235
  for (const [, operationSpec] of Object.entries(operationsFlat)) {
194
236
  expect(operationSpec).toHaveProperty('handler');
195
237
  expect(operationSpec).toHaveProperty('bundle_scope');
196
- expect(operationSpec.handler).toMatch(handlerPattern);
238
+ expect(operationSpec.handler).toMatch(HANDLER_REF_PATTERN);
197
239
  expect(validScopes).toContain(operationSpec.bundle_scope);
198
240
  expect(operationSpec).not.toHaveProperty('endpoint');
199
241
  expect(operationSpec).not.toHaveProperty('method');
200
242
  }
201
243
  });
244
+
245
+ test('every declared handler resolves to an exported function', () => {
246
+ // The shape of the ref says nothing about whether the module is in the
247
+ // image. Boot resolves every one of them and refuses to start; this test
248
+ // asks the same question one environment earlier, by the same rule.
249
+ const operationsFlat = operations.operations || operations;
250
+
251
+ const resolved = resolveDeclaredHandlers(serviceRoot, operationsFlat);
252
+
253
+ expect(resolved).toHaveLength(Object.keys(operationsFlat).length);
254
+ for (const entry of resolved) {
255
+ expect(typeof entry.handler).toBe('function');
256
+ }
257
+ });
202
258
  });
203
259
  }
204
260
 
@@ -231,4 +287,4 @@ function generateMockInput(inputSchema) {
231
287
  return mockData;
232
288
  }
233
289
 
234
- module.exports = { createServiceReadinessTests };
290
+ module.exports = { createServiceReadinessTests, resolveDeclaredHandlers };
package/src/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * @module @onlineapps/conn-orch-validator
5
5
  * @description Service validation framework using the operations.json contract.
6
6
  *
7
- * Production entry point: {@link ValidationOrchestrator} (6-step pre-validation
7
+ * Production entry point: {@link ValidationOrchestrator} (7-step pre-validation
8
8
  * driven by operations.json — OpenAPI path iteration is NOT supported).
9
9
  *
10
10
  * @see /api/docs/biz/30-operations/registration-wire.md §3 (operations.json)
@@ -27,7 +27,6 @@ const ValidationProofGenerator = require('./validators/ValidationProofGenerator'
27
27
  // ./ValidationOrchestrator below requires the same module unguarded and took
28
28
  // the load down anyway. All it added was a misleading line before the real one.
29
29
  const CookbookTestRunner = require('./CookbookTestRunner');
30
- const WorkflowTestRunner = require('./WorkflowTestRunner');
31
30
  const BizCiGateContract = require('./utils/bizCiGateContract');
32
31
 
33
32
  const ServiceReadinessValidator = require('./ServiceReadinessValidator');
@@ -40,7 +39,6 @@ module.exports = {
40
39
  get MockRegistry() { return MockRegistry; },
41
40
  get MockStorage() { return MockStorage; },
42
41
 
43
- get WorkflowTestRunner() { return WorkflowTestRunner; },
44
42
  get CookbookTestUtils() { return CookbookTestUtils; },
45
43
  get ServiceReadinessValidator() { return ServiceReadinessValidator; },
46
44
  get CookbookTestRunner() { return CookbookTestRunner; },
@@ -52,6 +50,38 @@ module.exports = {
52
50
  // env, never chosen per test. Enforced by deploy-contract R8.
53
51
  get getTestNamespace() { return require('./utils/testNamespace').getTestNamespace; },
54
52
 
53
+ // The isolation half of the same boundary: a namespace the test must NOT see
54
+ // rows from, taken from the allowed classes rather than from a literal or
55
+ // from `tenant + 1` (99 + 1 = 100 is the LIVE tenant). Accepted by R8 exactly
56
+ // like getTestNamespace().
57
+ get getForeignTestNamespace() { return require('./utils/testNamespace').getForeignTestNamespace; },
58
+
59
+ // The same boundary asked about an id the caller already holds: an
60
+ // operational script is handed a tenant on the command line and must know
61
+ // whether it may touch it at all. Throws the refusal getTestNamespace()
62
+ // throws, rendered from one place with the caller's own name for the value,
63
+ // and returns the id normalized. The allowed classes are NOT exported — a
64
+ // copy of the boundary is a second boundary, and the refusal names them.
65
+ get assertAllowedTenant() { return require('./utils/testNamespace').assertAllowedTenant; },
66
+
67
+ // The throwaway schema an integration suite builds from the service's own
68
+ // declaration: one build for every DB-owning service, sharing every decision
69
+ // with the CI gate's `buildSchema` except the one that differs — the test's
70
+ // schema is dropped and recreated, the service's is never touched. A file
71
+ // that imports it is what deploy-contract R8 permits, in place of the
72
+ // `CREATE DATABASE` text heuristic it used to read.
73
+ get createThrowawaySchema() { return require('./utils/throwawaySchema').createThrowawaySchema; },
74
+
75
+ // What a v3 handler reference is, and what resolving one means — one
76
+ // definition for the package, and the one `service-wrapper`'s HandlerLoader
77
+ // can take over as an internal swap: `resolveHandlerModule` states exactly
78
+ // the rule it implements today (containment, require, export is a function),
79
+ // while the stricter declared form stays in `parseHandlerRef`, so adopting it
80
+ // narrows nothing the wrapper accepts.
81
+ get HANDLER_REF_PATTERN() { return require('./utils/handlerRef').HANDLER_REF_PATTERN; },
82
+ get parseHandlerRef() { return require('./utils/handlerRef').parseHandlerRef; },
83
+ get resolveHandlerModule() { return require('./utils/handlerRef').resolveHandlerModule; },
84
+
55
85
  get createServiceReadinessTests() { return createServiceReadinessTests; },
56
86
  get BizCiGateContract() { return BizCiGateContract; },
57
87
  get IntegrationRun() { return require('./utils/integrationRun'); },