@onlineapps/conn-orch-validator 6.0.1 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +2591 -2
  2. package/README.md +1075 -7
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +422 -104
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +78 -42
  10. package/src/ValidationOrchestrator.js +298 -75
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +12 -2
  16. package/src/helpers/createServiceReadinessTests.js +75 -6
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +213 -13
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +21 -20
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +290 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -0,0 +1,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
+ };
@@ -126,7 +126,16 @@ const operations = JSON.parse(fs.readFileSync(path.join(serviceRoot, 'config/ser
126
126
 
127
127
  // Extract metadata
128
128
  const serviceName = config.service.name;
129
- const serviceVersion = config.service.version;
129
+
130
+ // The version comes from package.json, NEVER from config.json. ConfigLoader
131
+ // (shared/connector/service-wrapper/src/ConfigLoader.js) assigns
132
+ // `config.service.version` from package.json unconditionally on every load, and
133
+ // does it BEFORE `${VAR}` placeholders are resolved — so `service.version` in
134
+ // config.json never reaches a running service under any value, and reading it
135
+ // here yielded the literal string `${npm_package_version}`.
136
+ const serviceVersion = JSON.parse(
137
+ fs.readFileSync(path.join(serviceRoot, 'package.json'), 'utf-8')
138
+ ).version;
130
139
  ```
131
140
 
132
141
  Nothing loads `src/app.js` and nothing reads a health endpoint: both belonged to
@@ -164,7 +173,8 @@ function createMyTests(testsDir, options = {}) {
164
173
  console.log(ServiceStructureValidator.formatResult(result));
165
174
 
166
175
  if (!result.valid) {
167
- 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.');
168
178
  }
169
179
 
170
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
 
@@ -84,9 +127,22 @@ function createServiceReadinessTests(testsDir, options = {}) {
84
127
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
85
128
  const operations = JSON.parse(fs.readFileSync(operationsPath, 'utf-8'));
86
129
 
87
- // Extract metadata from config
88
130
  const serviceName = config.service.name;
89
- const serviceVersion = config.service.version;
131
+
132
+ // The version comes from package.json, never from config.json — the same
133
+ // single source of truth the runtime uses:
134
+ //
135
+ // shared/connector/service-wrapper/src/ConfigLoader.js:239
136
+ // config.service.version = this.loadPackageVersion(basePath);
137
+ //
138
+ // That assignment is unconditional and happens BEFORE `${VAR}` placeholders
139
+ // are resolved, so `service.version` in config.json never reaches a running
140
+ // service under any value. Reading it here was reading a dead key, and the
141
+ // value it yielded was the literal string `${npm_package_version}`: the
142
+ // placeholder that no expansion pass ever touches, handed to
143
+ // ServiceReadinessValidator, which used to accept it for being truthy.
144
+ const packagePath = path.join(serviceRoot, 'package.json');
145
+ const serviceVersion = JSON.parse(fs.readFileSync(packagePath, 'utf-8')).version;
90
146
 
91
147
  describe(`${serviceName} Service Readiness @integration`, () => {
92
148
  test('service passes readiness validation', async () => {
@@ -175,17 +231,30 @@ function createServiceReadinessTests(testsDir, options = {}) {
175
231
  expect(Object.keys(operationsFlat).length).toBeGreaterThan(0);
176
232
 
177
233
  const validScopes = ['platform', 'tenant', 'workspace'];
178
- const handlerPattern = /^handlers\/[a-zA-Z0-9_/-]+#[a-zA-Z_][a-zA-Z0-9_]*$/;
179
234
 
180
235
  for (const [, operationSpec] of Object.entries(operationsFlat)) {
181
236
  expect(operationSpec).toHaveProperty('handler');
182
237
  expect(operationSpec).toHaveProperty('bundle_scope');
183
- expect(operationSpec.handler).toMatch(handlerPattern);
238
+ expect(operationSpec.handler).toMatch(HANDLER_REF_PATTERN);
184
239
  expect(validScopes).toContain(operationSpec.bundle_scope);
185
240
  expect(operationSpec).not.toHaveProperty('endpoint');
186
241
  expect(operationSpec).not.toHaveProperty('method');
187
242
  }
188
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
+ });
189
258
  });
190
259
  }
191
260
 
@@ -218,4 +287,4 @@ function generateMockInput(inputSchema) {
218
287
  return mockData;
219
288
  }
220
289
 
221
- module.exports = { createServiceReadinessTests };
290
+ module.exports = { createServiceReadinessTests, resolveDeclaredHandlers };