@onlineapps/conn-orch-validator 7.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2582 -2
- package/README.md +1038 -4
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +408 -101
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +10 -35
- package/src/ValidationOrchestrator.js +219 -71
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +2 -1
- package/src/helpers/createServiceReadinessTests.js +60 -4
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +140 -9
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +2 -1
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +409 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +22 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +123 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- package/src/WorkflowTestRunner.js +0 -402
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Installation contract for a
|
|
4
|
+
* Installation contract for a repository.
|
|
5
5
|
*
|
|
6
|
-
* SETUP_DOCS every repo carries docs/setup/{INSTALL,PLATFORM_MATRIX,VALIDATION}.md
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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:
|
|
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
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (!
|
|
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,
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
196
|
+
declared[field.name] = header.token;
|
|
81
197
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 };
|
package/src/utils/libCompat.js
CHANGED
|
@@ -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 =
|
|
75
|
-
const notGated =
|
|
76
|
-
? ours.filter(([name]) => !infraConsumed.has(name)).map(([name]) => name)
|
|
77
|
-
: [];
|
|
78
|
-
|
|
83
|
+
const gated = [];
|
|
84
|
+
const notGated = [];
|
|
79
85
|
const violations = [];
|
|
80
|
-
|
|
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
|
|
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 };
|