@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +2591 -2
  2. package/README.md +1075 -7
  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 +422 -104
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +78 -42
  10. package/src/ValidationOrchestrator.js +298 -75
  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 +12 -2
  16. package/src/helpers/createServiceReadinessTests.js +75 -6
  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 +213 -13
  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 +21 -20
  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,754 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The `identical` class: files a service does not get to decide, because the
5
+ * template already did (confirmation `biz-service-manifest` 001 §2).
6
+ *
7
+ * Two shapes of "identical", and the reason there are two: measured over the
8
+ * eight repositories on 2026-09-09, no whole file is byte-equal across nine
9
+ * copies, but a delimited BLOCK is (`init.sh`), and a file that carries no
10
+ * identity at all can be (`jest.config.js`). A row therefore names either a
11
+ * block or a whole file, and the template is the reference in both cases —
12
+ * reached through the row's `from:` reference, never copied into the manifest
13
+ * (004 point 2).
14
+ *
15
+ * The runner row is neither: a compose service cannot be byte-equal across
16
+ * repositories, because its name IS the repository. What the uniform fixes there
17
+ * is the shape — one profile, one budget, no restart, and the same build, env
18
+ * and networks as the service it tests — so `compose-runner` compares the two
19
+ * blocks of ONE file against each other and the two platform numbers against
20
+ * the row.
21
+ *
22
+ * @see api/docs/governance/confirmations/biz-service-manifest.md §2
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const { readReferencedFile, isPackageReference, referenceOwner } = require('../discovery');
29
+ const { whereOf } = require('./libraryContext');
30
+ const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
31
+ const {
32
+ SHARED_DECLARATIONS, CONTAINER_PLACEHOLDER, SERVICE_PLACEHOLDER, normalizeRunnerBlock
33
+ } = require('./composeRunnerBlock');
34
+ const { renderText, topLevelKeys } = require('../../sync/serviceTemplate');
35
+ const { readIdentity, requireIdentity, IDENTITY_FILE } = require('../serviceIdentity');
36
+
37
+ /** What the `test` profile is called. A runner is the service that declares it. */
38
+ const TEST_PROFILE = 'test';
39
+
40
+ /**
41
+ * What these checks need the workspace for, and nothing else: to READ the file
42
+ * the row's `from:` reference names. A reference into this package (d.229 —
43
+ * `manifestShape.js` § describeFromProblem) is carried by the package itself, so
44
+ * the row runs wherever the package is installed: in a service container, in the
45
+ * service's own CI checkout, and in the workspace exactly as before.
46
+ *
47
+ * Declared once, spread into every check of this module, because it is one
48
+ * sentence about all four (`change-discipline.md` § One rail per concern).
49
+ */
50
+ const READS_ITS_REFERENCE_ONLY = Object.freeze({
51
+ needsWorkspace: ({ row }) => !isPackageReference(row.from),
52
+
53
+ describeNotRun({ row }) {
54
+ return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
55
+ }
56
+ });
57
+
58
+ /**
59
+ * Read a file of the repository under check.
60
+ *
61
+ * @param {string} serviceRoot repository root
62
+ * @param {string} relative repository-relative path
63
+ * @returns {string|null} null when the file is not there
64
+ */
65
+ function readServiceFile(serviceRoot, relative) {
66
+ const target = path.join(serviceRoot, ...relative.split('/'));
67
+ if (!fs.existsSync(target)) return null;
68
+ return fs.readFileSync(target, 'utf8');
69
+ }
70
+
71
+ /**
72
+ * The lines of a delimited block, markers included.
73
+ *
74
+ * The markers are the two lines `api/tests/scripts/infra-init-scripts.bats`
75
+ * matches on (`GUARD_OPEN` / `GUARD_CLOSE`, its lines 202-203): the block opens
76
+ * with `# --- <name>` and closes with `# --- end <name>`. That bats file guards
77
+ * `api/infra/*` and says in its own comment that the `api_biz/*` copies are kept
78
+ * in sync by review only — this row is that reach extended to the biz
79
+ * repositories, which is why it reads the same two markers rather than
80
+ * inventing a third spelling.
81
+ *
82
+ * The marker is read TRIMMED, because the second block the manifest declares
83
+ * lives in a compose file: `oa-test-runner v1` sits inside `services:`, so its
84
+ * two markers are indented by two spaces. A YAML comment is a comment at any
85
+ * column, and a shell comment at column 0 still matches — `init.sh` is the
86
+ * control case both directions of this rule are measured against.
87
+ *
88
+ * @param {string} text file content
89
+ * @param {string} name the block name, e.g. `oa-deps-guard v1`
90
+ * @returns {string[]} the block's lines, or [] when there is none (or it never closes)
91
+ */
92
+ function blockLines(text, name) {
93
+ const open = `# --- ${name}`;
94
+ const close = `# --- end ${name}`;
95
+ const lines = text.split('\n');
96
+ const collected = [];
97
+ let inside = false;
98
+
99
+ for (const line of lines) {
100
+ const marker = line.trim();
101
+ if (!inside && marker.startsWith(open) && !marker.startsWith(close)) inside = true;
102
+ if (!inside) continue;
103
+ collected.push(line);
104
+ if (marker.startsWith(close)) return collected;
105
+ }
106
+ return [];
107
+ }
108
+
109
+ /**
110
+ * The first line at which two texts differ, 1-based, and what the service says
111
+ * there. A quoted line is what makes the finding actionable in a table cell.
112
+ *
113
+ * `ended` is what is quoted where the service's side has run out. It is a
114
+ * parameter rather than a constant because the same comparison serves a block
115
+ * (`oa-deps-guard v1` inside a longer file) and a whole file, and a finding that
116
+ * says "<end of block>" about a truncated FILE sends the reader looking for
117
+ * markers that are not the defect.
118
+ *
119
+ * @param {string[]} mine the service's lines
120
+ * @param {string[]} reference the template's lines
121
+ * @param {string} ended what to quote where `mine` has no line at all
122
+ * @returns {{ line: number, text: string }|null} null when they are equal
123
+ */
124
+ function firstDifference(mine, reference, ended = '<end of block>') {
125
+ const length = Math.max(mine.length, reference.length);
126
+ for (let i = 0; i < length; i += 1) {
127
+ if (mine[i] === reference[i]) continue;
128
+ return { line: i + 1, text: mine[i] === undefined ? ended : mine[i].trim() };
129
+ }
130
+ return null;
131
+ }
132
+
133
+ const templateBlock = Object.freeze({
134
+ scope: 'bearer',
135
+ requires: Object.freeze(['path', 'block', 'from']),
136
+
137
+ ...READS_ITS_REFERENCE_ONLY,
138
+
139
+ run({ row, serviceRoot, workspaceRoot }) {
140
+ const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
141
+ const text = readServiceFile(serviceRoot, row.path);
142
+ if (text === null) {
143
+ return [{ where, what: `absent — the uniform's ${row.path} carries the "${row.block}" block` }];
144
+ }
145
+
146
+ const reference = blockLines(readReferencedFile({ from: row.from, workspaceRoot }), row.block);
147
+ if (reference.length === 0) {
148
+ throw new Error(`[ManifestFiles] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - `
149
+ + 'the template is the reference bearer of this row and carries no such block. '
150
+ + 'Fix: restore the block in the template, or correct the row.');
151
+ }
152
+
153
+ const mine = blockLines(text, row.block);
154
+ if (mine.length === 0) {
155
+ return [{
156
+ where,
157
+ what: `carries no "${row.block}" block — the template ships one and every ${row.path} repeats it byte for byte`
158
+ }];
159
+ }
160
+
161
+ const difference = firstDifference(mine, reference);
162
+ if (difference === null) return [];
163
+
164
+ return [{
165
+ where,
166
+ what: `the "${row.block}" block differs from the template at block line ${difference.line}: "${difference.text}"`
167
+ }];
168
+ }
169
+ });
170
+
171
+ const templateFile = Object.freeze({
172
+ scope: 'bearer',
173
+ requires: Object.freeze(['path', 'from']),
174
+
175
+ ...READS_ITS_REFERENCE_ONLY,
176
+
177
+ run({ row, serviceRoot, workspaceRoot }) {
178
+ const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
179
+ const text = readServiceFile(serviceRoot, row.path);
180
+ if (text === null) {
181
+ return [{ where, what: `absent — this file is ${referenceOwner(row.from)}, byte for byte` }];
182
+ }
183
+
184
+ const reference = readReferencedFile({ from: row.from, workspaceRoot });
185
+ if (text === reference) return [];
186
+
187
+ return [{ where, what: `differs from ${referenceOwner(row.from)} — this file is the template's, byte for byte` }];
188
+ }
189
+ });
190
+
191
+ /**
192
+ * The `generated` whole-file shape: the file IS the template rendered with this
193
+ * repository's name, and nothing else (owner decision
194
+ * `api/docs/governance/confirmations/server-topology.md` 005).
195
+ *
196
+ * The difference from `template-file` is the render, and it is the whole reason
197
+ * a second check exists: `jest.config.js` carries no identity, so the template
198
+ * IS the file; a compose file names the container, the image repository and the
199
+ * service's own env file, so the template is the file only after the one
200
+ * parameter a service has has been put in it. Comparing the raw template there
201
+ * would report every conformant service as drifted.
202
+ *
203
+ * The difference from `template-skeleton` is what is compared: a skeleton row
204
+ * owns the headings of a document whose prose is the service's, so it compares
205
+ * sections. Here there is no prose to protect — the measurement behind 005 is
206
+ * that the seven old-shape files were byte-identical once the name was
207
+ * normalised, so what a service "decides" about its production compose is its
208
+ * name and nothing more.
209
+ */
210
+ const templateRender = Object.freeze({
211
+ scope: 'bearer',
212
+ requires: Object.freeze(['path', 'from']),
213
+
214
+ ...READS_ITS_REFERENCE_ONLY,
215
+
216
+ run({ row, serviceRoot, workspaceRoot }) {
217
+ const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
218
+ const text = readServiceFile(serviceRoot, row.path);
219
+ if (text === null) {
220
+ return [{ where, what: `absent — this file is ${referenceOwner(row.from)}, rendered for this service` }];
221
+ }
222
+
223
+ // Nothing can be rendered for a repository that has not said who it is, and
224
+ // saying so here would give that defect a second owner: `C-SERVICE` puts it
225
+ // on the table with its own fix (`serviceIdentity.js`).
226
+ const identity = readIdentity(serviceRoot);
227
+ if (identity.problem !== undefined) {
228
+ if (identity.undecided === null) return [];
229
+ return [{
230
+ where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: IDENTITY_FILE }),
231
+ what: `${row.path} could not be compared — ${identity.undecided}`
232
+ }];
233
+ }
234
+
235
+ const reference = renderText(readReferencedFile({ from: row.from, workspaceRoot }), identity.params);
236
+ if (text === reference) return [];
237
+
238
+ // BOTH sides of the first differing line. One of them alone is unreadable
239
+ // for a whole-file row: "line 1: services:" says the file is wrong without
240
+ // saying what belongs there, and the fix command is the only other thing
241
+ // the reader gets.
242
+ const mine = text.split('\n');
243
+ const wanted = reference.split('\n');
244
+ const difference = firstDifference(mine, wanted, '<end of file>');
245
+ const expected = wanted[difference.line - 1];
246
+
247
+ return [{
248
+ where,
249
+ what: `differs from ${referenceOwner(row.from)} rendered for this service, `
250
+ + `at line ${difference.line}: ${JSON.stringify(difference.text)} — expected `
251
+ + `${JSON.stringify(expected === undefined ? '<end of file>' : expected.trim())}`
252
+ }];
253
+ }
254
+ });
255
+
256
+ /**
257
+ * The `generated` BLOCK shape: one delimited region of a file is the template's,
258
+ * rendered with this repository's name, and every line outside the markers is
259
+ * the repository's own (lead decision `api/shared/TODO.md` §0.2b-49, d.251).
260
+ *
261
+ * It is `template-block` plus the render, and `template-render` minus the rest
262
+ * of the file — and the reason it is neither of them is the defect it replaces.
263
+ * `.gitlab-ci.yml` carried a whole-file row for three days, and its fix could not
264
+ * be run: the template is 151 lines and the eight biz pipelines are 176 to 198,
265
+ * because each declares the service containers, the `ci:gate:*` steps and the
266
+ * artefact ITS integration needs. Syncing the whole file would have deleted
267
+ * those, which is a finding a gate can report and nobody can fix
268
+ * (`.claude/rules/automation-gates.md` §3, last bullet). The block is the same
269
+ * split `docker-compose.yml` already lives under, one level up: there a compose
270
+ * SERVICE is the platform's, here a set of top-level jobs is.
271
+ *
272
+ * The render is what separates it from `template-block`: the platform's half
273
+ * names the service once, in `GIT_CLONE_PATH`, which is what puts the CI
274
+ * checkout in the layout the engine needs. A byte comparison against the raw
275
+ * template would report every conformant service as drifted.
276
+ */
277
+ const delimitedBlockRender = Object.freeze({
278
+ scope: 'bearer',
279
+ requires: Object.freeze(['path', 'block', 'from']),
280
+
281
+ ...READS_ITS_REFERENCE_ONLY,
282
+
283
+ run({ row, serviceRoot, workspaceRoot }) {
284
+ const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
285
+ const text = readServiceFile(serviceRoot, row.path);
286
+ if (text === null) {
287
+ return [{
288
+ where,
289
+ what: `absent — this file is ${referenceOwner(row.from)}, `
290
+ + 'and the platform half of it is rendered for this service'
291
+ }];
292
+ }
293
+
294
+ // Nothing can be rendered for a repository that has not said who it is, and
295
+ // saying so here would give that defect a second owner: `C-SERVICE` puts it
296
+ // on the table with its own fix (`serviceIdentity.js`).
297
+ const identity = readIdentity(serviceRoot);
298
+ if (identity.problem !== undefined) {
299
+ if (identity.undecided === null) return [];
300
+ return [{
301
+ where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: IDENTITY_FILE }),
302
+ what: `${row.path} could not be compared — ${identity.undecided}`
303
+ }];
304
+ }
305
+
306
+ const rendered = renderText(readReferencedFile({ from: row.from, workspaceRoot }), identity.params);
307
+ const reference = blockLines(rendered, row.block);
308
+ if (reference.length === 0) {
309
+ throw new Error(`[ManifestFiles] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - `
310
+ + 'the template is the reference bearer of this row and carries no such block. '
311
+ + 'Fix: restore the block in the template, or correct the row.');
312
+ }
313
+
314
+ const mine = blockLines(text, row.block);
315
+ if (mine.length === 0) {
316
+ return [{
317
+ where,
318
+ what: `carries no "${row.block}" block — the platform's half of this pipeline `
319
+ + `(${topLevelKeys(reference).join(', ')}) is what the template owns`
320
+ }];
321
+ }
322
+
323
+ const difference = firstDifference(mine, reference);
324
+ if (difference === null) return [];
325
+
326
+ const expected = reference[difference.line - 1];
327
+ return [{
328
+ where,
329
+ what: `the "${row.block}" block differs from ${referenceOwner(row.from)} rendered for this service, `
330
+ + `at block line ${difference.line}: ${JSON.stringify(difference.text)} — expected `
331
+ + `${JSON.stringify(expected === undefined ? '<end of block>' : expected.trim())}`
332
+ }];
333
+ }
334
+ });
335
+
336
+ const composeRunner = Object.freeze({
337
+ scope: 'bearer',
338
+ requires: Object.freeze(['path', 'runner_memory', 'block', 'from']),
339
+
340
+ ...READS_ITS_REFERENCE_ONLY,
341
+
342
+ run({ row, serviceRoot, workspaceRoot }) {
343
+ // The locator names the repository, not just the file: this row runs once
344
+ // per bearer in a workspace run, and five services reporting
345
+ // "docker-compose.yml" are five findings nobody can tell apart.
346
+ const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
347
+ const text = readServiceFile(serviceRoot, row.path);
348
+ if (text === null) {
349
+ return [{ where, what: 'absent — the service is built and run from this file' }];
350
+ }
351
+
352
+ const services = readComposeServices(text);
353
+ const runners = [...services.entries()]
354
+ .filter(([, node]) => (node.sequences.get('profiles') || []).includes(TEST_PROFILE));
355
+
356
+ if (runners.length === 0) {
357
+ return [{
358
+ where,
359
+ what: `declares no service with profiles: [${TEST_PROFILE}] — the one-shot test runner is missing`
360
+ }];
361
+ }
362
+ if (runners.length > 1) {
363
+ return [{
364
+ where,
365
+ what: `declares ${runners.length} services with profiles: [${TEST_PROFILE}] — the uniform has one runner`
366
+ }];
367
+ }
368
+
369
+ const [runnerName, runner] = runners[0];
370
+ const others = [...services.entries()].filter(([name]) => name !== runnerName);
371
+ if (others.length !== 1) {
372
+ return [{
373
+ where,
374
+ what: `declares ${others.length} services beside the runner — the uniform is one service and its runner`
375
+ }];
376
+ }
377
+
378
+ const [, service] = others[0];
379
+ const findings = [];
380
+ // Every sentence of this row is about the compose file, apart from the one
381
+ // that is about the file this repository's identity is declared in.
382
+ const say = (what, relative = row.path) => findings.push({
383
+ where: relative === row.path
384
+ ? where
385
+ : whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative }),
386
+ what
387
+ });
388
+
389
+ const memory = scalarAt(runner, 'mem_limit');
390
+ if (memory !== row.runner_memory) {
391
+ say(`the runner ${runnerName} declares mem_limit ${JSON.stringify(memory)}, `
392
+ + `the uniform's runner budget is ${JSON.stringify(row.runner_memory)}`);
393
+ }
394
+ for (const forbidden of ['restart', 'healthcheck']) {
395
+ if (declares(runner, forbidden)) {
396
+ say(`the runner ${runnerName} declares "${forbidden}" — it runs once and exits`);
397
+ }
398
+ }
399
+ for (const shared of SHARED_DECLARATIONS) {
400
+ if (declarationOf(runner, shared) !== declarationOf(service, shared)) {
401
+ say(`the runner ${runnerName} declares a different "${shared}" than the service it tests`);
402
+ }
403
+ }
404
+
405
+ // The rest of the runner is the template's, byte for byte. What is compared
406
+ // and what is deliberately not is one definition, in composeRunnerBlock.js,
407
+ // and the generator renders from that same definition.
408
+ const reference = blockLines(readReferencedFile({ from: row.from, workspaceRoot }), row.block);
409
+ if (reference.length === 0) {
410
+ throw new Error(`[ManifestFiles] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - `
411
+ + 'the template is the reference bearer of this row and carries no such block. '
412
+ + 'Fix: restore the block in the template, or correct the row.');
413
+ }
414
+
415
+ // Who this repository is, as it declares it. NOT the directory name: inside
416
+ // the image the row now runs in, every service lies in `/app`
417
+ // (`serviceIdentity.js`).
418
+ const identity = readIdentity(serviceRoot);
419
+ if (identity.problem !== undefined) {
420
+ if (identity.undecided !== null) {
421
+ say(`the "${row.block}" block could not be compared — ${identity.undecided}`, IDENTITY_FILE);
422
+ }
423
+ return findings;
424
+ }
425
+
426
+ const [containerName] = others[0];
427
+ const serviceName = identity.params.service_name;
428
+ const mine = blockLines(text, row.block);
429
+
430
+ if (mine.length === 0) {
431
+ say(`carries no "${row.block}" block — the template ships one and every ${row.path} repeats it, `
432
+ + 'apart from what the runner shares with its service');
433
+ return findings;
434
+ }
435
+
436
+ const difference = firstDifference(
437
+ normalizeRunnerBlock({ lines: mine, containerName, serviceName }),
438
+ normalizeRunnerBlock({ lines: reference, containerName: CONTAINER_PLACEHOLDER, serviceName: SERVICE_PLACEHOLDER })
439
+ );
440
+ if (difference !== null) {
441
+ say(`the "${row.block}" block differs from the template at block line ${difference.line}: `
442
+ + `"${difference.text}" (compared without ${SHARED_DECLARATIONS.join(', ')}, which the rows above `
443
+ + 'compare against the service this runner tests)');
444
+ }
445
+
446
+ return findings;
447
+ }
448
+ });
449
+
450
+ /**
451
+ * A document's level-2 headings, in file order — the shape of a document, with
452
+ * none of its prose in it.
453
+ *
454
+ * Level 2 and no deeper on purpose: `##` is the section an operator navigates
455
+ * by, and the template's own `###` sub-headings are the way ONE service happens
456
+ * to have written a section. A rule that demanded those would be prescribing
457
+ * prose, which the `generated` class explicitly does not do (confirmation
458
+ * `biz-service-manifest` 001 §2: `skeleton_only` compares headings and required
459
+ * sections, not prose).
460
+ *
461
+ * @param {string} text the document
462
+ * @returns {string[]}
463
+ */
464
+ function headingsOf(text) {
465
+ return text.split('\n')
466
+ .filter((line) => /^## +\S/.test(line))
467
+ .map((line) => line.replace(/^##\s+/, '').trim());
468
+ }
469
+
470
+ /**
471
+ * The reference document with this repository's parameters in it.
472
+ *
473
+ * The parameters are the repository's DECLARED identity (`serviceIdentity.js`);
474
+ * a repository that cannot say who it is gets the fail-fast that module writes,
475
+ * which is what a generator run must see. The check below asks the same question
476
+ * without throwing, because a run reports every row before it stops.
477
+ *
478
+ * @param {{row: object, serviceRoot: string, workspaceRoot: string}} args
479
+ * @returns {string}
480
+ */
481
+ function renderedReference({ row, serviceRoot, workspaceRoot }) {
482
+ return renderText(readReferencedFile({ from: row.from, workspaceRoot }), requireIdentity(serviceRoot));
483
+ }
484
+
485
+ /**
486
+ * The skeleton of a document: its header, its title and its sections — and not
487
+ * one sentence of what any of them says.
488
+ *
489
+ * This is what the generator CREATES a missing installation document from. It
490
+ * deliberately writes no prose: what this service's installation actually
491
+ * requires is the service's own fact, and a generated paragraph describing a
492
+ * different service is exactly the sentence nobody re-derives
493
+ * (`.claude/rules/doc-code-binding.md` §1).
494
+ *
495
+ * @param {string} reference the rendered reference document
496
+ * @returns {string}
497
+ */
498
+ function skeletonOf(reference) {
499
+ const lines = reference.split('\n');
500
+ const titleAt = lines.findIndex((line) => /^# +\S/.test(line));
501
+ if (titleAt === -1) {
502
+ throw new Error('[ManifestFiles] Reference document has no title - a skeleton is its header, its title '
503
+ + 'and its sections, and this document declares no "# " line. '
504
+ + 'Fix: restore the title in the template document.');
505
+ }
506
+
507
+ const header = lines.slice(0, titleAt).filter((line) => line.trim() !== '');
508
+ const sections = headingsOf(reference)
509
+ .map((heading) => `## ${heading}\n\nTODO — this section is this service's own.`)
510
+ .join('\n\n');
511
+ return `${[...header, '', lines[titleAt], '', sections].join('\n')}\n`;
512
+ }
513
+
514
+ const templateSkeleton = Object.freeze({
515
+ scope: 'bearer',
516
+ requires: Object.freeze(['path', 'from', 'skeleton_only']),
517
+
518
+ ...READS_ITS_REFERENCE_ONLY,
519
+
520
+ run({ row, serviceRoot, workspaceRoot }) {
521
+ const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
522
+ const text = readServiceFile(serviceRoot, row.path);
523
+
524
+ // An absent document is NOT this row's finding. The installation contract
525
+ // raises SETUP_DOCS for exactly that, and `G-SETUP` puts it on the table
526
+ // with its own fix; saying it twice would give one defect two owners
527
+ // (`.claude/rules/change-discipline.md` § One rail per concern).
528
+ if (text === null) return [];
529
+
530
+ // The document is rendered for THIS repository, so it cannot be compared
531
+ // before the repository has said who it is (`serviceIdentity.js`).
532
+ const identity = readIdentity(serviceRoot);
533
+ if (identity.problem !== undefined) {
534
+ if (identity.undecided === null) return [];
535
+ return [{
536
+ where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: IDENTITY_FILE }),
537
+ what: `the sections of ${row.path} could not be compared — ${identity.undecided}`
538
+ }];
539
+ }
540
+
541
+ const reference = renderedReference({ row, serviceRoot, workspaceRoot });
542
+ const missing = headingsOf(reference).filter((heading) => !headingsOf(text).includes(heading));
543
+ if (missing.length === 0) return [];
544
+
545
+ return [{
546
+ where,
547
+ what: `has no section ${missing.map((heading) => `"${heading}"`).join(', ')} — `
548
+ + `${referenceOwner(row.from)} declares it and an operator installs from these headings`
549
+ }];
550
+ }
551
+ });
552
+
553
+
554
+ /** The block a sync writes the platform's ignore entries into, so a reader can tell them apart. */
555
+ const IGNORE_BLOCK = 'oa-ignore v1';
556
+
557
+ /**
558
+ * The entries a `.gitignore`-shaped file DECLARES: every non-blank line that is
559
+ * not a comment, trimmed. Order is not read, and neither are the comments — a
560
+ * repository groups its own lines however it likes.
561
+ *
562
+ * @param {string} text the file
563
+ * @returns {string[]} in the order they appear, duplicates kept out
564
+ */
565
+ function ignoreEntries(text) {
566
+ const seen = [];
567
+ for (const line of text.split('\n')) {
568
+ const entry = line.trim();
569
+ if (entry === '' || entry.startsWith('#')) continue;
570
+ if (!seen.includes(entry)) seen.push(entry);
571
+ }
572
+ return seen;
573
+ }
574
+
575
+ /**
576
+ * The file with the generated block taken out, markers included. What is left is
577
+ * the repository's own — which is the half a sync never rewrites.
578
+ *
579
+ * @param {string} text the file
580
+ * @param {string} name the block name
581
+ * @returns {string}
582
+ */
583
+ function withoutBlock(text, name) {
584
+ const block = blockLines(text, name);
585
+ if (block.length === 0) return text;
586
+ const lines = text.split('\n');
587
+ const start = lines.indexOf(block[0]);
588
+ const kept = [...lines.slice(0, start), ...lines.slice(start + block.length)];
589
+ return kept.join('\n').replace(/\n{3,}$/, '\n');
590
+ }
591
+
592
+ /** The block a sync writes the platform's build-context exclusions into. */
593
+ const DOCKERIGNORE_BLOCK = 'oa-dockerignore v1';
594
+
595
+ /**
596
+ * The one exclusion `.gitignore` cannot declare, because git never has to say it
597
+ * about itself: `.git`. Without it a LOCAL production build copies the whole
598
+ * history into the image, and the image stops being what a clean checkout would
599
+ * have produced — which is the criterion the whole row is measured by.
600
+ */
601
+ const DOCKER_IMPLICIT = Object.freeze(['.git']);
602
+
603
+ /**
604
+ * The exclusions a `.dockerignore` must carry, DERIVED from the very declaration
605
+ * the `.gitignore` row reads. One list, two consumers
606
+ * (`.claude/rules/change-discipline.md` § One rail per concern): a second
607
+ * hand-written list is the drift this row exists to prevent.
608
+ *
609
+ * The translation is not a copy, because the two formats do not mean the same
610
+ * thing by the same line. A `.gitignore` pattern with no slash matches at EVERY
611
+ * depth; a `.dockerignore` pattern is matched against the path from the context
612
+ * root, so `*.log` there catches `debug.log` and not `logs/debug.log`. Measured
613
+ * by BIZ-hello on 2026-09-11: a local production image of hello came to 6.6 GB,
614
+ * 5.2 GB of it `logs/`, and carried `config/env-active/*.env` with DB_PASSWORD,
615
+ * JWT_SECRET, the MinIO keys and RABBITMQ_URL. So every slash-less entry is
616
+ * emitted in both shapes, and an entry that already carries a slash is anchored
617
+ * in both formats alike and travels unchanged.
618
+ *
619
+ * A negation keeps its `!` in front of the pattern rather than inside it, and
620
+ * keeps its place in the order — in both formats a later line wins.
621
+ *
622
+ * What the derivation does NOT do is exclude anything `.gitignore` does not:
623
+ * `tests/` is the measured trap on that side, because Tier-1 of the boot reads
624
+ * `tests/cookbooks` and an image without it fails at phase 0.2.
625
+ *
626
+ * @param {string} gitignoreText the declaration both rows read
627
+ * @returns {string[]}
628
+ */
629
+ function dockerignoreEntries(gitignoreText) {
630
+ const entries = [];
631
+ const add = (entry) => { if (!entries.includes(entry)) entries.push(entry); };
632
+
633
+ for (const declared of ignoreEntries(gitignoreText)) {
634
+ const negated = declared.startsWith('!');
635
+ const pattern = (negated ? declared.slice(1) : declared).replace(/\/+$/, '');
636
+ if (pattern === '') continue;
637
+ const mark = (value) => (negated ? `!${value}` : value);
638
+
639
+ add(mark(pattern));
640
+ if (!pattern.includes('/')) add(mark(`**/${pattern}`));
641
+ }
642
+
643
+ for (const entry of DOCKER_IMPLICIT) add(entry);
644
+ return entries;
645
+ }
646
+
647
+ /**
648
+ * The `contains` class: a file whose platform ENTRIES must all be there, and
649
+ * whose every other line is the repository's own.
650
+ *
651
+ * The class exists because `.gitignore` is neither of the other two. It cannot
652
+ * be `identical` — a repository ignores its own scratch directories, and
653
+ * measured 2026-09-10 the eight services carry nine different files. It is not
654
+ * `generated` either — nothing renders it from a declaration. What must be true
655
+ * of it is a SET: every path the platform WRITES into a repository is a path git
656
+ * must not see. `ci/deployability.json` is the measured case — `oa-validate` and
657
+ * the pre-push hook of confirmation 006 both write it, and in four of the eight
658
+ * repositories every run left it untracked.
659
+ *
660
+ * A narrower pattern does not satisfy the entry it narrows, and that is not
661
+ * pedantry: `config/env-active/*.env` (property's line) leaves every non-`.env`
662
+ * file in a directory of live secrets committable.
663
+ */
664
+ const templateEntries = Object.freeze({
665
+ scope: 'bearer',
666
+ requires: Object.freeze(['path']),
667
+ ...READS_ITS_REFERENCE_ONLY,
668
+
669
+ run({ row, serviceRoot, workspaceRoot }) {
670
+ const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
671
+ const reference = ignoreEntries(readReferencedFile({ from: row.from, workspaceRoot }));
672
+ const text = readServiceFile(serviceRoot, row.path);
673
+
674
+ // ONE finding, not one per entry: the repository has no such file, and the
675
+ // fix is the same single command whichever entry a reader looks at.
676
+ if (text === null) {
677
+ return [{
678
+ where,
679
+ what: `the repository carries no ${row.path} — every path this platform writes into it `
680
+ + `(${reference.join(', ')}) is committable`
681
+ }];
682
+ }
683
+
684
+ const declared = ignoreEntries(text);
685
+ return reference
686
+ .filter((entry) => !declared.includes(entry))
687
+ .map((entry) => ({
688
+ where,
689
+ what: `does not ignore ${JSON.stringify(entry)}, which ${referenceOwner(row.from)} declares — `
690
+ + 'a path this platform writes into the repository, so every run leaves it in git status'
691
+ }));
692
+ }
693
+ });
694
+
695
+ /**
696
+ * `F-DOCKERIGNORE` — what a LOCAL production build must not copy into the image.
697
+ *
698
+ * The same `contains` shape as `F-GITIGNORE`, over the same declaration, and for
699
+ * the same reason one step further along: `Dockerfile` line `COPY . .` takes
700
+ * everything `.dockerignore` does not exclude, and being ignored by git excludes
701
+ * nothing at all. A CI image is clean by accident — a fresh checkout has no
702
+ * ignored file to copy — so the defect is invisible exactly where it is built:
703
+ * on a developer's machine.
704
+ */
705
+ const dockerignoreEntriesCheck = Object.freeze({
706
+ scope: 'bearer',
707
+ requires: Object.freeze(['path']),
708
+ ...READS_ITS_REFERENCE_ONLY,
709
+
710
+ run({ row, serviceRoot, workspaceRoot }) {
711
+ const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
712
+ const reference = dockerignoreEntries(readReferencedFile({ from: row.from, workspaceRoot }));
713
+ const text = readServiceFile(serviceRoot, row.path);
714
+
715
+ if (text === null) {
716
+ return [{
717
+ where,
718
+ what: `the repository carries no ${row.path} — a local production build copies the whole working `
719
+ + 'tree into the image, live secrets in config/env-active/ and the log directory with them'
720
+ }];
721
+ }
722
+
723
+ const declared = ignoreEntries(text);
724
+ return reference
725
+ .filter((entry) => !declared.includes(entry))
726
+ .map((entry) => ({
727
+ where,
728
+ what: `does not exclude ${JSON.stringify(entry)}, derived from ${referenceOwner(row.from)} — `
729
+ + 'a local production build copies it into the image, where a clean checkout would not have'
730
+ }));
731
+ }
732
+ });
733
+
734
+ module.exports = {
735
+ checks: [
736
+ { name: 'template-block', check: templateBlock },
737
+ { name: 'template-file', check: templateFile },
738
+ { name: 'template-render', check: templateRender },
739
+ { name: 'delimited-block-render', check: delimitedBlockRender },
740
+ { name: 'compose-runner', check: composeRunner },
741
+ { name: 'template-skeleton', check: templateSkeleton },
742
+ { name: 'template-entries', check: templateEntries },
743
+ { name: 'dockerignore-entries', check: dockerignoreEntriesCheck }
744
+ ],
745
+ blockLines,
746
+ headingsOf,
747
+ skeletonOf,
748
+ renderedReference,
749
+ ignoreEntries,
750
+ dockerignoreEntries,
751
+ withoutBlock,
752
+ IGNORE_BLOCK,
753
+ DOCKERIGNORE_BLOCK
754
+ };