@onlineapps/conn-orch-validator 9.0.0 → 11.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 +546 -0
- package/README.md +337 -19
- package/docs/DESIGN.md +32 -9
- package/manifests/biz-service.manifest.json +56 -6
- package/package.json +3 -2
- package/src/CookbookTestRunner.js +134 -22
- package/src/ValidationOrchestrator.js +312 -73
- package/src/cli/biz-ci-gate.js +191 -15
- package/src/cli/oa-sync-template.js +23 -8
- package/src/cli/oa-validate.js +70 -2
- package/src/index.js +21 -13
- package/src/lint/scripts/lintScripts.js +65 -18
- package/src/manifest/checks/composeRunnerBlock.js +37 -20
- package/src/manifest/checks/discoveryOrphan.js +2 -1
- package/src/manifest/checks/docsLintBridge.js +79 -21
- package/src/manifest/checks/gitTracked.js +14 -28
- package/src/manifest/checks/libraryPackage.js +3 -1
- package/src/manifest/checks/libraryWorkspace.js +18 -3
- package/src/manifest/checks/readmeRegion.js +9 -1
- package/src/manifest/checks/serviceConfig.js +29 -12
- package/src/manifest/checks/serviceDb.js +176 -7
- package/src/manifest/checks/serviceFiles.js +34 -7
- package/src/manifest/checks/serviceIdentityRows.js +3 -1
- package/src/manifest/checks/serviceRuntime.js +126 -1
- package/src/manifest/discovery.js +25 -7
- package/src/manifest/gitCheckout.js +84 -0
- package/src/manifest/runManifest.js +58 -7
- package/src/manifest/workspaceRoot.js +91 -5
- package/src/sync/serviceTemplate.js +76 -7
- package/src/sync/sharedEnv.js +11 -4
- package/src/sync/uniformFiles.js +91 -21
- package/src/utils/bizCiGateContract.js +25 -1
- package/src/utils/dbAccountGrants.js +126 -0
- package/src/utils/envContract.js +36 -6
- package/src/utils/envReads.js +102 -0
- package/src/utils/installContract.js +46 -5
- package/src/utils/libCompat.js +39 -19
- package/src/utils/preValidation.js +56 -11
- package/src/utils/stepFailure.js +106 -19
- package/src/utils/stepReferences.js +278 -0
- package/src/utils/testCoverageContract.js +60 -2
- package/src/utils/throwawaySchema.js +92 -7
- package/src/validatorIdentity.js +31 -0
- package/src/validators/ServiceStructureValidator.js +47 -15
- package/src/validators/ValidationProofGenerator.js +73 -34
- package/templates/business-service/.dockerignore +9 -1
- package/templates/business-service/.gitlab-ci.yml +199 -35
- package/templates/business-service/Dockerfile +49 -16
- package/templates/business-service/README.md +56 -9
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
- package/templates/business-service/config/env-templates/shared.env +8 -2
- package/templates/business-service/docker-compose.production.yml +9 -0
- package/templates/business-service/docker-compose.yml +17 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +1 -1
- package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
- package/templates/business-service/jest.config.js +9 -1
- package/templates/business-service/package.json.template +1 -1
- package/src/mocks/MockStorage.js +0 -188
|
@@ -19,7 +19,7 @@ const path = require('path');
|
|
|
19
19
|
const { verifyManifestShape, rowNeedsWorkspace } = require('./manifestShape');
|
|
20
20
|
const { collectRows } = require('./walk');
|
|
21
21
|
const { discoverBearers, rootOfPattern } = require('./discovery');
|
|
22
|
-
const { resolveWorkspacePath, canonicalRoot } = require('./workspaceRoot');
|
|
22
|
+
const { resolveWorkspacePath, canonicalRoot, describeWorkspaceFix } = require('./workspaceRoot');
|
|
23
23
|
const { CHECK_REGISTRY } = require('./checks');
|
|
24
24
|
|
|
25
25
|
/** The three scopes, by the name the runner branches on. */
|
|
@@ -164,22 +164,45 @@ function runManifest({ manifest, serviceRoot = null, workspaceRoot = null, check
|
|
|
164
164
|
const check = checkRegistry[row.check];
|
|
165
165
|
const needsWorkspace = rowNeedsWorkspace({ check, row, block });
|
|
166
166
|
|
|
167
|
+
// The sentence a row is reported NOT RUN with belongs to the CHECK, because
|
|
168
|
+
// only the check knows what it could not read. Until d.518 a check that
|
|
169
|
+
// declared none borrowed `the workspace root is not reachable` from here —
|
|
170
|
+
// a sentence about a row this file knows nothing about, with no fix in it,
|
|
171
|
+
// which nothing reached: every registered workspace-dependent check owns
|
|
172
|
+
// one, and `service-identity` declares `needsWorkspace: () => false` and
|
|
173
|
+
// cannot arrive here at all. A default nobody reaches is the false
|
|
174
|
+
// guarantee `.claude/rules/automation-gates.md` §5 calls a defect, so the
|
|
175
|
+
// duty is stated rather than covered for — and stated on EVERY run, not
|
|
176
|
+
// only on the ones without a workspace, which is where it would otherwise
|
|
177
|
+
// be discovered (`architecture-principles.md` §4, fail-fast).
|
|
178
|
+
if (needsWorkspace && typeof check.describeNotRun !== 'function') {
|
|
179
|
+
throw new Error(`[Manifest] Check "${row.check}" owns no NOT RUN sentence - row ${row.id} needs the `
|
|
180
|
+
+ 'workspace, so a run without one has to say why this row was not answered and how to answer it. '
|
|
181
|
+
+ 'Fix: add describeNotRun({ row, block }) to the check, ending it with '
|
|
182
|
+
+ 'workspaceRoot.js § describeWorkspaceFix.');
|
|
183
|
+
}
|
|
184
|
+
|
|
167
185
|
if (needsWorkspace && workspace === null) {
|
|
168
186
|
notRun.push({
|
|
169
187
|
id: row.id,
|
|
170
188
|
severity: row.severity,
|
|
171
|
-
reason: check.describeNotRun
|
|
172
|
-
? check.describeNotRun({ row, block })
|
|
173
|
-
: 'the workspace root is not reachable'
|
|
189
|
+
reason: check.describeNotRun({ row, block })
|
|
174
190
|
});
|
|
175
191
|
continue;
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
if (needsWorkspace && outsideWorkspace) {
|
|
195
|
+
// A state sentence is not a remedy. This one said where the repository
|
|
196
|
+
// is NOT, and stopped — the same half-answer d.518 removed from the
|
|
197
|
+
// checks' own sentences, in the branch that reports it for them. The
|
|
198
|
+
// command comes from the one owner of it, and what the checkout has to
|
|
199
|
+
// carry is the repository under check (d.523).
|
|
179
200
|
notRun.push({
|
|
180
201
|
id: row.id,
|
|
181
202
|
severity: row.severity,
|
|
182
|
-
reason: `service root is outside the workspace root ${workspace}`
|
|
203
|
+
reason: `service root is outside the workspace root ${workspace} `
|
|
204
|
+
+ '- a row about the workspace cannot be answered about a repository that is not in it. '
|
|
205
|
+
+ describeWorkspaceFix(root)
|
|
183
206
|
});
|
|
184
207
|
continue;
|
|
185
208
|
}
|
|
@@ -320,13 +343,41 @@ function missingRoots({ roots, workspaceRoot }) {
|
|
|
320
343
|
}
|
|
321
344
|
|
|
322
345
|
/**
|
|
346
|
+
* The NOT RUN reason for a row whose sibling root is not there, naming every
|
|
347
|
+
* missing one AND what to do about it.
|
|
348
|
+
*
|
|
349
|
+
* The fix half is not decoration. This line is read where the reader has no
|
|
350
|
+
* workspace to look at — a service's own CI log, a container — and until d.511
|
|
351
|
+
* it stopped at "is not present in this checkout": true, and nothing anybody
|
|
352
|
+
* could act on. Measured 2026-09-15 over a `git archive` export of
|
|
353
|
+
* `api_biz/hello-service` placed beside an api clone with no `api_biz/` around
|
|
354
|
+
* them: the run printed `NOT RUN U-ORPHAN — sibling root api_biz is not
|
|
355
|
+
* present in this checkout` and, because the report's own incomplete-run
|
|
356
|
+
* sentence carries its fix only on a CLEAR verdict (`report.js`
|
|
357
|
+
* § describeClearOutcome), a run that also had findings named no fix at all.
|
|
358
|
+
* `automation-gates.md` §1 requirement 4 asks for the exact command, and the
|
|
359
|
+
* sync half of this package already gives it (`src/sync/uniformFiles.js`
|
|
360
|
+
* § planRow).
|
|
361
|
+
*
|
|
362
|
+
* The command is DERIVED from the missing roots rather than written beside
|
|
363
|
+
* them, so a row that starts reading a new sibling needs no second edit here.
|
|
364
|
+
* It is also not written HERE: the sentence belongs to `workspaceRoot.js`
|
|
365
|
+
* § describeWorkspaceFix, because the bridge to the documentation lint reports
|
|
366
|
+
* the same absence through the linter's own NOT RUN channel and must offer the
|
|
367
|
+
* same remedy (d.516, `.claude/rules/change-discipline.md` § One rail per
|
|
368
|
+
* concern). This function still owns what is missing; the helper owns what to
|
|
369
|
+
* do about it.
|
|
370
|
+
*
|
|
323
371
|
* @param {string[]} missing the roots that are not there
|
|
324
|
-
* @returns {string} the NOT RUN reason, naming every one of them
|
|
372
|
+
* @returns {string} the NOT RUN reason, naming every one of them and the fix
|
|
325
373
|
*/
|
|
326
374
|
function describeMissingRoots(missing) {
|
|
327
|
-
|
|
375
|
+
const found = missing.length === 1
|
|
328
376
|
? `sibling root ${missing[0]} is not present in this checkout`
|
|
329
377
|
: `sibling roots ${missing.join(', ')} are not present in this checkout`;
|
|
378
|
+
|
|
379
|
+
return `${found} - an absent directory is not an empty one, so this row is not answered. `
|
|
380
|
+
+ describeWorkspaceFix(missing.join(', '));
|
|
330
381
|
}
|
|
331
382
|
|
|
332
383
|
/**
|
|
@@ -37,10 +37,34 @@
|
|
|
37
37
|
* `startDir` and get the workspace THAT TREE belongs to. It is a different
|
|
38
38
|
* question, asked explicitly, never a fallback of the other one.
|
|
39
39
|
*
|
|
40
|
+
* ## The service a run is ABOUT (d.540)
|
|
41
|
+
*
|
|
42
|
+
* A run over a service repository asks a third question, and it is as
|
|
43
|
+
* deterministic as the other two: the workspace is the nearest ancestor OF THE
|
|
44
|
+
* SERVICE ROOT that carries an api checkout — the root the caller named, never
|
|
45
|
+
* the directory the shell happens to stand in. `oa-validate` passes it as
|
|
46
|
+
* `serviceRoot`.
|
|
47
|
+
*
|
|
48
|
+
* It exists because the package question answers about the PACKAGE, and the copy
|
|
49
|
+
* a service runs is the one it installed: `oa-validate .` inside
|
|
50
|
+
* `<workspace>/api_biz/<service>` printed `Workspace root: NOT RESOLVED` and
|
|
51
|
+
* reported every `U-*` and `D-*` row NOT RUN, for a service lying in a complete
|
|
52
|
+
* workspace two directories up (measured 2026-09-16, BIZ-hello). The run knew
|
|
53
|
+
* where the service was and refused to look there.
|
|
54
|
+
*
|
|
55
|
+
* The three questions are asked in a stated order, and none of them reads
|
|
56
|
+
* ambient state: an explicit `--workspace` wins, then the tree the run is about
|
|
57
|
+
* (`startDir`, or `serviceRoot`), then the checkout this package is part of.
|
|
58
|
+
* The last one is what answers for a run whose subject lies in NO workspace —
|
|
59
|
+
* the service root is then reported as outside the workspace it names, which is
|
|
60
|
+
* true and actionable — and when neither question finds a checkout the run says
|
|
61
|
+
* NOT RESOLVED, exactly as before.
|
|
62
|
+
*
|
|
40
63
|
* An INSTALLED copy (`<service>/node_modules/@onlineapps/conn-orch-validator`)
|
|
41
|
-
* lies in no checkout, so it
|
|
42
|
-
* are
|
|
43
|
-
*
|
|
64
|
+
* lies in no checkout, so it speaks for none: the rows that read the platform
|
|
65
|
+
* SSOT are answered from the workspace the SERVICE lies in, or reported NOT RUN
|
|
66
|
+
* — loudly, never as a pass (`automation-gates.md` §5). A service container,
|
|
67
|
+
* whose image carries no workspace above `/app`, is unchanged by this rule.
|
|
44
68
|
*/
|
|
45
69
|
|
|
46
70
|
const fs = require('fs');
|
|
@@ -201,11 +225,19 @@ function workspaceAbove(startDir) {
|
|
|
201
225
|
}
|
|
202
226
|
|
|
203
227
|
/**
|
|
204
|
-
*
|
|
228
|
+
* The three questions, in the order the header states them: the root the caller
|
|
229
|
+
* NAMED, then the tree the run is about, then the checkout this package is part
|
|
230
|
+
* of. `startDir` and `serviceRoot` are both "the tree this run is about" and
|
|
231
|
+
* differ in what happens when that tree lies in no workspace — a run that WRITES
|
|
232
|
+
* into a tree stops there (there is nowhere to write from), a run that MEASURES
|
|
233
|
+
* one lets the package answer, so the report can say which workspace the service
|
|
234
|
+
* root is outside of.
|
|
235
|
+
*
|
|
236
|
+
* @param {{ explicit?: string|null, startDir?: string|null, serviceRoot?: string|null }} params
|
|
205
237
|
* @returns {string|null} absolute workspace root, or null when neither the named
|
|
206
238
|
* tree nor this package lies in one
|
|
207
239
|
*/
|
|
208
|
-
function resolveWorkspaceRoot({ explicit = null, startDir = null } = {}) {
|
|
240
|
+
function resolveWorkspaceRoot({ explicit = null, startDir = null, serviceRoot = null } = {}) {
|
|
209
241
|
if (explicit !== null && explicit !== undefined) {
|
|
210
242
|
const resolved = path.resolve(explicit);
|
|
211
243
|
if (apiCheckoutOf(resolved) === null) {
|
|
@@ -226,17 +258,71 @@ function resolveWorkspaceRoot({ explicit = null, startDir = null } = {}) {
|
|
|
226
258
|
return above === null ? null : canonicalRoot(above);
|
|
227
259
|
}
|
|
228
260
|
|
|
261
|
+
if (serviceRoot !== null && serviceRoot !== undefined) {
|
|
262
|
+
if (typeof serviceRoot !== 'string' || serviceRoot.length === 0) {
|
|
263
|
+
throw new Error('[ManifestWorkspace] Service root is required - resolveWorkspaceRoot({ serviceRoot }) got '
|
|
264
|
+
+ `${JSON.stringify(serviceRoot)}. Fix: pass the repository the run is about, or omit it to use this `
|
|
265
|
+
+ 'package\'s own checkout.');
|
|
266
|
+
}
|
|
267
|
+
const above = workspaceAbove(serviceRoot);
|
|
268
|
+
if (above !== null) return canonicalRoot(above);
|
|
269
|
+
}
|
|
270
|
+
|
|
229
271
|
return API_CHECKOUT_ROOT === null ? null : canonicalRoot(path.dirname(API_CHECKOUT_ROOT));
|
|
230
272
|
}
|
|
231
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The command that turns "this run had no such checkout" into a run that does —
|
|
276
|
+
* one sentence, one owner, wherever a row is reported NOT RUN because the tree
|
|
277
|
+
* it needed was not under the workspace.
|
|
278
|
+
*
|
|
279
|
+
* It exists as a function because there are two such places and they were
|
|
280
|
+
* drifting apart. `runManifest.js` § describeMissingRoots gained it in d.511,
|
|
281
|
+
* from the roots a row declares; the bridge to the documentation lint
|
|
282
|
+
* (`checks/docsLintBridge.js`) reports the same absence through the linter's own
|
|
283
|
+
* NOT RUN channel and, until d.516, ended at "the probe could not be evaluated"
|
|
284
|
+
* — true, and nothing anybody could act on (`.claude/rules/automation-gates.md`
|
|
285
|
+
* §1 requirement 4). Two sentences for one remedy is the second rail
|
|
286
|
+
* `.claude/rules/change-discipline.md` § One rail per concern names.
|
|
287
|
+
*
|
|
288
|
+
* What the checkout must CARRY is the caller's, because only the caller knows
|
|
289
|
+
* it: a row declares its roots and names them, while the bridge is handed a
|
|
290
|
+
* reason written by the linter — which names the probe target itself, and which
|
|
291
|
+
* this package neither writes nor parses (the bridge cites the lint, it does not
|
|
292
|
+
* restate it).
|
|
293
|
+
*
|
|
294
|
+
* @param {string} carries what a workspace has to hold for the row to be
|
|
295
|
+
* answered — the missing roots when the caller resolved them, otherwise the
|
|
296
|
+
* thing the reason in front of this sentence already named
|
|
297
|
+
* @returns {string} the `Fix:` sentence, ending in a full stop
|
|
298
|
+
*/
|
|
299
|
+
function describeWorkspaceFix(carries) {
|
|
300
|
+
if (typeof carries !== 'string' || carries.length === 0) {
|
|
301
|
+
throw new Error('[ManifestWorkspace] Fix subject is required - describeWorkspaceFix() got '
|
|
302
|
+
+ `${JSON.stringify(carries)}, so the sentence would tell the reader to carry nothing. `
|
|
303
|
+
+ 'Fix: pass the missing roots, or the phrase naming what the reason before it points at.');
|
|
304
|
+
}
|
|
305
|
+
return `Fix: run with --workspace pointing at a checkout that carries ${carries}.`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* What the bridge to a delegated checker passes as `carries`: the absent thing
|
|
310
|
+
* is named by the checker's own reason, which sits immediately before this
|
|
311
|
+
* sentence, so the fix points back at it instead of repeating it in this
|
|
312
|
+
* package's words.
|
|
313
|
+
*/
|
|
314
|
+
const AS_THE_REASON_NAMES = 'what this reason names';
|
|
315
|
+
|
|
232
316
|
module.exports = {
|
|
233
317
|
resolveWorkspaceRoot,
|
|
234
318
|
canonicalRoot,
|
|
235
319
|
workspaceAbove,
|
|
236
320
|
resolveWorkspacePath,
|
|
321
|
+
describeWorkspaceFix,
|
|
237
322
|
apiCheckoutOf,
|
|
238
323
|
API_CHECKOUT_ROOT,
|
|
239
324
|
API_MARKER,
|
|
325
|
+
AS_THE_REASON_NAMES,
|
|
240
326
|
PACKAGE_ROOT,
|
|
241
327
|
WORKSPACE_MARKER
|
|
242
328
|
};
|
|
@@ -124,13 +124,33 @@ const PACKED_NAMES = Object.freeze({
|
|
|
124
124
|
/**
|
|
125
125
|
* The files a service is expected to EXECUTE, so the ones written executable.
|
|
126
126
|
*
|
|
127
|
-
* `init.sh` is the file a service is started through.
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* false guarantee `automation-gates.md` §5 names.
|
|
127
|
+
* `init.sh` is the file a service is started through — and since d.555 it is the
|
|
128
|
+
* only one: the deploy gate is no longer rendered into a service (see
|
|
129
|
+
* `PACKAGE_ONLY` below), so there is no copy of it in a repository to give a bit
|
|
130
|
+
* to.
|
|
132
131
|
*/
|
|
133
|
-
const EXECUTABLE_FILES = Object.freeze(['init.sh'
|
|
132
|
+
const EXECUTABLE_FILES = Object.freeze(['init.sh']);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Files this package carries INSIDE the template directory that are not part of
|
|
136
|
+
* the scaffold — the package's own, reached by the path a pin installs.
|
|
137
|
+
*
|
|
138
|
+
* `scripts/verify-deploy-uniform.sh` is the uniform gate. Until d.529 the
|
|
139
|
+
* rendered `.gitlab-ci.yml` ran the copy in the service repository; since then
|
|
140
|
+
* BOTH callers — the `validate-uniform` job and `deploy-production` — invoke
|
|
141
|
+
* `node_modules/@onlineapps/conn-orch-validator/templates/business-service/scripts/verify-deploy-uniform.sh`,
|
|
142
|
+
* the pinned one. So the rendered copy is read by nothing, and the four
|
|
143
|
+
* questions `change-discipline.md` § "Removing something removes its
|
|
144
|
+
* declaration" asks answer cleanly: it came to exist with the deploy job of
|
|
145
|
+
* d.421, it carried gate 008, nothing reads it because the callers moved to the
|
|
146
|
+
* pin, and what replaces it is MORE conceptual — one pin, one gate, versioned
|
|
147
|
+
* with the manifest it measures against, instead of eight copies that drift the
|
|
148
|
+
* day one of them is edited.
|
|
149
|
+
*
|
|
150
|
+
* It stays in the package rather than moving elsewhere in it: the path is what
|
|
151
|
+
* the rendered CI file names, and the file lives where that path points.
|
|
152
|
+
*/
|
|
153
|
+
const PACKAGE_ONLY = Object.freeze(['scripts/verify-deploy-uniform.sh']);
|
|
134
154
|
|
|
135
155
|
/**
|
|
136
156
|
* The env template's name in the template, and the name it is written under.
|
|
@@ -340,7 +360,7 @@ function listTemplateFiles(root) {
|
|
|
340
360
|
}
|
|
341
361
|
};
|
|
342
362
|
walk(root, '');
|
|
343
|
-
return collected.sort();
|
|
363
|
+
return collected.filter((relative) => !PACKAGE_ONLY.includes(relative)).sort();
|
|
344
364
|
}
|
|
345
365
|
|
|
346
366
|
/**
|
|
@@ -483,6 +503,53 @@ function insertBlockUnder({ text, key, replacement }) {
|
|
|
483
503
|
return [...lines.slice(0, at + 1), ...replacement, '', ...lines.slice(at + 1)].join('\n');
|
|
484
504
|
}
|
|
485
505
|
|
|
506
|
+
/**
|
|
507
|
+
* A file with a delimited block INSERTED after a shell function's closing brace,
|
|
508
|
+
* and every other line of it left exactly as it was.
|
|
509
|
+
*
|
|
510
|
+
* The sibling of `insertBlockUnder`, for the file that is not a mapping. Both
|
|
511
|
+
* answer the same question — where does a block that is not there yet go? — and
|
|
512
|
+
* both answer it from something the FILE declares rather than from a judgement
|
|
513
|
+
* about the service. In a compose file that is the `services:` key; in an
|
|
514
|
+
* `init.sh` it is the end of the function the block's own text refers to
|
|
515
|
+
* ("A service's own install steps belong in oa_npm_install() above, everything
|
|
516
|
+
* else it needs goes below"), which the row names as its anchor.
|
|
517
|
+
*
|
|
518
|
+
* Measured 2026-09-16 in api_biz/invoicing: the sync refused this state with
|
|
519
|
+
* "paste the block from the template once", a message telling a human to
|
|
520
|
+
* hand-copy generated content, which is what `automation-gates.md` §1.4 calls a
|
|
521
|
+
* defect — the run knows the bytes and, with the anchor, the place.
|
|
522
|
+
*
|
|
523
|
+
* Without the anchor the refusal STAYS a refusal, and names the anchor rather
|
|
524
|
+
* than the block: where a service's own install steps end is that service's
|
|
525
|
+
* decision, and a generator guessing it would write the block above or below
|
|
526
|
+
* code that has to run on the other side.
|
|
527
|
+
*
|
|
528
|
+
* @param {{text: string, fn: string, replacement: string[]}} args the file, the
|
|
529
|
+
* shell function the block belongs after, and the block's lines
|
|
530
|
+
* @returns {string}
|
|
531
|
+
*/
|
|
532
|
+
function insertBlockAfterFunction({ text, fn, replacement }) {
|
|
533
|
+
const lines = text.split('\n');
|
|
534
|
+
const opens = new RegExp(`^${fn}\\s*\\(\\)\\s*\\{`);
|
|
535
|
+
|
|
536
|
+
const start = lines.findIndex((line) => opens.test(line.trimEnd()));
|
|
537
|
+
if (start === -1) {
|
|
538
|
+
throw new Error(`[ServiceTemplate] No ${fn}() to insert the block after - the file declares no line `
|
|
539
|
+
+ `opening "${fn}() {", so where this service's own install steps end is undefined and the block `
|
|
540
|
+
+ `has no unambiguous place. Fix: wrap this file's install command in a ${fn}() function, then run `
|
|
541
|
+
+ 'the sync again.');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const end = lines.findIndex((line, index) => index > start && line.trimEnd() === '}');
|
|
545
|
+
if (end === -1) {
|
|
546
|
+
throw new Error(`[ServiceTemplate] The ${fn}() function never closes - "${fn}() {" is there, its "}" `
|
|
547
|
+
+ 'is not, so the end of the install steps is undefined. Fix: close the function, then run the sync again.');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return [...lines.slice(0, end + 1), '', ...replacement, ...lines.slice(end + 1)].join('\n');
|
|
551
|
+
}
|
|
552
|
+
|
|
486
553
|
/** A top-level mapping key: a line that starts in column 0 and ends its key with a colon. */
|
|
487
554
|
const TOP_LEVEL_KEY = /^([A-Za-z_][A-Za-z0-9_.-]*):/;
|
|
488
555
|
|
|
@@ -565,6 +632,7 @@ module.exports = {
|
|
|
565
632
|
SSOT_PIN,
|
|
566
633
|
PACKED_NAMES,
|
|
567
634
|
EXECUTABLE_FILES,
|
|
635
|
+
PACKAGE_ONLY,
|
|
568
636
|
ENV_TEMPLATE_SOURCE,
|
|
569
637
|
envTemplateTarget,
|
|
570
638
|
deriveParams,
|
|
@@ -577,6 +645,7 @@ module.exports = {
|
|
|
577
645
|
renderText,
|
|
578
646
|
renderTree,
|
|
579
647
|
spliceBlock,
|
|
648
|
+
insertBlockAfterFunction,
|
|
580
649
|
topLevelKeys,
|
|
581
650
|
replaceTopLevelKeys,
|
|
582
651
|
insertBlockUnder
|
package/src/sync/sharedEnv.js
CHANGED
|
@@ -66,10 +66,17 @@ function assertManifest(manifest) {
|
|
|
66
66
|
* nothing that varies between two runs - the same manifest always renders the
|
|
67
67
|
* same bytes, which is what makes `--check` a gate rather than a diff of noise.
|
|
68
68
|
*
|
|
69
|
-
* `consumers` is deliberately NOT rendered:
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* `consumers` is deliberately NOT rendered: a hand-maintained copy of who reads
|
|
70
|
+
* a key, inside every service's env file, would rot the moment a reader moved
|
|
71
|
+
* (`.claude/rules/doc-code-binding.md` §1). That half is measured - the CONTROL
|
|
72
|
+
* case in `tests/unit/sharedEnv.test.js` moves the field and asserts the
|
|
73
|
+
* rendered text does not move with it. The field's own content is held by
|
|
74
|
+
* REVIEW: no code in this package reads it, and no gate elsewhere does either
|
|
75
|
+
* (`api/config/shared-env.json`, `_consumers`). The machine answer to "who
|
|
76
|
+
* reads this name" is per service, not per platform: the `env-contract` check
|
|
77
|
+
* (row `C-ENV-READS` of `manifests/biz-service.manifest.json`) measures the
|
|
78
|
+
* names a service's code reads against what its
|
|
79
|
+
* `config/service/integration-contract.json` declares.
|
|
73
80
|
*
|
|
74
81
|
* @param {{keys: Array<{name: string, value: string, why: string}>}} manifest
|
|
75
82
|
* @returns {string}
|
package/src/sync/uniformFiles.js
CHANGED
|
@@ -11,12 +11,19 @@
|
|
|
11
11
|
* points the check at. A second list here would be a second owner of the shape,
|
|
12
12
|
* and the two would diverge exactly the way the nine copies of `init.sh` did.
|
|
13
13
|
*
|
|
14
|
-
* The corollary is that a row the manifest does not make renderable is
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* (
|
|
19
|
-
*
|
|
14
|
+
* The corollary is that a row the manifest does not make renderable is not a
|
|
15
|
+
* row of this run at all. `G-PROD-IMAGE` and `G-SETUP` name no reference — the
|
|
16
|
+
* first is a requirement about a pin inside a file, the second about a directory
|
|
17
|
+
* existing — so the MANIFEST run answers them and the sync does not plan them
|
|
18
|
+
* (`syncRows` below). Until d.507 the sync planned them and printed NOT RUN: a
|
|
19
|
+
* line every `--check` in every repository ended on, naming a state its reader
|
|
20
|
+
* could do nothing about, which is the false guarantee
|
|
21
|
+
* `.claude/rules/automation-gates.md` §5 calls a defect and the `Fix`-less
|
|
22
|
+
* message its §1 requirement 4 forbids.
|
|
23
|
+
*
|
|
24
|
+
* NOT RUN stays for the other case, which is the one it was made for: a row the
|
|
25
|
+
* sync MUST render and cannot reach the reference of — named, with the reason
|
|
26
|
+
* and the command that makes it reachable, never skipped in silence.
|
|
20
27
|
*
|
|
21
28
|
* WHERE A ROW NEEDS MORE THAN A SPLICE, THE MODULE THAT OWNS THE RULE RENDERS
|
|
22
29
|
* IT. Two rows are not "the reference, verbatim": the runner block carries three
|
|
@@ -39,10 +46,11 @@ const {
|
|
|
39
46
|
withoutBlock, IGNORE_BLOCK, DOCKERIGNORE_BLOCK
|
|
40
47
|
} = require('../manifest/checks/serviceFiles');
|
|
41
48
|
const {
|
|
42
|
-
spliceBlock, insertBlockUnder, topLevelKeys, replaceTopLevelKeys
|
|
49
|
+
spliceBlock, insertBlockUnder, insertBlockAfterFunction, topLevelKeys, replaceTopLevelKeys
|
|
43
50
|
} = require('./serviceTemplate');
|
|
44
51
|
const { requireIdentity } = require('../manifest/serviceIdentity');
|
|
45
52
|
const { rowNeedsWorkspace } = require('../manifest/manifestShape');
|
|
53
|
+
const { describeWorkspaceFix } = require('../manifest/workspaceRoot');
|
|
46
54
|
const { KINDS, applyUniformRegion } = require('./readmePointer');
|
|
47
55
|
const { serviceRegion } = require('./readmeLocation');
|
|
48
56
|
const { CHECK_REGISTRY } = require('../manifest/checks');
|
|
@@ -291,7 +299,12 @@ function renderDockerignoreEntries({ row, current, workspaceRoot }) {
|
|
|
291
299
|
`# --- ${DOCKERIGNORE_BLOCK}`,
|
|
292
300
|
`# Derived from ${referenceOwner(row.from)}, the declaration .gitignore reads too —`,
|
|
293
301
|
'# what a LOCAL production build must not copy into the image (being ignored by git',
|
|
294
|
-
|
|
302
|
+
'# excludes nothing from COPY . .), PLUS the test tree: tests stay in the repository',
|
|
303
|
+
'# and out of the artefact that runs, the same decision the library uniform makes as',
|
|
304
|
+
'# L-PACK-TESTS. The one exception is tests/cookbooks, which is not a test but a',
|
|
305
|
+
'# declaration the runtime reads — Tier-1 of the boot runs those cookbooks at phase',
|
|
306
|
+
'# 0.2, and an image without them refuses its own validation proof as NO_TESTS.',
|
|
307
|
+
"# Everything above this block is this repository's own.",
|
|
295
308
|
...missing,
|
|
296
309
|
`# --- end ${DOCKERIGNORE_BLOCK}`,
|
|
297
310
|
''
|
|
@@ -322,7 +335,11 @@ const RENDERERS = Object.freeze({
|
|
|
322
335
|
const SYNCABLE_CLASSES = Object.freeze(['identical', 'generated', 'contains']);
|
|
323
336
|
|
|
324
337
|
/**
|
|
325
|
-
* The manifest rows
|
|
338
|
+
* The manifest rows of the classes the generator owns, in manifest order.
|
|
339
|
+
*
|
|
340
|
+
* Not every one of them is a row this run renders — see `syncRows`. This is the
|
|
341
|
+
* full declaration, and the CLI needs it to tell a path the uniform declares as
|
|
342
|
+
* a REQUIREMENT from a path it has never heard of.
|
|
326
343
|
*
|
|
327
344
|
* @param {object} manifest
|
|
328
345
|
* @returns {object[]}
|
|
@@ -332,6 +349,34 @@ function uniformRows(manifest) {
|
|
|
332
349
|
return SYNCABLE_CLASSES.flatMap((className) => files[className] || []);
|
|
333
350
|
}
|
|
334
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Whether the sync is defined for this row: does it name what its content comes
|
|
354
|
+
* from?
|
|
355
|
+
*
|
|
356
|
+
* The predicate is the manifest's own distinction, not a list of ids: a row with
|
|
357
|
+
* a `from:` reference is a file rendered from that reference, a row without one
|
|
358
|
+
* is a requirement about the repository, and the reference is exactly what
|
|
359
|
+
* `desiredContent` would read. Judging the reference rather than the id is why a
|
|
360
|
+
* new requirement row needs no change here.
|
|
361
|
+
*
|
|
362
|
+
* @param {object} row
|
|
363
|
+
* @returns {boolean}
|
|
364
|
+
*/
|
|
365
|
+
function isSyncRow(row) {
|
|
366
|
+
if (!row || !row.from) return false;
|
|
367
|
+
return typeof row.from.path === 'string' || isPackageReference(row.from);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The manifest rows this run is defined by, in manifest order.
|
|
372
|
+
*
|
|
373
|
+
* @param {object} manifest
|
|
374
|
+
* @returns {object[]}
|
|
375
|
+
*/
|
|
376
|
+
function syncRows(manifest) {
|
|
377
|
+
return uniformRows(manifest).filter(isSyncRow);
|
|
378
|
+
}
|
|
379
|
+
|
|
335
380
|
/**
|
|
336
381
|
* Where two texts first differ, what the file says there, and what the run would
|
|
337
382
|
* put there instead.
|
|
@@ -373,17 +418,14 @@ function readServiceFile(serviceRoot, relative) {
|
|
|
373
418
|
}
|
|
374
419
|
|
|
375
420
|
/**
|
|
376
|
-
* What a row's file should contain
|
|
421
|
+
* What a row's file should contain.
|
|
377
422
|
*
|
|
378
|
-
*
|
|
423
|
+
* Only ever called for a row `isSyncRow` accepts, so the reference is there to
|
|
424
|
+
* be read; `planRow` is where that is checked, once, before anything reads.
|
|
425
|
+
*
|
|
426
|
+
* @returns {{desired: string}}
|
|
379
427
|
*/
|
|
380
428
|
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
429
|
const render = RENDERERS[row.check];
|
|
388
430
|
if (render !== undefined) return { desired: render({ row, current, serviceRoot, workspaceRoot }) };
|
|
389
431
|
|
|
@@ -401,6 +443,18 @@ function desiredContent({ row, current, serviceRoot, workspaceRoot }) {
|
|
|
401
443
|
throw new Error(`[UniformSync] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - the row `
|
|
402
444
|
+ 'names a block the template does not carry. Fix: restore the block in the template, or correct the row.');
|
|
403
445
|
}
|
|
446
|
+
|
|
447
|
+
// The block is missing from a file that exists. That is "not synced", not
|
|
448
|
+
// "paste it in by hand": the content is generated and the run holds it, so the
|
|
449
|
+
// only open question is WHERE — and a row answers that by naming an anchor the
|
|
450
|
+
// file itself declares (`insert_after_function`). Without one the refusal
|
|
451
|
+
// stands, because the position would then be a decision about this service
|
|
452
|
+
// rather than a fact about the file (the same line `insertBlockUnder` draws
|
|
453
|
+
// for a compose mapping). A message that tells a human to copy generated bytes
|
|
454
|
+
// is the defect `.claude/rules/automation-gates.md` §1.4 names.
|
|
455
|
+
if (blockLines(current, row.block).length === 0 && typeof row.insert_after_function === 'string') {
|
|
456
|
+
return { desired: insertBlockAfterFunction({ text: current, fn: row.insert_after_function, replacement }) };
|
|
457
|
+
}
|
|
404
458
|
return { desired: spliceBlock({ text: current, block: row.block, replacement }) };
|
|
405
459
|
}
|
|
406
460
|
|
|
@@ -412,6 +466,15 @@ function desiredContent({ row, current, serviceRoot, workspaceRoot }) {
|
|
|
412
466
|
* current: string|null, desired?: string, detail?: string, reason?: string}}
|
|
413
467
|
*/
|
|
414
468
|
function planRow({ row, serviceRoot, workspaceRoot }) {
|
|
469
|
+
// Fail-fast rather than a NOT RUN line: `planSync` hands over only the rows
|
|
470
|
+
// `syncRows` selected, so a requirement row arriving here is a caller's
|
|
471
|
+
// mistake, and the caller is told which run does answer that row.
|
|
472
|
+
if (!isSyncRow(row)) {
|
|
473
|
+
throw new Error(`[UniformSync] ${row.id} declares no "from" reference - nothing says what ${row.path} `
|
|
474
|
+
+ 'would be rendered from, so this row is a requirement about the repository rather than a file this '
|
|
475
|
+
+ 'run writes. Fix: check it with npx oa-validate <serviceRoot>, which is the run that answers it.');
|
|
476
|
+
}
|
|
477
|
+
|
|
415
478
|
const current = readServiceFile(serviceRoot, row.path);
|
|
416
479
|
const base = { id: row.id, path: row.path, current };
|
|
417
480
|
|
|
@@ -425,9 +488,17 @@ function planRow({ row, serviceRoot, workspaceRoot }) {
|
|
|
425
488
|
return {
|
|
426
489
|
...base,
|
|
427
490
|
outcome: 'not-run',
|
|
491
|
+
// The check's own sentence, remedy included — not that sentence plus one
|
|
492
|
+
// of this module's. `workspaceRoot.js` § describeWorkspaceFix is the ONE
|
|
493
|
+
// owner of "point --workspace at a checkout that carries X" (d.516), and
|
|
494
|
+
// since d.523 every workspace-dependent check ends its NOT RUN sentence
|
|
495
|
+
// with it. Appending a second `Fix:` here produced two remedies for one
|
|
496
|
+
// absence, separated by a stray full stop — the second rail
|
|
497
|
+
// `.claude/rules/change-discipline.md` § One rail per concern names, in
|
|
498
|
+
// the one line a reader acts on.
|
|
428
499
|
reason: check.describeNotRun
|
|
429
500
|
? check.describeNotRun({ row, block: null })
|
|
430
|
-
:
|
|
501
|
+
: `the workspace root is not reachable. ${describeWorkspaceFix('api/ and api_biz/')}`
|
|
431
502
|
};
|
|
432
503
|
}
|
|
433
504
|
|
|
@@ -438,7 +509,6 @@ function planRow({ row, serviceRoot, workspaceRoot }) {
|
|
|
438
509
|
return { ...base, outcome: 'blocked', reason: error.message };
|
|
439
510
|
}
|
|
440
511
|
|
|
441
|
-
if (outcome.reason !== undefined) return { ...base, outcome: 'not-run', reason: outcome.reason };
|
|
442
512
|
if (outcome.desired === current) return { ...base, outcome: 'unchanged', desired: outcome.desired };
|
|
443
513
|
|
|
444
514
|
return {
|
|
@@ -456,7 +526,7 @@ function planRow({ row, serviceRoot, workspaceRoot }) {
|
|
|
456
526
|
* @returns {object[]} one entry per row, in manifest order
|
|
457
527
|
*/
|
|
458
528
|
function planSync({ manifest, serviceRoot, workspaceRoot, paths = [] }) {
|
|
459
|
-
const rows =
|
|
529
|
+
const rows = syncRows(manifest);
|
|
460
530
|
|
|
461
531
|
const wanted = paths.length === 0 ? rows : paths.map((wantedPath) => {
|
|
462
532
|
const row = rows.find((candidate) => candidate.path === wantedPath);
|
|
@@ -471,4 +541,4 @@ function planSync({ manifest, serviceRoot, workspaceRoot, paths = [] }) {
|
|
|
471
541
|
return wanted.map((row) => planRow({ row, serviceRoot, workspaceRoot }));
|
|
472
542
|
}
|
|
473
543
|
|
|
474
|
-
module.exports = { SYNCABLE_CLASSES, uniformRows, planRow, planSync, firstDifference };
|
|
544
|
+
module.exports = { SYNCABLE_CLASSES, uniformRows, isSyncRow, syncRows, planRow, planSync, firstDifference };
|
|
@@ -82,7 +82,31 @@ function assertRepoRelativePath(value, fieldName) {
|
|
|
82
82
|
function normalizeDatabaseDeclaration(database, connectors) {
|
|
83
83
|
// Absent means "this service has no database". Null rather than {} so callers
|
|
84
84
|
// distinguish that from an empty declaration and skip the steps explicitly.
|
|
85
|
-
|
|
85
|
+
//
|
|
86
|
+
// Absent is only allowed together with its connector. `db: true` with no
|
|
87
|
+
// block used to pass — a transition allowance while the six repositories
|
|
88
|
+
// still carried their own ci-setup-db.js ("F4 tightens this"). F4 landed:
|
|
89
|
+
// utils/setupDatabase.js replaced all six, none of the eight biz
|
|
90
|
+
// repositories carries one, and every repository declaring `db: true`
|
|
91
|
+
// declares a block (measured 2026-09-16), so the gate lands with compliance
|
|
92
|
+
// already in place (`automation-gates.md` §3) and the allowance goes with it
|
|
93
|
+
// (`architecture-principles.md` §11, no transition shims).
|
|
94
|
+
//
|
|
95
|
+
// What it cost while it stood: the combination makes `setup-db` report NOT
|
|
96
|
+
// APPLICABLE — correctly, there is no schema to build — while
|
|
97
|
+
// `wait-connectors` waits for a database and `run-prevalidation` then
|
|
98
|
+
// dispatches the service's own handlers against a schema nobody built. The
|
|
99
|
+
// DB steps must run exactly where a database is declared, and the two keys
|
|
100
|
+
// are one fact stated twice.
|
|
101
|
+
if (database === undefined || database === null) {
|
|
102
|
+
if (connectors.db) {
|
|
103
|
+
throw new Error('[BizCiGate] Contradictory contract - requiredConnectors.db is true but the contract '
|
|
104
|
+
+ 'carries no "database" block, so setup-db has no schema to build while wait-connectors waits for '
|
|
105
|
+
+ 'one and the cookbooks run against whatever is there. '
|
|
106
|
+
+ 'Fix: declare the database block (engine, schema, migrations), or set requiredConnectors.db to false.');
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
86
110
|
|
|
87
111
|
if (typeof database !== 'object' || Array.isArray(database)) {
|
|
88
112
|
throw new Error('[BizCiGate] Invalid database - Expected an object with engine, schema and migrations. '
|