@onlineapps/conn-orch-validator 6.0.1 → 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.
- package/CHANGELOG.md +2591 -2
- package/README.md +1075 -7
- 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 +422 -104
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +78 -42
- package/src/ValidationOrchestrator.js +298 -75
- 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 +12 -2
- package/src/helpers/createServiceReadinessTests.js +75 -6
- 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 +213 -13
- 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 +21 -20
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -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 +4 -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 +101 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- package/src/WorkflowTestRunner.js +0 -402
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The business-service template, as a renderer.
|
|
5
|
+
*
|
|
6
|
+
* The template ships INSIDE this package (confirmation `biz-service-manifest`
|
|
7
|
+
* 001 §2), so the shape a service is created from is pinned by the same version
|
|
8
|
+
* that pins the manifest it is checked against. `api/templates/business-service`
|
|
9
|
+
* is no longer the source: it is this renderer's output, committed for reading
|
|
10
|
+
* (§5), and `templateMirror.test.js` is what keeps that true.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is text in, text out. Nothing reads `process.env`, nothing
|
|
13
|
+
* writes a file, nothing decides where a service lives
|
|
14
|
+
* (`.claude/rules/architecture-principles.md` §1) — the CLI does all three. That
|
|
15
|
+
* is what lets the identity render be compared byte for byte against the
|
|
16
|
+
* template itself, which is the only way §9's acceptance ("the directory in
|
|
17
|
+
* api/ differs from the generator's output by nothing") can be measured rather
|
|
18
|
+
* than asserted.
|
|
19
|
+
*
|
|
20
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md §2, §5, §9
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
/** The template as it lies in this package. */
|
|
27
|
+
const TEMPLATE_ROOT = path.join(__dirname, '..', '..', 'templates', 'business-service');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One placeholder, one fact — and the reason there are four rather than three.
|
|
31
|
+
*
|
|
32
|
+
* The template used to spend `__SERVICE_NAME__` on two different facts: the
|
|
33
|
+
* registry identity (`biz-reporting`) and the directory-derived paths
|
|
34
|
+
* (`config/env-active/reporting.env`). Substitution cannot satisfy both, so the
|
|
35
|
+
* scaffold patched the identity back in afterwards with two `jq` calls — which
|
|
36
|
+
* is a second writer of one file, and it reformatted the JSON while it was
|
|
37
|
+
* there. `__REGISTRY_NAME__` is that second fact given its own name, and it is
|
|
38
|
+
* what makes rendering a pure text substitution.
|
|
39
|
+
*/
|
|
40
|
+
const PLACEHOLDERS = Object.freeze({
|
|
41
|
+
service_name: '__SERVICE_NAME__',
|
|
42
|
+
container_name: '__CONTAINER_NAME__',
|
|
43
|
+
repo_name: '__REPO_NAME__',
|
|
44
|
+
registry_name: '__REGISTRY_NAME__',
|
|
45
|
+
description: '__SERVICE_DESCRIPTION__'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The parameters that render the template into itself.
|
|
50
|
+
*
|
|
51
|
+
* Not a test fixture: it is the definition of what `api/templates/business-service`
|
|
52
|
+
* IS. Rendering with these values substitutes every placeholder by itself, so
|
|
53
|
+
* the output is the template — which is exactly the claim §9 makes, expressed
|
|
54
|
+
* as something a run can compare.
|
|
55
|
+
*/
|
|
56
|
+
const IDENTITY_PARAMS = Object.freeze({
|
|
57
|
+
service_name: PLACEHOLDERS.service_name,
|
|
58
|
+
container_name: PLACEHOLDERS.container_name,
|
|
59
|
+
repo_name: PLACEHOLDERS.repo_name,
|
|
60
|
+
registry_name: PLACEHOLDERS.registry_name,
|
|
61
|
+
description: null
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What a platform pin looks like in the template, until the run that creates a
|
|
66
|
+
* service resolves it.
|
|
67
|
+
*
|
|
68
|
+
* A version number typed into `package.json.template` is a descriptive fact
|
|
69
|
+
* written by hand: true on the day it was typed and a lie from the next publish
|
|
70
|
+
* onwards (`.claude/rules/doc-code-binding.md` §1). Measured 2026-09-14, the day
|
|
71
|
+
* this placeholder landed: the template pinned `@onlineapps/service-wrapper`
|
|
72
|
+
* 2.1.119 and `@onlineapps/service-common` 1.1.1, while `api/config/libraries.json`
|
|
73
|
+
* - the platform SSOT - held 7.0.0 and 2.0.1. The numbers had never done any
|
|
74
|
+
* work either: `oa-sync-template --new` has overwritten every `@onlineapps/*`
|
|
75
|
+
* pin from the SSOT since the template moved into this package, so what the
|
|
76
|
+
* template carried only ever misled the reader of the template.
|
|
77
|
+
*
|
|
78
|
+
* It is NOT a `PLACEHOLDERS` entry, and the difference is the point: `renderText`
|
|
79
|
+
* substitutes facts about the service being created, which the caller types.
|
|
80
|
+
* This one is a fact about the PLATFORM, and only the SSOT knows it - so the
|
|
81
|
+
* renderer leaves it standing and the CLI's pin step is the single place that
|
|
82
|
+
* resolves it (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
83
|
+
* An identity render therefore shows the placeholder, which is what
|
|
84
|
+
* `api/templates/business-service/package.json` reads as: "this pin comes from
|
|
85
|
+
* the SSOT".
|
|
86
|
+
*
|
|
87
|
+
* Where an EXISTING service's pins come from is a different rail, and it is not
|
|
88
|
+
* this one: `api/scripts/publish-library.sh` STEP 5 rewrites every dependent's
|
|
89
|
+
* `package.json` when a library is published. The template pins the first
|
|
90
|
+
* install; the cascade pins every install after it.
|
|
91
|
+
*/
|
|
92
|
+
const SSOT_PIN = '__SSOT_PIN__';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Files whose packed name differs from the name they are written under, because
|
|
96
|
+
* something outside this package reserves the written name.
|
|
97
|
+
*
|
|
98
|
+
* Both entries were measured on 2026-09-09, and neither is a matter of taste:
|
|
99
|
+
*
|
|
100
|
+
* * `gitignore` — npm never packs a file called `.gitignore`. It was the one
|
|
101
|
+
* file of 22 missing from `npm pack --dry-run` while every other dotfile
|
|
102
|
+
* (`.gitlab-ci.yml`) shipped, and this package's own `.npmignore` does not
|
|
103
|
+
* mention it.
|
|
104
|
+
* * `package.json.template` — a `package.json` inside a package is a SECOND
|
|
105
|
+
* package to every tool that walks a package tree. With the file under its
|
|
106
|
+
* written name, `oa-validate --library --all` reported eight findings about
|
|
107
|
+
* the template (U-ORPHAN, U-MISMATCH, L-PINS ×2, L-PACK-TESTS, L-CHANGELOG,
|
|
108
|
+
* L-README, L-CONSUMER) and `scripts/ci/verify-manifest-pins.mjs`, step 1 of
|
|
109
|
+
* the pre-push hook, reported two more. All ten are false: the template's
|
|
110
|
+
* pins are DELIBERATELY not the platform's — the generator writes the SSOT's
|
|
111
|
+
* versions when it creates a service, which is the property
|
|
112
|
+
* `api/tests/scripts/add-service.bats` asserts — and the template is not a
|
|
113
|
+
* published library at all.
|
|
114
|
+
*
|
|
115
|
+
* The name is where the fact lives, so no walker has to be taught an exception,
|
|
116
|
+
* now or later. One map, applied in one place, rather than a rule each caller
|
|
117
|
+
* remembers.
|
|
118
|
+
*/
|
|
119
|
+
const PACKED_NAMES = Object.freeze({
|
|
120
|
+
gitignore: '.gitignore',
|
|
121
|
+
'package.json.template': 'package.json'
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The files a service is expected to EXECUTE, so the ones written executable.
|
|
126
|
+
*
|
|
127
|
+
* `init.sh` is the file a service is started through. `scripts/verify-deploy-uniform.sh`
|
|
128
|
+
* is invoked by name from the `deploy-production` job of the rendered
|
|
129
|
+
* `.gitlab-ci.yml`, so a copy without the bit fails that job with "permission
|
|
130
|
+
* denied" instead of measuring the uniform — and a gate that cannot run is the
|
|
131
|
+
* false guarantee `automation-gates.md` §5 names.
|
|
132
|
+
*/
|
|
133
|
+
const EXECUTABLE_FILES = Object.freeze(['init.sh', 'scripts/verify-deploy-uniform.sh']);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The env template's name in the template, and the name it is written under.
|
|
137
|
+
*
|
|
138
|
+
* The service's own env template is called after the service in every live repo
|
|
139
|
+
* (`api_biz/emailer/config/env-templates/emailer.env`), so the name is
|
|
140
|
+
* per-service and cannot be a fixed file name in the template. The rename is a
|
|
141
|
+
* step of creating a service, NOT of rendering: `renderTree` substitutes TEXT,
|
|
142
|
+
* and a file NAME is not text it touches.
|
|
143
|
+
*
|
|
144
|
+
* The source is named with the placeholder rather than with a word of its own,
|
|
145
|
+
* so that an identity render — the one `api/templates/business-service` is
|
|
146
|
+
* (001 §5, §9) — is self-consistent: the compose files load
|
|
147
|
+
* `env-active/__SERVICE_NAME__.env`, and that is what the template carries.
|
|
148
|
+
* Until d.236 it was called `service.env`, which made the template the one
|
|
149
|
+
* bearer wearing two names, and `C-IDENTITY` said so the day it landed.
|
|
150
|
+
*/
|
|
151
|
+
const ENV_TEMPLATE_SOURCE = 'config/env-templates/__SERVICE_NAME__.env';
|
|
152
|
+
const envTemplateTarget = (serviceName) => `config/env-templates/${serviceName}.env`;
|
|
153
|
+
|
|
154
|
+
/** The directory name of a business service, which every derived fact comes from. */
|
|
155
|
+
const SERVICE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The identity of one service, derived from the one thing a caller types.
|
|
159
|
+
*
|
|
160
|
+
* @param {{name: string, description?: string|null}} params
|
|
161
|
+
* @returns {{service_name: string, container_name: string, repo_name: string,
|
|
162
|
+
* registry_name: string, description: string|null}}
|
|
163
|
+
*/
|
|
164
|
+
function deriveParams({ name, description = null } = {}) {
|
|
165
|
+
if (typeof name !== 'string' || !SERVICE_NAME.test(name)) {
|
|
166
|
+
throw new Error(`[ServiceTemplate] Invalid service name ${JSON.stringify(name)} - expected the `
|
|
167
|
+
+ 'directory name under api_biz/: lower-case letters, digits and hyphens, starting with a letter. '
|
|
168
|
+
+ 'Fix: oa-sync-template --new <name>, e.g. --new reporting.');
|
|
169
|
+
}
|
|
170
|
+
if (description !== null && (typeof description !== 'string' || description.trim() === '')) {
|
|
171
|
+
throw new Error(`[ServiceTemplate] Invalid description ${JSON.stringify(description)} - a description `
|
|
172
|
+
+ 'is a sentence or it is absent; an empty one would write a blank line where the template asks what '
|
|
173
|
+
+ 'this service is for. Fix: pass --description "<sentence>", or leave it out.');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
service_name: name,
|
|
178
|
+
container_name: `api_service_${name.replace(/-/g, '_')}`,
|
|
179
|
+
repo_name: `biz-${name}`,
|
|
180
|
+
registry_name: `biz-${name}`,
|
|
181
|
+
description
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The name a packed template file is written under.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} packed template-relative path, as it lies in the package
|
|
189
|
+
* @returns {string} service-relative path
|
|
190
|
+
*/
|
|
191
|
+
function outputRelative(packed) {
|
|
192
|
+
const target = PACKED_NAMES[packed];
|
|
193
|
+
return target === undefined ? packed : target;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The template's own ignore file, under the name it is PACKED as. It is the one
|
|
198
|
+
* place saying which paths of a rendered service tree are generated output, and
|
|
199
|
+
* every reader of that rule reads it here.
|
|
200
|
+
*/
|
|
201
|
+
const GITIGNORE_SOURCE = 'gitignore';
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* One `.gitignore` line as a rule, or `null` for a line that is not one.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} line the line, verbatim
|
|
207
|
+
* @returns {{negated: boolean, directoryOnly: boolean, anchored: boolean, match: RegExp}|null}
|
|
208
|
+
*/
|
|
209
|
+
function compileRule(line) {
|
|
210
|
+
const trimmed = line.replace(/\r$/, '').trim();
|
|
211
|
+
if (trimmed === '' || trimmed.startsWith('#')) return null;
|
|
212
|
+
|
|
213
|
+
let pattern = trimmed;
|
|
214
|
+
const negated = pattern.startsWith('!');
|
|
215
|
+
if (negated) pattern = pattern.slice(1);
|
|
216
|
+
|
|
217
|
+
const directoryOnly = pattern.endsWith('/');
|
|
218
|
+
if (directoryOnly) pattern = pattern.slice(0, -1);
|
|
219
|
+
|
|
220
|
+
const anchored = pattern.startsWith('/') || pattern.includes('/');
|
|
221
|
+
if (pattern.startsWith('/')) pattern = pattern.slice(1);
|
|
222
|
+
|
|
223
|
+
return { negated, directoryOnly, anchored, match: globToRegExp(pattern) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A `.gitignore` glob as a regular expression: `*` stays inside one segment,
|
|
228
|
+
* `**` crosses them, `?` is one character, everything else is literal.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} glob
|
|
231
|
+
* @returns {RegExp}
|
|
232
|
+
*/
|
|
233
|
+
function globToRegExp(glob) {
|
|
234
|
+
let source = '^';
|
|
235
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
236
|
+
const character = glob[index];
|
|
237
|
+
if (character === '*') {
|
|
238
|
+
if (glob[index + 1] === '*') {
|
|
239
|
+
source += '.*';
|
|
240
|
+
index += 1;
|
|
241
|
+
} else {
|
|
242
|
+
source += '[^/]*';
|
|
243
|
+
}
|
|
244
|
+
} else if (character === '?') {
|
|
245
|
+
source += '[^/]';
|
|
246
|
+
} else {
|
|
247
|
+
source += character.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return new RegExp(`${source}$`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read a `.gitignore` and return the question it answers: is this path ignored?
|
|
255
|
+
*
|
|
256
|
+
* Git's own reading, in the part of it these files use: a rule ending in `/`
|
|
257
|
+
* covers directories only, a rule carrying a `/` is anchored at the root and one
|
|
258
|
+
* without matches a name at any depth, a matched DIRECTORY carries everything
|
|
259
|
+
* under it, and the LAST rule that matches decides — which is what makes a `!`
|
|
260
|
+
* exception work.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} text the file, verbatim
|
|
263
|
+
* @returns {(relative: string) => boolean} for a path relative to the tree root, `/`-separated
|
|
264
|
+
*/
|
|
265
|
+
function compileGitignore(text) {
|
|
266
|
+
const rules = text.split('\n').map(compileRule).filter((rule) => rule !== null);
|
|
267
|
+
|
|
268
|
+
return (relative) => {
|
|
269
|
+
const segments = relative.split('/');
|
|
270
|
+
|
|
271
|
+
// Every ancestor directory of the path, then the path itself. An ancestor
|
|
272
|
+
// that matches ignores everything below it, which is how `ci/` covers
|
|
273
|
+
// `ci/deployability.json`.
|
|
274
|
+
const candidates = segments.slice(0, -1)
|
|
275
|
+
.map((_, index) => ({ path: segments.slice(0, index + 1).join('/'), directory: true }));
|
|
276
|
+
candidates.push({ path: relative, directory: false });
|
|
277
|
+
|
|
278
|
+
let ignored = false;
|
|
279
|
+
for (const rule of rules) {
|
|
280
|
+
const hit = candidates.some((candidate) => {
|
|
281
|
+
if (rule.directoryOnly && !candidate.directory) return false;
|
|
282
|
+
const subject = rule.anchored ? candidate.path : candidate.path.split('/').pop();
|
|
283
|
+
return rule.match.test(subject);
|
|
284
|
+
});
|
|
285
|
+
if (hit) ignored = !rule.negated;
|
|
286
|
+
}
|
|
287
|
+
return ignored;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The template's ignore rules, as the packaged template writes them.
|
|
293
|
+
*
|
|
294
|
+
* @returns {(relative: string) => boolean}
|
|
295
|
+
*/
|
|
296
|
+
function templateIgnores() {
|
|
297
|
+
return compileGitignore(fs.readFileSync(path.join(TEMPLATE_ROOT, GITIGNORE_SOURCE), 'utf8'));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The files of a rendered template directory that the mirror comparison is
|
|
302
|
+
* ABOUT: everything the tree carries, minus what the template's own `.gitignore`
|
|
303
|
+
* covers.
|
|
304
|
+
*
|
|
305
|
+
* `oa-validate` records its verdict in the tree it measured (d.232), so a run
|
|
306
|
+
* over `api/templates/business-service` leaves `ci/deployability.json` there.
|
|
307
|
+
* Git does not see that file — the template's `.gitignore` says so — and calling
|
|
308
|
+
* it a stale copy made a legitimate run look like drift (d.237). The rule has
|
|
309
|
+
* ONE owner, the template's `.gitignore`, and no reader keeps a list of names
|
|
310
|
+
* beside it (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
311
|
+
*
|
|
312
|
+
* @param {string} root a rendered template directory
|
|
313
|
+
* @returns {string[]} output-relative paths, sorted
|
|
314
|
+
*/
|
|
315
|
+
function listOutputFiles(root) {
|
|
316
|
+
const ignored = templateIgnores();
|
|
317
|
+
return listTemplateFiles(root).filter((relative) => !ignored(relative));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Every file of a template root, template-relative, sorted so two runs list them
|
|
322
|
+
* in the same order.
|
|
323
|
+
*
|
|
324
|
+
* @param {string} root
|
|
325
|
+
* @returns {string[]}
|
|
326
|
+
*/
|
|
327
|
+
function listTemplateFiles(root) {
|
|
328
|
+
if (!fs.existsSync(root)) {
|
|
329
|
+
throw new Error(`[ServiceTemplate] Template root does not exist - ${root}. `
|
|
330
|
+
+ 'Fix: reinstall @onlineapps/conn-orch-validator; the template ships inside the package '
|
|
331
|
+
+ '(confirmation biz-service-manifest 001 §2).');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const collected = [];
|
|
335
|
+
const walk = (dir, prefix) => {
|
|
336
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
337
|
+
const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
338
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name), relative);
|
|
339
|
+
else collected.push(relative);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
walk(root, '');
|
|
343
|
+
return collected.sort();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* One file's text with the service's parameters in it.
|
|
348
|
+
*
|
|
349
|
+
* `__SERVICE_DESCRIPTION__` is substituted only when a description was given.
|
|
350
|
+
* With none, it is LEFT STANDING on purpose: this run has no description, and
|
|
351
|
+
* writing a plausible one would put a fact in the repository that nobody stated
|
|
352
|
+
* (`.claude/rules/doc-code-binding.md` §1). The caller reports the files that
|
|
353
|
+
* still carry it, which is what `add-service.sh` has always done.
|
|
354
|
+
*
|
|
355
|
+
* @param {string} text
|
|
356
|
+
* @param {object} params from `deriveParams`
|
|
357
|
+
* @returns {string}
|
|
358
|
+
*/
|
|
359
|
+
function renderText(text, params) {
|
|
360
|
+
if (typeof text !== 'string') {
|
|
361
|
+
throw new Error(`[ServiceTemplate] File text is required - renderText(text, params) got ${
|
|
362
|
+
text === null ? 'null' : typeof text}. Fix: read the template file before rendering it.`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let rendered = text;
|
|
366
|
+
for (const key of ['service_name', 'container_name', 'repo_name', 'registry_name']) {
|
|
367
|
+
const value = params && params[key];
|
|
368
|
+
if (typeof value !== 'string') {
|
|
369
|
+
throw new Error(`[ServiceTemplate] Missing parameter "${key}" - the template spends a placeholder `
|
|
370
|
+
+ `${PLACEHOLDERS[key]} on it and nothing else can supply it. `
|
|
371
|
+
+ 'Fix: build the parameters with deriveParams({ name }).');
|
|
372
|
+
}
|
|
373
|
+
rendered = rendered.split(PLACEHOLDERS[key]).join(value);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (params.description !== null && params.description !== undefined) {
|
|
377
|
+
rendered = rendered.split(PLACEHOLDERS.description).join(params.description);
|
|
378
|
+
}
|
|
379
|
+
return rendered;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The whole template rendered for one service, keyed by the path each file is
|
|
384
|
+
* written under.
|
|
385
|
+
*
|
|
386
|
+
* The env-template rename is deliberately NOT here — see `ENV_TEMPLATE_SOURCE`.
|
|
387
|
+
*
|
|
388
|
+
* @param {{templateRoot?: string, params: object}} args
|
|
389
|
+
* @returns {Map<string, string>} service-relative path → file text
|
|
390
|
+
*/
|
|
391
|
+
function renderTree({ templateRoot = TEMPLATE_ROOT, params } = {}) {
|
|
392
|
+
const tree = new Map();
|
|
393
|
+
for (const packed of listTemplateFiles(templateRoot)) {
|
|
394
|
+
const text = fs.readFileSync(path.join(templateRoot, packed), 'utf8');
|
|
395
|
+
tree.set(outputRelative(packed), renderText(text, params));
|
|
396
|
+
}
|
|
397
|
+
return tree;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The marker lines of a delimited block, in the one spelling
|
|
402
|
+
* `api/tests/scripts/infra-init-scripts.bats` and the manifest check
|
|
403
|
+
* `template-block` already read: `# --- <name>` … `# --- end <name>`.
|
|
404
|
+
*/
|
|
405
|
+
const blockOpen = (block) => `# --- ${block}`;
|
|
406
|
+
const blockClose = (block) => `# --- end ${block}`;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The markers are matched TRIMMED, the same way `checks/serviceFiles.js` reads
|
|
410
|
+
* them: `oa-deps-guard v1` sits at column 0 in a shell script, `oa-test-runner
|
|
411
|
+
* v1` two spaces in, inside a compose file's `services:` mapping. A comment is a
|
|
412
|
+
* comment at either column, and one spelling of the rule is what keeps the check
|
|
413
|
+
* and the splice from disagreeing about where a block begins.
|
|
414
|
+
*/
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A file with one delimited block replaced, and every other line of it left
|
|
418
|
+
* exactly as it was.
|
|
419
|
+
*
|
|
420
|
+
* A file that carries no such block is REFUSED rather than repaired: where the
|
|
421
|
+
* block belongs is a decision about that service's own install steps, and a
|
|
422
|
+
* generator that picks a position is guessing (`.claude/rules/change-discipline.md`
|
|
423
|
+
* — no workarounds, fix at source). That reasoning holds for `init.sh`, whose
|
|
424
|
+
* block sits after install steps only that service knows about; it does NOT hold
|
|
425
|
+
* for a compose file, where a mapping key has exactly one place its entries can
|
|
426
|
+
* start — see `insertBlockUnder`, which is why F-RUNNER's fix is a command and
|
|
427
|
+
* F-INIT's still is not.
|
|
428
|
+
*
|
|
429
|
+
* @param {{text: string, block: string, replacement: string[]}} args
|
|
430
|
+
* @returns {string}
|
|
431
|
+
*/
|
|
432
|
+
function spliceBlock({ text, block, replacement }) {
|
|
433
|
+
const lines = text.split('\n');
|
|
434
|
+
const open = blockOpen(block);
|
|
435
|
+
const close = blockClose(block);
|
|
436
|
+
|
|
437
|
+
const start = lines.findIndex((line) => line.trim().startsWith(open) && !line.trim().startsWith(close));
|
|
438
|
+
if (start === -1) {
|
|
439
|
+
throw new Error(`[ServiceTemplate] No "${block}" block to replace - the file carries no line opening `
|
|
440
|
+
+ `with "${open}". Fix: paste the block from the template once, at the place it belongs in this `
|
|
441
|
+
+ 'file — after this file\'s own install steps in an init.sh, under services: in a compose file; '
|
|
442
|
+
+ 'from then on the sync keeps it current.');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const end = lines.findIndex((line, index) => index >= start && line.trim().startsWith(close));
|
|
446
|
+
if (end === -1) {
|
|
447
|
+
throw new Error(`[ServiceTemplate] The "${block}" block never closes - "${open}" is there, "${close}" `
|
|
448
|
+
+ 'is not, so what would be replaced is undefined. Fix: close the block, then run the sync again.');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return [...lines.slice(0, start), ...replacement, ...lines.slice(end + 1)].join('\n');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* A file with a delimited block INSERTED as the first entry of a mapping, and
|
|
456
|
+
* every other line of it left exactly as it was.
|
|
457
|
+
*
|
|
458
|
+
* The position is not a guess, which is what separates this from `spliceBlock`'s
|
|
459
|
+
* refusal: `services:` is a mapping, its entries are unordered by the language,
|
|
460
|
+
* and "immediately under the key" is the one place that needs no judgement about
|
|
461
|
+
* the file's contents. Measured 2026-09-09: seven of the eight biz repositories
|
|
462
|
+
* carried no runner block at all, so the row's `fix` read "BLOCKED — paste it in
|
|
463
|
+
* by hand once", which is advice rather than the command confirmation
|
|
464
|
+
* `biz-service-manifest` 001 §3.2 requires of every finding.
|
|
465
|
+
*
|
|
466
|
+
* Text in, text out: the rest of the file is not re-serialized, so a comment,
|
|
467
|
+
* a quoting style and a blank line survive a run that only had to add a block
|
|
468
|
+
* (`.claude/rules/automation-gates.md` §1 requirement 3).
|
|
469
|
+
*
|
|
470
|
+
* @param {{text: string, key: string, replacement: string[]}} args the file, the
|
|
471
|
+
* top-level mapping key the block belongs under, and the block's lines
|
|
472
|
+
* @returns {string}
|
|
473
|
+
*/
|
|
474
|
+
function insertBlockUnder({ text, key, replacement }) {
|
|
475
|
+
const lines = text.split('\n');
|
|
476
|
+
const at = lines.findIndex((line) => line.trimEnd() === `${key}:`);
|
|
477
|
+
if (at === -1) {
|
|
478
|
+
throw new Error(`[ServiceTemplate] No "${key}:" mapping to insert the block under - the file declares no `
|
|
479
|
+
+ `line reading exactly "${key}:", so there is no unambiguous place for it. `
|
|
480
|
+
+ `Fix: give the file its "${key}:" mapping, then run the sync again.`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return [...lines.slice(0, at + 1), ...replacement, '', ...lines.slice(at + 1)].join('\n');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** A top-level mapping key: a line that starts in column 0 and ends its key with a colon. */
|
|
487
|
+
const TOP_LEVEL_KEY = /^([A-Za-z_][A-Za-z0-9_.-]*):/;
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The top-level mapping keys a run of lines declares, in the order it declares
|
|
491
|
+
* them.
|
|
492
|
+
*
|
|
493
|
+
* ONE OWNER for "what does this block claim". The check names them in its
|
|
494
|
+
* finding and the sync removes exactly those from a file that predates the
|
|
495
|
+
* markers; deriving them twice would be two answers to one question
|
|
496
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern), and the day the
|
|
497
|
+
* block gained a job the two would disagree about which file had drifted.
|
|
498
|
+
*
|
|
499
|
+
* Column 0 is the whole rule, and it is enough for the one file this reads:
|
|
500
|
+
* a `.gitlab-ci.yml` job is a top-level key and everything it owns is indented
|
|
501
|
+
* under it. Text in, text out — nothing is re-serialized, so a comment and a
|
|
502
|
+
* quoting style survive (`.claude/rules/automation-gates.md` §1 requirement 3).
|
|
503
|
+
*
|
|
504
|
+
* @param {string[]} lines
|
|
505
|
+
* @returns {string[]}
|
|
506
|
+
*/
|
|
507
|
+
function topLevelKeys(lines) {
|
|
508
|
+
return lines.map((line) => (TOP_LEVEL_KEY.exec(line) || [])[1]).filter((key) => key !== undefined);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* A file with the named top-level keys REMOVED and a block put where the first
|
|
513
|
+
* of them stood.
|
|
514
|
+
*
|
|
515
|
+
* This is the migration `spliceBlock` cannot do and `insertBlockUnder` must not:
|
|
516
|
+
* measured over the eight biz repositories on 2026-09-11, every one carries the
|
|
517
|
+
* platform's jobs as unmarked top-level keys copied before the markers existed.
|
|
518
|
+
* Inserting the block beside them would give the file a second `build:` and a
|
|
519
|
+
* second `deploy-production:` — duplicate mapping keys, which GitLab either
|
|
520
|
+
* refuses or resolves by taking the last one. Neither is a sync.
|
|
521
|
+
*
|
|
522
|
+
* The position is not a guess either: the block lands exactly where the platform
|
|
523
|
+
* keys already were, so a file nobody has to re-read comes back in the order its
|
|
524
|
+
* author left it. A key's span starts at the KEY and not at a comment above it —
|
|
525
|
+
* that comment is the repository's prose, the same rule `composeRunnerBlock.js`
|
|
526
|
+
* § serviceSpan states for a compose node.
|
|
527
|
+
*
|
|
528
|
+
* @param {{text: string, keys: string[], replacement: string[]}} args
|
|
529
|
+
* @returns {string}
|
|
530
|
+
*/
|
|
531
|
+
function replaceTopLevelKeys({ text, keys, replacement }) {
|
|
532
|
+
const lines = text.split('\n');
|
|
533
|
+
const starts = lines
|
|
534
|
+
.map((line, index) => ({ key: (TOP_LEVEL_KEY.exec(line) || [])[1], index }))
|
|
535
|
+
.filter((entry) => entry.key !== undefined);
|
|
536
|
+
|
|
537
|
+
const doomed = new Set();
|
|
538
|
+
for (let i = 0; i < starts.length; i += 1) {
|
|
539
|
+
if (!keys.includes(starts[i].key)) continue;
|
|
540
|
+
const end = i + 1 < starts.length ? starts[i + 1].index : lines.length;
|
|
541
|
+
for (let line = starts[i].index; line < end; line += 1) doomed.add(line);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const at = doomed.size === 0 ? 0 : Math.min(...doomed);
|
|
545
|
+
const kept = [];
|
|
546
|
+
for (let line = 0; line < lines.length; line += 1) {
|
|
547
|
+
if (line === at) kept.push(...replacement, '');
|
|
548
|
+
if (!doomed.has(line)) kept.push(lines[line]);
|
|
549
|
+
}
|
|
550
|
+
if (at >= lines.length) kept.push(...replacement, '');
|
|
551
|
+
|
|
552
|
+
// No tidying pass over the rest of the file: a removed span reaches to the
|
|
553
|
+
// next top-level key, so it takes its own trailing blank line with it, and the
|
|
554
|
+
// block brings exactly one of its own. Collapsing blank lines globally would
|
|
555
|
+
// rewrite text outside the block, which is the one thing this run must not do
|
|
556
|
+
// (`.claude/rules/automation-gates.md` §1 requirement 3). Measured over all
|
|
557
|
+
// eight biz pipelines on 2026-09-11: no stacked blank line in any output.
|
|
558
|
+
return kept.join('\n');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
module.exports = {
|
|
562
|
+
TEMPLATE_ROOT,
|
|
563
|
+
PLACEHOLDERS,
|
|
564
|
+
IDENTITY_PARAMS,
|
|
565
|
+
SSOT_PIN,
|
|
566
|
+
PACKED_NAMES,
|
|
567
|
+
EXECUTABLE_FILES,
|
|
568
|
+
ENV_TEMPLATE_SOURCE,
|
|
569
|
+
envTemplateTarget,
|
|
570
|
+
deriveParams,
|
|
571
|
+
outputRelative,
|
|
572
|
+
listTemplateFiles,
|
|
573
|
+
listOutputFiles,
|
|
574
|
+
templateIgnores,
|
|
575
|
+
compileGitignore,
|
|
576
|
+
GITIGNORE_SOURCE,
|
|
577
|
+
renderText,
|
|
578
|
+
renderTree,
|
|
579
|
+
spliceBlock,
|
|
580
|
+
topLevelKeys,
|
|
581
|
+
replaceTopLevelKeys,
|
|
582
|
+
insertBlockUnder
|
|
583
|
+
};
|