@onlineapps/conn-orch-validator 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +2582 -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 +409 -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 +22 -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 +123 -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,298 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SCRIPTS-STANDARD §1-§3, as a module of the package that ships the uniform.
5
+ *
6
+ * WHY IT LIVES HERE. Confirmation `biz-service-manifest` 003 §19 makes
7
+ * `scripts/**` of every bearer a duty of the uniform, checked by "lint-scripts,
8
+ * shipped in the validator package". Until d.218 the rules were a file of the
9
+ * `api` checkout, and a file of one checkout cannot decide a rule about eight
10
+ * repositories: a biz service has no `api/` directory in its own CI and none at
11
+ * all inside its image, so the row would have been NOT RUN exactly where it is
12
+ * meant to answer. The rules moved; `api/scripts/ci/lint-scripts.mjs` calls
13
+ * this module and holds no rule of its own, so there is one implementation
14
+ * (`.claude/rules/change-discipline.md` § One rail per concern).
15
+ *
16
+ * WHAT DID NOT CHANGE. Every id, every message and every vocabulary is the one
17
+ * `api/tests/scripts/scripts-lint.bats` already asserts. The header probe stays
18
+ * anchored to the header SHAPE in the first lines of a file rather than to a
19
+ * token appearing anywhere: the unanchored probe missed the header linter
20
+ * itself.
21
+ *
22
+ * WHAT DID. An `@see api/…` target used to be resolved by stripping the `api/`
23
+ * prefix and looking under the run's own root — correct exactly once, when the
24
+ * run IS the api checkout. A biz script citing `api/docs/standards/…` would
25
+ * then have been measured against `<service>/docs/standards/…` and reported as
26
+ * a dead pointer it is not. So the api checkout is a PARAMETER: given, such a
27
+ * target is resolved against it; absent, the target is returned as UNRESOLVED
28
+ * and raises nothing, because a run that could not look must not report a pass
29
+ * (`.claude/rules/automation-gates.md` §5). Measured 2026-09-10 over the eight
30
+ * biz repositories: 47 `@see` lines under their scripts directories, of which 4
31
+ * open with the `api/` prefix.
32
+ *
33
+ * Two scopes, two rules. S001-S008 read the HEADER of every script under
34
+ * `scripts/`. S009 reads the COMMENTS of every test under `tests/scripts/` and
35
+ * nothing else in them: a `<path>.<ext>:<line>` citation in prose has no
36
+ * mechanism keeping it true (`doc-code-binding.md` §1), while the same shape on
37
+ * a CODE line is a fixture assertion whose target the test creates itself and
38
+ * which therefore cannot rot.
39
+ *
40
+ * @see api/docs/standards/SCRIPTS-STANDARD.md
41
+ * @see api/docs/governance/confirmations/biz-service-manifest.md
42
+ */
43
+
44
+ const fs = require('fs');
45
+ const path = require('path');
46
+
47
+ /** The five header fields, in the order SCRIPTS-STANDARD §1 writes them. */
48
+ const ORDER = Object.freeze(['Owns', 'Usage', 'Shell', 'Exit', 'Status']);
49
+
50
+ /** SCRIPTS-STANDARD §2: both vocabularies are closed, and a foreign value is a finding. */
51
+ const STATUS_VOCAB = new Set(['current', 'draft', 'archived']);
52
+ const SHELL_VOCAB = new Set(['bash>=4', 'bash>=3.2', 'sh', 'node>=18', 'node>=24']);
53
+
54
+ /** The directory whose scripts carry the header. */
55
+ const SCRIPT_SCOPE = 'scripts';
56
+
57
+ /** The directory whose COMMENTS S009 reads, and nothing else in it. */
58
+ const CITATION_SCOPE = 'tests/scripts';
59
+
60
+ /**
61
+ * The directories whose files are libraries rather than entry points.
62
+ *
63
+ * This is the half of S007 the DIRECTORY decides, and the only half: a library
64
+ * is not invoked, so instead of a command line its `Usage:` carries the word by
65
+ * which it is brought into another file. WHICH word that is, is decided by the
66
+ * language (`usageWordFor`), never by the directory — d.249, on the BIZ-ingest
67
+ * finding of 2026-09-11: the rule used to read `scripts/lib/` as "shell" and
68
+ * `scripts/ci/lib/` as "Node", so a Node library under the first could not go
69
+ * green without writing something false about itself — ingest 1 file, meta 7,
70
+ * both red on this one rule and on nothing else (measured 2026-09-11, before
71
+ * and after). Making a file pass by writing a word that is not true of it is
72
+ * exactly what `architecture-principles.md` §10 forbids, so the rule moved
73
+ * rather than the files.
74
+ */
75
+ const LIBRARY_SCOPES = Object.freeze(['scripts/lib/', 'scripts/ci/lib/']);
76
+
77
+ /**
78
+ * Shell is sourced; everything else this lint reads is a Node module, and a
79
+ * Node module is required. The test is the extension, because that is what
80
+ * decides how the file can be brought in at all — `.` on a `.js` is not a thing
81
+ * a shell can do, and `require` on a `.sh` is not a thing Node can do.
82
+ *
83
+ * Written as "shell, or else Node" rather than as a list of Node extensions on
84
+ * purpose: the extensions this lint reads are `SCRIPT_EXTENSIONS`, and a second
85
+ * list here would be a copy of it that drifts (`change-discipline.md` § One rail
86
+ * per concern). A `.cjs` is outside the scan today; the day it enters, it needs
87
+ * no edit here.
88
+ *
89
+ * @param {string} relative path inside the repository
90
+ * @returns {'sourced'|'required'}
91
+ */
92
+ const usageWordFor = (relative) => (relative.endsWith('.sh') ? 'sourced' : 'required');
93
+
94
+ /** What the message calls the file, from the same test. */
95
+ const languageOf = (relative) => (relative.endsWith('.sh') ? 'shell' : 'Node');
96
+
97
+ /** What a script may be written in. */
98
+ const SCRIPT_EXTENSIONS = /\.(sh|mjs|js)$/;
99
+
100
+ /** What a test under the citation scope may be written in. */
101
+ const CITATION_EXTENSIONS = /\.(bats|bash|sh)$/;
102
+
103
+ /**
104
+ * A line number written into a comment. The extension list carries every module
105
+ * extension this platform writes, `.env` included: a generated env template
106
+ * renumbers every citation of it without one edit to the cited file, which is
107
+ * the shape a line citation can least survive.
108
+ */
109
+ const LINE_CITATION = /[A-Za-z0-9_/.-]+\.(?:js|mjs|cjs|ts|mts|sh|bash|bats|yml|json|md|env):\d+/;
110
+
111
+ /** A comment line of a shell-family test. */
112
+ const COMMENT_LINE = /^\s*#/;
113
+
114
+ /** The prefix a citation of the api checkout opens with, as every document writes it. */
115
+ const API_PREFIX = 'api/';
116
+
117
+ /**
118
+ * Every file under `dir` whose name matches, depth first, as `/`-separated
119
+ * paths relative to `root`.
120
+ *
121
+ * @param {string} dir absolute directory
122
+ * @param {string} root the root paths are reported against
123
+ * @param {RegExp} matches which file names belong to the scope
124
+ * @returns {string[]} sorted
125
+ */
126
+ function walk(dir, root, matches) {
127
+ if (!fs.existsSync(dir)) return [];
128
+ const found = [];
129
+ const descend = (current) => {
130
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
131
+ const full = path.join(current, entry.name);
132
+ if (entry.isDirectory()) descend(full);
133
+ else if (matches.test(entry.name)) found.push(path.relative(root, full).split(path.sep).join('/'));
134
+ }
135
+ };
136
+ descend(dir);
137
+ return found.sort();
138
+ }
139
+
140
+ /**
141
+ * Where an `@see` target lies, or null when this run cannot say.
142
+ *
143
+ * @param {{target: string, root: string, apiRoot: string|null}} params
144
+ * @returns {string|null} the absolute path to test, or null when unresolvable here
145
+ */
146
+ function resolveSeeTarget({ target, root, apiRoot }) {
147
+ if (!target.startsWith(API_PREFIX)) return path.join(root, target);
148
+ if (apiRoot === null) return null;
149
+ return path.join(apiRoot, target.slice(API_PREFIX.length));
150
+ }
151
+
152
+ /**
153
+ * The header findings of one script.
154
+ *
155
+ * @param {{relative: string, lines: string[], root: string, apiRoot: string|null}} params
156
+ * @param {(id: string, line: number, message: string) => void} report
157
+ * @param {(target: string) => void} unresolved
158
+ */
159
+ function lintHeader({ relative, lines, root, apiRoot }, report, unresolved) {
160
+ const marker = relative.endsWith('.sh') ? '#' : '//';
161
+ let i = lines[0] !== undefined && lines[0].startsWith('#!') ? 1 : 0;
162
+
163
+ // S001: five fields, exact order, contiguous from the top · S003: the comment
164
+ // marker matches the file type. (There is no S002 — the ids are the standard's,
165
+ // and it never defined one.)
166
+ const fields = {};
167
+ for (const name of ORDER) {
168
+ const line = lines[i] === undefined ? '' : lines[i];
169
+ const parsed = line.match(/^(#|\/\/) ([A-Za-z]+): (.*)$/);
170
+ if (parsed === null || parsed[2] !== name) {
171
+ report('S001', i + 1,
172
+ `Header field "${marker} ${name}: " expected here, found "${line.slice(0, 60)}". `
173
+ + `The five fields (${ORDER.join(', ')}) sit in this order immediately after the shebang `
174
+ + '(SCRIPTS-STANDARD §1). Fix: write the missing/misplaced field.');
175
+ return;
176
+ }
177
+ if (parsed[1] !== marker) {
178
+ report('S003', i + 1, `Comment marker "${parsed[1]}" does not match the file type - `
179
+ + `${relative.endsWith('.sh') ? 'shell scripts use "# "' : 'Node scripts use "// "'} (SCRIPTS-STANDARD §1).`);
180
+ return;
181
+ }
182
+ fields[name] = { value: parsed[3].trim(), line: i + 1 };
183
+ i += 1;
184
+ }
185
+
186
+ // S004 / S008: the two closed vocabularies. A parseable-but-foreign value is a
187
+ // lie with a green checkmark.
188
+ if (!STATUS_VOCAB.has(fields.Status.value)) {
189
+ report('S004', fields.Status.line,
190
+ `Status "${fields.Status.value}" is not in the closed vocabulary current|draft|archived `
191
+ + '(SCRIPTS-STANDARD §2). A parseable-but-foreign value is a lie with a green checkmark. '
192
+ + 'Fix: use one of the three words; extra prose goes below the blank comment line.');
193
+ }
194
+ if (!SHELL_VOCAB.has(fields.Shell.value)) {
195
+ report('S008', fields.Shell.line,
196
+ `Shell "${fields.Shell.value}" is not in the closed vocabulary `
197
+ + 'bash>=4|bash>=3.2|sh|node>=18|node>=24 (SCRIPTS-STANDARD §2).');
198
+ }
199
+
200
+ // S005: blank comment line after Status
201
+ if ((lines[i] === undefined ? '' : lines[i]).trim() !== marker) {
202
+ report('S005', i + 1,
203
+ `A blank comment line ("${marker}") must follow Status before any free text or @see (SCRIPTS-STANDARD §1).`);
204
+ return;
205
+ }
206
+ i += 1;
207
+
208
+ // S006: every @see immediately following points at a file that is there. The
209
+ // target is the first whitespace-delimited token after "@see" — section
210
+ // fragments (§…), em-dash annotations and parenthetical notes follow it.
211
+ while ((lines[i] === undefined ? '' : lines[i]).startsWith(`${marker} @see `)) {
212
+ const target = lines[i].slice(marker.length + 6).trim().split(/\s+/)[0];
213
+ const resolved = resolveSeeTarget({ target, root, apiRoot });
214
+ if (resolved === null) unresolved(target);
215
+ else if (!fs.existsSync(resolved)) {
216
+ report('S006', i + 1, `@see target "${target}" does not exist in the repo. Fix the path, or remove `
217
+ + 'the line; a dead pointer teaches the reader a wrong home (SCRIPTS-STANDARD §3).');
218
+ }
219
+ i += 1;
220
+ }
221
+
222
+ // S007: a library declares the usage word of ITS LANGUAGE.
223
+ if (LIBRARY_SCOPES.some((scope) => relative.startsWith(scope))) {
224
+ const expected = usageWordFor(relative);
225
+ if (fields.Usage.value !== expected) {
226
+ report('S007', fields.Usage.line, `A ${languageOf(relative)} library declares Usage: ${expected}, `
227
+ + `found "${fields.Usage.value}" (SCRIPTS-STANDARD §2).`);
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * The rules over one repository.
234
+ *
235
+ * A repository with no `scripts/` is an EMPTY scope, not an error: the caller
236
+ * that needs "there must be scripts here" is the api CLI, which says so itself,
237
+ * and the manifest row asks the question of eight repositories of which one may
238
+ * legitimately carry none. The returned `scanned` is what makes the difference
239
+ * visible — an empty scope and a clean scope do not look alike.
240
+ *
241
+ * @param {{root: string, apiRoot?: string|null}} params
242
+ * `root` — the repository to read; `apiRoot` — the api checkout an `api/…`
243
+ * citation is resolved against, or null when this run has none.
244
+ * @returns {{findings: Array<{id: string, file: string, line: number, message: string}>,
245
+ * scanned: string[], citationScanned: string[], unresolved: string[]}}
246
+ */
247
+ function lintScripts({ root, apiRoot = null }) {
248
+ if (typeof root !== 'string' || root.length === 0) {
249
+ throw new Error('[LintScripts] Repository root is required - lintScripts({ root }) got '
250
+ + `${JSON.stringify(root)}. Fix: pass the directory whose scripts/ is to be read.`);
251
+ }
252
+
253
+ const scanned = walk(path.join(root, SCRIPT_SCOPE), root, SCRIPT_EXTENSIONS);
254
+ const citationScanned = walk(path.join(root, ...CITATION_SCOPE.split('/')), root, CITATION_EXTENSIONS);
255
+
256
+ const findings = [];
257
+ const unresolved = [];
258
+
259
+ for (const relative of scanned) {
260
+ const lines = fs.readFileSync(path.join(root, ...relative.split('/')), 'utf8').split('\n');
261
+ lintHeader(
262
+ { relative, lines, root, apiRoot },
263
+ (id, line, message) => findings.push({ id, file: relative, line, message }),
264
+ (target) => unresolved.push(target)
265
+ );
266
+ }
267
+
268
+ // S009: a comment under tests/scripts/ cites a symbol or a literal string, and
269
+ // a covered defect cites the commit hash — never a line number.
270
+ for (const relative of citationScanned) {
271
+ const lines = fs.readFileSync(path.join(root, ...relative.split('/')), 'utf8').split('\n');
272
+ for (let n = 0; n < lines.length; n += 1) {
273
+ if (!COMMENT_LINE.test(lines[n])) continue;
274
+ const hit = lines[n].match(LINE_CITATION);
275
+ if (hit === null) continue;
276
+ findings.push({
277
+ id: 'S009',
278
+ file: relative,
279
+ line: n + 1,
280
+ message: `Comment cites a line number - "${hit[0]}". A line number has no mechanism keeping it true: `
281
+ + 'the file moves and the number stays (doc-code-binding.md §1). Fix: cite a symbol or a literal '
282
+ + 'string; a covered defect cites the commit hash that introduced or repaired it '
283
+ + "(git log -S'<string>' --format=%h -- <file>) - DOC-STANDARD rule 11."
284
+ });
285
+ }
286
+ }
287
+
288
+ return { findings, scanned, citationScanned, unresolved };
289
+ }
290
+
291
+ module.exports = {
292
+ lintScripts,
293
+ SCRIPT_SCOPE,
294
+ CITATION_SCOPE,
295
+ ORDER,
296
+ STATUS_VOCAB,
297
+ SHELL_VOCAB
298
+ };
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The one-shot test runner as a DELIMITED BLOCK — the single definition the
5
+ * conformance check and the generator both read.
6
+ *
7
+ * Why a block rather than a whole file: a compose file cannot be byte-equal
8
+ * across repositories, because the service it declares IS the repository. But
9
+ * the runner beside that service is a platform decision end to end (owner
10
+ * decision `api/docs/governance/confirmations/biz-test-container.md` 001) — its
11
+ * profile, its user, its own memory budget, its entrypoint, and the paragraph
12
+ * explaining why the budget is separate. Measured over the eight biz
13
+ * repositories on 2026-09-09, seven declare a runner that is semantically the
14
+ * same and differs only in three places, all of them named below.
15
+ *
16
+ * ## What "normalized" means, and why it is not a loosening
17
+ *
18
+ * Two of the runner's declarations are NOT the block's to fix, because another
19
+ * rule already fixes them: `serviceFiles.js` § `composeRunner` requires the
20
+ * runner's `build`, `env_file` and `networks` to equal **the service it tests**.
21
+ * A service that reaches a second network reaches it in both nodes or its suite
22
+ * cannot see what the service sees. So those three are compared against the
23
+ * service (there), and removed before the block is compared against the template
24
+ * (here) — one concern, one rail (`.claude/rules/change-discipline.md` § One rail
25
+ * per concern). Everything else in the block is compared byte for byte.
26
+ *
27
+ * The service's own identity is the other normalization: the runner's key is
28
+ * `<container>_tests`, and the container name is the one thing that is per
29
+ * repository by construction. It is substituted back to the template's
30
+ * placeholders before the comparison, so the block is read as the template
31
+ * wrote it.
32
+ *
33
+ * ## The same definition renders
34
+ *
35
+ * `renderRunnerBlock` performs exactly the inverse: the template's block with
36
+ * this repository's identity in it and this repository's three shared
37
+ * declarations grafted in. That is what makes `npx oa-sync-template
38
+ * docker-compose.yml` produce a block the check then finds clean — a generator
39
+ * whose output its own check rejects is the defect this module exists to avoid.
40
+ *
41
+ * @see api/docs/governance/confirmations/biz-service-manifest.md §2, §3.2
42
+ * @see api/docs/governance/confirmations/biz-test-container.md
43
+ */
44
+
45
+ /** The declarations a runner shares with the service, or it is not testing the same thing. */
46
+ const SHARED_DECLARATIONS = Object.freeze(['build', 'env_file', 'networks']);
47
+
48
+ /** How deep the keys of one compose service sit, in the two-space indentation every compose file here uses. */
49
+ const MEMBER_INDENT = 4;
50
+
51
+ /** How deep a compose service's own name sits. */
52
+ const SERVICE_INDENT = 2;
53
+
54
+ /** The template's placeholders this module substitutes in both directions. */
55
+ const CONTAINER_PLACEHOLDER = '__CONTAINER_NAME__';
56
+ const SERVICE_PLACEHOLDER = '__SERVICE_NAME__';
57
+
58
+ const indentOf = (line) => line.length - line.replace(/^ +/, '').length;
59
+
60
+ /**
61
+ * The lines of `key:` and everything nested under it, within one node's lines.
62
+ *
63
+ * @param {string[]} lines the node's lines, its own name line included
64
+ * @param {string} key the declaration
65
+ * @param {number} indent the indentation the node's keys sit at
66
+ * @returns {{start: number, end: number}|null} null when the node does not declare it
67
+ */
68
+ function declarationSpan(lines, key, indent = MEMBER_INDENT) {
69
+ const start = lines.findIndex((line) => indentOf(line) === indent && line.trim().startsWith(`${key}:`));
70
+ if (start === -1) return null;
71
+
72
+ let end = start + 1;
73
+ while (end < lines.length && lines[end].trim() !== '' && indentOf(lines[end]) > indent) end += 1;
74
+ return { start, end };
75
+ }
76
+
77
+ /**
78
+ * Where one compose service's lines begin and end, its name line first.
79
+ *
80
+ * The span starts at the KEY and not at whatever comment sits above it: those
81
+ * lines are the repository's own prose — in `api_biz/converter` they are the
82
+ * memory measurement taken inside that container — and a run that replaces the
83
+ * declaration has no business deleting them.
84
+ *
85
+ * @param {string} text the compose file
86
+ * @param {string} name the compose service name
87
+ * @returns {{start: number, end: number}|null} null when the file declares no such service
88
+ */
89
+ function serviceSpan(text, name) {
90
+ const lines = text.split('\n');
91
+ const start = lines.findIndex((line) => indentOf(line) === SERVICE_INDENT && line.trim() === `${name}:`);
92
+ if (start === -1) return null;
93
+
94
+ let end = start + 1;
95
+ while (end < lines.length && (lines[end].trim() === '' || indentOf(lines[end]) > SERVICE_INDENT)) end += 1;
96
+ while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
97
+ return { start, end };
98
+ }
99
+
100
+ /**
101
+ * The lines of one compose service, its name line first.
102
+ *
103
+ * Read from the raw text rather than from the parsed node, because the graft
104
+ * writes the service's declaration back into a file VERBATIM — a re-serialized
105
+ * copy would rewrite formatting nobody asked to change
106
+ * (`.claude/rules/automation-gates.md` §1 requirement 3).
107
+ *
108
+ * @param {string} text the compose file
109
+ * @param {string} name the compose service name
110
+ * @returns {string[]} [] when the file declares no such service
111
+ */
112
+ function serviceLines(text, name) {
113
+ const span = serviceSpan(text, name);
114
+ return span === null ? [] : text.split('\n').slice(span.start, span.end);
115
+ }
116
+
117
+ /**
118
+ * A compose file with ONE service's declaration replaced, and every other line
119
+ * of it left exactly as it was.
120
+ *
121
+ * The case it exists for was measured on 2026-09-10: seven of the eight biz
122
+ * repositories carry a runner that predates the block markers (d.220), so a run
123
+ * that only knew how to INSERT gave those files a second
124
+ * `<container>_tests:` key — a duplicate mapping key, which compose either
125
+ * refuses or resolves by taking the last one. Replacing it in place keeps one
126
+ * key, at the position the repository already chose for it.
127
+ *
128
+ * @param {{text: string, name: string, replacement: string[]}} args
129
+ * @returns {string}
130
+ */
131
+ function replaceServiceNode({ text, name, replacement }) {
132
+ const span = serviceSpan(text, name);
133
+ if (span === null) {
134
+ throw new Error(`[ComposeRunnerBlock] No compose service "${name}" to replace - the file declares no `
135
+ + `line reading " ${name}:". Fix: name a service the file declares.`);
136
+ }
137
+ const lines = text.split('\n');
138
+ return [...lines.slice(0, span.start), ...replacement, ...lines.slice(span.end)].join('\n');
139
+ }
140
+
141
+ /**
142
+ * The block without the declarations another rule owns.
143
+ *
144
+ * @param {string[]} lines
145
+ * @param {ReadonlyArray<string>} keys
146
+ * @returns {string[]}
147
+ */
148
+ function stripDeclarations(lines, keys) {
149
+ let remaining = lines.slice();
150
+ for (const key of keys) {
151
+ const span = declarationSpan(remaining, key);
152
+ if (span === null) continue;
153
+ remaining = [...remaining.slice(0, span.start), ...remaining.slice(span.end)];
154
+ }
155
+ return remaining;
156
+ }
157
+
158
+ /**
159
+ * A repository's runner block as the template wrote it: its identity replaced by
160
+ * the placeholders, and the three shared declarations removed.
161
+ *
162
+ * The container name is substituted BEFORE the service name on purpose: the
163
+ * container name normally contains the service name (`api_service_converter`
164
+ * carries `converter`), so the wider match has to go first or it is destroyed by
165
+ * the narrower one.
166
+ *
167
+ * @param {{lines: string[], containerName: string, serviceName: string}} args
168
+ * @returns {string[]}
169
+ */
170
+ function normalizeRunnerBlock({ lines, containerName, serviceName }) {
171
+ return stripDeclarations(lines, SHARED_DECLARATIONS).map((line) => line
172
+ .split(containerName).join(CONTAINER_PLACEHOLDER)
173
+ .split(serviceName).join(SERVICE_PLACEHOLDER));
174
+ }
175
+
176
+ /**
177
+ * The template's runner block for one repository: its identity substituted in,
178
+ * and its `build`, `env_file` and `networks` taken from the service this runner
179
+ * tests.
180
+ *
181
+ * A declaration the service does not make is REMOVED from the rendered block
182
+ * rather than left at the template's value: "the same as the service" is the
183
+ * rule, and a runner that reaches a network its service does not is exactly what
184
+ * the check would then report (`.claude/rules/architecture-principles.md` §3 —
185
+ * no fallback to a default nobody chose).
186
+ *
187
+ * @param {{reference: string[], composeText: string, containerName: string, serviceName: string}} args
188
+ * @returns {string[]} the block's lines, markers included
189
+ */
190
+ function renderRunnerBlock({ reference, composeText, containerName, serviceName }) {
191
+ let rendered = reference.map((line) => line
192
+ .split(CONTAINER_PLACEHOLDER).join(containerName)
193
+ .split(SERVICE_PLACEHOLDER).join(serviceName));
194
+
195
+ const service = serviceLines(composeText, containerName);
196
+
197
+ for (const key of SHARED_DECLARATIONS) {
198
+ const mine = declarationSpan(rendered, key);
199
+ if (mine === null) continue;
200
+
201
+ const own = declarationSpan(service, key);
202
+ const replacement = own === null ? [] : service.slice(own.start, own.end);
203
+ rendered = [...rendered.slice(0, mine.start), ...replacement, ...rendered.slice(mine.end)];
204
+ }
205
+
206
+ return rendered;
207
+ }
208
+
209
+ module.exports = {
210
+ SHARED_DECLARATIONS,
211
+ CONTAINER_PLACEHOLDER,
212
+ SERVICE_PLACEHOLDER,
213
+ MEMBER_INDENT,
214
+ SERVICE_INDENT,
215
+ declarationSpan,
216
+ serviceSpan,
217
+ serviceLines,
218
+ replaceServiceNode,
219
+ stripDeclarations,
220
+ normalizeRunnerBlock,
221
+ renderRunnerBlock
222
+ };
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The two facts a compose file answers for this uniform — which services it
5
+ * declares, and what each of them says about itself — read straight from the
6
+ * text.
7
+ *
8
+ * There is no YAML parser here on purpose. `@onlineapps/conn-orch-validator`
9
+ * declares two dependencies, both `@onlineapps`, and a package sees only what it
10
+ * declares (`architecture-principles.md` § Shared Packages, gate G6): pulling a
11
+ * parser in to read four keys would change what every biz service installs.
12
+ * `utils/deployContract.js` reads the same files by line for the same reason,
13
+ * so this is that rail, not a second one.
14
+ *
15
+ * The subset it accepts is the one every compose file in this workspace is
16
+ * written in: two-space indentation, `key: value`, `key:` with a nested block,
17
+ * and `- item` sequences. A tab makes it fail loudly rather than mis-read a
18
+ * limit (`automation-gates.md` §1 requirement 4).
19
+ */
20
+
21
+ /** How deep one level of nesting is, in spaces, in every compose file here. */
22
+ const INDENT = 2;
23
+
24
+ const indentOf = (line) => line.length - line.replace(/^ +/, '').length;
25
+ const isBlank = (line) => line.trim() === '' || line.trim().startsWith('#');
26
+
27
+ /**
28
+ * @typedef {object} ComposeNode
29
+ * @property {Map<string,string>} scalars `key: value` pairs at this level
30
+ * @property {Map<string,ComposeNode>} blocks nested mappings
31
+ * @property {Map<string,string[]>} sequences `- item` lists, items verbatim
32
+ * @property {string[]} keys every key at this level, in file order
33
+ */
34
+
35
+ /** @returns {ComposeNode} */
36
+ const emptyNode = () => ({ scalars: new Map(), blocks: new Map(), sequences: new Map(), keys: [] });
37
+
38
+ /**
39
+ * Parse the lines at one indentation level into a node.
40
+ *
41
+ * @param {string[]} lines the whole file, split
42
+ * @param {number} start index of the first line of this level
43
+ * @param {number} indent the indentation this level sits at
44
+ * @returns {{ node: ComposeNode, next: number }}
45
+ */
46
+ function parseLevel(lines, start, indent) {
47
+ const node = emptyNode();
48
+ let i = start;
49
+
50
+ while (i < lines.length) {
51
+ const line = lines[i];
52
+ if (isBlank(line)) { i += 1; continue; }
53
+ if (indentOf(line) < indent) break;
54
+
55
+ const trimmed = line.trim();
56
+
57
+ if (trimmed.startsWith('- ') || trimmed === '-') { i += 1; continue; }
58
+
59
+ const colon = trimmed.indexOf(':');
60
+ if (colon === -1) { i += 1; continue; }
61
+
62
+ const key = trimmed.slice(0, colon).trim();
63
+ const value = trimmed.slice(colon + 1).trim();
64
+ node.keys.push(key);
65
+
66
+ if (value !== '') {
67
+ node.scalars.set(key, value);
68
+ i += 1;
69
+ continue;
70
+ }
71
+
72
+ // A key with nothing after the colon opens either a nested mapping or a
73
+ // sequence; which one is decided by the first non-blank line under it.
74
+ let j = i + 1;
75
+ while (j < lines.length && isBlank(lines[j])) j += 1;
76
+
77
+ if (j >= lines.length || indentOf(lines[j]) <= indent) {
78
+ node.scalars.set(key, '');
79
+ i = j;
80
+ continue;
81
+ }
82
+
83
+ if (lines[j].trim().startsWith('-')) {
84
+ const items = [];
85
+ while (j < lines.length && (isBlank(lines[j]) || indentOf(lines[j]) > indent)) {
86
+ if (!isBlank(lines[j])) items.push(lines[j].trim().replace(/^-\s*/, ''));
87
+ j += 1;
88
+ }
89
+ node.sequences.set(key, items);
90
+ i = j;
91
+ continue;
92
+ }
93
+
94
+ const nested = parseLevel(lines, j, indentOf(lines[j]));
95
+ node.blocks.set(key, nested.node);
96
+ i = nested.next;
97
+ }
98
+
99
+ return { node, next: i };
100
+ }
101
+
102
+ /**
103
+ * The services a compose file declares, by name.
104
+ *
105
+ * @param {string} text the file's content
106
+ * @returns {Map<string, ComposeNode>} empty when the file declares no `services:`
107
+ */
108
+ function readComposeServices(text) {
109
+ if (/^\t/m.test(text)) {
110
+ throw new Error('[ComposeShape] Compose file is indented with tabs - this reader accepts the two-space '
111
+ + 'indentation every compose file in this workspace uses. Fix: re-indent the file with spaces.');
112
+ }
113
+
114
+ const lines = text.split('\n');
115
+ const top = parseLevel(lines, 0, 0).node;
116
+ const services = top.blocks.get('services');
117
+ return services ? services.blocks : new Map();
118
+ }
119
+
120
+ /**
121
+ * Follow a dotted path of nested keys and return the scalar at its end.
122
+ *
123
+ * @param {ComposeNode} node where to start
124
+ * @param {string} dotted e.g. `deploy.resources.limits.memory`
125
+ * @returns {string|null} the value, or null when any step is absent
126
+ */
127
+ function scalarAt(node, dotted) {
128
+ const parts = dotted.split('.');
129
+ const last = parts.pop();
130
+ let current = node;
131
+ for (const part of parts) {
132
+ current = current && current.blocks.get(part);
133
+ if (!current) return null;
134
+ }
135
+ const value = current.scalars.get(last);
136
+ return value === undefined ? null : value.replace(/^["']|["']$/g, '');
137
+ }
138
+
139
+ /**
140
+ * Is this key declared at all — as a scalar, a block or a sequence?
141
+ *
142
+ * @param {ComposeNode} node the service block
143
+ * @param {string} key the key
144
+ * @returns {boolean}
145
+ */
146
+ const declares = (node, key) => node.keys.includes(key);
147
+
148
+ /**
149
+ * One declaration as comparable text, whatever shape it takes. Two services
150
+ * that say the same thing compare equal even when one writes a sequence and the
151
+ * other a scalar.
152
+ *
153
+ * @param {ComposeNode} node the service block
154
+ * @param {string} key the key
155
+ * @returns {string} '' when the key is absent
156
+ */
157
+ function declarationOf(node, key) {
158
+ if (node.sequences.has(key)) return node.sequences.get(key).join('\n');
159
+ if (node.scalars.has(key)) return node.scalars.get(key);
160
+ const block = node.blocks.get(key);
161
+ if (!block) return '';
162
+ return block.keys.map((child) => `${child}=${block.scalars.get(child) ?? declarationOf(block, child)}`).join('\n');
163
+ }
164
+
165
+ module.exports = { readComposeServices, scalarAt, declares, declarationOf, INDENT };