@onlineapps/conn-orch-validator 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +2558 -2
  2. package/README.md +1038 -4
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +140 -9
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +2 -1
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +290 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -1,17 +1,34 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Installation contract for a biz service repository.
4
+ * Installation contract for a repository.
5
5
  *
6
- * SETUP_DOCS every repo carries docs/setup/{INSTALL,PLATFORM_MATRIX,VALIDATION}.md
7
- * SQL_PACKAGE a repo WITH a database carries the BASELINE / SEED SQL tree
8
- * SQL_HEADERS every SQL package file declares its dataset class and safety
6
+ * SETUP_DOCS every repo carries docs/80-setup/{INSTALL,PLATFORM_MATRIX,VALIDATION}.md
7
+ * The branch is numbered because DOC-STANDARD rule 7 governs the
8
+ * shape of a docs tree and the contract follows it, not the other
9
+ * way round (confirmation docs-setup-branch-naming 001). The number
10
+ * is 80: property, the only numbered biz tree so far, already carries
11
+ * docs/80-setup/; 30 would collide with property's 30-recurring; and
12
+ * setup is an operational appendix behind the contract nodes, in the
13
+ * same 80-decisions/90-migration band as the platform tree.
14
+ * The number is a literal and not the `\d{2}-setup` shape rule 7
15
+ * permits, because eight repositories each free to pick a digit pair
16
+ * is eight layouts: the confirmation delegates ONE number, and this
17
+ * is where it is written down.
18
+ * SQL_PACKAGE a repo WITH a database carries the BASELINE / SEED SQL tree, and a
19
+ * manifest package (§3 model 2) names exactly what the installer
20
+ * applies — in both directions (§7)
21
+ * SQL_HEADERS every live SQL file declares its dataset class and safety, with
22
+ * VALUES from the contract's closed vocabularies and not merely the
23
+ * four keys (§4)
9
24
  *
10
- * Rules: ADR 0006 (api/docs/biz/80-decisions/) — database and test-data contract.
25
+ * Rules: api/docs/standards/repository-installation-sql-contract.md §2-§5, §7
26
+ * @see api/docs/governance/confirmations/installation-sql-contract-scope.md
27
+ * @see api/docs/governance/confirmations/docs-setup-branch-naming.md
11
28
  *
12
29
  * Whether the SQL half applies is DERIVED from the service's own
13
30
  * integration-contract.json database block, never declared a second time here.
14
- * The eight shell copies this replaces each carried a hand-maintained
31
+ * The nine shell copies this replaces each carried a hand-maintained
15
32
  * REPO_HAS_DB literal, and three of them (converter, hello-service, ingest) said
16
33
  * "false" while their contract declared a database — so the SQL half checked
17
34
  * nothing in those repos while still printing PASS. One fact, one owner: the
@@ -27,10 +44,30 @@
27
44
  const fs = require('fs');
28
45
  const path = require('path');
29
46
 
47
+ const { compareVersion } = require('./migrationOrder');
48
+ const { normalizeDatabaseDeclaration } = require('./bizCiGateContract');
49
+ // Exact-name existence: `fs.existsSync` answers true for `install.md` on a
50
+ // case-insensitive filesystem (macOS APFS), so a gate built on it says one thing
51
+ // on a developer machine and another in node:24-alpine (BIZ-PROPERTY, 2026-09-14:
52
+ // 361 findings vs 363). `automation-gates.md` §1.1 — same inputs, same result.
53
+ const { existsExactly } = require('../manifest/checks/gitTracked');
54
+
55
+ /**
56
+ * The requirement ids this module raises, and the ONLY place their extent is
57
+ * stated. `add()` below refuses an id that is not on this list, and the manifest
58
+ * bridge reads it to refuse a row citing a requirement nothing raises.
59
+ *
60
+ * Exported for the same reason `deployContract.js` exports its own list: the
61
+ * bridge had a hand-written copy of these three ids, and a copy of a list is a
62
+ * list that drifts — measured on the deploy contract, whose four messages named
63
+ * one requirement fewer than the module enforced, for months.
64
+ */
65
+ const INSTALL_CONTRACT_REQUIREMENTS = Object.freeze(['SETUP_DOCS', 'SQL_PACKAGE', 'SQL_HEADERS']);
66
+
30
67
  const SETUP_DOCS = [
31
- 'docs/setup/INSTALL.md',
32
- 'docs/setup/PLATFORM_MATRIX.md',
33
- 'docs/setup/VALIDATION.md'
68
+ 'docs/80-setup/INSTALL.md',
69
+ 'docs/80-setup/PLATFORM_MATRIX.md',
70
+ 'docs/80-setup/VALIDATION.md'
34
71
  ];
