@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,162 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `shared.env` is generated, never written: the platform manifest
5
+ * `api/config/shared-env.json` owns the shared key set, and every copy of the
6
+ * file - the platform template and each service's - is rendered from it
7
+ * (confirmation `biz-service-manifest` 003 §18, 004).
8
+ *
9
+ * Two functions, both pure: nothing here reads `process.env`, opens a file or
10
+ * knows a path. The caller loads the manifest, the caller reads the target, the
11
+ * caller writes it (`.claude/rules/architecture-principles.md` §1).
12
+ *
13
+ * @see api/docs/governance/confirmations/biz-service-manifest.md §18
14
+ */
15
+
16
+ /** What every env parser in this repository accepts as a key (`^[A-Z_][A-Z0-9_]*=`). */
17
+ const KEY_NAME = /^[A-Z_][A-Z0-9_]*$/;
18
+
19
+ const HEADER = Object.freeze([
20
+ '# GENERATED FILE - do not edit. Edit the manifest instead:',
21
+ '# api/config/shared-env.json, then run `npx oa-sync-template shared-env --target <dir>`.'
22
+ ]);
23
+
24
+ const DIFF_HEADER = Object.freeze([
25
+ '--- generated from api/config/shared-env.json',
26
+ '+++ on disk'
27
+ ]);
28
+
29
+ function assertManifest(manifest) {
30
+ const keys = manifest && manifest.keys;
31
+ if (!Array.isArray(keys) || keys.length === 0) {
32
+ throw new Error('[SharedEnv] Manifest has no keys - expected { keys: [ { name, value, why } ] }. '
33
+ + 'Fix: declare the shared key set in api/config/shared-env.json.');
34
+ }
35
+
36
+ keys.forEach((key) => {
37
+ const name = key && key.name;
38
+ if (typeof name !== 'string' || !KEY_NAME.test(name)) {
39
+ throw new Error(`[SharedEnv] Invalid key name ${JSON.stringify(name)} - expected UPPER_SNAKE_CASE, `
40
+ + 'so every env parser reads it. Fix: rename the key in api/config/shared-env.json.');
41
+ }
42
+ if (typeof key.value !== 'string') {
43
+ throw new Error(`[SharedEnv] Key ${name} has no "value" - the template value is required, `
44
+ + 'CHANGE_ME where the value is a per-machine secret. Fix: add "value" to that key in '
45
+ + 'api/config/shared-env.json.');
46
+ }
47
+ if (typeof key.why !== 'string' || key.why.trim() === '') {
48
+ throw new Error(`[SharedEnv] Key ${name} has no "why" - every shared key states why the platform `
49
+ + 'shares it (docs/biz/70-contracts/env-contract.md). Fix: add "why" to that key in '
50
+ + 'api/config/shared-env.json.');
51
+ }
52
+ if (/[\r\n]/.test(key.why)) {
53
+ throw new Error(`[SharedEnv] Key ${name} has a multi-line "why" - the generated file carries one `
54
+ + 'comment line per key. Fix: write it as a single sentence in api/config/shared-env.json.');
55
+ }
56
+ if (/[\r\n]/.test(key.value)) {
57
+ throw new Error(`[SharedEnv] Key ${name} has a multi-line "value" - an env file carries one line `
58
+ + 'per key. Fix: correct the value in api/config/shared-env.json.');
59
+ }
60
+ });
61
+ }
62
+
63
+ /**
64
+ * The file text the manifest describes: header, then one commented key per
65
+ * declaration, in manifest order. LF endings, exactly one trailing newline, and
66
+ * nothing that varies between two runs - the same manifest always renders the
67
+ * same bytes, which is what makes `--check` a gate rather than a diff of noise.
68
+ *
69
+ * `consumers` is deliberately NOT rendered: who reads a key is a fact the code
70
+ * owns and a check measures, and a hand-maintained copy of it inside every
71
+ * service's env file would rot the moment a reader moved
72
+ * (`.claude/rules/doc-code-binding.md` §1).
73
+ *
74
+ * @param {{keys: Array<{name: string, value: string, why: string}>}} manifest
75
+ * @returns {string}
76
+ */
77
+ function renderSharedEnv(manifest) {
78
+ assertManifest(manifest);
79
+
80
+ const lines = [...HEADER];
81
+ manifest.keys.forEach((key) => {
82
+ lines.push('', `# ${key.why}`, `${key.name}=${key.value}`);
83
+ });
84
+
85
+ return `${lines.join('\n')}\n`;
86
+ }
87
+
88
+ /**
89
+ * How many lines at the end of both arrays are identical.
90
+ * Measured from the end first, so an appended block reads as an insertion at
91
+ * the place it was appended rather than as a rewrite of the last line.
92
+ */
93
+ function commonSuffix(expected, actual) {
94
+ let count = 0;
95
+ while (count < expected.length
96
+ && count < actual.length
97
+ && expected[expected.length - 1 - count] === actual[actual.length - 1 - count]) {
98
+ count += 1;
99
+ }
100
+ return count;
101
+ }
102
+
103
+ function commonPrefix(expected, actual) {
104
+ let count = 0;
105
+ while (count < expected.length && count < actual.length && expected[count] === actual[count]) {
106
+ count += 1;
107
+ }
108
+ return count;
109
+ }
110
+
111
+ /**
112
+ * The lines that differ between what the key set renders and what is on disk,
113
+ * so a failure names the drift instead of announcing it
114
+ * (`.claude/rules/automation-gates.md` §1 requirement 4).
115
+ *
116
+ * Two callers, one shape: the CLI compares the target against what the manifest
117
+ * renders here and now, and the manifest row `G-SHARED-ENV` compares it against
118
+ * the render this package carries (d.229). Both are the same generated text and
119
+ * both name the same owner, so the diff has one owner too
120
+ * (`change-discipline.md` § One rail per concern).
121
+ *
122
+ * @param {string} expectedText the generated text
123
+ * @param {string} fileText the target file's current content
124
+ * @returns {string} the two header lines, then the differing lines
125
+ */
126
+ function diffAgainst(expectedText, fileText) {
127
+ const expected = expectedText.split('\n');
128
+ const actual = fileText.split('\n');
129
+
130
+ const suffix = commonSuffix(expected, actual);
131
+ const expectedBody = expected.slice(0, expected.length - suffix);
132
+ const actualBody = actual.slice(0, actual.length - suffix);
133
+ const prefix = commonPrefix(expectedBody, actualBody);
134
+
135
+ return [
136
+ ...DIFF_HEADER,
137
+ ...expectedBody.slice(prefix).map((line) => `-${line}`),
138
+ ...actualBody.slice(prefix).map((line) => `+${line}`)
139
+ ].join('\n');
140
+ }
141
+
142
+ /**
143
+ * Whether a file on disk is what the manifest renders, and - when it is not -
144
+ * the lines that differ.
145
+ *
146
+ * @param {object} manifest
147
+ * @param {string} fileText the target file's current content
148
+ * @returns {{ok: boolean, diff: string}} `diff` is '' exactly when `ok` is true
149
+ */
150
+ function checkSharedEnv(manifest, fileText) {
151
+ if (typeof fileText !== 'string') {
152
+ throw new Error(`[SharedEnv] File text is required - checkSharedEnv(manifest, fileText) got ${
153
+ fileText === null ? 'null' : typeof fileText}. Fix: read the target file before checking it.`);
154
+ }
155
+
156
+ const expectedText = renderSharedEnv(manifest);
157
+ if (expectedText === fileText) return { ok: true, diff: '' };
158
+
159
+ return { ok: false, diff: diffAgainst(expectedText, fileText) };
160
+ }
161
+
162
+ module.exports = { renderSharedEnv, checkSharedEnv, diffAgainst };
@@ -0,0 +1,474 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The generator half of the manifest (confirmation `biz-service-manifest` 001
5
+ * §3.2): `npx oa-sync-template [path…]` rewrites the files of the classes
6
+ * `identical` and `generated`, and never touches `own`.
7
+ *
8
+ * ONE DEFINITION, READ TWICE. This module holds no list of files, no template
9
+ * path and no block name of its own: it walks the same rows the conformance
10
+ * check walks, and renders each from the same `from:` reference that row already
11
+ * points the check at. A second list here would be a second owner of the shape,
12
+ * and the two would diverge exactly the way the nine copies of `init.sh` did.
13
+ *
14
+ * The corollary is that a row the manifest does not make renderable is NOT
15
+ * rendered. `G-PROD-IMAGE` names no reference — it is a requirement about a
16
+ * file, not a file — so it is reported NOT RUN, with the reason, rather than
17
+ * being filled from something this module decided
18
+ * (`.claude/rules/automation-gates.md` §5: silence is a defect, and so is a
19
+ * mechanism that quietly covers less than it claims).
20
+ *
21
+ * WHERE A ROW NEEDS MORE THAN A SPLICE, THE MODULE THAT OWNS THE RULE RENDERS
22
+ * IT. Two rows are not "the reference, verbatim": the runner block carries three
23
+ * declarations it takes from the service beside it, and an installation document
24
+ * is created from a skeleton and never rewritten over its prose. Both are
25
+ * rendered by the check module that decides them (`RENDERERS` below), so the
26
+ * generator cannot produce a file its own check rejects.
27
+ *
28
+ * @see api/docs/governance/confirmations/biz-service-manifest.md §3.2
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+
34
+ const { readReferencedFile, isPackageReference, referenceOwner } = require('../manifest/discovery');
35
+ const { readComposeServices } = require('../manifest/checks/composeShape');
36
+ const { renderRunnerBlock, replaceServiceNode } = require('../manifest/checks/composeRunnerBlock');
37
+ const {
38
+ blockLines, headingsOf, skeletonOf, renderedReference, ignoreEntries, dockerignoreEntries,
39
+ withoutBlock, IGNORE_BLOCK, DOCKERIGNORE_BLOCK
40
+ } = require('../manifest/checks/serviceFiles');
41
+ const {
42
+ spliceBlock, insertBlockUnder, topLevelKeys, replaceTopLevelKeys
43
+ } = require('./serviceTemplate');
44
+ const { requireIdentity } = require('../manifest/serviceIdentity');
45
+ const { rowNeedsWorkspace } = require('../manifest/manifestShape');
46
+ const { KINDS, applyUniformRegion } = require('./readmePointer');
47
+ const { serviceRegion } = require('./readmeLocation');
48
+ const { CHECK_REGISTRY } = require('../manifest/checks');
49
+
50
+ /** What the `test` profile is called; the runner is the compose service declaring it. */
51
+ const TEST_PROFILE = 'test';
52
+
53
+ /** The mapping a compose service is an entry of, and the one the runner belongs under. */
54
+ const COMPOSE_SERVICES_KEY = 'services';
55
+
56
+ /**
57
+ * The runner block, rendered for THIS repository: the template's block with this
58
+ * repository's identity in it and its own build, env_file and networks grafted
59
+ * in — the inverse of what `compose-runner` removes before it compares.
60
+ *
61
+ * A compose file that is not there is refused rather than created: a service is
62
+ * created by `oa-sync-template --new`, which renders the whole tree from one
63
+ * name. Rendering a single compose file for a service that does not exist would
64
+ * have to invent the container name, and the one measured counter-example says
65
+ * it cannot be derived — `api_biz/hello-service` is `api_service_hello`, not
66
+ * `api_service_hello_service`.
67
+ *
68
+ * @param {{row: object, current: string|null, serviceRoot: string, workspaceRoot: string}} args
69
+ * @returns {string}
70
+ */
71
+ function renderRunner({ row, current, serviceRoot, workspaceRoot }) {
72
+ if (current === null) {
73
+ throw new Error(`[UniformSync] ${row.path} is absent - the runner block is spliced into the compose file `
74
+ + 'this repository is built from, and this run has no container name to invent one with. '
75
+ + 'Fix: create the service with oa-sync-template --new <name> --into <dir>.');
76
+ }
77
+
78
+ const services = readComposeServices(current);
79
+ const runners = [...services.entries()]
80
+ .filter(([, node]) => (node.sequences.get('profiles') || []).includes(TEST_PROFILE));
81
+ const resident = [...services.entries()]
82
+ .filter(([, node]) => !(node.sequences.get('profiles') || []).includes(TEST_PROFILE));
83
+ if (resident.length !== 1) {
84
+ throw new Error(`[UniformSync] ${row.path} declares ${resident.length} services beside the runner - `
85
+ + 'the uniform is one service and its runner, and the block is rendered against that one service. '
86
+ + 'Fix: reduce the file to the service and its runner, then run the sync again.');
87
+ }
88
+
89
+ const reference = blockLines(readReferencedFile({ from: row.from, workspaceRoot }), row.block);
90
+ if (reference.length === 0) {
91
+ throw new Error(`[UniformSync] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - the row `
92
+ + 'names a block the template does not carry. Fix: restore the block in the template, or correct the row.');
93
+ }
94
+
95
+ const replacement = renderRunnerBlock({
96
+ reference,
97
+ composeText: current,
98
+ containerName: resident[0][0],
99
+ serviceName: requireIdentity(serviceRoot).service_name
100
+ });
101
+
102
+ // Three states, and the third one is why this is not two lines.
103
+ //
104
+ // * the file HAS the block → it is rewritten between its markers;
105
+ // * it has a runner but NO markers → that runner's declaration is REPLACED
106
+ // where it stands. Measured 2026-09-10 over a copy of api_biz/converter:
107
+ // seven of the eight repositories are in this state (d.220), and a run
108
+ // that only inserted gave them a SECOND `<container>_tests:` key — a
109
+ // duplicate mapping key, which compose either refuses or resolves by
110
+ // taking the last one;
111
+ // * it has neither → the block is inserted under
112
+ // `services:`. That position is not a judgement about this service: a
113
+ // mapping has one place its entries can start (lead decision,
114
+ // `api/shared/TODO.md` §0.2b-35 point 4). `init.sh` stays refused because
115
+ // there the position IS a decision about the service's own install steps.
116
+ if (blockLines(current, row.block).length > 0) {
117
+ return spliceBlock({ text: current, block: row.block, replacement });
118
+ }
119
+ if (runners.length === 1) {
120
+ return replaceServiceNode({ text: current, name: runners[0][0], replacement });
121
+ }
122
+ return insertBlockUnder({ text: current, key: COMPOSE_SERVICES_KEY, replacement });
123
+ }
124
+
125
+ /**
126
+ * An installation document: created from the skeleton when it is missing, left
127
+ * exactly as it is when it carries every section, and REFUSED when a section is
128
+ * missing.
129
+ *
130
+ * The refusal is the point. What each section says is this service's own fact
131
+ * (`skeleton_only`, confirmation `biz-service-manifest` 001 §2), so a run that
132
+ * rewrote an existing document would delete prose somebody wrote in order to add
133
+ * a heading. The generator names the missing heading and stops.
134
+ *
135
+ * @param {{row: object, current: string|null, serviceRoot: string, workspaceRoot: string}} args
136
+ * @returns {string}
137
+ */
138
+ function renderSkeleton({ row, current, serviceRoot, workspaceRoot }) {
139
+ const reference = renderedReference({ row, serviceRoot, workspaceRoot });
140
+ if (current === null) return skeletonOf(reference);
141
+
142
+ const missing = headingsOf(reference).filter((heading) => !headingsOf(current).includes(heading));
143
+ if (missing.length === 0) return current;
144
+
145
+ throw new Error(`[UniformSync] ${row.path} has no section ${missing.map((h) => `"${h}"`).join(', ')} - `
146
+ + 'this document is a skeleton with this service\'s own prose in it, so the run adds no section over '
147
+ + 'text it did not write. Fix: add the heading, and under it what this service requires there.');
148
+ }
149
+
150
+ /**
151
+ * The README with its uniform pointer current: the region rendered from the
152
+ * packaged manifest, and every line outside the markers left exactly as it was.
153
+ *
154
+ * The file itself is never created. A README is prose somebody wrote, and this
155
+ * run owns one region of it, not the document — an absent one is the finding
156
+ * `G-README` raises, with the same sentence.
157
+ *
158
+ * @param {{row: object, current: string|null, serviceRoot: string}} args
159
+ * @returns {string}
160
+ */
161
+ function renderReadme({ row, current, serviceRoot }) {
162
+ if (current === null) {
163
+ throw new Error(`[UniformSync] ${row.path} is absent - the uniform pointer is a generated REGION of that `
164
+ + 'file, and the rest of it is prose this run has no business writing. '
165
+ + `Fix: give the repository a ${row.path}, then run the sync again.`);
166
+ }
167
+ return applyUniformRegion(current, serviceRegion(serviceRoot), { kind: KINDS.service });
168
+ }
169
+
170
+ /**
171
+ * A whole file rendered for THIS repository: the template with the one parameter
172
+ * a service has in it, its declared name.
173
+ *
174
+ * It is the same call the check makes, so the generator cannot write a file its
175
+ * own row rejects — and it THROWS on a repository that has not said who it is,
176
+ * which is what a run asked to write that repository's files must do rather than
177
+ * invent a name (`serviceIdentity.js`).
178
+ *
179
+ * @param {{row: object, serviceRoot: string, workspaceRoot: string}} args
180
+ * @returns {string}
181
+ */
182
+ function renderRendered({ row, serviceRoot, workspaceRoot }) {
183
+ return renderedReference({ row, serviceRoot, workspaceRoot });
184
+ }
185
+
186
+ /**
187
+ * A file whose PLATFORM half is a delimited block: the block rendered for this
188
+ * repository, put where the repository already keeps those keys, and every other
189
+ * line left exactly as it was.
190
+ *
191
+ * Three states, and the third is the migration this renderer exists for.
192
+ *
193
+ * * the file HAS the block → it is rewritten between its markers;
194
+ * * it has the platform's keys as UNMARKED top-level keys → those keys are
195
+ * REPLACED by the block, in place. Measured over the eight biz repositories
196
+ * on 2026-09-11, all eight are in this state: `include:`, `stages:`,
197
+ * `variables:`, `build:`, `secret_detection:` and `deploy-production:`
198
+ * copied before the markers existed. A run that only inserted would give
199
+ * each file a second `build:` and a second `deploy-production:` — duplicate
200
+ * mapping keys, which GitLab either refuses or resolves by taking the last
201
+ * one;
202
+ * * the file is not there at all → it is created whole, which is the state
203
+ * `--new` writes: a repository with no half of its own yet gets the
204
+ * template's default `test:` job to own from then on.
205
+ *
206
+ * What the block claims is read from the block, never from a list here: the
207
+ * keys come from `topLevelKeys` over the rendered reference, so adding a job to
208
+ * the platform's half is one edit to the template
209
+ * (`.claude/rules/change-discipline.md` § One rail per concern).
210
+ *
211
+ * @param {{row: object, current: string|null, serviceRoot: string, workspaceRoot: string}} args
212
+ * @returns {string}
213
+ */
214
+ function renderDelimitedBlock({ row, current, serviceRoot, workspaceRoot }) {
215
+ const rendered = renderedReference({ row, serviceRoot, workspaceRoot });
216
+ if (current === null) return rendered;
217
+
218
+ const replacement = blockLines(rendered, row.block);
219
+ if (replacement.length === 0) {
220
+ throw new Error(`[UniformSync] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - the row `
221
+ + 'names a block the template does not carry. Fix: restore the block in the template, or correct the row.');
222
+ }
223
+
224
+ if (blockLines(current, row.block).length > 0) {
225
+ return spliceBlock({ text: current, block: row.block, replacement });
226
+ }
227
+ return replaceTopLevelKeys({ text: current, keys: topLevelKeys(replacement), replacement });
228
+ }
229
+
230
+ /**
231
+ * A `contains` file: the repository's own lines, then ONE labelled block holding
232
+ * the platform entries those lines do not already declare.
233
+ *
234
+ * Self-healing rather than append-only, which is what makes it idempotent: the
235
+ * existing block is taken out FIRST, so what the run compares is the
236
+ * repository's own half. A second run therefore has nothing to add and writes no
237
+ * second block, and an entry the repository has since declared by hand drops out
238
+ * of the block instead of standing beside it twice.
239
+ *
240
+ * The block is the FIX's shape, never the check's — `template-entries` compares
241
+ * a set and never looks for a marker (`serviceFiles.js` § templateEntries). It
242
+ * exists so a reader can tell the platform's lines from this repository's.
243
+ *
244
+ * @param {{row: object, current: string|null, workspaceRoot: string}} args
245
+ * @returns {string}
246
+ */
247
+ function renderIgnoreEntries({ row, current, workspaceRoot }) {
248
+ const reference = ignoreEntries(readReferencedFile({ from: row.from, workspaceRoot }));
249
+ const own = current === null ? '' : withoutBlock(current, IGNORE_BLOCK);
250
+ const declared = ignoreEntries(own);
251
+ const missing = reference.filter((entry) => !declared.includes(entry));
252
+
253
+ const head = own === '' ? '' : `${own.replace(/\n+$/, '')}\n`;
254
+ if (missing.length === 0) return head;
255
+
256
+ const block = [
257
+ `# --- ${IGNORE_BLOCK}`,
258
+ `# Written by ${referenceOwner(row.from)} — paths this platform generates inside the`,
259
+ '# repository. Everything above this block is this repository\'s own.',
260
+ ...missing,
261
+ `# --- end ${IGNORE_BLOCK}`,
262
+ ''
263
+ ].join('\n');
264
+
265
+ return head === '' ? block : `${head}\n${block}`;
266
+ }
267
+
268
+ /**
269
+ * The build-context half of the same declaration.
270
+ *
271
+ * Identical in shape to `renderIgnoreEntries` — the repository's own lines, then
272
+ * ONE labelled block — and deliberately NOT the same function: what goes in the
273
+ * block is the DERIVED list (`serviceFiles.js` § dockerignoreEntries), because a
274
+ * `.dockerignore` does not mean by a slash-less pattern what a `.gitignore`
275
+ * means by it. The two rows read one declaration and translate it for their own
276
+ * format; neither carries a list of its own.
277
+ *
278
+ * @param {{row: object, current: string|null, workspaceRoot: string}} args
279
+ * @returns {string}
280
+ */
281
+ function renderDockerignoreEntries({ row, current, workspaceRoot }) {
282
+ const reference = dockerignoreEntries(readReferencedFile({ from: row.from, workspaceRoot }));
283
+ const own = current === null ? '' : withoutBlock(current, DOCKERIGNORE_BLOCK);
284
+ const declared = ignoreEntries(own);
285
+ const missing = reference.filter((entry) => !declared.includes(entry));
286
+
287
+ const head = own === '' ? '' : `${own.replace(/\n+$/, '')}\n`;
288
+ if (missing.length === 0) return head;
289
+
290
+ const block = [
291
+ `# --- ${DOCKERIGNORE_BLOCK}`,
292
+ `# Derived from ${referenceOwner(row.from)}, the declaration .gitignore reads too —`,
293
+ '# what a LOCAL production build must not copy into the image (being ignored by git',
294
+ "# excludes nothing from COPY . .). Everything above this block is this repository's own.",
295
+ ...missing,
296
+ `# --- end ${DOCKERIGNORE_BLOCK}`,
297
+ ''
298
+ ].join('\n');
299
+
300
+ return head === '' ? block : `${head}\n${block}`;
301
+ }
302
+
303
+ /**
304
+ * The rows whose content the module that owns their check renders, by the check
305
+ * the row names. One table, in one place: a row is renderable because its rule
306
+ * says how, never because this module recognised its id.
307
+ */
308
+ const RENDERERS = Object.freeze({
309
+ 'compose-runner': renderRunner,
310
+ 'template-entries': renderIgnoreEntries,
311
+ 'dockerignore-entries': renderDockerignoreEntries,
312
+ 'template-render': renderRendered,
313
+ 'delimited-block-render': renderDelimitedBlock,
314
+ 'template-skeleton': renderSkeleton,
315
+ 'readme-uniform-current': renderReadme
316
+ });
317
+
318
+ /**
319
+ * The file classes the generator owns. `own` and `forbidden` are not written:
320
+ * one is the repository's, the other must not exist.
321
+ */
322
+ const SYNCABLE_CLASSES = Object.freeze(['identical', 'generated', 'contains']);
323
+
324
+ /**
325
+ * The manifest rows this run is defined by, in manifest order.
326
+ *
327
+ * @param {object} manifest
328
+ * @returns {object[]}
329
+ */
330
+ function uniformRows(manifest) {
331
+ const files = (manifest && manifest.files) || {};
332
+ return SYNCABLE_CLASSES.flatMap((className) => files[className] || []);
333
+ }
334
+
335
+ /**
336
+ * Where two texts first differ, what the file says there, and what the run would
337
+ * put there instead.
338
+ *
339
+ * BOTH sides, because one of them alone lies about half the runs. A splice over
340
+ * existing content is described by what the file says; an INSERTION is not —
341
+ * measured 2026-09-10, a run that spliced the runner block into
342
+ * `api_biz/converter` reported `line 2: "api_service_converter:"`, naming the
343
+ * line the block was pushed down past rather than the block. The reader of a
344
+ * `wrote` line wants to know where it went.
345
+ *
346
+ * @param {string} desired
347
+ * @param {string} current
348
+ * @returns {string}
349
+ */
350
+ function firstDifference(desired, current) {
351
+ const want = desired.split('\n');
352
+ const have = current.split('\n');
353
+ const say = (line) => JSON.stringify(line === undefined ? '<end of file>' : line.trim());
354
+ const length = Math.max(want.length, have.length);
355
+ for (let index = 0; index < length; index += 1) {
356
+ if (want[index] === have[index]) continue;
357
+ return `line ${index + 1}: ${say(have[index])} → ${say(want[index])}`;
358
+ }
359
+ return 'no line differs';
360
+ }
361
+
362
+ /**
363
+ * Read a repository-relative file, or null when there is no file there.
364
+ *
365
+ * A row may name a directory - `G-SETUP` names `docs/80-setup/` - so "is there
366
+ * a file" is asked of the entry, not of the path. That row renders nothing
367
+ * anyway, but reading its content must not be what says so.
368
+ */
369
+ function readServiceFile(serviceRoot, relative) {
370
+ const file = path.join(serviceRoot, ...relative.split('/'));
371
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return null;
372
+ return fs.readFileSync(file, 'utf8');
373
+ }
374
+
375
+ /**
376
+ * What a row's file should contain, or why this run cannot say.
377
+ *
378
+ * @returns {{desired: string}|{reason: string}}
379
+ */
380
+ function desiredContent({ row, current, serviceRoot, workspaceRoot }) {
381
+ if (!row.from || (typeof row.from.path !== 'string' && !isPackageReference(row.from))) {
382
+ return {
383
+ reason: 'the row declares no "from" reference, so nothing says what this file\'s content is rendered from'
384
+ };
385
+ }
386
+
387
+ const render = RENDERERS[row.check];
388
+ if (render !== undefined) return { desired: render({ row, current, serviceRoot, workspaceRoot }) };
389
+
390
+ const reference = readReferencedFile({ from: row.from, workspaceRoot });
391
+
392
+ // A whole-file row: the file IS the reference.
393
+ if (typeof row.block !== 'string') return { desired: reference };
394
+
395
+ // A block row: the reference owns the block, the service owns the rest of the
396
+ // file — so an existing file is spliced and an absent one is created whole.
397
+ if (current === null) return { desired: reference };
398
+
399
+ const replacement = blockLines(reference, row.block);
400
+ if (replacement.length === 0) {
401
+ throw new Error(`[UniformSync] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - the row `
402
+ + 'names a block the template does not carry. Fix: restore the block in the template, or correct the row.');
403
+ }
404
+ return { desired: spliceBlock({ text: current, block: row.block, replacement }) };
405
+ }
406
+
407
+ /**
408
+ * One row's plan.
409
+ *
410
+ * @param {{row: object, serviceRoot: string, workspaceRoot: string}} args
411
+ * @returns {{id: string, path: string, outcome: 'unchanged'|'change'|'not-run'|'blocked',
412
+ * current: string|null, desired?: string, detail?: string, reason?: string}}
413
+ */
414
+ function planRow({ row, serviceRoot, workspaceRoot }) {
415
+ const current = readServiceFile(serviceRoot, row.path);
416
+ const base = { id: row.id, path: row.path, current };
417
+
418
+ // The same question the manifest run asks of the same row, answered by the
419
+ // same predicate: does THIS row need the workspace? Since d.229 most of them
420
+ // reference a file this package carries, so the sync answers inside a service
421
+ // container — which is where the `fix` command of those rows is read
422
+ // (`.claude/rules/change-discipline.md` § One rail per concern).
423
+ const check = CHECK_REGISTRY[row.check];
424
+ if (workspaceRoot === null && check !== undefined && rowNeedsWorkspace({ check, row, block: null })) {
425
+ return {
426
+ ...base,
427
+ outcome: 'not-run',
428
+ reason: check.describeNotRun
429
+ ? check.describeNotRun({ row, block: null })
430
+ : 'the workspace root is not reachable'
431
+ };
432
+ }
433
+
434
+ let outcome;
435
+ try {
436
+ outcome = desiredContent({ row, current, serviceRoot, workspaceRoot });
437
+ } catch (error) {
438
+ return { ...base, outcome: 'blocked', reason: error.message };
439
+ }
440
+
441
+ if (outcome.reason !== undefined) return { ...base, outcome: 'not-run', reason: outcome.reason };
442
+ if (outcome.desired === current) return { ...base, outcome: 'unchanged', desired: outcome.desired };
443
+
444
+ return {
445
+ ...base,
446
+ outcome: 'change',
447
+ desired: outcome.desired,
448
+ detail: current === null ? 'absent' : firstDifference(outcome.desired, current)
449
+ };
450
+ }
451
+
452
+ /**
453
+ * The plan for a whole service, or for the paths the caller named.
454
+ *
455
+ * @param {{manifest: object, serviceRoot: string, workspaceRoot: string, paths?: string[]}} args
456
+ * @returns {object[]} one entry per row, in manifest order
457
+ */
458
+ function planSync({ manifest, serviceRoot, workspaceRoot, paths = [] }) {
459
+ const rows = uniformRows(manifest);
460
+
461
+ const wanted = paths.length === 0 ? rows : paths.map((wantedPath) => {
462
+ const row = rows.find((candidate) => candidate.path === wantedPath);
463
+ if (row === undefined) {
464
+ throw new Error(`[UniformSync] No manifest row names "${wantedPath}" - the run writes only what the `
465
+ + `uniform declares. Paths it knows: ${rows.map((candidate) => candidate.path).join(', ')}. `
466
+ + 'Fix: name one of those, or leave the paths out to plan them all.');
467
+ }
468
+ return row;
469
+ });
470
+
471
+ return wanted.map((row) => planRow({ row, serviceRoot, workspaceRoot }));
472
+ }
473
+
474
+ module.exports = { SYNCABLE_CLASSES, uniformRows, planRow, planSync, firstDifference };