@onlineapps/conn-orch-validator 7.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2582 -2
- package/README.md +1038 -4
- 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 +408 -101
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +10 -35
- package/src/ValidationOrchestrator.js +219 -71
- 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 +2 -1
- package/src/helpers/createServiceReadinessTests.js +60 -4
- 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 +140 -9
- 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 +2 -1
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +409 -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 +22 -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 +123 -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,539 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test-coverage contract — every test file the service has is RUN somewhere,
|
|
5
|
+
* and the somewhere is either `test:all` or a declared stack tier.
|
|
6
|
+
*
|
|
7
|
+
* A test suite that runs nowhere fails nowhere. It sits in the repository
|
|
8
|
+
* looking like coverage, it is green whenever somebody runs it by hand, and
|
|
9
|
+
* nothing reports that CI never touches it. Measured over the eight biz
|
|
10
|
+
* repositories on 2026-09-07: hello-service matched 17 test files and ran 14 —
|
|
11
|
+
* the three `tests/bootstrap/*` suites were in no job at all.
|
|
12
|
+
*
|
|
13
|
+
* ## What the gate compares
|
|
14
|
+
*
|
|
15
|
+
* T every file the service's jest config matches `jest --listTests`
|
|
16
|
+
* A every file the `test:all` chain runs the chain decomposed
|
|
17
|
+
* (`npm run …`) down to
|
|
18
|
+
* jest invocations, each
|
|
19
|
+
* asked `--listTests`
|
|
20
|
+
* B every file the declared stack tiers run same, per declared script
|
|
21
|
+
* D every platform test file present on disk the repository walked,
|
|
22
|
+
* `**\/tests\/**\/*.test.js`
|
|
23
|
+
*
|
|
24
|
+
* ## Why the disk is a side of the comparison at all
|
|
25
|
+
*
|
|
26
|
+
* T, A and B all ask the SAME jest. Narrow `testMatch` to `tests/unit`, and
|
|
27
|
+
* `tests/integration/` disappears from every one of them at once: absent from T
|
|
28
|
+
* and absent from A, it cannot fall out of T \ (A ∪ B), and the gate prints OK
|
|
29
|
+
* over a suite that runs nowhere. Measured 2026-09-08 on a fixture repository —
|
|
30
|
+
* `EXIT=0`, "1 file(s) matched, 1 run by the test:all chain", with
|
|
31
|
+
* `tests/integration/beta.spec.js` on disk and in no job.
|
|
32
|
+
*
|
|
33
|
+
* So the gate asks one question jest cannot be the source for: what is actually
|
|
34
|
+
* THERE. What counts as a test file on disk is the PLATFORM's `testMatch`, not
|
|
35
|
+
* the service's — the service's is the thing under test, and a pattern invented
|
|
36
|
+
* here would be a third opinion nobody declared. The platform pattern is the one
|
|
37
|
+
* `templates/business-service/jest.config.js` declares, and all eight biz
|
|
38
|
+
* repositories use it verbatim (verified 2026-09-08).
|
|
39
|
+
*
|
|
40
|
+
* The decomposition asks JEST, never a parser. A parse of `jest --config
|
|
41
|
+
* jest.config.js tests/unit --runInBand` answers with the shape of the command;
|
|
42
|
+
* only jest answers with the files, and the files are the question. That is also
|
|
43
|
+
* why the jest binary is the SERVICE's own (resolved from its `node_modules`),
|
|
44
|
+
* not this package's: a different jest version resolves a different `testMatch`.
|
|
45
|
+
*
|
|
46
|
+
* ## Why a declared tier exists at all
|
|
47
|
+
*
|
|
48
|
+
* `tests/e2e/mq-invocation` needs a live consumer on the service workflow queue.
|
|
49
|
+
* CI runs mariadb/redis/rabbitmq sidecars and no service runtime, so in
|
|
50
|
+
* `test:all` that suite would be red in every pipeline forever. The honest
|
|
51
|
+
* answer is a declaration in the contract — the script, and the reason it cannot
|
|
52
|
+
* run in `test:all`:
|
|
53
|
+
*
|
|
54
|
+
* "stackTiers": [
|
|
55
|
+
* { "script": "test:e2e", "requires": "needs a live biz-hello consumer on biz-hello.workflow" }
|
|
56
|
+
* ]
|
|
57
|
+
*
|
|
58
|
+
* Declaring is a valid answer. Silence is not: a file in T that is in neither
|
|
59
|
+
* A nor B is the defect this gate exists to name (automation-gates.md §5).
|
|
60
|
+
*
|
|
61
|
+
* There is deliberately no exemption by directory name. `tests/e2e/**` is not
|
|
62
|
+
* self-evidently un-runnable — hello-service's e2e HTTP suite was deleted rather
|
|
63
|
+
* than declared, because it tested something the platform no longer does. A name
|
|
64
|
+
* cannot carry a reason; only a declaration can (automation-gates.md §3).
|
|
65
|
+
*
|
|
66
|
+
* Pure module: reads the repository, spawns jest to ask it questions, returns a
|
|
67
|
+
* structured result. It renders nothing and exits nothing — the CLI owns
|
|
68
|
+
* presentation, this owns the rules.
|
|
69
|
+
*
|
|
70
|
+
* @see .claude/rules/automation-gates.md
|
|
71
|
+
* @see .claude/rules/change-discipline.md
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
const fs = require('fs');
|
|
75
|
+
const path = require('path');
|
|
76
|
+
const { spawnSync } = require('child_process');
|
|
77
|
+
|
|
78
|
+
const TEST_COVERAGE_SCOPE = 'files the service jest config matches (jest --listTests) vs. the files '
|
|
79
|
+
+ 'the test:all chain runs, decomposed npm run → jest, plus the scripts declared in contract stackTiers; '
|
|
80
|
+
+ 'and that same matched set vs. every platform test file present on disk (**/tests/**/*.test.js)';
|
|
81
|
+
|
|
82
|
+
const TEST_COVERAGE_NOT_MEASURED = 'whether a declared stack tier is ever RUN — the declaration says '
|
|
83
|
+
+ 'why it cannot run in test:all, never that something else runs it';
|
|
84
|
+
|
|
85
|
+
const STACK_TIER_KEYS = ['script', 'requires'];
|
|
86
|
+
|
|
87
|
+
/** The entry script of the chain. Every biz repo already declares it; the integration minimum requires it. */
|
|
88
|
+
const CHAIN_ENTRY_SCRIPT = 'test:all';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate and normalize the `stackTiers` block of an integration contract.
|
|
92
|
+
*
|
|
93
|
+
* Absent means "this service has no suite outside test:all" — the ordinary case,
|
|
94
|
+
* and null rather than [] so a caller can say so instead of reporting an empty
|
|
95
|
+
* declaration as a satisfied one.
|
|
96
|
+
*
|
|
97
|
+
* @param {Array|undefined|null} rawStackTiers the contract's `stackTiers` value
|
|
98
|
+
* @returns {Array<{script: string, requires: string}>|null}
|
|
99
|
+
*/
|
|
100
|
+
function normalizeStackTiersDeclaration(rawStackTiers) {
|
|
101
|
+
if (rawStackTiers === undefined || rawStackTiers === null) return null;
|
|
102
|
+
|
|
103
|
+
if (!Array.isArray(rawStackTiers)) {
|
|
104
|
+
throw new Error('[BizCiGate] Invalid stackTiers - Expected an array of '
|
|
105
|
+
+ '{ "script": "...", "requires": "..." } entries. Fix: use an array, or omit the key entirely.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (rawStackTiers.length === 0) {
|
|
109
|
+
throw new Error('[BizCiGate] Empty stackTiers - The array declares no script. '
|
|
110
|
+
+ 'Fix: declare the script that needs a running stack, or omit the "stackTiers" key entirely.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const declared = new Set();
|
|
114
|
+
|
|
115
|
+
return rawStackTiers.map((entry, index) => {
|
|
116
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
117
|
+
throw new Error(`[BizCiGate] Invalid stackTiers[${index}] - Expected an object with "script" and `
|
|
118
|
+
+ '"requires". Fix: { "script": "test:e2e", "requires": "<why it cannot run in test:all>" }.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const key of Object.keys(entry)) {
|
|
122
|
+
if (!STACK_TIER_KEYS.includes(key)) {
|
|
123
|
+
throw new Error(`[BizCiGate] Unknown stackTiers[${index}] key - "${key}" is not part of a `
|
|
124
|
+
+ 'stack-tier entry. Fix: an entry has exactly two keys, "script" and "requires".');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof entry.script !== 'string' || entry.script.trim() === '') {
|
|
129
|
+
throw new Error(`[BizCiGate] Invalid stackTiers[${index}].script - Expected the name of an npm `
|
|
130
|
+
+ 'script. Fix: "script": "test:e2e".');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The reason is the whole point of the declaration. A tier nobody can justify
|
|
134
|
+
// is exactly the one nobody dares delete (change-discipline.md § Removing
|
|
135
|
+
// something removes its declaration), and a reader of a red pipeline must be
|
|
136
|
+
// able to see why this suite is not in it.
|
|
137
|
+
if (typeof entry.requires !== 'string' || entry.requires.trim() === '') {
|
|
138
|
+
throw new Error(`[BizCiGate] Invalid stackTiers[${index}].requires - Expected a non-empty sentence `
|
|
139
|
+
+ 'saying why the script cannot run in test:all. Fix: "requires": "needs a live biz-hello '
|
|
140
|
+
+ 'consumer on biz-hello.workflow; CI has no service runtime".');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (declared.has(entry.script)) {
|
|
144
|
+
throw new Error(`[BizCiGate] Duplicate stackTiers entry - "${entry.script}" is declared twice. `
|
|
145
|
+
+ 'Fix: keep one entry per script.');
|
|
146
|
+
}
|
|
147
|
+
declared.add(entry.script);
|
|
148
|
+
|
|
149
|
+
return { script: entry.script, requires: entry.requires };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* What a test file looks like on disk, platform-wide.
|
|
155
|
+
*
|
|
156
|
+
* Kept as the literal pattern the platform template declares, and split into the
|
|
157
|
+
* two halves the walk needs, so the constant and the check cannot drift apart.
|
|
158
|
+
* ❌ Do NOT derive this from the service's jest config: that config is the side
|
|
159
|
+
* under test, and a gate that takes its yardstick from the thing it measures
|
|
160
|
+
* agrees with everything.
|
|
161
|
+
*
|
|
162
|
+
* @see templates/business-service/jest.config.js — the declaration this mirrors
|
|
163
|
+
*/
|
|
164
|
+
const PLATFORM_TEST_FILE_PATTERN = '**/tests/**/*.test.js';
|
|
165
|
+
const PLATFORM_TEST_DIRECTORY = 'tests';
|
|
166
|
+
const PLATFORM_TEST_FILE_SUFFIX = '.test.js';
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Directories a repository walk never descends into.
|
|
170
|
+
*
|
|
171
|
+
* `node_modules` holds other people's suites — reporting them would blame the
|
|
172
|
+
* service for a dependency's files. `.git` holds no source at all.
|
|
173
|
+
*/
|
|
174
|
+
const UNWALKED_DIRECTORIES = new Set(['node_modules', '.git']);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Every file under the repository that matches PLATFORM_TEST_FILE_PATTERN.
|
|
178
|
+
*
|
|
179
|
+
* Returned sorted, so a repository with several hidden suites reports them in
|
|
180
|
+
* the same order on every run — a gate whose output moves between runs is not
|
|
181
|
+
* predictable (automation-gates.md §1.1).
|
|
182
|
+
*
|
|
183
|
+
* @param {string} serviceRoot repository root
|
|
184
|
+
* @returns {string[]} absolute paths
|
|
185
|
+
*/
|
|
186
|
+
function listPlatformTestFilesOnDisk(serviceRoot) {
|
|
187
|
+
const found = [];
|
|
188
|
+
|
|
189
|
+
const walk = (directory, insideTestsDirectory) => {
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new Error(`[BizCiGate] Could not read ${directory} - ${error.message}. `
|
|
195
|
+
+ 'Fix: the test-coverage gate walks the repository for test files; make the directory readable.');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
const absolutePath = path.join(directory, entry.name);
|
|
200
|
+
|
|
201
|
+
if (entry.isDirectory()) {
|
|
202
|
+
if (UNWALKED_DIRECTORIES.has(entry.name)) continue;
|
|
203
|
+
walk(absolutePath, insideTestsDirectory || entry.name === PLATFORM_TEST_DIRECTORY);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!entry.isFile()) continue;
|
|
208
|
+
if (!insideTestsDirectory) continue;
|
|
209
|
+
if (!entry.name.endsWith(PLATFORM_TEST_FILE_SUFFIX)) continue;
|
|
210
|
+
|
|
211
|
+
found.push(absolutePath);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
walk(serviceRoot, false);
|
|
216
|
+
|
|
217
|
+
return found.sort();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The service's own jest binary, resolved without following a symlink.
|
|
222
|
+
*
|
|
223
|
+
* `node_modules/.bin/jest` is a symlink, and `require.resolve('jest/bin/jest.js')`
|
|
224
|
+
* fails on jest 29: the package's `exports` map publishes `./bin/jest`, not the
|
|
225
|
+
* `.js` path. So the package manifest is resolved (it is always exported) and its
|
|
226
|
+
* own declared `bin` is joined to it — whatever jest calls its entry point.
|
|
227
|
+
*/
|
|
228
|
+
function resolveJestBin(serviceRoot) {
|
|
229
|
+
let manifestPath;
|
|
230
|
+
try {
|
|
231
|
+
manifestPath = require.resolve('jest/package.json', { paths: [serviceRoot] });
|
|
232
|
+
} catch (error) {
|
|
233
|
+
throw new Error(`[BizCiGate] jest is not installed for ${serviceRoot} - the test-coverage gate asks `
|
|
234
|
+
+ 'jest which files it would run, so it cannot run before the dependencies are there. '
|
|
235
|
+
+ 'Fix: run "npm ci" in the service repository before the gate.');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const declaredBin = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).bin;
|
|
239
|
+
const relativeBin = typeof declaredBin === 'string' ? declaredBin : declaredBin && declaredBin.jest;
|
|
240
|
+
if (!relativeBin) {
|
|
241
|
+
throw new Error(`[BizCiGate] Unusable jest installation - ${manifestPath} declares no "bin". `
|
|
242
|
+
+ 'Fix: reinstall jest in the service repository.');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return path.join(path.dirname(manifestPath), relativeBin);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Split a script body into `&&`-separated steps; anything else is left in the step, and rejected there. */
|
|
249
|
+
function splitChainSteps(command) {
|
|
250
|
+
return command
|
|
251
|
+
.split('&&')
|
|
252
|
+
.map((step) => step.trim())
|
|
253
|
+
.filter((step) => step !== '');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Minimal shell tokenizer: whitespace, single and double quotes. Enough for an npm script body. */
|
|
257
|
+
function tokenize(step) {
|
|
258
|
+
const tokens = [];
|
|
259
|
+
let current = '';
|
|
260
|
+
let quote = null;
|
|
261
|
+
let started = false;
|
|
262
|
+
|
|
263
|
+
for (const character of step) {
|
|
264
|
+
if (quote) {
|
|
265
|
+
if (character === quote) quote = null;
|
|
266
|
+
else current += character;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (character === '"' || character === "'") {
|
|
270
|
+
quote = character;
|
|
271
|
+
started = true;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (/\s/.test(character)) {
|
|
275
|
+
if (started || current !== '') tokens.push(current);
|
|
276
|
+
current = '';
|
|
277
|
+
started = false;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
current += character;
|
|
281
|
+
}
|
|
282
|
+
if (started || current !== '') tokens.push(current);
|
|
283
|
+
|
|
284
|
+
return tokens;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isJestExecutable(token) {
|
|
288
|
+
return token === 'jest' || /(^|[/\\])jest(\.js)?$/.test(token);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Classify one step of a chain.
|
|
293
|
+
* @returns {{kind: 'script', script: string}|{kind: 'jest', args: string[]}|{kind: 'unreadable'}}
|
|
294
|
+
*/
|
|
295
|
+
function classifyStep(step) {
|
|
296
|
+
// A step the gate would have to GUESS about is never guessed. `;`, `|` and `||`
|
|
297
|
+
// change what actually runs, and a chain read as if they were not there would
|
|
298
|
+
// report a count nobody can rely on.
|
|
299
|
+
if (/[;|]/.test(step)) return { kind: 'unreadable' };
|
|
300
|
+
|
|
301
|
+
const tokens = tokenize(step);
|
|
302
|
+
if (tokens.length === 0) return { kind: 'unreadable' };
|
|
303
|
+
|
|
304
|
+
const [head, ...rest] = tokens;
|
|
305
|
+
|
|
306
|
+
if (head === 'npm') {
|
|
307
|
+
if ((rest[0] === 'run' || rest[0] === 'run-script') && rest[1]) {
|
|
308
|
+
return { kind: 'script', script: rest[1] };
|
|
309
|
+
}
|
|
310
|
+
if (rest[0] === 'test' || rest[0] === 't') {
|
|
311
|
+
return { kind: 'script', script: 'test' };
|
|
312
|
+
}
|
|
313
|
+
return { kind: 'unreadable' };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (head === 'npx' && rest[0] && isJestExecutable(rest[0])) {
|
|
317
|
+
return { kind: 'jest', args: rest.slice(1) };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (isJestExecutable(head)) {
|
|
321
|
+
return { kind: 'jest', args: rest };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { kind: 'unreadable' };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Decompose an npm script into the jest invocations it eventually runs.
|
|
329
|
+
*
|
|
330
|
+
* @returns {{commands: Array<{args: string[], viaScript: string}>, problems: Array<{message: string}>}}
|
|
331
|
+
*/
|
|
332
|
+
function decomposeScriptChain(scripts, entryScript, visited = new Set(), problems = [], commands = []) {
|
|
333
|
+
if (visited.has(entryScript)) {
|
|
334
|
+
problems.push({
|
|
335
|
+
message: `Cyclic npm script chain - "${entryScript}" runs itself, directly or through another `
|
|
336
|
+
+ 'script, so the gate cannot list what it runs. Fix: break the cycle.',
|
|
337
|
+
});
|
|
338
|
+
return { commands, problems };
|
|
339
|
+
}
|
|
340
|
+
visited.add(entryScript);
|
|
341
|
+
|
|
342
|
+
const body = scripts[entryScript];
|
|
343
|
+
if (typeof body !== 'string' || body.trim() === '') {
|
|
344
|
+
problems.push({
|
|
345
|
+
message: `Missing script in the ${CHAIN_ENTRY_SCRIPT} chain - "${entryScript}" is run by the chain `
|
|
346
|
+
+ 'but package.json defines no such script. Fix: add the script, or stop calling it.',
|
|
347
|
+
});
|
|
348
|
+
return { commands, problems };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const step of splitChainSteps(body)) {
|
|
352
|
+
const classified = classifyStep(step);
|
|
353
|
+
|
|
354
|
+
if (classified.kind === 'script') {
|
|
355
|
+
decomposeScriptChain(scripts, classified.script, visited, problems, commands);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (classified.kind === 'jest') {
|
|
360
|
+
commands.push({ args: classified.args, viaScript: entryScript });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
problems.push({
|
|
365
|
+
message: `Unreadable step in the ${CHAIN_ENTRY_SCRIPT} chain - "${step}" (via ${entryScript}) is `
|
|
366
|
+
+ 'neither "npm run <script>" nor a jest invocation, so the gate cannot ask which tests it runs. '
|
|
367
|
+
+ 'Fix: express the step as "npm run <script>" or as a jest command.',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { commands, problems };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Ask jest which files a command would run.
|
|
376
|
+
*
|
|
377
|
+
* An empty match is jest's own exit 0 with no output (measured, jest 29.7.0), so
|
|
378
|
+
* a non-zero exit is a real failure and is raised rather than counted as "no
|
|
379
|
+
* tests" — the difference between "this script runs nothing" and "this gate
|
|
380
|
+
* could not run" must never be silently the same number.
|
|
381
|
+
*/
|
|
382
|
+
function listTests({ serviceRoot, jestBin, args, spawn }) {
|
|
383
|
+
const result = spawn(process.execPath, [jestBin, ...args, '--listTests'], {
|
|
384
|
+
cwd: serviceRoot,
|
|
385
|
+
encoding: 'utf8',
|
|
386
|
+
env: process.env,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
if (result.error) {
|
|
390
|
+
throw new Error(`[BizCiGate] Could not run jest --listTests - ${result.error.message}. `
|
|
391
|
+
+ `Fix: verify the jest installation in ${serviceRoot}.`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (result.status !== 0) {
|
|
395
|
+
throw new Error(`[BizCiGate] jest --listTests failed (exit ${result.status}) for `
|
|
396
|
+
+ `"jest ${args.join(' ')}" in ${serviceRoot}. jest said: ${(result.stderr || '').trim()}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return (result.stdout || '')
|
|
400
|
+
.split('\n')
|
|
401
|
+
.map((line) => line.trim())
|
|
402
|
+
.filter((line) => line !== '');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Verify the test-coverage contract for one service repository.
|
|
407
|
+
*
|
|
408
|
+
* Every violation is reported, not just the first, so a repo can be fixed in one
|
|
409
|
+
* pass.
|
|
410
|
+
*
|
|
411
|
+
* @param {object} options
|
|
412
|
+
* @param {string} options.serviceRoot repository root
|
|
413
|
+
* @param {object} options.contract normalized integration contract (carries stackTiers)
|
|
414
|
+
* @param {Function} [options.spawn] injected spawnSync, for tests that must not run jest
|
|
415
|
+
*/
|
|
416
|
+
function verifyTestCoverage({ serviceRoot, contract, spawn = spawnSync }) {
|
|
417
|
+
const packageJsonPath = path.join(serviceRoot, 'package.json');
|
|
418
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
419
|
+
throw new Error(`[BizCiGate] Missing file - ${packageJsonPath}`);
|
|
420
|
+
}
|
|
421
|
+
const scripts = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).scripts || {};
|
|
422
|
+
|
|
423
|
+
if (!scripts[CHAIN_ENTRY_SCRIPT]) {
|
|
424
|
+
throw new Error(`[BizCiGate] Missing package script - ${CHAIN_ENTRY_SCRIPT} is required by the `
|
|
425
|
+
+ 'test-coverage gate: it is the chain every test file must be reachable from. '
|
|
426
|
+
+ `Fix: define "${CHAIN_ENTRY_SCRIPT}" in package.json.`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const jestBin = resolveJestBin(serviceRoot);
|
|
430
|
+
const memo = new Map();
|
|
431
|
+
const ask = (args) => {
|
|
432
|
+
const key = JSON.stringify(args);
|
|
433
|
+
if (!memo.has(key)) memo.set(key, listTests({ serviceRoot, jestBin, args, spawn }));
|
|
434
|
+
return memo.get(key);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const relative = (absolutePath) => path.relative(serviceRoot, absolutePath);
|
|
438
|
+
const violations = [];
|
|
439
|
+
const add = (requirement, message) => violations.push({ requirement, message });
|
|
440
|
+
|
|
441
|
+
// T — everything the service's jest config matches, asked without a path filter.
|
|
442
|
+
const matched = new Set(ask([]));
|
|
443
|
+
|
|
444
|
+
// D \ T — a suite that is on disk and in no jest answer. Checked FIRST because
|
|
445
|
+
// it is the only defect the other three cannot express: a file jest does not
|
|
446
|
+
// match is missing from both sides of every comparison below, so silence there
|
|
447
|
+
// says nothing about it (automation-gates.md §5).
|
|
448
|
+
for (const file of listPlatformTestFilesOnDisk(serviceRoot)) {
|
|
449
|
+
if (matched.has(file)) continue;
|
|
450
|
+
add('TEST_NOT_MATCHED', `Test file matched by nothing - ${relative(file)} is a platform test file `
|
|
451
|
+
+ `on disk (${PLATFORM_TEST_FILE_PATTERN}, the testMatch templates/business-service/jest.config.js `
|
|
452
|
+
+ 'declares), but the service jest config does not match it, so no script can run it and no other '
|
|
453
|
+
+ `check can even name it. Fix: widen testMatch in jest.config.js to the platform pattern, or move `
|
|
454
|
+
+ 'the file out of tests/ (or delete it) if it is not a test.');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// A — everything the test:all chain runs.
|
|
458
|
+
const chain = decomposeScriptChain(scripts, CHAIN_ENTRY_SCRIPT);
|
|
459
|
+
for (const problem of chain.problems) add('TEST_ALL_CHAIN', problem.message);
|
|
460
|
+
const inTestAll = new Set();
|
|
461
|
+
for (const command of chain.commands) {
|
|
462
|
+
for (const file of ask(command.args)) inTestAll.add(file);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// B — everything the declared stack tiers run.
|
|
466
|
+
const declaredTiers = contract.stackTiers || [];
|
|
467
|
+
const inStackTiers = new Set();
|
|
468
|
+
const stackTiers = [];
|
|
469
|
+
|
|
470
|
+
for (const tier of declaredTiers) {
|
|
471
|
+
if (!scripts[tier.script]) {
|
|
472
|
+
add('STACK_TIER_SCRIPT', `Declared stack tier has no script - stackTiers declares `
|
|
473
|
+
+ `"${tier.script}", but package.json defines no such script. `
|
|
474
|
+
+ 'Fix: add the script, or remove the declaration.');
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const tierChain = decomposeScriptChain(scripts, tier.script);
|
|
479
|
+
for (const problem of tierChain.problems) add('TEST_ALL_CHAIN', problem.message);
|
|
480
|
+
|
|
481
|
+
const tierFiles = [];
|
|
482
|
+
for (const command of tierChain.commands) {
|
|
483
|
+
for (const file of ask(command.args)) {
|
|
484
|
+
if (!tierFiles.includes(file)) tierFiles.push(file);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (tierFiles.length === 0) {
|
|
489
|
+
add('STACK_TIER_EMPTY', `Declared stack tier runs no test - "${tier.script}" lists 0 test files, `
|
|
490
|
+
+ 'so the declaration has no consumer. '
|
|
491
|
+
+ 'Fix: point the script at the tests it is meant to run, or remove the declaration.');
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const alsoInTestAll = tierFiles.filter((file) => inTestAll.has(file));
|
|
496
|
+
if (alsoInTestAll.length > 0) {
|
|
497
|
+
add('STACK_TIER_IN_TEST_ALL', `Declared stack tier already runs in test:all - "${tier.script}" `
|
|
498
|
+
+ `lists ${alsoInTestAll.map(relative).join(', ')}, which the test:all chain runs too. `
|
|
499
|
+
+ 'Fix: remove the declaration — a stack tier is the part test:all CANNOT run, '
|
|
500
|
+
+ 'and declaring one that it can run states something untrue.');
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
for (const file of tierFiles) inStackTiers.add(file);
|
|
505
|
+
stackTiers.push({ script: tier.script, requires: tier.requires, files: tierFiles.map(relative) });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// (1) — the defect the gate exists for.
|
|
509
|
+
for (const file of matched) {
|
|
510
|
+
if (inTestAll.has(file) || inStackTiers.has(file)) continue;
|
|
511
|
+
add('TEST_RUNS_NOWHERE', `Test file runs nowhere - ${relative(file)} is matched by the jest config, `
|
|
512
|
+
+ 'the test:all chain does not run it, and no stackTiers entry declares it. '
|
|
513
|
+
+ 'Fix: run it from a script the test:all chain reaches, or — if it needs a running stack — '
|
|
514
|
+
+ 'declare its script in config/service/integration-contract.json: '
|
|
515
|
+
+ '"stackTiers": [{ "script": "test:e2e", "requires": "<why it cannot run in test:all>" }].');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
ok: violations.length === 0,
|
|
520
|
+
violations,
|
|
521
|
+
totals: {
|
|
522
|
+
matched: matched.size,
|
|
523
|
+
inTestAll: inTestAll.size,
|
|
524
|
+
inStackTiers: inStackTiers.size,
|
|
525
|
+
},
|
|
526
|
+
stackTiers,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
module.exports = {
|
|
531
|
+
TEST_COVERAGE_SCOPE,
|
|
532
|
+
TEST_COVERAGE_NOT_MEASURED,
|
|
533
|
+
PLATFORM_TEST_FILE_PATTERN,
|
|
534
|
+
listPlatformTestFilesOnDisk,
|
|
535
|
+
normalizeStackTiersDeclaration,
|
|
536
|
+
decomposeScriptChain,
|
|
537
|
+
resolveJestBin,
|
|
538
|
+
verifyTestCoverage,
|
|
539
|
+
};
|