@onlineapps/conn-orch-validator 8.1.0 → 10.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 +412 -0
- package/README.md +83 -9
- package/docs/DESIGN.md +21 -7
- package/manifests/biz-service.manifest.json +28 -5
- package/package.json +3 -3
- package/src/CookbookTestRunner.js +84 -16
- package/src/ValidationOrchestrator.js +73 -20
- package/src/cli/biz-ci-gate.js +28 -14
- package/src/cli/oa-sync-template.js +23 -8
- package/src/cli/oa-validate.js +7 -1
- 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 +12 -1
- 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/serviceFiles.js +34 -7
- package/src/manifest/checks/serviceIdentityRows.js +3 -1
- package/src/manifest/checks/serviceRuntime.js +3 -1
- package/src/manifest/discovery.js +25 -7
- 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/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/testCoverageContract.js +60 -2
- package/src/utils/throwawaySchema.js +92 -7
- package/src/validatorIdentity.js +31 -0
- package/src/validators/ServiceStructureValidator.js +41 -15
- package/src/validators/ValidationProofGenerator.js +73 -34
- package/templates/business-service/.dockerignore +9 -1
- package/templates/business-service/.gitlab-ci.yml +97 -30
- package/templates/business-service/README.md +14 -5
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
- package/templates/business-service/config/env-templates/shared.env +7 -1
- package/templates/business-service/docs/80-setup/INSTALL.md +13 -6
- 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
|
@@ -27,9 +27,10 @@ const path = require('path');
|
|
|
27
27
|
|
|
28
28
|
const { readReferencedFile, isPackageReference, referenceOwner } = require('../discovery');
|
|
29
29
|
const { whereOf } = require('./libraryContext');
|
|
30
|
+
const { describeWorkspaceFix } = require('../workspaceRoot');
|
|
30
31
|
const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
|
|
31
32
|
const {
|
|
32
|
-
SHARED_DECLARATIONS,
|
|
33
|
+
SHARED_DECLARATIONS, renderRunnerIdentity, stripDeclarations
|
|
33
34
|
} = require('./composeRunnerBlock');
|
|
34
35
|
const { renderText, topLevelKeys } = require('../../sync/serviceTemplate');
|
|
35
36
|
const { readIdentity, requireIdentity, IDENTITY_FILE } = require('../serviceIdentity');
|
|
@@ -51,7 +52,8 @@ const READS_ITS_REFERENCE_ONLY = Object.freeze({
|
|
|
51
52
|
needsWorkspace: ({ row }) => !isPackageReference(row.from),
|
|
52
53
|
|
|
53
54
|
describeNotRun({ row }) {
|
|
54
|
-
|
|
55
|
+
const owner = referenceOwner(row.from);
|
|
56
|
+
return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
|
|
55
57
|
}
|
|
56
58
|
});
|
|
57
59
|
|
|
@@ -433,9 +435,14 @@ const composeRunner = Object.freeze({
|
|
|
433
435
|
return findings;
|
|
434
436
|
}
|
|
435
437
|
|
|
438
|
+
// The reference is rendered for THIS repository and compared with the file
|
|
439
|
+
// as it stands — the direction the generator writes in, and the only one
|
|
440
|
+
// that is exact (`composeRunnerBlock.js` § renderRunnerIdentity, d.531).
|
|
441
|
+
// The three shared declarations come off both sides, because the rows above
|
|
442
|
+
// compare those against the service this runner tests.
|
|
436
443
|
const difference = firstDifference(
|
|
437
|
-
|
|
438
|
-
|
|
444
|
+
stripDeclarations(mine, SHARED_DECLARATIONS),
|
|
445
|
+
stripDeclarations(renderRunnerIdentity({ lines: reference, containerName, serviceName }), SHARED_DECLARATIONS)
|
|
439
446
|
);
|
|
440
447
|
if (difference !== null) {
|
|
441
448
|
say(`the "${row.block}" block differs from the template at block line ${difference.line}: `
|
|
@@ -600,6 +607,25 @@ const DOCKERIGNORE_BLOCK = 'oa-dockerignore v1';
|
|
|
600
607
|
*/
|
|
601
608
|
const DOCKER_IMPLICIT = Object.freeze(['.git']);
|
|
602
609
|
|
|
610
|
+
/**
|
|
611
|
+
* The test tree, which `.gitignore` must NOT declare and the image must not
|
|
612
|
+
* carry: tests live in the repository and never in the artefact that runs. It is
|
|
613
|
+
* the same decision the library uniform makes one row over as `L-PACK-TESTS`,
|
|
614
|
+
* applied to an image instead of a tarball — the reason is one, so the sentence
|
|
615
|
+
* is one (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
616
|
+
*
|
|
617
|
+
* The exception is the reason this is a list and not a line. `tests/cookbooks`
|
|
618
|
+
* is NOT a test: it is a declaration the RUNTIME reads. Tier-1 of the boot runs
|
|
619
|
+
* those cookbooks at phase 0.2, and an image without the directory measures zero
|
|
620
|
+
* of them, so `ValidationProofGenerator` refuses the proof as NO_TESTS
|
|
621
|
+
* (`src/validators/ValidationProofGenerator.js`) and the service never starts.
|
|
622
|
+
* Measured on 2026-09-16 against the wrapper's boot path.
|
|
623
|
+
*
|
|
624
|
+
* Order carries the meaning: in a `.dockerignore` a later line wins, so the
|
|
625
|
+
* re-inclusion stands AFTER the two exclusions and never before them.
|
|
626
|
+
*/
|
|
627
|
+
const DOCKER_TEST_TREE = Object.freeze(['tests', '**/tests', '!tests/cookbooks']);
|
|
628
|
+
|
|
603
629
|
/**
|
|
604
630
|
* The exclusions a `.dockerignore` must carry, DERIVED from the very declaration
|
|
605
631
|
* the `.gitignore` row reads. One list, two consumers
|
|
@@ -619,9 +645,9 @@ const DOCKER_IMPLICIT = Object.freeze(['.git']);
|
|
|
619
645
|
* A negation keeps its `!` in front of the pattern rather than inside it, and
|
|
620
646
|
* keeps its place in the order — in both formats a later line wins.
|
|
621
647
|
*
|
|
622
|
-
* What the derivation
|
|
623
|
-
*
|
|
624
|
-
* `
|
|
648
|
+
* What the derivation adds to the declaration is the test tree, and exactly one
|
|
649
|
+
* exception inside it — see `DOCKER_TEST_TREE` above. Nothing else: an entry the
|
|
650
|
+
* `.gitignore` does not declare has no business being invented here.
|
|
625
651
|
*
|
|
626
652
|
* @param {string} gitignoreText the declaration both rows read
|
|
627
653
|
* @returns {string[]}
|
|
@@ -640,6 +666,7 @@ function dockerignoreEntries(gitignoreText) {
|
|
|
640
666
|
if (!pattern.includes('/')) add(mark(`**/${pattern}`));
|
|
641
667
|
}
|
|
642
668
|
|
|
669
|
+
for (const entry of DOCKER_TEST_TREE) add(entry);
|
|
643
670
|
for (const entry of DOCKER_IMPLICIT) add(entry);
|
|
644
671
|
return entries;
|
|
645
672
|
}
|
|
@@ -46,6 +46,7 @@ const { readComposeServices } = require('./composeShape');
|
|
|
46
46
|
const { readIdentity, IDENTITY_FILE } = require('../serviceIdentity');
|
|
47
47
|
const { PLACEHOLDERS } = require('../../sync/serviceTemplate');
|
|
48
48
|
const { resolveFromMap } = require('../discovery');
|
|
49
|
+
const { describeWorkspaceFix } = require('../workspaceRoot');
|
|
49
50
|
|
|
50
51
|
/** Where a service declares the name npm knows it by. */
|
|
51
52
|
const PACKAGE_FILE = 'package.json';
|
|
@@ -271,7 +272,8 @@ const ssotIdentity = Object.freeze({
|
|
|
271
272
|
requires: Object.freeze([]),
|
|
272
273
|
|
|
273
274
|
describeNotRun({ block }) {
|
|
274
|
-
return `the workspace root is not reachable, so ${block.from.path} cannot be read
|
|
275
|
+
return `the workspace root is not reachable, so ${block.from.path} cannot be read. `
|
|
276
|
+
+ describeWorkspaceFix(block.from.path);
|
|
275
277
|
},
|
|
276
278
|
|
|
277
279
|
run({ block, serviceRoot, workspaceRoot }) {
|
|
@@ -44,6 +44,7 @@ const path = require('path');
|
|
|
44
44
|
|
|
45
45
|
const { resolveFromValue, isPackageReference, referenceOwner } = require('../discovery');
|
|
46
46
|
const { whereOf } = require('./libraryContext');
|
|
47
|
+
const { describeWorkspaceFix } = require('../workspaceRoot');
|
|
47
48
|
const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
|
|
48
49
|
|
|
49
50
|
/** Where a service block states its own budget, in both compose files. */
|
|
@@ -106,7 +107,8 @@ const nodeMajor = Object.freeze({
|
|
|
106
107
|
needsWorkspace: ({ row }) => !isPackageReference(row.from),
|
|
107
108
|
|
|
108
109
|
describeNotRun({ row }) {
|
|
109
|
-
|
|
110
|
+
const owner = referenceOwner(row.from);
|
|
111
|
+
return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
|
|
110
112
|
},
|
|
111
113
|
|
|
112
114
|
run({ row, serviceRoot, workspaceRoot }) {
|
|
@@ -237,21 +237,38 @@ function resolveFromValue({ from, workspaceRoot }) {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
|
-
* The
|
|
240
|
+
* The whole referenced file as the document it is: `{ path, text: true }` over a
|
|
241
|
+
* JSON SSOT, for a row that does not take a value out of it but hands it to the
|
|
242
|
+
* module that owns its shape (`G-SHARED-ENV` renders `api/config/shared-env.json`
|
|
243
|
+
* through `src/sync/sharedEnv.js`).
|
|
241
244
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
245
|
+
* It is the same read and the same refusal `resolveFromMap` makes, said once:
|
|
246
|
+
* a broken SSOT is reported as a broken SSOT wherever a row reads it, and the
|
|
247
|
+
* second call site is the one that would have written its own sentence
|
|
248
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
249
|
+
*
|
|
250
|
+
* @param {{ from: {path: string}, workspaceRoot: string }} params
|
|
251
|
+
* @returns {object|Array} the parsed document
|
|
244
252
|
*/
|
|
245
|
-
function
|
|
253
|
+
function resolveFromDocument({ from, workspaceRoot }) {
|
|
246
254
|
const raw = readReferencedFile({ from, workspaceRoot });
|
|
247
255
|
|
|
248
|
-
let document;
|
|
249
256
|
try {
|
|
250
|
-
|
|
257
|
+
return JSON.parse(raw);
|
|
251
258
|
} catch (cause) {
|
|
252
|
-
throw new Error(`[ManifestDiscovery] Referenced file is not valid JSON - ${from
|
|
259
|
+
throw new Error(`[ManifestDiscovery] Referenced file is not valid JSON - ${referenceOwner(from)}. `
|
|
253
260
|
+ 'Fix: repair the file; it is the SSOT this row reads.', { cause });
|
|
254
261
|
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The node a `from.list` path points at, as the owner file writes it.
|
|
266
|
+
*
|
|
267
|
+
* @param {{ from: {path: string, list: string}, workspaceRoot: string }} params
|
|
268
|
+
* @returns {Array|object} the array or the object map the reference names
|
|
269
|
+
*/
|
|
270
|
+
function resolveFromMap({ from, workspaceRoot }) {
|
|
271
|
+
const document = resolveFromDocument({ from, workspaceRoot });
|
|
255
272
|
|
|
256
273
|
const node = from.list.split('.').reduce((current, key) => (current == null ? undefined : current[key]), document);
|
|
257
274
|
if (node === null || node === undefined || typeof node !== 'object') {
|
|
@@ -378,6 +395,7 @@ module.exports = {
|
|
|
378
395
|
resolveFromValue,
|
|
379
396
|
resolveFromMap,
|
|
380
397
|
readReferencedFile,
|
|
398
|
+
resolveFromDocument,
|
|
381
399
|
expandPattern,
|
|
382
400
|
rootOfPattern,
|
|
383
401
|
containerOf,
|
|
@@ -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}
|