@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
@@ -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
+ };