@onlineapps/conn-orch-validator 12.2.0 → 13.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 +597 -0
- package/README.md +126 -19
- package/manifests/biz-service.manifest.json +15 -2
- package/manifests/library.manifest.json +4 -4
- package/package.json +11 -3
- package/src/CookbookTestRunner.js +275 -105
- package/src/CookbookTestUtils.js +79 -68
- package/src/ServiceReadinessValidator.js +42 -52
- package/src/ValidationOrchestrator.js +65 -44
- package/src/cli/biz-ci-gate.js +2 -2
- package/src/cli/oa-sync-template.js +97 -47
- package/src/cli/oa-validate.js +44 -10
- package/src/helpers/README.md +6 -6
- package/src/helpers/createServiceReadinessTests.js +87 -33
- package/src/index.js +14 -5
- package/src/lint/scripts/lintScripts.js +11 -4
- package/src/manifest/checks/libraryContext.js +6 -3
- package/src/manifest/checks/libraryDocs.js +174 -4
- package/src/manifest/checks/libraryTests.js +200 -19
- package/src/manifest/checks/scriptHeaders.js +6 -13
- package/src/manifest/checks/serviceConfig.js +36 -16
- package/src/manifest/checks/serviceConnectors.js +180 -2
- package/src/manifest/checks/serviceDb.js +0 -3
- package/src/manifest/checks/serviceScripts.js +3 -20
- package/src/manifest/runManifest.js +90 -13
- package/src/manifest/workspaceRoot.js +133 -4
- package/src/mocks/MockMQClient.js +2 -2
- package/src/sync/docsRegion.js +2 -2
- package/src/sync/readmeFile.js +30 -0
- package/src/sync/readmeLocation.js +2 -12
- package/src/sync/readmePointer.js +10 -4
- package/src/sync/serviceTemplate.js +9 -11
- package/src/sync/sharedEnv.js +59 -3
- package/src/sync/uniformFiles.js +81 -8
- package/src/utils/bizCiGateContract.js +2 -2
- package/src/utils/connectorContract.js +54 -2
- package/src/utils/cookbookFormat.js +25 -115
- package/src/utils/dbAccountGrants.js +5 -3
- package/src/utils/deployContract.js +153 -28
- package/src/utils/envContract.js +2 -2
- package/src/utils/handlerRef.js +8 -10
- package/src/utils/integrationRun.js +1 -1
- package/src/utils/operationsDocumentRules.js +242 -0
- package/src/utils/operationsRules.js +157 -0
- package/src/utils/resolveHeaders.js +12 -1
- package/src/utils/setupDatabase.js +1 -1
- package/src/utils/stepFailure.js +3 -3
- package/src/utils/stepReferences.js +28 -87
- package/src/utils/throwawaySchema.js +1 -1
- package/src/utils/yamlTopLevel.js +105 -0
- package/src/validators/ServiceStructureValidator.js +67 -152
- package/templates/business-service/README.md +3 -2
- package/templates/business-service/config/env-templates/shared.env +1 -0
- package/templates/business-service/src/config/index.js +15 -0
- package/TESTING_STRATEGY.md +0 -92
- package/jest.config.js +0 -37
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Cookbook format
|
|
5
|
-
* cookbook
|
|
4
|
+
* Cookbook format helpers this package still owns — what
|
|
5
|
+
* `@onlineapps/cookbook-core` `validateCookbook` does not do.
|
|
6
6
|
*
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* The format rules themselves (`version`, the `steps` array, a step's
|
|
8
|
+
* `step_id`/`type`/`service`/`operation`) are cookbook-core's; both entry
|
|
9
|
+
* points of this package run it FIRST — the Tier-1 gate
|
|
10
|
+
* (`CookbookTestRunner.validateCookbook()`, d.980) and the readiness-wrapper
|
|
11
|
+
* path (`CookbookTestUtils.validateCookbook()`, d.984). Copies of its rules
|
|
12
|
+
* went with their last readers: `checkCookbookFormatVersion` (the version rule)
|
|
13
|
+
* in d.984, and the ban on a step key `id` in d.985 — cookbook-core refuses it
|
|
14
|
+
* since d.983 (`Forbidden field 'id' at /steps/<n>/id`).
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Returned messages carry the `Problem - Expected/Fix` part of the error
|
|
18
|
-
* format (architecture-principles §5); the `[Context]` prefix belongs to the
|
|
19
|
-
* caller, which is the one that knows whether it read a file or was handed an
|
|
20
|
-
* object.
|
|
16
|
+
* What stays is read by code that holds a cookbook nobody has validated yet
|
|
17
|
+
* (`CookbookTestUtils.compareCookbooks`, `CookbookTestRunner._operationsOf`,
|
|
18
|
+
* `hasExpectClauses`): `readCookbookSteps`, which hands over the steps as the
|
|
19
|
+
* array they must be; `stepIdentityOf`; and `MIN_COOKBOOK_FORMAT_VERSION`, the
|
|
20
|
+
* version this package writes into the cookbooks it synthesises.
|
|
21
21
|
*
|
|
22
22
|
* @see api/docs/biz/40-cookbooks/format.md
|
|
23
23
|
* @see api/docs/biz/40-cookbooks/test-runner-flow.md
|
|
@@ -27,21 +27,7 @@
|
|
|
27
27
|
const MIN_COOKBOOK_FORMAT_VERSION = '2.1.0';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
* The machine SSOT is `shared/cookbook/cookbook-core/schemas/cookbook.v2.schema.json:16`
|
|
33
|
-
* — `"pattern": "^2\\.\\d+\\.\\d+$"`. Until 2026-09-05 this gate accepted 1 to 3
|
|
34
|
-
* components, so `"version": "2.1"` passed Tier-1 here and failed schema
|
|
35
|
-
* validation there: two rails, two answers, the offline one lenient. The major
|
|
36
|
-
* is deliberately NOT pinned to 2 here — that ceiling belongs to the schema; the
|
|
37
|
-
* floor this module owns is `MIN_COOKBOOK_FORMAT_VERSION`.
|
|
38
|
-
*/
|
|
39
|
-
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
40
|
-
|
|
41
|
-
const DOC_REFERENCE = 'api/docs/biz/40-cookbooks/format.md § Required fields';
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* The same node, § The array is the only accepted shape — the section that owns
|
|
30
|
+
* The format node's section `The array is the only accepted shape` owns
|
|
45
31
|
* both halves of the rule below: `steps` is an array, and a step identifies
|
|
46
32
|
* itself with `step_id`. The node has no section called `Steps`; this constant
|
|
47
33
|
* named one until 2026-09-14, and `L008` never noticed, because it checks the
|
|
@@ -52,63 +38,6 @@ const STEPS_DOC_REFERENCE = 'api/docs/biz/40-cookbooks/format.md § The array is
|
|
|
52
38
|
/** `2.1` — how the format version is spoken about in prose, derived, never typed twice. */
|
|
53
39
|
const MIN_FORMAT_MAJOR_MINOR = MIN_COOKBOOK_FORMAT_VERSION.split('.').slice(0, 2).join('.');
|
|
54
40
|
|
|
55
|
-
/**
|
|
56
|
-
* @param {string} version numeric version string, already pattern-checked
|
|
57
|
-
* @returns {number[]} exactly three components, missing ones are 0
|
|
58
|
-
*/
|
|
59
|
-
function toComponents(version) {
|
|
60
|
-
const parts = version.split('.').map((part) => parseInt(part, 10));
|
|
61
|
-
while (parts.length < 3) {
|
|
62
|
-
parts.push(0);
|
|
63
|
-
}
|
|
64
|
-
return parts;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* @param {string} a numeric version string
|
|
69
|
-
* @param {string} b numeric version string
|
|
70
|
-
* @returns {number} negative when a < b, 0 when equal, positive when a > b
|
|
71
|
-
*/
|
|
72
|
-
function compareVersions(a, b) {
|
|
73
|
-
const left = toComponents(a);
|
|
74
|
-
const right = toComponents(b);
|
|
75
|
-
|
|
76
|
-
for (let i = 0; i < 3; i++) {
|
|
77
|
-
if (left[i] !== right[i]) {
|
|
78
|
-
return left[i] - right[i];
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return 0;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Check a cookbook's `version` field against the documented format rule.
|
|
86
|
-
*
|
|
87
|
-
* @param {*} version the raw value of the cookbook's top-level `version`
|
|
88
|
-
* @returns {string|null} `null` when the version satisfies the rule, otherwise
|
|
89
|
-
* the problem in `Problem - Expected/Fix` form
|
|
90
|
-
*/
|
|
91
|
-
function checkCookbookFormatVersion(version) {
|
|
92
|
-
if (version === undefined || version === null || version === '') {
|
|
93
|
-
return `cookbook format version is missing - Expected top-level "version": "${MIN_COOKBOOK_FORMAT_VERSION}" or higher. `
|
|
94
|
-
+ `Fix: add "version": "${MIN_COOKBOOK_FORMAT_VERSION}" to the cookbook (${DOC_REFERENCE}).`;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (typeof version !== 'string' || !VERSION_PATTERN.test(version)) {
|
|
98
|
-
return `cookbook format version ${JSON.stringify(version)} is not a version string - `
|
|
99
|
-
+ `Expected numeric "<major>.<minor>.<patch>", "${MIN_COOKBOOK_FORMAT_VERSION}" or higher. `
|
|
100
|
-
+ `Fix: write the version as "${MIN_COOKBOOK_FORMAT_VERSION}" (${DOC_REFERENCE}).`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (compareVersions(version, MIN_COOKBOOK_FORMAT_VERSION) < 0) {
|
|
104
|
-
return `cookbook format version "${version}" is below the required minimum ${MIN_COOKBOOK_FORMAT_VERSION} - `
|
|
105
|
-
+ `Expected "${MIN_COOKBOOK_FORMAT_VERSION}" or higher. `
|
|
106
|
-
+ `Fix: migrate the cookbook to the v2.1 format and set "version": "${MIN_COOKBOOK_FORMAT_VERSION}" (${DOC_REFERENCE}).`;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
41
|
/**
|
|
113
42
|
* @param {*} steps a `steps` value that is neither an array nor absent
|
|
114
43
|
* @returns {string} the shape, worded for the refusal message
|
|
@@ -118,19 +47,17 @@ function describeStepsShape(steps) {
|
|
|
118
47
|
}
|
|
119
48
|
|
|
120
49
|
/**
|
|
121
|
-
* Read a cookbook's `steps` in the ONE shape the format allows — an array
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* Two refusals, one rule each:
|
|
50
|
+
* Read a cookbook's `steps` in the ONE shape the format allows — an array —
|
|
51
|
+
* and refuse any other shape.
|
|
125
52
|
*
|
|
126
|
-
*
|
|
53
|
+
* **Any shape but an array.** Owner decision 2026-09-05, fully authoritative
|
|
127
54
|
* (`api/docs/governance/confirmations/cookbook-steps-shape.md` 001): `steps`
|
|
128
55
|
* is an ARRAY, the only allowed shape, and the object variant "must not be
|
|
129
56
|
* permitted or used anywhere". It supersedes the 2026-08-17 object
|
|
130
57
|
* directive, which never landed in any runtime. The reason is order:
|
|
131
58
|
* JavaScript reorders numeric object keys and Postgres `jsonb` does not
|
|
132
59
|
* preserve key order either (measured M9,
|
|
133
|
-
* `infra/api_monitoring/src/consumer/cookbookSplit.js
|
|
60
|
+
* `infra/api_monitoring/src/consumer/cookbookSplit.js`, `canonicalize`), so an
|
|
134
61
|
* object-keyed cookbook has no defined execution order at all. Arrays keep
|
|
135
62
|
* theirs, because a reordered step list IS a different cookbook.
|
|
136
63
|
*
|
|
@@ -139,22 +66,16 @@ function describeStepsShape(steps) {
|
|
|
139
66
|
* gate accepted a cookbook the production WorkflowOrchestrator — which only
|
|
140
67
|
* ever read arrays — threw on.
|
|
141
68
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* `architecture-principles.md` §3 forbids, and it hides an authoring mistake
|
|
146
|
-
* until something downstream reports `Step undefined`. There is no `id`
|
|
147
|
-
* anywhere any more: the adapter that wrote `id = step_id` went with
|
|
148
|
-
* `@onlineapps/cookbook-executor` (deleted 2026-08-31), and
|
|
149
|
-
* WorkflowOrchestrator — the single owner of cookbook execution — reads
|
|
150
|
-
* `step_id`.
|
|
69
|
+
* What a step inside the array may carry is cookbook-core's to judge, not this
|
|
70
|
+
* function's: until d.985 it also refused a step spelling its identifier `id`,
|
|
71
|
+
* a copy of the rule cookbook-core has owned since d.983.
|
|
151
72
|
*
|
|
152
73
|
* An absent `steps` is NOT a shape problem: the caller owns that message,
|
|
153
74
|
* because only it knows whether it is reading a file or an object handed to it.
|
|
154
75
|
*
|
|
155
76
|
* @param {*} steps the raw value of the cookbook's top-level `steps`
|
|
156
77
|
* @returns {object[]} the steps, unchanged; `[]` when `steps` is absent
|
|
157
|
-
* @throws {Error} when `steps` is present but not an array
|
|
78
|
+
* @throws {Error} when `steps` is present but not an array
|
|
158
79
|
*/
|
|
159
80
|
function readCookbookSteps(steps) {
|
|
160
81
|
if (steps === undefined || steps === null) {
|
|
@@ -174,22 +95,12 @@ function readCookbookSteps(steps) {
|
|
|
174
95
|
);
|
|
175
96
|
}
|
|
176
97
|
|
|
177
|
-
steps.forEach((step, index) => {
|
|
178
|
-
if (step && typeof step === 'object' && 'id' in step) {
|
|
179
|
-
const label = stepIdentityOf(step) || `#${index + 1}`;
|
|
180
|
-
throw new Error(
|
|
181
|
-
`[CookbookFormat] step "${label}" carries "id" - Expected: "step_id" only `
|
|
182
|
-
+ `(${STEPS_DOC_REFERENCE}). Fix: rename "id" to "step_id"`
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
|
|
187
98
|
return steps;
|
|
188
99
|
}
|
|
189
100
|
|
|
190
101
|
/**
|
|
191
102
|
* The identifier a step declares. `step_id` is the ONLY spelling the format
|
|
192
|
-
* allows;
|
|
103
|
+
* allows; cookbook-core refuses a step that carries `id` (d.983), so nothing
|
|
193
104
|
* downstream needs to know that name ever existed.
|
|
194
105
|
*
|
|
195
106
|
* @param {object} step one normalised step
|
|
@@ -207,7 +118,6 @@ function stepIdentityOf(step) {
|
|
|
207
118
|
|
|
208
119
|
module.exports = {
|
|
209
120
|
MIN_COOKBOOK_FORMAT_VERSION,
|
|
210
|
-
checkCookbookFormatVersion,
|
|
211
121
|
readCookbookSteps,
|
|
212
122
|
stepIdentityOf
|
|
213
123
|
};
|
|
@@ -42,9 +42,11 @@
|
|
|
42
42
|
* schema name before either statement is built; the production runbook
|
|
43
43
|
* (`api/docs/setup/INSTALL.md` § the account and its grants, `schema_grant`)
|
|
44
44
|
* already escaped it, and a definition laxer than the runbook installing from it
|
|
45
|
-
* is the wrong way round.
|
|
46
|
-
* `tests/unit/bizCiGateCli.setupDbAccount.integration.test.js
|
|
47
|
-
*
|
|
45
|
+
* is the wrong way round. What the server does with it is measured on a real
|
|
46
|
+
* MariaDB by `tests/unit/bizCiGateCli.setupDbAccount.integration.test.js`,
|
|
47
|
+
* describe block `the production grant on a real MariaDB — how far it reaches
|
|
48
|
+
* @integration`: the escaped grant refuses the neighbour that differs in that
|
|
49
|
+
* one character with `ERROR 1142`, and still reaches its own schema.
|
|
48
50
|
*
|
|
49
51
|
* `%` — the other wildcard of that position — is REFUSED rather than escaped,
|
|
50
52
|
* for the reason the comment above `FORBIDDEN_IN_VALUE` gives: no platform
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
const fs = require('fs');
|
|
34
34
|
const path = require('path');
|
|
35
35
|
|
|
36
|
+
const { topLevelKeys, topLevelKeyAt } = require('./yamlTopLevel');
|
|
37
|
+
|
|
36
38
|
/**
|
|
37
39
|
* The requirements this module raises, and the ONLY place their extent is
|
|
38
40
|
* stated. Every message naming the scope renders it from here.
|
|
@@ -156,7 +158,10 @@ function checkPublishedPorts(compose, add) {
|
|
|
156
158
|
//
|
|
157
159
|
// the ancestor check — a commit that is not on the branch this box follows is
|
|
158
160
|
// a pipeline for a different history, and resetting to it puts the box on code
|
|
159
|
-
// production never took. Fail-fast, before anything is touched
|
|
161
|
+
// production never took. Fail-fast, before anything is touched — and in the
|
|
162
|
+
// SAME JOB as the reset, because the job is the shell: a check standing in a
|
|
163
|
+
// different job did not run when this reset ran, however early in the file it
|
|
164
|
+
// is written.
|
|
160
165
|
//
|
|
161
166
|
// Until 2026-09-17 this function demanded the literal `git reset --hard
|
|
162
167
|
// origin/production`, which is the FIRST half's opposite. The packaged template
|
|
@@ -206,13 +211,26 @@ function shellWord(raw) {
|
|
|
206
211
|
return unquote(raw.replace(/[;&|)]+$/, ''));
|
|
207
212
|
}
|
|
208
213
|
|
|
214
|
+
// A comment neither VIOLATES nor PERMITS — the symmetry R8 already keeps for a
|
|
215
|
+
// tenant literal, applied to every command this rule reads. Until d.707d only
|
|
216
|
+
// half of it held: a commented-out guard was accepted (d.707c closed that),
|
|
217
|
+
// while a commented-out `git pull`, `git merge` or `git reset --hard` was still
|
|
218
|
+
// reported as the deploy doing it. Both halves are the same sentence — a line
|
|
219
|
+
// that opens with `#` does not run — so both take the same predicate, with
|
|
220
|
+
// YAML's marker rather than JavaScript's.
|
|
221
|
+
//
|
|
222
|
+
// The asymmetry was not merely untidy: a repository whose only `git reset
|
|
223
|
+
// --hard` sat in a comment was told it resets to an unverified commit, while
|
|
224
|
+
// the thing actually wrong with it — that nothing forces the tree to the
|
|
225
|
+
// measured commit at all — was never said. A gate has to name what it found
|
|
226
|
+
// (`.claude/rules/automation-gates.md` §1 requirement 4).
|
|
209
227
|
function checkDeploySequence(ci, add) {
|
|
210
|
-
if (
|
|
228
|
+
if (matchesOnExecutingLine(ci, GIT_PULL, YAML_COMMENT_OPENERS)) {
|
|
211
229
|
add('R2', "'git pull' in the deploy path.\n"
|
|
212
230
|
+ ' A local modification on the server turns the merge into a conflict and aborts the deploy halfway.\n'
|
|
213
231
|
+ DEPLOY_SEQUENCE_FIX);
|
|
214
232
|
}
|
|
215
|
-
if (
|
|
233
|
+
if (matchesOnExecutingLine(ci, GIT_MERGE, YAML_COMMENT_OPENERS)) {
|
|
216
234
|
add('R2', "'git merge' in the deploy path.\n"
|
|
217
235
|
+ ' A deploy that merges builds a commit that exists on no branch, so what the box runs\n'
|
|
218
236
|
+ ' is no longer any commit a pipeline measured.\n'
|
|
@@ -220,7 +238,8 @@ function checkDeploySequence(ci, add) {
|
|
|
220
238
|
}
|
|
221
239
|
|
|
222
240
|
const resets = [...ci.matchAll(RESET_HARD)]
|
|
223
|
-
.map((match) => ({ at: match.index, target: shellWord(match[1]) }))
|
|
241
|
+
.map((match) => ({ at: match.index, target: shellWord(match[1]) }))
|
|
242
|
+
.filter((reset) => !isCommentLine(lineAt(ci, reset.at), YAML_COMMENT_OPENERS));
|
|
224
243
|
|
|
225
244
|
if (resets.length === 0) {
|
|
226
245
|
add('R2', "No 'git reset --hard' found in .gitlab-ci.yml.\n"
|
|
@@ -230,14 +249,39 @@ function checkDeploySequence(ci, add) {
|
|
|
230
249
|
return;
|
|
231
250
|
}
|
|
232
251
|
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
252
|
+
// A guard counts for a reset when it is in the SAME JOB and earlier in it.
|
|
253
|
+
//
|
|
254
|
+
// The job is the unit because the job is the shell: GitLab runs each job in
|
|
255
|
+
// its own container on its own runner, concatenating before_script and script
|
|
256
|
+
// into one script, and nothing a neighbouring job ran is in scope when this
|
|
257
|
+
// one resets. Until d.707 ordering was read over the whole file, which made
|
|
258
|
+
// the verdict a fact about the ORDER OF UNRELATED JOBS — a guard in a `test`
|
|
259
|
+
// job written above the deploy, or in a job whose `rules:` never fire, covered
|
|
260
|
+
// a reset that ran unguarded. Same input, different result depending on where
|
|
261
|
+
// in the file somebody put an unrelated job, which is the predictability
|
|
262
|
+
// `.claude/rules/automation-gates.md` §1 requirement 1 asks for, and a gate
|
|
263
|
+
// green over a box that resets unverified is the silence its §5 calls a defect.
|
|
264
|
+
//
|
|
265
|
+
// What is deliberately NOT resolved here is `extends:` and `!reference`: a
|
|
266
|
+
// guard pulled in from a hidden key is invisible to this reading and reported
|
|
267
|
+
// as being elsewhere. That direction is the safe one — it names a fix that
|
|
268
|
+
// makes the sequence readable in the job that runs it — and no biz repository
|
|
269
|
+
// writes the guard that way (measured over all eight, 2026-09-20).
|
|
270
|
+
//
|
|
271
|
+
// A guard has to be a line that RUNS — the permit half of the symmetry stated
|
|
272
|
+
// above this function. `# - if ! git merge-base …` left over a reset used to
|
|
273
|
+
// cover it, so a repository could comment the check out and keep the gate
|
|
274
|
+
// green; a rule a mention satisfies checks nothing (`automation-gates.md` §5).
|
|
238
275
|
const guards = [...ci.matchAll(ANCESTOR_CHECK)]
|
|
239
276
|
.map((match) => ({ at: match.index, commit: shellWord(match[1]), branch: shellWord(match[2]) }))
|
|
240
|
-
.filter((guard) => guard.branch === PRODUCTION_BRANCH)
|
|
277
|
+
.filter((guard) => guard.branch === PRODUCTION_BRANCH)
|
|
278
|
+
.filter((guard) => !isCommentLine(lineAt(ci, guard.at), YAML_COMMENT_OPENERS));
|
|
279
|
+
|
|
280
|
+
// Which JOB a line is in: the one reading of that question this package has
|
|
281
|
+
// (`src/utils/yamlTopLevel.js`), taken here in its widest form — a reset or a
|
|
282
|
+
// guard written under a hidden key is placed under that key rather than under
|
|
283
|
+
// whichever job happens to precede it.
|
|
284
|
+
const jobKeys = topLevelKeys(ci);
|
|
241
285
|
|
|
242
286
|
for (const reset of resets) {
|
|
243
287
|
if (reset.target === PRODUCTION_BRANCH) {
|
|
@@ -248,15 +292,34 @@ function checkDeploySequence(ci, add) {
|
|
|
248
292
|
+ DEPLOY_SEQUENCE_FIX);
|
|
249
293
|
continue;
|
|
250
294
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
295
|
+
|
|
296
|
+
const resetJob = topLevelKeyAt(jobKeys, reset.at);
|
|
297
|
+
const named = guards.filter((guard) => guard.commit === reset.target);
|
|
298
|
+
|
|
299
|
+
if (named.some((guard) => guard.at < reset.at && topLevelKeyAt(jobKeys, guard.at) === resetJob)) continue;
|
|
300
|
+
|
|
301
|
+
const elsewhere = named.find((guard) => topLevelKeyAt(jobKeys, guard.at) !== resetJob);
|
|
302
|
+
if (elsewhere) {
|
|
303
|
+
add('R2', `The ancestor check for ${reset.target} is in job "${topLevelKeyAt(jobKeys, elsewhere.at)}", `
|
|
304
|
+
+ `but the reset runs in job "${resetJob}".\n`
|
|
305
|
+
+ ' Every job is its own shell on its own runner, so a guard in another job never ran\n'
|
|
306
|
+
+ ' when this reset does: the box resets to a commit nothing verified, and whether the\n'
|
|
307
|
+
+ ' guard is there at all is decided by that other job\'s rules, not by this one.\n'
|
|
308
|
+
+ ` Expected: git merge-base --is-ancestor ${reset.target} ${PRODUCTION_BRANCH} inside\n`
|
|
309
|
+
+ ` job "${resetJob}", on a line BEFORE the reset — its before_script counts, GitLab\n`
|
|
310
|
+
+ ' runs it as the same shell.\n'
|
|
311
|
+
+ ` Fix: move the guard into job "${resetJob}", on the line before the reset.\n`
|
|
258
312
|
+ DEPLOY_SEQUENCE_FIX);
|
|
313
|
+
continue;
|
|
259
314
|
}
|
|
315
|
+
|
|
316
|
+
add('R2', `The deploy resets to ${reset.target}, which is not verified to be an ancestor of `
|
|
317
|
+
+ `${PRODUCTION_BRANCH} first.\n`
|
|
318
|
+
+ ' A commit that is not on the branch this box follows belongs to a different history,\n'
|
|
319
|
+
+ ' and resetting to it puts the box on code production never took.\n'
|
|
320
|
+
+ ` Expected: git merge-base --is-ancestor ${reset.target} ${PRODUCTION_BRANCH}, on a line\n`
|
|
321
|
+
+ ` BEFORE the reset, in job "${resetJob}", naming that same commit.\n`
|
|
322
|
+
+ DEPLOY_SEQUENCE_FIX);
|
|
260
323
|
}
|
|
261
324
|
}
|
|
262
325
|
|
|
@@ -472,6 +535,9 @@ const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'coverage', 'dist',
|
|
|
472
535
|
/** A call to either library helper: the own namespace, or the foreign one. */
|
|
473
536
|
const NAMESPACE_HELPER_CALL = /\bget(?:Foreign)?TestNamespace\s*\(/;
|
|
474
537
|
|
|
538
|
+
/** `require('./testNamespace')`, `require('../helpers/test-namespace')` — the repo's own module. */
|
|
539
|
+
const REQUIRE_NAMESPACE_MODULE = /require\([^)]*test-?namespace[^)]*\)/i;
|
|
540
|
+
|
|
475
541
|
/** True for the repo's shared test-namespace module, whatever it is named. */
|
|
476
542
|
function isNamespaceModule(relativePath) {
|
|
477
543
|
return /(^|\/)(test-?namespace)[^/]*\.js$/i.test(relativePath);
|
|
@@ -498,15 +564,41 @@ function callsNamespaceHelper(text) {
|
|
|
498
564
|
.some((line) => !isCommentLine(line) && NAMESPACE_HELPER_CALL.test(line));
|
|
499
565
|
}
|
|
500
566
|
|
|
501
|
-
/** True when `pattern` matches on a line that executes, not one that describes.
|
|
502
|
-
|
|
503
|
-
|
|
567
|
+
/** True when `pattern` matches on a line that executes, not one that describes.
|
|
568
|
+
* `openers` says which marker opens a comment in THIS text; the rule is the
|
|
569
|
+
* same one either way. */
|
|
570
|
+
function matchesOnExecutingLine(text, pattern, openers = JS_COMMENT_OPENERS) {
|
|
571
|
+
return text.split('\n').some((line) => !isCommentLine(line, openers) && pattern.test(line));
|
|
504
572
|
}
|
|
505
573
|
|
|
506
|
-
/**
|
|
507
|
-
|
|
574
|
+
/**
|
|
575
|
+
* What opens a comment, per language. ONE list rather than one predicate each:
|
|
576
|
+
* the RULE is "a line that opens with a comment marker describes, it does not
|
|
577
|
+
* execute", and only the marker differs between the files this module reads
|
|
578
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
579
|
+
*
|
|
580
|
+
* `#` is deliberately NOT in the JavaScript list: a private class field
|
|
581
|
+
* (`#tenantId = 100`) opens with it and very much executes, so adding it there
|
|
582
|
+
* would quietly excuse R8's own subject.
|
|
583
|
+
*/
|
|
584
|
+
const JS_COMMENT_OPENERS = Object.freeze(['//', '*', '/*']);
|
|
585
|
+
|
|
586
|
+
/** YAML's marker — and, inside a `script:` block, the shell's. Same character. */
|
|
587
|
+
const YAML_COMMENT_OPENERS = Object.freeze(['#']);
|
|
588
|
+
|
|
589
|
+
/** A literal inside a comment is prose — it cannot reach a database, and a
|
|
590
|
+
* command inside one cannot run. */
|
|
591
|
+
function isCommentLine(line, openers = JS_COMMENT_OPENERS) {
|
|
508
592
|
const trimmed = line.trim();
|
|
509
|
-
return
|
|
593
|
+
return openers.some((opener) => trimmed.startsWith(opener));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The whole line the offset sits on — what turns a match position into
|
|
597
|
+
* something `isCommentLine` can judge. */
|
|
598
|
+
function lineAt(text, offset) {
|
|
599
|
+
const start = text.lastIndexOf('\n', offset) + 1;
|
|
600
|
+
const end = text.indexOf('\n', offset);
|
|
601
|
+
return text.slice(start, end === -1 ? text.length : end);
|
|
510
602
|
}
|
|
511
603
|
|
|
512
604
|
/**
|
|
@@ -671,8 +763,26 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
|
|
|
671
763
|
if (!/tenant_id|workspace_id/i.test(text)) continue;
|
|
672
764
|
// Either the shared helper from conn-orch-validator, or a repo-local module
|
|
673
765
|
// that itself reads the platform env (checked above).
|
|
766
|
+
//
|
|
767
|
+
// BOTH are earned on a line that EXECUTES. The require half was measured
|
|
768
|
+
// over the whole text until d.782b — the only one of the four permits that
|
|
769
|
+
// was — so `// TODO: require('./testNamespace')` in a comment excused a file
|
|
770
|
+
// from R8 entirely, which is the defect the helper permit had before d.700:
|
|
771
|
+
// a rule a mention satisfies checks nothing (`automation-gates.md` §5).
|
|
772
|
+
// Unifying cost nobody anything, and that was measured before it landed:
|
|
773
|
+
// of the 161 files R8 judges across `api_biz/*/tests/integration/**`, 54
|
|
774
|
+
// pass on this permit alone and all 54 carry the require on an executing
|
|
775
|
+
// line — the gate arrived with compliance, not with a backlog (§3).
|
|
776
|
+
//
|
|
777
|
+
// What deliberately stays looser: this half reads the require, not a use of
|
|
778
|
+
// what it returns, so a file that imports the module and then ignores it
|
|
779
|
+
// still passes. That is a choice, not an oversight — the module itself is
|
|
780
|
+
// the thing being trusted (it reads the platform env, and `isNamespaceModule`
|
|
781
|
+
// above holds it to that), so importing it is the statement R8 is after.
|
|
782
|
+
// Tightening it to "and then uses it" is its own decision, with its own
|
|
783
|
+
// measurement.
|
|
674
784
|
if (callsNamespaceHelper(text)) continue;
|
|
675
|
-
if (
|
|
785
|
+
if (matchesOnExecutingLine(text, REQUIRE_NAMESPACE_MODULE)) continue;
|
|
676
786
|
|
|
677
787
|
// Third permit — the file builds its throwaway through the library.
|
|
678
788
|
//
|
|
@@ -722,18 +832,33 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
|
|
|
722
832
|
+ ' at it, so nothing it runs can land in live data.\n'
|
|
723
833
|
+ " Fix: set process.env.DB_NAME to that schema before the first require\n"
|
|
724
834
|
+ ' of the service\'s database config; or, if the file does target the\n'
|
|
725
|
-
+ ' shared namespace after all, take it from the
|
|
726
|
-
+ '
|
|
835
|
+
+ ' shared namespace after all, take it from one of the two sources R8\n'
|
|
836
|
+
+ ' accepts: a getTestNamespace() / getForeignTestNamespace() call on an\n'
|
|
837
|
+
+ ' executing line, or a require() of the repo\'s\n'
|
|
838
|
+
+ ' tests/integration/testNamespace.js.\n'
|
|
727
839
|
+ ' Rule: api/docs/standards/tenant-allocation.md § Enforcement');
|
|
728
840
|
continue;
|
|
729
841
|
}
|
|
730
842
|
|
|
843
|
+
// The Expected line names BOTH sources the permits above accept, in the
|
|
844
|
+
// shape each one is actually read: `callsNamespaceHelper()` wants the call
|
|
845
|
+
// on a line that executes, the require permit wants a require() whose path
|
|
846
|
+
// names the module. Until d.778 the Fix named only the second, so a test
|
|
847
|
+
// that already used the first and failed for another reason was sent to fix
|
|
848
|
+
// a file that was not the problem (measured by BIZ-invoicing over their MR
|
|
849
|
+
// pipeline). A gate says what it checked, what it expected and what fixes
|
|
850
|
+
// it (`automation-gates.md` §1 requirement 4).
|
|
731
851
|
add('R8', `${file.relative} names a tenant / workspace without taking it from the\n`
|
|
732
|
-
+ '
|
|
852
|
+
+ ' namespace source this rule reads.\n'
|
|
733
853
|
+ ' An integration test writes into a real database, so the namespace it\n'
|
|
734
854
|
+ ' targets is a safety boundary — and a boundary rewritten per file is a\n'
|
|
735
855
|
+ ' boundary that drifts onto a live tenant.\n'
|
|
736
|
-
+ '
|
|
856
|
+
+ ' Expected: one of the TWO sources this rule accepts — a call to\n'
|
|
857
|
+
+ ' getTestNamespace() or getForeignTestNamespace() on a line that executes,\n'
|
|
858
|
+
+ ' or a require() whose path names the repo\'s\n'
|
|
859
|
+
+ ' tests/integration/testNamespace.js.\n'
|
|
860
|
+
+ ' Fix: add whichever this test needs; a helper named only in a comment or\n'
|
|
861
|
+
+ ' inside a string earns neither.');
|
|
737
862
|
}
|
|
738
863
|
}
|
|
739
864
|
|
package/src/utils/envContract.js
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
* - environment read inside an installed library (`node_modules`), e.g.
|
|
53
53
|
* `SECRETS_MASTER_KEY` in @onlineapps/service-wrapper. Those names are
|
|
54
54
|
* declared by hand until libraries export their own env schemas (variant D
|
|
55
|
-
* of the F16 design
|
|
55
|
+
* of the F16 design — tracked in api/shared/TODO.md under F16);
|
|
56
56
|
* - anything outside `src/`, `index.js`, `scripts/` and `config/service/*.json`.
|
|
57
57
|
*
|
|
58
58
|
* The scan therefore proves one direction only: what it CAN see is declared.
|
|
@@ -463,7 +463,7 @@ function verifyEnvCompleteness({ serviceRoot, contract }) {
|
|
|
463
463
|
*
|
|
464
464
|
* Runs in Tier-1 phase 0.2, before the wrapper opens a connector — a missing
|
|
465
465
|
* key must fail where its name is known, not several layers deeper (defect D2:
|
|
466
|
-
* SECRETS_MASTER_KEY failed in ServiceWrapper.
|
|
466
|
+
* SECRETS_MASTER_KEY failed in `ServiceWrapper._initializeSecrets()`, after MQ registration).
|
|
467
467
|
*
|
|
468
468
|
* @param {object} args
|
|
469
469
|
* @param {object|null} args.declaration normalized env declaration, null when absent
|
package/src/utils/handlerRef.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* - `parseHandlerRef` states the DECLARED FORM. `handlers/<path>#<export>` is
|
|
24
24
|
* owned by api/docs/biz/30-operations/schema-v3.md § handler, and every
|
|
25
|
-
* shape check in this package
|
|
25
|
+
* shape check in this package goes through `parseHandlerRef` instead of
|
|
26
26
|
* restating it.
|
|
27
27
|
* - `resolveHandlerModule` states RESOLUTION: containment inside
|
|
28
28
|
* `<serviceRoot>/src`, `require`, and an export that is a function. It does
|
|
@@ -35,16 +35,14 @@
|
|
|
35
35
|
*
|
|
36
36
|
* @see api/docs/biz/10-invocation/handler-dispatch.md § `HandlerRegistry` — resolution rules
|
|
37
37
|
* @see api/docs/biz/30-operations/schema-v3.md § handler
|
|
38
|
+
*
|
|
39
|
+
* The value `HANDLER_REF_REGEX` has one owner: the public entry of
|
|
40
|
+
* `@onlineapps/service-validator-core` (the mirror of the registry's rules).
|
|
41
|
+
* This module reads it from there and keeps no copy of its own.
|
|
38
42
|
*/
|
|
39
43
|
|
|
40
44
|
const path = require('path');
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* The declared form of a handler ref — the single literal in this package.
|
|
44
|
-
* The path charset carries no `.`, which is why a conformant ref can express
|
|
45
|
-
* neither a file extension nor a `..` traversal.
|
|
46
|
-
*/
|
|
47
|
-
const HANDLER_REF_PATTERN = /^handlers\/[a-zA-Z0-9_/-]+#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
45
|
+
const { HANDLER_REF_REGEX } = require('@onlineapps/service-validator-core');
|
|
48
46
|
|
|
49
47
|
/** How the ref is printed back to whoever has to fix it. */
|
|
50
48
|
function printRef(value) {
|
|
@@ -97,7 +95,7 @@ function splitHandlerRef(handlerRef) {
|
|
|
97
95
|
function parseHandlerRef(handlerRef) {
|
|
98
96
|
const parts = splitHandlerRef(handlerRef);
|
|
99
97
|
|
|
100
|
-
if (!
|
|
98
|
+
if (!HANDLER_REF_REGEX.test(handlerRef)) {
|
|
101
99
|
throw new Error(
|
|
102
100
|
'[HandlerRef] handlerRef does not match the declared form - Expected '
|
|
103
101
|
+ '"handlers/<path>#<exportName>" (path relative to src/, no file extension). '
|
|
@@ -178,4 +176,4 @@ function resolveHandlerModule({ serviceRoot, handlerRef } = {}) {
|
|
|
178
176
|
return { modulePath, exportName, handler };
|
|
179
177
|
}
|
|
180
178
|
|
|
181
|
-
module.exports = {
|
|
179
|
+
module.exports = { parseHandlerRef, resolveHandlerModule };
|
|
@@ -183,7 +183,7 @@ function assertExistingPath(value, name, fix) {
|
|
|
183
183
|
* One runner process per integration test file.
|
|
184
184
|
*
|
|
185
185
|
* The service declares WHAT its integration suite is; the library decides HOW it
|
|
186
|
-
* is executed (docs/biz/00-model/uniformity-principle.md). Per-file processes are
|
|
186
|
+
* is executed (api/docs/biz/00-model/uniformity-principle.md). Per-file processes are
|
|
187
187
|
* the uniform HOW because they are the only shape that also fits biz-invoicing,
|
|
188
188
|
* whose suite was OOM-killed in a single process — and they bound the blast radius
|
|
189
189
|
* of one leaking suite for everybody else. No new declaration is needed for it.
|