@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.
- package/CHANGELOG.md +2591 -2
- package/README.md +1075 -7
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +422 -104
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +78 -42
- package/src/ValidationOrchestrator.js +298 -75
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +12 -2
- package/src/helpers/createServiceReadinessTests.js +75 -6
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +213 -13
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +21 -20
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- 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
|
+
};
|
package/src/helpers/README.md
CHANGED
|
@@ -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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 };
|