35
72
 
36
73
  const SQL_DIRECTORIES = [
@@ -39,15 +76,44 @@ const SQL_DIRECTORIES = [
39
76
  'migrations/SEED/test_only'
40
77
  ];
41
78
 
42
- const REQUIRED_HEADERS = [
43
- '-- Dataset-Class:',
44
- '-- Target-DB:',
45
- '-- Safe-For-Production:',
46
- '-- Idempotency:'
47
- ];
79
+ const MIGRATIONS_ROOT = 'migrations';
80
+
81
+ /**
82
+ * History is outside the installation contract: the contract is what a cold
83
+ * start applies, and these two hold the record of how a baseline was reached
84
+ * (owner, `installation-sql-contract-scope` 001 — "archive/ určitě nepatří",
85
+ * and `superseded/` is to be merged into it by the repositories that own one).
86
+ * The installer agrees by construction: `setupDatabase.resolveMigrationPlan`
87
+ * reads one directory's own .sql files and descends into no subdirectory.
88
+ */
89
+ const HISTORY_DIRECTORIES = new Set(['archive', 'superseded']);
90
+
91
+ /**
92
+ * §4. Three of the four fields are CLOSED vocabularies and the fourth is a name.
93
+ * The gate enforces the vocabulary and not mere presence, because a header that
94
+ * parses but lies is worse than a missing one: measured 2026-08-31, a live file
95
+ * shipped `Idempotency: full` — a value this contract never defined — and every
96
+ * copy of the gate printed PASS.
97
+ *
98
+ * `Target-DB` carries `null`: the contract asks only that it is present, and
99
+ * comparing it against the schema the integration contract declares would make
100
+ * the header a second copy of that fact. One copy of the gate does compare them;
101
+ * the contract does not ask for it, and this module implements the contract.
102
+ */
103
+ const HEADER_FIELDS = Object.freeze([
104
+ Object.freeze({ name: 'Dataset-Class', values: Object.freeze(['BASELINE', 'PRODUCTION_LIKE', 'TEST_ONLY', 'MIGRATION']) }),
105
+ Object.freeze({ name: 'Target-DB', values: null }),
106
+ Object.freeze({ name: 'Safe-For-Production', values: Object.freeze(['yes', 'no']) }),
107
+ Object.freeze({ name: 'Idempotency', values: Object.freeze(['yes', 'partial', 'no']) })
108
+ ]);
48
109
 
49
110
  const TEST_ONLY_DIRECTORY = 'migrations/SEED/test_only';
50
111
 
112
+ /** The marker §4 asks every test-only file to carry visibly, in comments. */
113
+ const TEST_ONLY_MARKER = 'TEST-ONLY';
114
+
115
+ const APPLIES_PREFIX = '-- Applies:';
116
+
51
117
  /** Sorted so the report order is the directory order, not the filesystem's. */
52
118
  function listSqlFiles(absoluteDir) {
53
119
  return fs.readdirSync(absoluteDir)
@@ -55,41 +121,168 @@ function listSqlFiles(absoluteDir) {
55
121
  .sort();
56
122
  }
57
123
 
124
+ /**
125
+ * Every live SQL file of the package, repo-relative and depth-first in name
126
+ * order, with history pruned. The whole tree is in scope and not the three
127
+ * package directories alone: the root `migrations/*.sql` set IS the live
128
+ * migration set of four services, and until this loop reached it those files
129
+ * carried no header requirement at all while the gate reported PASS.
130
+ */
131
+ function listLiveSqlFiles(serviceRoot) {
132
+ const found = [];
133
+
134
+ const walk = (relativeDir) => {
135
+ const absoluteDir = path.join(serviceRoot, relativeDir);
136
+ if (!fs.existsSync(absoluteDir)) return;
137
+
138
+ const entries = fs.readdirSync(absoluteDir, { withFileTypes: true })
139
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
140
+
141
+ for (const entry of entries) {
142
+ if (entry.isDirectory()) {
143
+ if (HISTORY_DIRECTORIES.has(entry.name)) continue;
144
+ walk(`${relativeDir}/${entry.name}`);
145
+ } else if (entry.isFile() && entry.name.endsWith('.sql')) {
146
+ found.push(`${relativeDir}/${entry.name}`);
147
+ }
148
+ }
149
+ };
150
+
151
+ walk(MIGRATIONS_ROOT);
152
+ return found;
153
+ }
154
+
155
+ /**
156
+ * The declared value of one header: the whole text after the colon, and the
157
+ * first token of it. §4 — "the value is the first token on the line; free text
158
+ * after it is allowed", which is what makes a claim checkable (`yes (CREATE
159
+ * TABLE IF NOT EXISTS)` says why, `yes` asks to be believed).
160
+ *
161
+ * @returns {{declared: string, token: string}|null} null when the file carries
162
+ * no such header line at all
163
+ */
164
+ function readHeader(lines, name) {
165
+ const prefix = `-- ${name}:`;
166
+ const line = lines.find((candidate) => candidate.startsWith(prefix));
167
+ if (line === undefined) return null;
168
+
169
+ const declared = line.slice(prefix.length).trim();
170
+ return { declared, token: declared.split(/\s+/)[0] ?? '' };
171
+ }
172
+
58
173
  function checkSetupDocs(serviceRoot, add) {
59
174
  for (const relativePath of SETUP_DOCS) {
60
- if (!fs.existsSync(path.join(serviceRoot, relativePath))) {
175
+ if (!existsExactly(serviceRoot, relativePath.split('/'))) {
61
176
  add('SETUP_DOCS', `Missing required file - ${relativePath}. `
62
- + `Fix: add ${relativePath} to the repository.`);
177
+ + `Fix: add ${relativePath} to the repository; `
178
+ + 'a repo carrying the older docs/setup/ branch renames it to docs/80-setup/.');
63
179
  }
64
180
  }
65
181
  }
66
182
 
67
- function checkSqlHeaders(serviceRoot, relativeDir, add) {
68
- for (const name of listSqlFiles(path.join(serviceRoot, relativeDir))) {
69
- const relativeFile = `${relativeDir}/${name}`;
70
- const content = fs.readFileSync(path.join(serviceRoot, relativeFile), 'utf8');
71
- const lines = content.split('\n');
183
+ function checkSqlHeaders(serviceRoot, relativeFile, add) {
184
+ const content = fs.readFileSync(path.join(serviceRoot, relativeFile), 'utf8');
185
+ const lines = content.split('\n');
72
186
 
73
- for (const header of REQUIRED_HEADERS) {
74
- if (!lines.some((line) => line.startsWith(header))) {
75
- add('SQL_HEADERS', `Missing "${header}" header - ${relativeFile}. `
76
- + `Fix: add the "${header}" header line to the file.`);
77
- }
187
+ const declared = {};
188
+ for (const field of HEADER_FIELDS) {
189
+ const header = readHeader(lines, field.name);
190
+ if (header === null) {
191
+ add('SQL_HEADERS', `Missing "-- ${field.name}:" header - ${relativeFile}. `
192
+ + `Fix: add the "-- ${field.name}:" header line to the file.`);
193
+ continue;
78
194
  }
79
195
 
80
- if (relativeDir !== TEST_ONLY_DIRECTORY) continue;
196
+ declared[field.name] = header.token;
81
197
 
82
- // Test data must be unmistakable at the file level: a TEST_ONLY package that
83
- // reads as production-safe is the one mistake that reaches a real database.
84
- if (!lines.some((line) => line.startsWith('-- Dataset-Class: TEST_ONLY'))) {
85
- add('SQL_HEADERS', `Wrong Dataset-Class - ${relativeFile} must declare `
86
- + '"-- Dataset-Class: TEST_ONLY". Fix: correct the Dataset-Class header.');
87
- }
88
- if (!lines.some((line) => line.startsWith('-- Safe-For-Production: no'))) {
89
- add('SQL_HEADERS', `Wrong Safe-For-Production - ${relativeFile} must declare `
90
- + '"-- Safe-For-Production: no". Fix: correct the Safe-For-Production header.');
198
+ if (field.values !== null && !field.values.includes(header.token)) {
199
+ add('SQL_HEADERS', `Value outside the vocabulary - ${relativeFile} declares `
200
+ + `"-- ${field.name}: ${header.declared}". Fix: the first token must be one of `
201
+ + `${field.values.join(', ')}; free text may follow it.`);
91
202
  }
92
203
  }
204
+
205
+ // Test data must be unmistakable at the file level: a TEST_ONLY package that
206
+ // reads as production-safe is the one mistake that reaches a real database.
207
+ // The directory separates it (§5 point 3) and the class declares it (§4); a
208
+ // file is test-only as soon as EITHER says so, because the mistake is exactly
209
+ // the case where the two disagree.
210
+ const inTestOnlyDirectory = relativeFile.startsWith(`${TEST_ONLY_DIRECTORY}/`);
211
+ if (inTestOnlyDirectory && declared['Dataset-Class'] !== 'TEST_ONLY') {
212
+ add('SQL_HEADERS', `Wrong Dataset-Class - ${relativeFile} must declare `
213
+ + '"-- Dataset-Class: TEST_ONLY". Fix: correct the Dataset-Class header.');
214
+ }
215
+
216
+ const isTestOnly = inTestOnlyDirectory || declared['Dataset-Class'] === 'TEST_ONLY';
217
+ if (!isTestOnly) return;
218
+
219
+ if (declared['Safe-For-Production'] !== 'no') {
220
+ add('SQL_HEADERS', `Wrong Safe-For-Production - ${relativeFile} must declare `
221
+ + '"-- Safe-For-Production: no". Fix: correct the Safe-For-Production header.');
222
+ }
223
+
224
+ // §4 last bullet: the marker is a SECOND, human-visible signal, deliberately
225
+ // not the same string as the class header (TEST-ONLY, not TEST_ONLY), so a
226
+ // reader who opens the file half-way down still sees it.
227
+ const hasMarker = lines.some((line) => line.includes('--') && line.includes(TEST_ONLY_MARKER));
228
+ if (!hasMarker) {
229
+ add('SQL_HEADERS', `Missing ${TEST_ONLY_MARKER} marker - ${relativeFile} is test-only but carries no `
230
+ + `comment containing "${TEST_ONLY_MARKER}". Fix: add a comment line with the ${TEST_ONLY_MARKER} marker.`);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * §7 last bullet: a manifest package is incomplete in either direction — a file
236
+ * it names does not exist, or a file the installer applies is not named.
237
+ * Headers alone say nothing about this: a manifest is a hand-written list of
238
+ * file names, and such a list rots the moment somebody adds a file beside the
239
+ * ones it names (`doc-code-binding.md` §1), silently, with every header still
240
+ * in place.
241
+ *
242
+ * A file IS a manifest exactly when it carries an `-- Applies:` line. That is
243
+ * what distinguishes §3's two allowed models without a second declaration: a
244
+ * native package file (model 1) carries the statements itself and names nothing.
245
+ *
246
+ * The source directory is likewise DERIVED — it is the directory of the paths
247
+ * the manifest already names, which is why all lines of one manifest must live
248
+ * in one directory. The order compared against is the installer's own
249
+ * (`migrationOrder.compareVersion`, shared with `setupDatabase.js` and with the
250
+ * operator's `sort -V`), because with migrations the order IS semantics.
251
+ */
252
+ function checkManifestCompleteness(serviceRoot, manifestFile, add) {
253
+ const content = fs.readFileSync(path.join(serviceRoot, manifestFile), 'utf8');
254
+ const declared = content.split('\n')
255
+ .filter((line) => line.startsWith(APPLIES_PREFIX))
256
+ .map((line) => line.slice(APPLIES_PREFIX.length).trim())
257
+ .filter((value) => value.length > 0);
258
+
259
+ if (declared.length === 0) return;
260
+
261
+ const missing = declared.filter((relativeFile) => !existsExactly(serviceRoot, relativeFile.split('/')));
262
+ if (missing.length > 0) {
263
+ add('SQL_PACKAGE', `Manifest names a file that does not exist - ${manifestFile} declares `
264
+ + `"${missing.join(', ')}". Fix: correct the "-- Applies:" path, or add the file.`);
265
+ return;
266
+ }
267
+
268
+ const sourceDirectory = path.posix.dirname(declared[0]);
269
+ const stray = declared.filter((relativeFile) => path.posix.dirname(relativeFile) !== sourceDirectory);
270
+ if (stray.length > 0) {
271
+ add('SQL_PACKAGE', `Manifest straddles two directories - ${manifestFile} names ${sourceDirectory} `
272
+ + `first but also declares "${stray.join(', ')}". Fix: one manifest covers one source directory; `
273
+ + 'give the other directory its own manifest.');
274
+ return;
275
+ }
276
+
277
+ const applied = listSqlFiles(path.join(serviceRoot, sourceDirectory))
278
+ .sort(compareVersion)
279
+ .map((name) => `${sourceDirectory}/${name}`);
280
+
281
+ if (declared.join('\n') !== applied.join('\n')) {
282
+ add('SQL_PACKAGE', `Manifest does not match what the installer applies - ${manifestFile} declares `
283
+ + `"${declared.join(', ')}" and ${sourceDirectory}/*.sql applies "${applied.join(', ')}". `
284
+ + 'Fix: make the "-- Applies:" lines name exactly those files, in that order.');
285
+ }
93
286
  }
94
287
 
95
288
  function checkSqlPackage(serviceRoot, add) {
@@ -107,10 +300,47 @@ function checkSqlPackage(serviceRoot, add) {
107
300
  if (listSqlFiles(absoluteDir).length === 0) {
108
301
  add('SQL_PACKAGE', `No SQL package files - ${relativeDir} contains no .sql file. `
109
302
  + 'Fix: add the SQL package files, or remove the database declaration from the contract.');
110
- continue;
111
303
  }
304
+ }
305
+
306
+ const liveFiles = listLiveSqlFiles(serviceRoot);
307
+
308
+ for (const relativeFile of liveFiles) {
309
+ checkSqlHeaders(serviceRoot, relativeFile, add);
310
+ }
311
+
312
+ for (const relativeFile of liveFiles) {
313
+ checkManifestCompleteness(serviceRoot, relativeFile, add);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Principle 4: the gate validates its own input before it judges anything.
319
+ *
320
+ * `database` decides whether the SQL half applies at all, and it used to be read
321
+ * as a mere truthiness: the loader envelope (`{serviceRoot, contractPath,
322
+ * contract}`) or the whole normalized contract passed straight through, and the
323
+ * SQL package was then judged — or skipped — on a value this module had never
324
+ * recognised. A gate that cannot say what it was handed prints a guarantee it
325
+ * does not have (automation-gates.md §5).
326
+ *
327
+ * What a normalized block IS stays where it is defined: the check runs
328
+ * `normalizeDatabaseDeclaration` over the argument rather than restating its
329
+ * four fields, so this module cannot drift from the shape it accepts.
330
+ * A normalized block round-trips through it unchanged, which is precisely what
331
+ * makes it usable as the entry check.
332
+ */
333
+ function assertNormalizedDatabase(database) {
334
+ if (database === null || database === undefined) return;
112
335
 
113
- checkSqlHeaders(serviceRoot, relativeDir, add);
336
+ try {
337
+ normalizeDatabaseDeclaration(database, { db: true });
338
+ } catch (error) {
339
+ throw new Error('[InstallContract] Invalid database argument - Expected the normalized database block '
340
+ + 'of an integration contract, or null when the service declares none; the value given is neither: '
341
+ + `${error.message} `
342
+ + 'Fix: pass loadAndValidateIntegrationContract(serviceRoot).contract.database — not the loader '
343
+ + 'envelope that wraps it, and not the whole contract.');
114
344
  }
115
345
  }
116
346
 
@@ -121,8 +351,24 @@ function checkSqlPackage(serviceRoot, add) {
121
351
  * @returns {{service: string, ok: boolean, databaseChecked: boolean, violations: Array<{requirement: string, message: string}>}}
122
352
  */
123
353
  function verifyInstallContract(serviceRoot, database) {
354
+ if (typeof serviceRoot !== 'string' || serviceRoot.trim() === '') {
355
+ throw new Error('[InstallContract] Invalid serviceRoot - Expected a non-empty path to the repository '
356
+ + `root, got ${typeof serviceRoot}. Fix: pass the directory to inspect, e.g. `
357
+ + 'loadAndValidateIntegrationContract(serviceRoot).serviceRoot.');
358
+ }
359
+
360
+ assertNormalizedDatabase(database);
361
+
124
362
  const violations = [];
125
- const add = (requirement, message) => violations.push({ requirement, message });
363
+ const add = (requirement, message) => {
364
+ if (!INSTALL_CONTRACT_REQUIREMENTS.includes(requirement)) {
365
+ throw new Error(`[InstallContract] Unknown requirement "${requirement}" - Expected one of `
366
+ + `${INSTALL_CONTRACT_REQUIREMENTS.join(', ')}. Fix: add the id to `
367
+ + 'INSTALL_CONTRACT_REQUIREMENTS in utils/installContract.js, so the list every reader '
368
+ + 'takes from this module stays this module\'s own.');
369
+ }
370
+ violations.push({ requirement, message });
371
+ };
126
372
 
127
373
  checkSetupDocs(serviceRoot, add);
128
374
 
@@ -139,4 +385,4 @@ function verifyInstallContract(serviceRoot, database) {
139
385
  };
140
386
  }
141
387
 
142
- module.exports = { verifyInstallContract };
388
+ module.exports = { verifyInstallContract, INSTALL_CONTRACT_REQUIREMENTS };
@@ -14,6 +14,15 @@
14
14
  * list — a platform-release entry records what was deployed, so everything in
15
15
  * it is by definition shipped and must match.
16
16
  *
17
+ * That narrowing covers a package the SSOT DECLARES and infra does not install.
18
+ * A package the SSOT declares in NEITHER list is a different thing: not
19
+ * biz-only, but unknown to the platform. R6's rule is that a service pins
20
+ * exactly what the SSOT declares (`.claude/rules/architecture-principles.md`
21
+ * § Version pinning), and for an undeclared package there is no such version at
22
+ * all — so it is refused, never narrowed away. Passing it would be a fallback
23
+ * to "not gated" (principle 3), and it would let a typo or a retired package
24
+ * name travel into an image with the gate reporting OK (measured 2026-09-14).
25
+ *
17
26
  * Pure module: the rules and the fetch live here, presentation and exit codes
18
27
  * live in the CLI.
19
28
  *
@@ -71,13 +80,26 @@ function checkLibCompat(pkg, librarySet) {
71
80
  const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
72
81
  const ours = Object.entries(deps).filter(([name]) => name.startsWith(SCOPE));
73
82
 
74
- const gated = infraConsumed ? ours.filter(([name]) => infraConsumed.has(name)) : ours;
75
- const notGated = infraConsumed
76
- ? ours.filter(([name]) => !infraConsumed.has(name)).map(([name]) => name)
77
- : [];
78
-
83
+ const gated = [];
84
+ const notGated = [];
79
85
  const violations = [];
80
- for (const [name, version] of gated) {
86
+
87
+ for (const [name, version] of ours) {
88
+ if (infraConsumed && !infraConsumed.has(name)) {
89
+ if (!(name in versions)) {
90
+ violations.push({
91
+ package: name,
92
+ message: `${name}: unknown to the platform library SSOT `
93
+ + "- declared in neither 'libraries' nor 'infraConsumed', so no platform version exists to pin against. "
94
+ + `Fix: publish ${name} and add it to config/libraries.json, or remove the pin.`
95
+ });
96
+ continue;
97
+ }
98
+ notGated.push(name);
99
+ continue;
100
+ }
101
+
102
+ gated.push(name);
81
103
  if (!EXACT_VERSION.test(version)) {
82
104
  violations.push({
83
105
  package: name,
@@ -104,7 +126,7 @@ function checkLibCompat(pkg, librarySet) {
104
126
 
105
127
  return {
106
128
  ok: violations.length === 0,
107
- gated: gated.map(([name]) => name),
129
+ gated,
108
130
  notGated,
109
131
  violations
110
132
  };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The order migrations are applied in — ONE definition, shared with the operator.
5
+ *
6
+ * The schema of one service is built by two runners: `ci:gate:setup` (this
7
+ * package, `setupDatabase.js`) proves the set in CI, and the operator script
8
+ * `api/scripts/apply-oagen-meta-install-sql.sh` builds the real installation
9
+ * with `find … -name '*.sql' | LC_ALL=C sort -V`. The installation is the
10
+ * reference: CI has to prove the artefact the operator produces, not one of its
11
+ * own (`.claude/rules/architecture-principles.md` § Version pinning — CI proving
12
+ * one artefact while the deployment runs another is the defect class, and with
13
+ * migrations the order IS semantics: two files that touch the same table give a
14
+ * different result, or fail, when swapped).
15
+ *
16
+ * Until this module existed the CI side used JavaScript `Array#sort`, which is
17
+ * lexicographic by UTF-16 code unit. On three-digit zero-padded prefixes the two
18
+ * agree, which is why the divergence was reported as latent (BIZ-META
19
+ * 2026-08-29). It is not latent: measured 2026-09-14 over
20
+ * `api_biz/property/migrations` (87 files), `999_layer_tick.sql` is applied
21
+ * LAST by `sort -V` and FIRST of the `999*` group by `Array#sort`, because `_`
22
+ * (0x5F) precedes `a` (0x61) lexicographically while version order compares the
23
+ * digit run first and only then the bytes after it.
24
+ *
25
+ * So this is a port of what `sort -V` actually does — GNU coreutils `filevercmp`
26
+ * over gnulib `verrevcmp` — and not an approximation of it that happens to agree
27
+ * on today's file names. Its expectations are taken from the real tool's output,
28
+ * never from reasoning about it (`.claude/rules/architecture-principles.md`
29
+ * §10a).
30
+ *
31
+ * @see api/scripts/apply-oagen-meta-install-sql.sh
32
+ */
33
+
34
+ /** Byte-wise, because `LC_ALL=C sort -V` compares bytes and not code points. */
35
+ function isDigit(byte) {
36
+ return byte >= 0x30 && byte <= 0x39;
37
+ }
38
+
39
+ /**
40
+ * gnulib `order()`: within a non-digit run, letters sort before every other
41
+ * punctuation byte, and `~` sorts before everything including the end of the
42
+ * string. That is what puts `999a_x.sql` ahead of `999_x.sql`.
43
+ */
44
+ function order(byte) {
45
+ if (isDigit(byte)) return 0;
46
+ if ((byte >= 0x41 && byte <= 0x5A) || (byte >= 0x61 && byte <= 0x7A)) return byte;
47
+ if (byte === 0x7E) return -1;
48
+ return byte + 256;
49
+ }
50
+
51
+ /**
52
+ * gnulib `verrevcmp`: alternate between non-digit runs (compared through
53
+ * `order`) and digit runs (compared numerically, leading zeros skipped).
54
+ *
55
+ * @param {Buffer} a
56
+ * @param {Buffer} b
57
+ * @returns {number}
58
+ */
59
+ function verrevcmp(a, b) {
60
+ let i = 0;
61
+ let j = 0;
62
+
63
+ while (i < a.length || j < b.length) {
64
+ let firstDiff = 0;
65
+
66
+ while ((i < a.length && !isDigit(a[i])) || (j < b.length && !isDigit(b[j]))) {
67
+ const ca = i === a.length ? 0 : order(a[i]);
68
+ const cb = j === b.length ? 0 : order(b[j]);
69
+ if (ca !== cb) return ca - cb;
70
+ i += 1;
71
+ j += 1;
72
+ }
73
+
74
+ while (a[i] === 0x30) i += 1;
75
+ while (b[j] === 0x30) j += 1;
76
+
77
+ while (i < a.length && j < b.length && isDigit(a[i]) && isDigit(b[j])) {
78
+ if (firstDiff === 0) firstDiff = a[i] - b[j];
79
+ i += 1;
80
+ j += 1;
81
+ }
82
+
83
+ // A digit run that outlives the other one is the larger number: `10` beats
84
+ // `9` even though `9` beats `1` byte for byte.
85
+ if (i < a.length && isDigit(a[i])) return 1;
86
+ if (j < b.length && isDigit(b[j])) return -1;
87
+ if (firstDiff !== 0) return firstDiff;
88
+ }
89
+
90
+ return 0;
91
+ }
92
+
93
+ /**
94
+ * gnulib `file_prefixlen`: where the file suffix starts, as a byte offset.
95
+ *
96
+ * The suffix is the trailing run of `(\.[A-Za-z~][A-Za-z0-9~]*)+`, and it is
97
+ * compared only after the bases are equal — otherwise `010_a.sql` would sort
98
+ * after `010_ab.sql`, because `.` (0x2E) precedes `b` under `order` and the
99
+ * comparison would reach the dot before the name had ended.
100
+ *
101
+ * @param {string} name
102
+ * @returns {number} byte offset where the suffix begins, or the full length
103
+ */
104
+ function suffixOffset(name) {
105
+ const match = /(?:\.[A-Za-z~][A-Za-z0-9~]*)+$/.exec(name);
106
+ if (match === null) return Buffer.byteLength(name, 'utf8');
107
+ return Buffer.byteLength(name.slice(0, match.index), 'utf8');
108
+ }
109
+
110
+ /**
111
+ * Compare two file names the way `LC_ALL=C sort -V` orders them.
112
+ *
113
+ * The hidden-file cases come first, exactly as gnulib `filevercmp` has them:
114
+ * `.` before `..` before every other dot-name before every ordinary name.
115
+ * Measured — `sort -V` puts `.sql` ahead of `000_a.sql`, which the suffix
116
+ * comparison alone gets backwards.
117
+ *
118
+ * @param {string} a
119
+ * @param {string} b
120
+ * @returns {number} negative when `a` is applied first, positive when `b` is
121
+ */
122
+ function compareVersion(a, b) {
123
+ if (a === b) return 0;
124
+ if (a === '') return -1;
125
+ if (b === '') return 1;
126
+ if (a === '.') return -1;
127
+ if (b === '.') return 1;
128
+ if (a === '..') return -1;
129
+ if (b === '..') return 1;
130
+
131
+ const aHidden = a.startsWith('.');
132
+ const bHidden = b.startsWith('.');
133
+ if (aHidden && !bHidden) return -1;
134
+ if (!aHidden && bHidden) return 1;
135
+
136
+ const x = aHidden ? a.slice(1) : a;
137
+ const y = bHidden ? b.slice(1) : b;
138
+
139
+ const xb = Buffer.from(x, 'utf8');
140
+ const yb = Buffer.from(y, 'utf8');
141
+
142
+ const base = verrevcmp(xb.subarray(0, suffixOffset(x)), yb.subarray(0, suffixOffset(y)));
143
+ if (base !== 0) return base;
144
+
145
+ const whole = verrevcmp(xb, yb);
146
+ if (whole !== 0) return whole;
147
+
148
+ // `sort` falls back to the plain byte comparison for names version order
149
+ // cannot separate, so two names never compare equal unless they are equal.
150
+ return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
151
+ }
152
+
153
+ /**
154
+ * The migration files of one directory, in the order they are applied.
155
+ *
156
+ * @param {string[]} names
157
+ * @returns {string[]} a new array; the input is left alone
158
+ */
159
+ function sortByVersion(names) {
160
+ return [...names].sort(compareVersion);
161
+ }
162
+
163
+ module.exports = { compareVersion, sortByVersion };