@onlineapps/conn-orch-validator 6.0.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2591 -2
- package/README.md +1075 -7
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +422 -104
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +78 -42
- package/src/ValidationOrchestrator.js +298 -75
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +12 -2
- package/src/helpers/createServiceReadinessTests.js +75 -6
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +213 -13
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +21 -20
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- 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 };
|