@onlineapps/conn-orch-validator 12.1.1 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +607 -0
  2. package/README.md +126 -19
  3. package/manifests/biz-service.manifest.json +15 -2
  4. package/manifests/library.manifest.json +4 -4
  5. package/package.json +11 -3
  6. package/src/CookbookTestRunner.js +275 -105
  7. package/src/CookbookTestUtils.js +79 -68
  8. package/src/ServiceReadinessValidator.js +42 -52
  9. package/src/ValidationOrchestrator.js +65 -44
  10. package/src/cli/biz-ci-gate.js +2 -2
  11. package/src/cli/oa-sync-template.js +97 -47
  12. package/src/cli/oa-validate.js +44 -10
  13. package/src/helpers/README.md +6 -6
  14. package/src/helpers/createServiceReadinessTests.js +87 -33
  15. package/src/index.js +14 -5
  16. package/src/lint/scripts/lintScripts.js +11 -4
  17. package/src/manifest/checks/libraryContext.js +6 -3
  18. package/src/manifest/checks/libraryDocs.js +174 -4
  19. package/src/manifest/checks/libraryTests.js +200 -19
  20. package/src/manifest/checks/scriptHeaders.js +6 -13
  21. package/src/manifest/checks/serviceConfig.js +36 -16
  22. package/src/manifest/checks/serviceConnectors.js +180 -2
  23. package/src/manifest/checks/serviceDb.js +0 -3
  24. package/src/manifest/checks/serviceScripts.js +3 -20
  25. package/src/manifest/runManifest.js +90 -13
  26. package/src/manifest/workspaceRoot.js +133 -4
  27. package/src/mocks/MockMQClient.js +2 -2
  28. package/src/sync/docsRegion.js +2 -2
  29. package/src/sync/readmeFile.js +30 -0
  30. package/src/sync/readmeLocation.js +2 -12
  31. package/src/sync/readmePointer.js +10 -4
  32. package/src/sync/serviceTemplate.js +9 -11
  33. package/src/sync/sharedEnv.js +59 -3
  34. package/src/sync/uniformFiles.js +81 -8
  35. package/src/utils/bizCiGateContract.js +2 -2
  36. package/src/utils/connectorContract.js +54 -2
  37. package/src/utils/cookbookFormat.js +25 -115
  38. package/src/utils/dbAccountGrants.js +5 -3
  39. package/src/utils/deployContract.js +153 -28
  40. package/src/utils/envContract.js +2 -2
  41. package/src/utils/handlerRef.js +8 -10
  42. package/src/utils/integrationRun.js +1 -1
  43. package/src/utils/operationsDocumentRules.js +242 -0
  44. package/src/utils/operationsRules.js +157 -0
  45. package/src/utils/resolveHeaders.js +12 -1
  46. package/src/utils/setupDatabase.js +1 -1
  47. package/src/utils/stepFailure.js +3 -3
  48. package/src/utils/stepReferences.js +28 -87
  49. package/src/utils/throwawaySchema.js +1 -1
  50. package/src/utils/yamlTopLevel.js +105 -0
  51. package/src/validators/ServiceStructureValidator.js +67 -152
  52. package/templates/business-service/.gitlab-ci.yml +203 -37
  53. package/templates/business-service/README.md +7 -4
  54. package/templates/business-service/config/env-templates/shared.env +1 -0
  55. package/templates/business-service/docs/80-setup/INSTALL.md +31 -3
  56. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +5 -2
  57. package/templates/business-service/src/config/index.js +15 -0
  58. package/TESTING_STRATEGY.md +0 -92
  59. package/jest.config.js +0 -37
@@ -1,23 +1,23 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Cookbook format rule — the single place in this package that decides which
5
- * cookbook format version the platform accepts.
4
+ * Cookbook format helpers this package still owns — what
5
+ * `@onlineapps/cookbook-core` `validateCookbook` does not do.
6
6
  *
7
- * The rule itself is owned by the biz doc tree, not by this file:
8
- * api/docs/biz/40-cookbooks/format.md § "Required fields" — `version` is a
9
- * REQUIRED string, `"2.1.0"` or higher. This module only implements that
10
- * sentence so both entry points enforce the same thing:
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
- * - `CookbookTestRunner.validateCookbook()` — the Tier-1 gate every biz
13
- * service runs at boot (mandatory, no per-repo opt-in);
14
- * - `CookbookTestUtils.validateCookbook()` — the readiness-wrapper path
15
- * three repos still call from `tests/bootstrap/service-readiness.test.js`.
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
- * `major.minor.patch`, digits only, all three components required.
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 of
122
- * step objects, each carrying `step_id` — and refuse everything else.
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
- * 1. **Any shape but an array.** Owner decision 2026-09-05, fully authoritative
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:32-37`), so an
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
- * 2. **A step spelling its identifier `id`.** The node bans it outright
143
- * (format.md § The array is the only accepted shape). Silently reading `id`
144
- * is the fallback
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, or a step carries `id`
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; `readCookbookSteps` refuses a step that carries `id`, so nothing
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. Measured against the dev server in
46
- * `tests/unit/bizCiGateCli.setupDbAccount.integration.test.js`: with the
47
- * unescaped grant the account reads the neighbour's rows; with it, `ERROR 1142`.
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 (GIT_PULL.test(ci)) {
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 (GIT_MERGE.test(ci)) {
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
- // Ordering is read over the whole file rather than per job: which YAML key a
234
- // line sits under is the service's own half of the pipeline, and a gate that
235
- // parsed jobs would be judging that half too. What it does measure is the
236
- // thing that can go wrong — a guard that names another commit, another branch,
237
- // or runs after the reset it is supposed to protect.
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
- if (!guards.some((guard) => guard.at < reset.at && guard.commit === reset.target)) {
252
- add('R2', `The deploy resets to ${reset.target}, which is not verified to be an ancestor of `
253
- + `${PRODUCTION_BRANCH} first.\n`
254
- + ' A commit that is not on the branch this box follows belongs to a different history,\n'
255
- + ' and resetting to it puts the box on code production never took.\n'
256
- + ` Expected: git merge-base --is-ancestor ${reset.target} ${PRODUCTION_BRANCH}, on a line\n`
257
- + ' BEFORE the reset, naming that same commit.\n'
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
- function matchesOnExecutingLine(text, pattern) {
503
- return text.split('\n').some((line) => !isCommentLine(line) && pattern.test(line));
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
- /** A literal inside a comment is prose — it cannot reach a database. */
507
- function isCommentLine(line) {
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 trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
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 (/require\([^)]*test-?namespace[^)]*\)/i.test(text)) continue;
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 repo\'s\n'
726
- + ' tests/integration/testNamespace.js instead.\n'
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
- + ' shared test-namespace module.\n'
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
- + ' Fix: require the repo\'s tests/integration/testNamespace.js and use it.');
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
 
@@ -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, api/shared/TODO.md §5);
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.js:916, after MQ registration).
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
@@ -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 now reads `HANDLER_REF_PATTERN` instead of
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 (!HANDLER_REF_PATTERN.test(handlerRef)) {
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 = { HANDLER_REF_PATTERN, parseHandlerRef, resolveHandlerModule };
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.