@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,463 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The generated regions of the documentation tree.
5
+ *
6
+ * Confirmation `biz-service-manifest` 002 §16.2: the list of required files,
7
+ * scripts, limits, categories and duties that `docs/biz/60-templates/…`,
8
+ * `docs/guides/library-publishing-process.md` and `docs/guides/DEVELOPMENT.md`
9
+ * copy by hand becomes a generated region fed by the manifests, and `--check`
10
+ * fails the run when a region is stale. Three documents were measured copying
11
+ * the same repository tree, and two copying the same script table; a copy is
12
+ * kept true by review only, so it is a descriptive fact written by hand -
13
+ * exactly what `.claude/rules/doc-code-binding.md` §1 forbids.
14
+ *
15
+ * What the region carries and what it deliberately does not:
16
+ *
17
+ * * the row's OBSERVABLE - its id, its check, the paths and values its own
18
+ * parameters name. Those are facts of the manifest, which is their single
19
+ * owner, so rendering them creates no second copy.
20
+ * * NEVER the row's `why` or `fix`. Those are prose with an owner, and a
21
+ * rendered copy of them in five documents is the duplication this mechanism
22
+ * exists to remove (d.216 established the shape in the library READMEs: a
23
+ * region of ids and links, not of paragraphs).
24
+ * * a `from:` reference renders as the PATH THAT OWNS the value, never as the
25
+ * value itself (confirmation 004). "Node major: `api/.nvmrc`" stays true when
26
+ * the platform moves to the next major; "Node 24" does not.
27
+ *
28
+ * Pure text: nothing here opens a file, reads the environment or resolves a
29
+ * workspace. The caller loads both manifests, lists the packaged template and
30
+ * says which document the region is going into
31
+ * (`.claude/rules/architecture-principles.md` §1).
32
+ *
33
+ * Deliberately absent from every region: a date, a count and a version. A count
34
+ * makes the region drift the day a row is added without anything else changing,
35
+ * and a date makes two runs render different bytes - which is precisely what
36
+ * stops a generator from being a gate.
37
+ *
38
+ * @see api/docs/governance/confirmations/biz-service-manifest.md § Confirmation 20260909-biz-service-manifest-002
39
+ */
40
+
41
+ const path = require('path');
42
+
43
+ const { markersFor, hasRegion, replaceRegion, checkRegion: checkAgainst } = require('./generatedRegion');
44
+ const { isPackageReference, referenceOwner } = require('../manifest/discovery');
45
+
46
+ /** The five regions the confirmation names. Adding a sixth is a change to it. */
47
+ const REGION_IDS = Object.freeze([
48
+ 'biz-service-tree',
49
+ 'biz-service-scripts',
50
+ 'biz-service-runtime',
51
+ 'biz-service-alignment',
52
+ 'library-duties'
53
+ ]);
54
+
55
+ /** What each region renders from - the one sentence `--list` prints. */
56
+ const DESCRIPTIONS = Object.freeze({
57
+ 'biz-service-tree': 'the packaged service template, annotated with the biz-service manifest row owning each path',
58
+ 'biz-service-scripts': 'scripts.required and scripts.forbidden of the biz-service manifest, bodies included',
59
+ 'biz-service-runtime': 'the runtime rows of the biz-service manifest and every row carrying a memory limit',
60
+ 'biz-service-alignment': 'every row of the biz-service manifest - id, severity, what it checks',
61
+ 'library-duties': 'the categories and duty sections of the library manifest, guidance marked apart'
62
+ });
63
+
64
+ /** Which manifest a region reads, for the diff header of a failed check. */
65
+ const SOURCES = Object.freeze({
66
+ 'biz-service-tree': 'manifests/biz-service.manifest.json',
67
+ 'biz-service-scripts': 'manifests/biz-service.manifest.json',
68
+ 'biz-service-runtime': 'manifests/biz-service.manifest.json',
69
+ 'biz-service-alignment': 'manifests/biz-service.manifest.json',
70
+ 'library-duties': 'manifests/library.manifest.json'
71
+ });
72
+
73
+ /**
74
+ * The keys every row carries as metadata. Everything else on a row is a
75
+ * PARAMETER - the value that row fixes - and the runtime region renders those
76
+ * without knowing their names in advance, so a parameter added to the manifest
77
+ * appears without this file being edited.
78
+ */
79
+ const ROW_METADATA = Object.freeze(['id', 'check', 'severity', 'owner', 'why', 'fix', 'doc']);
80
+
81
+ function requireKnownId(id) {
82
+ if (!REGION_IDS.includes(id)) {
83
+ throw new Error(`[DocsRegion] Unknown region id "${id}" - this package renders ${REGION_IDS.join(', ')}. `
84
+ + 'Fix: name one of those, or add the region here before a document declares it.');
85
+ }
86
+ }
87
+
88
+ /** What the marker tells the reader to run. Names the file, so it can be copied as it stands. */
89
+ function regenerateCommand(id, targetLabel) {
90
+ if (typeof targetLabel !== 'string' || targetLabel.length === 0) {
91
+ throw new Error('[DocsRegion] targetLabel is required - the marker names the file that regenerates the '
92
+ + 'region, and a marker with no file in it cannot be run. '
93
+ + 'Fix: pass the path of the document as the workspace knows it.');
94
+ }
95
+ return `npx oa-sync-template docs-region --id ${id} --file ${targetLabel}`;
96
+ }
97
+
98
+ /**
99
+ * The two markers of one region in one document.
100
+ *
101
+ * @param {string} id
102
+ * @param {string} targetLabel the document, as the workspace root names it
103
+ * @returns {{begin: string, end: string}}
104
+ */
105
+ function markersForRegion(id, targetLabel) {
106
+ requireKnownId(id);
107
+ return markersFor(id, regenerateCommand(id, targetLabel));
108
+ }
109
+
110
+ /** @param {string} id @returns {string} */
111
+ function describeRegion(id) {
112
+ requireKnownId(id);
113
+ return DESCRIPTIONS[id];
114
+ }
115
+
116
+ /**
117
+ * Which regions of this package a document declares, in a fixed order.
118
+ *
119
+ * The question goes through `generatedRegion.hasRegion`, which knows that a
120
+ * marker inside a fence is an example: these documents are the ones that teach
121
+ * the shape, and matching the marker string here would be a second, fence-blind
122
+ * copy of the module's own rule (d.319).
123
+ */
124
+ function regionIdsIn(text, targetLabel) {
125
+ return REGION_IDS.filter((id) => hasRegion(text, markersForRegion(id, targetLabel)));
126
+ }
127
+
128
+ /** A markdown link from the document to a file, computed from the two paths. */
129
+ function linkTo(targetPath, filePath) {
130
+ if (typeof targetPath !== 'string' || targetPath.length === 0
131
+ || typeof filePath !== 'string' || filePath.length === 0) {
132
+ throw new Error('[DocsRegion] targetPath and the manifest path are required - the link is computed from '
133
+ + 'them and never written by hand. Fix: pass both absolute paths.');
134
+ }
135
+ const relative = path.relative(path.dirname(targetPath), filePath).split(path.sep).join('/');
136
+ return relative.startsWith('.') ? relative : `./${relative}`;
137
+ }
138
+
139
+ const code = (value) => `\`${value}\``;
140
+
141
+ /** A table cell never breaks the table: a pipe in a value is escaped, not dropped. */
142
+ const cell = (value) => String(value).split('|').join('\\|');
143
+
144
+ /** Every row of a manifest, in the order the file declares them. */
145
+ function allRows(manifest) {
146
+ const rows = [];
147
+ const walk = (node) => {
148
+ if (Array.isArray(node)) {
149
+ for (const item of node) {
150
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string') rows.push(item);
151
+ else walk(item);
152
+ }
153
+ return;
154
+ }
155
+ if (node !== null && typeof node === 'object') for (const value of Object.values(node)) walk(value);
156
+ };
157
+ walk(manifest);
158
+ return rows;
159
+ }
160
+
161
+ /**
162
+ * A parameter as the region prints it: a `from` reference names the file that
163
+ * owns the value (004), anything else is the row's own literal.
164
+ */
165
+ function parameterValue(key, value) {
166
+ if (key === 'from') {
167
+ // A reference into this package (d.229) names its owner the way the package
168
+ // carries it; a reference out of it names the workspace path. One function
169
+ // decides which, in `manifest/discovery.js`.
170
+ if (value === null || typeof value !== 'object'
171
+ || (typeof value.path !== 'string' && !isPackageReference(value))) {
172
+ throw new Error('[DocsRegion] A "from" reference with no path - the region renders the owner of the value, '
173
+ + `and this row offers ${JSON.stringify(value)}. Fix: repair the manifest row.`);
174
+ }
175
+ return code(referenceOwner(value));
176
+ }
177
+ if (Array.isArray(value)) return value.map((item) => code(item)).join(', ');
178
+ if (value !== null && typeof value === 'object') return code(JSON.stringify(value));
179
+ return code(value);
180
+ }
181
+
182
+ function parametersOf(row) {
183
+ return Object.entries(row)
184
+ .filter(([key]) => !ROW_METADATA.includes(key))
185
+ .map(([key, value]) => `${code(key)}: ${parameterValue(key, value)}`);
186
+ }
187
+
188
+ /** The subject a row is about, when it names one; a row can check a whole repository. */
189
+ function subjectOf(row) {
190
+ if (typeof row.path === 'string') return row.path;
191
+ if (typeof row.name === 'string') return row.name;
192
+ return null;
193
+ }
194
+
195
+ /* ------------------------------------------------------------------ regions */
196
+
197
+ function renderAlignment({ serviceManifest, link }) {
198
+ const rows = allRows(serviceManifest);
199
+ return [
200
+ `Every row of the [biz-service uniform](${link}); \`oa-validate\` blocks on severity `
201
+ + `${serviceManifest.blocking_severities.map((severity) => code(severity)).join(' and ')}.`,
202
+ '',
203
+ '| row | severity | what |',
204
+ '|---|---|---|',
205
+ ...rows.map((row) => {
206
+ const subject = subjectOf(row);
207
+ const what = subject === null ? code(row.check) : `${code(row.check)} over ${code(subject)}`;
208
+ return `| ${code(row.id)} | ${cell(row.severity)} | ${cell(what)} |`;
209
+ })
210
+ ];
211
+ }
212
+
213
+ function renderScripts({ serviceManifest, link }) {
214
+ const scripts = serviceManifest.scripts;
215
+ if (scripts === undefined) {
216
+ throw new Error('[DocsRegion] The service manifest declares no scripts block - the region renders it and '
217
+ + 'knows no list of its own. Fix: repair manifests/biz-service.manifest.json.');
218
+ }
219
+ const forbidden = scripts.forbidden.map((row) => {
220
+ const subject = subjectOf(row);
221
+ return `${subject === null ? code(row.check) : code(subject)} (${code(row.id)})`;
222
+ });
223
+
224
+ return [
225
+ `The \`package.json\` scripts of the [biz-service uniform](${link}). A body is the row's own, `
226
+ + 'and `${runner}` stays unexpanded: the name of the one-shot test runner is per repository.',
227
+ '',
228
+ '| script | body | only with | row |',
229
+ '|---|---|---|---|',
230
+ ...scripts.required.map((row) => `| ${code(row.name)} | ${cell(code(row.body))} | `
231
+ + `${typeof row.only_with === 'string' ? code(row.only_with) : '—'} | ${code(row.id)} |`),
232
+ '',
233
+ `Never declared: ${forbidden.join(', ')}.`
234
+ ];
235
+ }
236
+
237
+ function renderRuntime({ serviceManifest, link }) {
238
+ const isMemory = (row) => Object.keys(row).some((key) => key.endsWith('_memory'));
239
+ const runtimeIds = new Set((serviceManifest.runtime.rows || []).map((row) => row.id));
240
+ const rows = allRows(serviceManifest).filter((row) => runtimeIds.has(row.id) || isMemory(row));
241
+
242
+ if (rows.length === 0) {
243
+ throw new Error('[DocsRegion] The service manifest declares no runtime rows - the region renders what the '
244
+ + 'platform fixes at run time. Fix: repair manifests/biz-service.manifest.json.');
245
+ }
246
+
247
+ return [
248
+ `What the [biz-service uniform](${link}) fixes at run time. Every value is a parameter of the row `
249
+ + 'beside it; a `from` parameter names the file that owns the value, which is where it is read.',
250
+ '',
251
+ '| row | check | parameters |',
252
+ '|---|---|---|',
253
+ ...rows.map((row) => `| ${code(row.id)} | ${code(row.check)} | ${cell(parametersOf(row).join(', ') || '—')} |`)
254
+ ];
255
+ }
256
+
257
+ /**
258
+ * The template's files as a tree, each node annotated with the rows that own it.
259
+ *
260
+ * The unannotated paths are NOT invented here: they are the files of the
261
+ * template that ships inside this package, which is the shape a service is
262
+ * created from. A hard-coded list beside it would be a second owner of the same
263
+ * fact (004 - a bearer's shape is read, never enumerated).
264
+ */
265
+ function renderTree({ serviceManifest, templateFiles, link }) {
266
+ if (!Array.isArray(templateFiles) || templateFiles.length === 0) {
267
+ throw new Error(`[DocsRegion] templateFiles is required - the tree is the packaged template's own file list, `
268
+ + 'never a list written here. Fix: pass listTemplateFiles(TEMPLATE_ROOT).map(outputRelative).');
269
+ }
270
+
271
+ const forbidden = serviceManifest.files.forbidden || [];
272
+ const forbiddenIds = new Set(forbidden.map((row) => row.id));
273
+
274
+ const owners = new Map();
275
+ for (const row of allRows(serviceManifest)) {
276
+ if (typeof row.path !== 'string' || forbiddenIds.has(row.id)) continue;
277
+ const key = row.path.replace(/\/+$/, '');
278
+ if (!owners.has(key)) owners.set(key, { ids: [], declared: row.path });
279
+ owners.get(key).ids.push(row.id);
280
+ }
281
+
282
+ const tree = new Map();
283
+ const insert = (segments, node) => {
284
+ const [head, ...rest] = segments;
285
+ if (!node.has(head)) node.set(head, new Map());
286
+ if (rest.length > 0) insert(rest, node.get(head));
287
+ };
288
+ for (const file of [...templateFiles].sort()) insert(file.split('/'), tree);
289
+
290
+ const covered = new Set();
291
+ const lines = [];
292
+ const walk = (node, prefix, parentPath) => {
293
+ const names = [...node.keys()].sort();
294
+ names.forEach((name, index) => {
295
+ const child = node.get(name);
296
+ const isDirectory = child.size > 0;
297
+ const full = parentPath === '' ? name : `${parentPath}/${name}`;
298
+ const last = index === names.length - 1;
299
+ const owned = owners.get(full);
300
+ if (owned !== undefined) covered.add(full);
301
+ lines.push(`${prefix}${last ? '└── ' : '├── '}${name}${isDirectory ? '/' : ''}`
302
+ + (owned === undefined ? '' : ` # ${owned.ids.join(', ')}`));
303
+ if (isDirectory) walk(child, `${prefix}${last ? ' ' : '│ '}`, full);
304
+ });
305
+ };
306
+ walk(tree, '', '');
307
+
308
+ const outside = [...owners.entries()].filter(([key]) => !covered.has(key));
309
+
310
+ return [
311
+ `The shape of a service repository: the files the packaged template carries, each annotated with the `
312
+ + `row of the [biz-service uniform](${link}) that owns it. An unannotated path is shape, not a check.`,
313
+ '',
314
+ '```text',
315
+ ...lines,
316
+ '```',
317
+ '',
318
+ `Never present: ${forbidden.map((row) => `${code(row.path)} (${code(row.id)})`).join(', ')}.`,
319
+ ...(outside.length === 0 ? [] : [
320
+ '',
321
+ `Owned by a row, outside the template: ${outside
322
+ .map(([, value]) => `${code(value.declared)} (${value.ids.map((id) => code(id)).join(', ')})`)
323
+ .join(', ')}.`
324
+ ])
325
+ ];
326
+ }
327
+
328
+ function renderLibraryDuties({ libraryManifest, link }) {
329
+ const discovery = libraryManifest.discovery.library;
330
+ const categories = Object.entries(discovery.categories);
331
+ const duties = Object.entries(libraryManifest.duties);
332
+ const guidance = Object.keys(libraryManifest.guidance || {});
333
+
334
+ return [
335
+ `The categories of the [library uniform](${link}) and the duty sections each one wears. `
336
+ + 'Row ids only: what a row demands and how it is repaired stays in the manifest.',
337
+ '',
338
+ '| category | layer | may depend on |',
339
+ '|---|---|---|',
340
+ ...categories.map(([name, block]) => `| ${code(name)} | ${cell(block.layer)} | `
341
+ + `${block.may_depend_on.length === 0 ? '—' : block.may_depend_on.map((item) => code(item)).join(', ')} |`),
342
+ '',
343
+ '| duty section | applies to | rows |',
344
+ '|---|---|---|',
345
+ ...duties.map(([name, block]) => `| ${code(name)} | ${code(block.applies_to)} | `
346
+ + `${block.rows.map((row) => code(row.id)).join(', ')} |`),
347
+ ...(guidance.length === 0 ? [] : [
348
+ '',
349
+ `Guidance — named by the manifest, checked by nothing, so not a rule: `
350
+ + `${guidance.map((key) => code(key)).join(', ')}.`
351
+ ])
352
+ ];
353
+ }
354
+
355
+ const RENDERERS = Object.freeze({
356
+ 'biz-service-tree': renderTree,
357
+ 'biz-service-scripts': renderScripts,
358
+ 'biz-service-runtime': renderRuntime,
359
+ 'biz-service-alignment': renderAlignment,
360
+ 'library-duties': renderLibraryDuties
361
+ });
362
+
363
+ /** Which manifest a region needs, so a run without it fails by name and not by `undefined`. */
364
+ const NEEDS_LIBRARY = Object.freeze(['library-duties']);
365
+
366
+ /**
367
+ * The text of one region, markers included, LF-terminated lines and no trailing
368
+ * newline - the caller places it in the file.
369
+ *
370
+ * @param {string} id one of REGION_IDS
371
+ * @param {object} params the manifests, where they lie, and which document this is
372
+ * @returns {string}
373
+ */
374
+ function renderRegion(id, {
375
+ serviceManifest,
376
+ libraryManifest,
377
+ serviceManifestPath,
378
+ libraryManifestPath,
379
+ targetPath,
380
+ targetLabel,
381
+ templateFiles
382
+ } = {}) {
383
+ requireKnownId(id);
384
+
385
+ const usesLibrary = NEEDS_LIBRARY.includes(id);
386
+ const manifest = usesLibrary ? libraryManifest : serviceManifest;
387
+ if (manifest === null || typeof manifest !== 'object') {
388
+ throw new Error(`[DocsRegion] Region "${id}" renders from the ${usesLibrary ? 'library' : 'biz-service'} `
389
+ + `manifest and got ${JSON.stringify(manifest)}. `
390
+ + 'Fix: load the manifest with loadManifest() and pass it in.');
391
+ }
392
+
393
+ const link = linkTo(targetPath, usesLibrary ? libraryManifestPath : serviceManifestPath);
394
+ const markers = markersForRegion(id, targetLabel);
395
+ const body = RENDERERS[id]({ serviceManifest, libraryManifest, templateFiles, link });
396
+
397
+ return [markers.begin, ...body, markers.end].join('\n');
398
+ }
399
+
400
+ /**
401
+ * The document with the region replaced. Everything outside the markers comes
402
+ * back byte-identical.
403
+ *
404
+ * A document carrying no markers is a FAILURE, never an insertion: these regions
405
+ * land in `api/docs/**`, a tree owned by other threads, and a generator that
406
+ * decides on its own where a section belongs in somebody else's document is
407
+ * exactly the unsafe side effect `.claude/rules/automation-gates.md` §1
408
+ * requirement 3 forbids. The message carries the two lines to paste.
409
+ *
410
+ * @param {string} fileText
411
+ * @param {string} id
412
+ * @param {string} regionText what renderRegion produced
413
+ * @param {{targetLabel: string}} where
414
+ * @returns {string}
415
+ */
416
+ function applyRegion(fileText, id, regionText, { targetLabel } = {}) {
417
+ if (typeof fileText !== 'string' || typeof regionText !== 'string') {
418
+ throw new Error('[DocsRegion] applyRegion(fileText, id, regionText) needs two strings - got '
419
+ + `${typeof fileText} and ${typeof regionText}. Fix: read the document before applying the region.`);
420
+ }
421
+ const markers = markersForRegion(id, targetLabel);
422
+ const applied = replaceRegion(fileText, markers, regionText, { file: targetLabel });
423
+
424
+ if (applied === null) {
425
+ throw new Error(`[DocsRegion] ${targetLabel} carries no region "${id}" - this run replaces a region, and `
426
+ + 'never decides on its own where one belongs in a document it does not own. '
427
+ + `Fix: put these two lines where the region belongs, then run the command again:\n`
428
+ + `${markers.begin}\n${markers.end}`);
429
+ }
430
+ return applied;
431
+ }
432
+
433
+ /**
434
+ * Whether the region in the document is what the manifests render, and the diff
435
+ * when it is not. Prose outside the markers is never compared.
436
+ *
437
+ * @returns {{ok: boolean, diff: string}}
438
+ */
439
+ function checkRegion(fileText, id, regionText, { targetLabel } = {}) {
440
+ if (typeof fileText !== 'string' || typeof regionText !== 'string') {
441
+ throw new Error('[DocsRegion] checkRegion(fileText, id, regionText) needs two strings - got '
442
+ + `${typeof fileText} and ${typeof regionText}. Fix: read the document before checking it.`);
443
+ }
444
+ requireKnownId(id);
445
+ return checkAgainst({
446
+ text: fileText,
447
+ markers: markersForRegion(id, targetLabel),
448
+ regionText,
449
+ source: SOURCES[id]
450
+ });
451
+ }
452
+
453
+ module.exports = {
454
+ REGION_IDS,
455
+ DESCRIPTIONS,
456
+ describeRegion,
457
+ markersForRegion,
458
+ regenerateCommand,
459
+ regionIdsIn,
460
+ renderRegion,
461
+ applyRegion,
462
+ checkRegion
463
+ };
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * One implementation of a generated region in this package.
5
+ *
6
+ * The marker shape is `api/scripts/ci/sync-biz-facts.mjs` (`BEGIN`/`END
7
+ * GENERATED`), taken over verbatim apart from the id and the command. Two
8
+ * renderers now write regions — `readmePointer.js` (the uniform pointer of a
9
+ * library README, d.216) and `docsRegion.js` (the descriptive lists of the
10
+ * documentation tree) — and they find, replace, extract and diff a region
11
+ * through this module rather than each carrying its own copy of the four
12
+ * functions (`.claude/rules/change-discipline.md` § One rail per concern).
13
+ *
14
+ * What is deliberately NOT here: where a region is placed when the file carries
15
+ * no markers. That is the one decision the two callers do not share — a library
16
+ * README gets one inserted after its node header, a document of somebody else's
17
+ * tree never gets one inserted at all — so it stays with each caller.
18
+ *
19
+ * Pure text: nothing here opens a file, reads the environment or knows a path.
20
+ *
21
+ * @see api/docs/governance/confirmations/biz-service-manifest.md § Confirmation 20260909-biz-service-manifest-005
22
+ */
23
+
24
+ /**
25
+ * Character offsets of every fenced code block, so a marker shown as an EXAMPLE
26
+ * is never mistaken for a region.
27
+ *
28
+ * Measured on the api side 2026-09-09, hours after the queue generator landed:
29
+ * `api/docs/standards/INFRA-DOC-STANDARD.md` documents the marker pair inside a
30
+ * fence (still does, at the section on generated regions), the generator read
31
+ * raw text, and it took its own documentation for a region naming a queue family
32
+ * that does not exist — a gate red on the tree it had just been added to
33
+ * (`.claude/rules/automation-gates.md` §3). `api/scripts/ci/lib/generatedRegion.js`
34
+ * has carried the fix since; this module, writing the same marker shape into the
35
+ * same tree, did not, so one input had two answers depending on which module met
36
+ * it (`automation-gates.md` §1.1, predictable). The documents `docsRegion.js`
37
+ * writes into are precisely the ones that TEACH the shape.
38
+ *
39
+ * @param {string} text
40
+ * @returns {Array<[number, number]>}
41
+ */
42
+ function fencedRanges(text) {
43
+ const ranges = [];
44
+ let offset = 0;
45
+ let openedAt = null;
46
+ for (const line of text.split('\n')) {
47
+ if (/^\s*(```|~~~)/.test(line)) {
48
+ if (openedAt === null) openedAt = offset;
49
+ else { ranges.push([openedAt, offset + line.length]); openedAt = null; }
50
+ }
51
+ offset += line.length + 1;
52
+ }
53
+ // An unterminated fence swallows the rest of the document, which is what a
54
+ // renderer does too.
55
+ if (openedAt !== null) ranges.push([openedAt, text.length]);
56
+ return ranges;
57
+ }
58
+
59
+ function isFenced(ranges, index) {
60
+ return ranges.some(([from, to]) => index >= from && index < to);
61
+ }
62
+
63
+ /** First occurrence of `needle` that is not inside a fence, or -1. */
64
+ function indexOutsideFence(text, needle, ranges) {
65
+ let at = text.indexOf(needle);
66
+ while (at !== -1 && isFenced(ranges, at)) at = text.indexOf(needle, at + 1);
67
+ return at;
68
+ }
69
+
70
+ /**
71
+ * The two marker lines of one region.
72
+ *
73
+ * @param {string} id the region's id, unique within the file it lands in
74
+ * @param {string} regenerate the command that rewrites it, printed in the marker
75
+ * @returns {{begin: string, end: string}}
76
+ */
77
+ function markersFor(id, regenerate) {
78
+ if (typeof id !== 'string' || id.length === 0) {
79
+ throw new Error('[GeneratedRegion] Region id is required - markersFor(id, regenerate) got '
80
+ + `${JSON.stringify(id)}. Fix: pass the id the document carries in its markers.`);
81
+ }
82
+ if (typeof regenerate !== 'string' || regenerate.length === 0) {
83
+ throw new Error('[GeneratedRegion] Regenerate command is required - a marker that does not say what '
84
+ + `rewrites the region leaves the reader nothing to run, and markersFor got ${JSON.stringify(regenerate)}. `
85
+ + 'Fix: pass the exact command.');
86
+ }
87
+ return {
88
+ begin: `<!-- BEGIN GENERATED: ${id} — regenerate: ${regenerate} -->`,
89
+ end: `<!-- END GENERATED: ${id} -->`
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Where the region sits in the text, or null when the text carries none.
95
+ *
96
+ * Damaged markers throw rather than being worked around: a half-written or
97
+ * inverted pair cannot be replaced in place without eating the file
98
+ * (`.claude/rules/architecture-principles.md` §3 - no fallbacks).
99
+ *
100
+ * The message names THIS module, not the caller: the error-message contract of
101
+ * this package is checked statically over the source
102
+ * (`tests/unit/error-message-contract.test.js`), so a prefix passed in as a
103
+ * parameter would be invisible to it — and `[GeneratedRegion]` is in any case
104
+ * the honest answer to "what rejected this". Which file the damaged markers are
105
+ * in is the caller's to say, and it travels in the message.
106
+ *
107
+ * @param {string} text
108
+ * @param {{begin: string, end: string}} markers
109
+ * @param {{file: string}} where the document the region belongs to, for the fix line
110
+ * @returns {{start: number, endExclusive: number}|null}
111
+ */
112
+ function findRegion(text, markers, { file } = {}) {
113
+ const ranges = fencedRanges(text);
114
+ // Both markers are searched from the start of the document, NOT the END from
115
+ // the BEGIN onwards: searching forward from BEGIN can never yield an END
116
+ // before it, so the inversion branch below would be dead code and a malformed
117
+ // document would render nothing while the run said nothing
118
+ // (`automation-gates.md` §5).
119
+ const begin = indexOutsideFence(text, markers.begin, ranges);
120
+ const end = indexOutsideFence(text, markers.end, ranges);
121
+
122
+ if (begin === -1 && end === -1) return null;
123
+ if (begin !== -1 && end !== -1 && end < begin) {
124
+ throw new Error('[GeneratedRegion] Generated region has END before BEGIN - the markers are damaged and '
125
+ + `replacing in place would eat the file. Fix: repair or delete the region in ${file}.`);
126
+ }
127
+ if (begin === -1 || end === -1) {
128
+ throw new Error('[GeneratedRegion] Generated region has only one marker - a half-written region cannot be '
129
+ + `replaced in place. Fix: remove the stray marker from ${file} and run the generator again.`);
130
+ }
131
+ return { start: begin, endExclusive: end + markers.end.length };
132
+ }
133
+
134
+ /**
135
+ * The text with the region replaced, or null when the text carries no region.
136
+ * Everything outside the markers comes back byte-identical.
137
+ *
138
+ * @returns {string|null}
139
+ */
140
+ function replaceRegion(text, markers, regionText, where) {
141
+ const found = findRegion(text, markers, where);
142
+ if (found === null) return null;
143
+ return text.slice(0, found.start) + regionText + text.slice(found.endExclusive);
144
+ }
145
+
146
+ /**
147
+ * The region as it stands in the text, or null when there is none. Tolerant by
148
+ * design: a check reports "no region" rather than throwing, because the caller
149
+ * turns that into a finding with a fix.
150
+ *
151
+ * @returns {string|null}
152
+ */
153
+ function extractRegion(text, markers) {
154
+ const ranges = fencedRanges(text);
155
+ const begin = indexOutsideFence(text, markers.begin, ranges);
156
+ const end = indexOutsideFence(text, markers.end, ranges);
157
+ if (begin === -1 || end === -1 || end < begin) return null;
158
+ return text.slice(begin, end + markers.end.length);
159
+ }
160
+
161
+ /**
162
+ * Whether the document DECLARES the region — the question `--all` asks before it
163
+ * renders anything.
164
+ *
165
+ * It is the only place that question is answered: a caller matching the marker
166
+ * string itself would be a second, fence-blind copy of this module's rule
167
+ * (`.claude/rules/change-discipline.md` § One rail per concern), which is what
168
+ * `docsRegion.regionIdsIn` was until d.319.
169
+ *
170
+ * A half-written region answers TRUE: the damage belongs in the report the
171
+ * caller produces, and answering "not declared" would hide it.
172
+ *
173
+ * @param {string} text
174
+ * @param {{begin: string, end: string}} markers
175
+ * @returns {boolean}
176
+ */
177
+ function hasRegion(text, markers) {
178
+ const ranges = fencedRanges(text);
179
+ return indexOutsideFence(text, markers.begin, ranges) !== -1
180
+ || indexOutsideFence(text, markers.end, ranges) !== -1;
181
+ }
182
+
183
+ /**
184
+ * The drift, named line by line, so a failing check says what differs instead of
185
+ * announcing that something does (`.claude/rules/automation-gates.md` §1
186
+ * requirement 4).
187
+ *
188
+ * @param {{expected: string, actual: string|null, source: string}} params
189
+ * @returns {string}
190
+ */
191
+ function renderDiff({ expected, actual, source }) {
192
+ if (actual === null) {
193
+ return [
194
+ `--- rendered from ${source}`,
195
+ '+++ on disk: no generated region',
196
+ ...expected.split('\n').map((line) => `-${line}`)
197
+ ].join('\n');
198
+ }
199
+ return [
200
+ `--- rendered from ${source}`,
201
+ '+++ on disk',
202
+ ...expected.split('\n').map((line) => `-${line}`),
203
+ ...actual.split('\n').map((line) => `+${line}`)
204
+ ].join('\n');
205
+ }
206
+
207
+ /**
208
+ * Whether the region on disk is what the renderer produces, and the diff when it
209
+ * is not. Only the region is compared; prose outside the markers is nobody's
210
+ * generated output.
211
+ *
212
+ * @returns {{ok: boolean, diff: string}} `diff` is '' exactly when `ok` is true
213
+ */
214
+ function checkRegion({ text, markers, regionText, source }) {
215
+ const actual = extractRegion(text, markers);
216
+ if (actual === regionText) return { ok: true, diff: '' };
217
+ return { ok: false, diff: renderDiff({ expected: regionText, actual, source }) };
218
+ }
219
+
220
+ module.exports = {
221
+ markersFor,
222
+ findRegion,
223
+ hasRegion,
224
+ replaceRegion,
225
+ extractRegion,
226
+ renderDiff,
227
+ checkRegion
228
+ };