@onlineapps/conn-orch-validator 7.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2558 -2
- package/README.md +1038 -4
- 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 +408 -101
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +10 -35
- package/src/ValidationOrchestrator.js +219 -71
- 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 +2 -1
- package/src/helpers/createServiceReadinessTests.js +60 -4
- 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 +140 -9
- 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 +2 -1
- 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,754 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `identical` class: files a service does not get to decide, because the
|
|
5
|
+
* template already did (confirmation `biz-service-manifest` 001 §2).
|
|
6
|
+
*
|
|
7
|
+
* Two shapes of "identical", and the reason there are two: measured over the
|
|
8
|
+
* eight repositories on 2026-09-09, no whole file is byte-equal across nine
|
|
9
|
+
* copies, but a delimited BLOCK is (`init.sh`), and a file that carries no
|
|
10
|
+
* identity at all can be (`jest.config.js`). A row therefore names either a
|
|
11
|
+
* block or a whole file, and the template is the reference in both cases —
|
|
12
|
+
* reached through the row's `from:` reference, never copied into the manifest
|
|
13
|
+
* (004 point 2).
|
|
14
|
+
*
|
|
15
|
+
* The runner row is neither: a compose service cannot be byte-equal across
|
|
16
|
+
* repositories, because its name IS the repository. What the uniform fixes there
|
|
17
|
+
* is the shape — one profile, one budget, no restart, and the same build, env
|
|
18
|
+
* and networks as the service it tests — so `compose-runner` compares the two
|
|
19
|
+
* blocks of ONE file against each other and the two platform numbers against
|
|
20
|
+
* the row.
|
|
21
|
+
*
|
|
22
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md §2
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
|
|
28
|
+
const { readReferencedFile, isPackageReference, referenceOwner } = require('../discovery');
|
|
29
|
+
const { whereOf } = require('./libraryContext');
|
|
30
|
+
const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
|
|
31
|
+
const {
|
|
32
|
+
SHARED_DECLARATIONS, CONTAINER_PLACEHOLDER, SERVICE_PLACEHOLDER, normalizeRunnerBlock
|
|
33
|
+
} = require('./composeRunnerBlock');
|
|
34
|
+
const { renderText, topLevelKeys } = require('../../sync/serviceTemplate');
|
|
35
|
+
const { readIdentity, requireIdentity, IDENTITY_FILE } = require('../serviceIdentity');
|
|
36
|
+
|
|
37
|
+
/** What the `test` profile is called. A runner is the service that declares it. */
|
|
38
|
+
const TEST_PROFILE = 'test';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What these checks need the workspace for, and nothing else: to READ the file
|
|
42
|
+
* the row's `from:` reference names. A reference into this package (d.229 —
|
|
43
|
+
* `manifestShape.js` § describeFromProblem) is carried by the package itself, so
|
|
44
|
+
* the row runs wherever the package is installed: in a service container, in the
|
|
45
|
+
* service's own CI checkout, and in the workspace exactly as before.
|
|
46
|
+
*
|
|
47
|
+
* Declared once, spread into every check of this module, because it is one
|
|
48
|
+
* sentence about all four (`change-discipline.md` § One rail per concern).
|
|
49
|
+
*/
|
|
50
|
+
const READS_ITS_REFERENCE_ONLY = Object.freeze({
|
|
51
|
+
needsWorkspace: ({ row }) => !isPackageReference(row.from),
|
|
52
|
+
|
|
53
|
+
describeNotRun({ row }) {
|
|
54
|
+
return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read a file of the repository under check.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} serviceRoot repository root
|
|
62
|
+
* @param {string} relative repository-relative path
|
|
63
|
+
* @returns {string|null} null when the file is not there
|
|
64
|
+
*/
|
|
65
|
+
function readServiceFile(serviceRoot, relative) {
|
|
66
|
+
const target = path.join(serviceRoot, ...relative.split('/'));
|
|
67
|
+
if (!fs.existsSync(target)) return null;
|
|
68
|
+
return fs.readFileSync(target, 'utf8');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The lines of a delimited block, markers included.
|
|
73
|
+
*
|
|
74
|
+
* The markers are the two lines `api/tests/scripts/infra-init-scripts.bats`
|
|
75
|
+
* matches on (`GUARD_OPEN` / `GUARD_CLOSE`, its lines 202-203): the block opens
|
|
76
|
+
* with `# --- <name>` and closes with `# --- end <name>`. That bats file guards
|
|
77
|
+
* `api/infra/*` and says in its own comment that the `api_biz/*` copies are kept
|
|
78
|
+
* in sync by review only — this row is that reach extended to the biz
|
|
79
|
+
* repositories, which is why it reads the same two markers rather than
|
|
80
|
+
* inventing a third spelling.
|
|
81
|
+
*
|
|
82
|
+
* The marker is read TRIMMED, because the second block the manifest declares
|
|
83
|
+
* lives in a compose file: `oa-test-runner v1` sits inside `services:`, so its
|
|
84
|
+
* two markers are indented by two spaces. A YAML comment is a comment at any
|
|
85
|
+
* column, and a shell comment at column 0 still matches — `init.sh` is the
|
|
86
|
+
* control case both directions of this rule are measured against.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} text file content
|
|
89
|
+
* @param {string} name the block name, e.g. `oa-deps-guard v1`
|
|
90
|
+
* @returns {string[]} the block's lines, or [] when there is none (or it never closes)
|
|
91
|
+
*/
|
|
92
|
+
function blockLines(text, name) {
|
|
93
|
+
const open = `# --- ${name}`;
|
|
94
|
+
const close = `# --- end ${name}`;
|
|
95
|
+
const lines = text.split('\n');
|
|
96
|
+
const collected = [];
|
|
97
|
+
let inside = false;
|
|
98
|
+
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
const marker = line.trim();
|
|
101
|
+
if (!inside && marker.startsWith(open) && !marker.startsWith(close)) inside = true;
|
|
102
|
+
if (!inside) continue;
|
|
103
|
+
collected.push(line);
|
|
104
|
+
if (marker.startsWith(close)) return collected;
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The first line at which two texts differ, 1-based, and what the service says
|
|
111
|
+
* there. A quoted line is what makes the finding actionable in a table cell.
|
|
112
|
+
*
|
|
113
|
+
* `ended` is what is quoted where the service's side has run out. It is a
|
|
114
|
+
* parameter rather than a constant because the same comparison serves a block
|
|
115
|
+
* (`oa-deps-guard v1` inside a longer file) and a whole file, and a finding that
|
|
116
|
+
* says "<end of block>" about a truncated FILE sends the reader looking for
|
|
117
|
+
* markers that are not the defect.
|
|
118
|
+
*
|
|
119
|
+
* @param {string[]} mine the service's lines
|
|
120
|
+
* @param {string[]} reference the template's lines
|
|
121
|
+
* @param {string} ended what to quote where `mine` has no line at all
|
|
122
|
+
* @returns {{ line: number, text: string }|null} null when they are equal
|
|
123
|
+
*/
|
|
124
|
+
function firstDifference(mine, reference, ended = '<end of block>') {
|
|
125
|
+
const length = Math.max(mine.length, reference.length);
|
|
126
|
+
for (let i = 0; i < length; i += 1) {
|
|
127
|
+
if (mine[i] === reference[i]) continue;
|
|
128
|
+
return { line: i + 1, text: mine[i] === undefined ? ended : mine[i].trim() };
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const templateBlock = Object.freeze({
|
|
134
|
+
scope: 'bearer',
|
|
135
|
+
requires: Object.freeze(['path', 'block', 'from']),
|
|
136
|
+
|
|
137
|
+
...READS_ITS_REFERENCE_ONLY,
|
|
138
|
+
|
|
139
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
140
|
+
const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
141
|
+
const text = readServiceFile(serviceRoot, row.path);
|
|
142
|
+
if (text === null) {
|
|
143
|
+
return [{ where, what: `absent — the uniform's ${row.path} carries the "${row.block}" block` }];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const reference = blockLines(readReferencedFile({ from: row.from, workspaceRoot }), row.block);
|
|
147
|
+
if (reference.length === 0) {
|
|
148
|
+
throw new Error(`[ManifestFiles] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - `
|
|
149
|
+
+ 'the template is the reference bearer of this row and carries no such block. '
|
|
150
|
+
+ 'Fix: restore the block in the template, or correct the row.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const mine = blockLines(text, row.block);
|
|
154
|
+
if (mine.length === 0) {
|
|
155
|
+
return [{
|
|
156
|
+
where,
|
|
157
|
+
what: `carries no "${row.block}" block — the template ships one and every ${row.path} repeats it byte for byte`
|
|
158
|
+
}];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const difference = firstDifference(mine, reference);
|
|
162
|
+
if (difference === null) return [];
|
|
163
|
+
|
|
164
|
+
return [{
|
|
165
|
+
where,
|
|
166
|
+
what: `the "${row.block}" block differs from the template at block line ${difference.line}: "${difference.text}"`
|
|
167
|
+
}];
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const templateFile = Object.freeze({
|
|
172
|
+
scope: 'bearer',
|
|
173
|
+
requires: Object.freeze(['path', 'from']),
|
|
174
|
+
|
|
175
|
+
...READS_ITS_REFERENCE_ONLY,
|
|
176
|
+
|
|
177
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
178
|
+
const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
179
|
+
const text = readServiceFile(serviceRoot, row.path);
|
|
180
|
+
if (text === null) {
|
|
181
|
+
return [{ where, what: `absent — this file is ${referenceOwner(row.from)}, byte for byte` }];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const reference = readReferencedFile({ from: row.from, workspaceRoot });
|
|
185
|
+
if (text === reference) return [];
|
|
186
|
+
|
|
187
|
+
return [{ where, what: `differs from ${referenceOwner(row.from)} — this file is the template's, byte for byte` }];
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The `generated` whole-file shape: the file IS the template rendered with this
|
|
193
|
+
* repository's name, and nothing else (owner decision
|
|
194
|
+
* `api/docs/governance/confirmations/server-topology.md` 005).
|
|
195
|
+
*
|
|
196
|
+
* The difference from `template-file` is the render, and it is the whole reason
|
|
197
|
+
* a second check exists: `jest.config.js` carries no identity, so the template
|
|
198
|
+
* IS the file; a compose file names the container, the image repository and the
|
|
199
|
+
* service's own env file, so the template is the file only after the one
|
|
200
|
+
* parameter a service has has been put in it. Comparing the raw template there
|
|
201
|
+
* would report every conformant service as drifted.
|
|
202
|
+
*
|
|
203
|
+
* The difference from `template-skeleton` is what is compared: a skeleton row
|
|
204
|
+
* owns the headings of a document whose prose is the service's, so it compares
|
|
205
|
+
* sections. Here there is no prose to protect — the measurement behind 005 is
|
|
206
|
+
* that the seven old-shape files were byte-identical once the name was
|
|
207
|
+
* normalised, so what a service "decides" about its production compose is its
|
|
208
|
+
* name and nothing more.
|
|
209
|
+
*/
|
|
210
|
+
const templateRender = Object.freeze({
|
|
211
|
+
scope: 'bearer',
|
|
212
|
+
requires: Object.freeze(['path', 'from']),
|
|
213
|
+
|
|
214
|
+
...READS_ITS_REFERENCE_ONLY,
|
|
215
|
+
|
|
216
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
217
|
+
const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
218
|
+
const text = readServiceFile(serviceRoot, row.path);
|
|
219
|
+
if (text === null) {
|
|
220
|
+
return [{ where, what: `absent — this file is ${referenceOwner(row.from)}, rendered for this service` }];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Nothing can be rendered for a repository that has not said who it is, and
|
|
224
|
+
// saying so here would give that defect a second owner: `C-SERVICE` puts it
|
|
225
|
+
// on the table with its own fix (`serviceIdentity.js`).
|
|
226
|
+
const identity = readIdentity(serviceRoot);
|
|
227
|
+
if (identity.problem !== undefined) {
|
|
228
|
+
if (identity.undecided === null) return [];
|
|
229
|
+
return [{
|
|
230
|
+
where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: IDENTITY_FILE }),
|
|
231
|
+
what: `${row.path} could not be compared — ${identity.undecided}`
|
|
232
|
+
}];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const reference = renderText(readReferencedFile({ from: row.from, workspaceRoot }), identity.params);
|
|
236
|
+
if (text === reference) return [];
|
|
237
|
+
|
|
238
|
+
// BOTH sides of the first differing line. One of them alone is unreadable
|
|
239
|
+
// for a whole-file row: "line 1: services:" says the file is wrong without
|
|
240
|
+
// saying what belongs there, and the fix command is the only other thing
|
|
241
|
+
// the reader gets.
|
|
242
|
+
const mine = text.split('\n');
|
|
243
|
+
const wanted = reference.split('\n');
|
|
244
|
+
const difference = firstDifference(mine, wanted, '<end of file>');
|
|
245
|
+
const expected = wanted[difference.line - 1];
|
|
246
|
+
|
|
247
|
+
return [{
|
|
248
|
+
where,
|
|
249
|
+
what: `differs from ${referenceOwner(row.from)} rendered for this service, `
|
|
250
|
+
+ `at line ${difference.line}: ${JSON.stringify(difference.text)} — expected `
|
|
251
|
+
+ `${JSON.stringify(expected === undefined ? '<end of file>' : expected.trim())}`
|
|
252
|
+
}];
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The `generated` BLOCK shape: one delimited region of a file is the template's,
|
|
258
|
+
* rendered with this repository's name, and every line outside the markers is
|
|
259
|
+
* the repository's own (lead decision `api/shared/TODO.md` §0.2b-49, d.251).
|
|
260
|
+
*
|
|
261
|
+
* It is `template-block` plus the render, and `template-render` minus the rest
|
|
262
|
+
* of the file — and the reason it is neither of them is the defect it replaces.
|
|
263
|
+
* `.gitlab-ci.yml` carried a whole-file row for three days, and its fix could not
|
|
264
|
+
* be run: the template is 151 lines and the eight biz pipelines are 176 to 198,
|
|
265
|
+
* because each declares the service containers, the `ci:gate:*` steps and the
|
|
266
|
+
* artefact ITS integration needs. Syncing the whole file would have deleted
|
|
267
|
+
* those, which is a finding a gate can report and nobody can fix
|
|
268
|
+
* (`.claude/rules/automation-gates.md` §3, last bullet). The block is the same
|
|
269
|
+
* split `docker-compose.yml` already lives under, one level up: there a compose
|
|
270
|
+
* SERVICE is the platform's, here a set of top-level jobs is.
|
|
271
|
+
*
|
|
272
|
+
* The render is what separates it from `template-block`: the platform's half
|
|
273
|
+
* names the service once, in `GIT_CLONE_PATH`, which is what puts the CI
|
|
274
|
+
* checkout in the layout the engine needs. A byte comparison against the raw
|
|
275
|
+
* template would report every conformant service as drifted.
|
|
276
|
+
*/
|
|
277
|
+
const delimitedBlockRender = Object.freeze({
|
|
278
|
+
scope: 'bearer',
|
|
279
|
+
requires: Object.freeze(['path', 'block', 'from']),
|
|
280
|
+
|
|
281
|
+
...READS_ITS_REFERENCE_ONLY,
|
|
282
|
+
|
|
283
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
284
|
+
const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
285
|
+
const text = readServiceFile(serviceRoot, row.path);
|
|
286
|
+
if (text === null) {
|
|
287
|
+
return [{
|
|
288
|
+
where,
|
|
289
|
+
what: `absent — this file is ${referenceOwner(row.from)}, `
|
|
290
|
+
+ 'and the platform half of it is rendered for this service'
|
|
291
|
+
}];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Nothing can be rendered for a repository that has not said who it is, and
|
|
295
|
+
// saying so here would give that defect a second owner: `C-SERVICE` puts it
|
|
296
|
+
// on the table with its own fix (`serviceIdentity.js`).
|
|
297
|
+
const identity = readIdentity(serviceRoot);
|
|
298
|
+
if (identity.problem !== undefined) {
|
|
299
|
+
if (identity.undecided === null) return [];
|
|
300
|
+
return [{
|
|
301
|
+
where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: IDENTITY_FILE }),
|
|
302
|
+
what: `${row.path} could not be compared — ${identity.undecided}`
|
|
303
|
+
}];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const rendered = renderText(readReferencedFile({ from: row.from, workspaceRoot }), identity.params);
|
|
307
|
+
const reference = blockLines(rendered, row.block);
|
|
308
|
+
if (reference.length === 0) {
|
|
309
|
+
throw new Error(`[ManifestFiles] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - `
|
|
310
|
+
+ 'the template is the reference bearer of this row and carries no such block. '
|
|
311
|
+
+ 'Fix: restore the block in the template, or correct the row.');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const mine = blockLines(text, row.block);
|
|
315
|
+
if (mine.length === 0) {
|
|
316
|
+
return [{
|
|
317
|
+
where,
|
|
318
|
+
what: `carries no "${row.block}" block — the platform's half of this pipeline `
|
|
319
|
+
+ `(${topLevelKeys(reference).join(', ')}) is what the template owns`
|
|
320
|
+
}];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const difference = firstDifference(mine, reference);
|
|
324
|
+
if (difference === null) return [];
|
|
325
|
+
|
|
326
|
+
const expected = reference[difference.line - 1];
|
|
327
|
+
return [{
|
|
328
|
+
where,
|
|
329
|
+
what: `the "${row.block}" block differs from ${referenceOwner(row.from)} rendered for this service, `
|
|
330
|
+
+ `at block line ${difference.line}: ${JSON.stringify(difference.text)} — expected `
|
|
331
|
+
+ `${JSON.stringify(expected === undefined ? '<end of block>' : expected.trim())}`
|
|
332
|
+
}];
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const composeRunner = Object.freeze({
|
|
337
|
+
scope: 'bearer',
|
|
338
|
+
requires: Object.freeze(['path', 'runner_memory', 'block', 'from']),
|
|
339
|
+
|
|
340
|
+
...READS_ITS_REFERENCE_ONLY,
|
|
341
|
+
|
|
342
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
343
|
+
// The locator names the repository, not just the file: this row runs once
|
|
344
|
+
// per bearer in a workspace run, and five services reporting
|
|
345
|
+
// "docker-compose.yml" are five findings nobody can tell apart.
|
|
346
|
+
const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
347
|
+
const text = readServiceFile(serviceRoot, row.path);
|
|
348
|
+
if (text === null) {
|
|
349
|
+
return [{ where, what: 'absent — the service is built and run from this file' }];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const services = readComposeServices(text);
|
|
353
|
+
const runners = [...services.entries()]
|
|
354
|
+
.filter(([, node]) => (node.sequences.get('profiles') || []).includes(TEST_PROFILE));
|
|
355
|
+
|
|
356
|
+
if (runners.length === 0) {
|
|
357
|
+
return [{
|
|
358
|
+
where,
|
|
359
|
+
what: `declares no service with profiles: [${TEST_PROFILE}] — the one-shot test runner is missing`
|
|
360
|
+
}];
|
|
361
|
+
}
|
|
362
|
+
if (runners.length > 1) {
|
|
363
|
+
return [{
|
|
364
|
+
where,
|
|
365
|
+
what: `declares ${runners.length} services with profiles: [${TEST_PROFILE}] — the uniform has one runner`
|
|
366
|
+
}];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const [runnerName, runner] = runners[0];
|
|
370
|
+
const others = [...services.entries()].filter(([name]) => name !== runnerName);
|
|
371
|
+
if (others.length !== 1) {
|
|
372
|
+
return [{
|
|
373
|
+
where,
|
|
374
|
+
what: `declares ${others.length} services beside the runner — the uniform is one service and its runner`
|
|
375
|
+
}];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const [, service] = others[0];
|
|
379
|
+
const findings = [];
|
|
380
|
+
// Every sentence of this row is about the compose file, apart from the one
|
|
381
|
+
// that is about the file this repository's identity is declared in.
|
|
382
|
+
const say = (what, relative = row.path) => findings.push({
|
|
383
|
+
where: relative === row.path
|
|
384
|
+
? where
|
|
385
|
+
: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative }),
|
|
386
|
+
what
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const memory = scalarAt(runner, 'mem_limit');
|
|
390
|
+
if (memory !== row.runner_memory) {
|
|
391
|
+
say(`the runner ${runnerName} declares mem_limit ${JSON.stringify(memory)}, `
|
|
392
|
+
+ `the uniform's runner budget is ${JSON.stringify(row.runner_memory)}`);
|
|
393
|
+
}
|
|
394
|
+
for (const forbidden of ['restart', 'healthcheck']) {
|
|
395
|
+
if (declares(runner, forbidden)) {
|
|
396
|
+
say(`the runner ${runnerName} declares "${forbidden}" — it runs once and exits`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
for (const shared of SHARED_DECLARATIONS) {
|
|
400
|
+
if (declarationOf(runner, shared) !== declarationOf(service, shared)) {
|
|
401
|
+
say(`the runner ${runnerName} declares a different "${shared}" than the service it tests`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The rest of the runner is the template's, byte for byte. What is compared
|
|
406
|
+
// and what is deliberately not is one definition, in composeRunnerBlock.js,
|
|
407
|
+
// and the generator renders from that same definition.
|
|
408
|
+
const reference = blockLines(readReferencedFile({ from: row.from, workspaceRoot }), row.block);
|
|
409
|
+
if (reference.length === 0) {
|
|
410
|
+
throw new Error(`[ManifestFiles] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - `
|
|
411
|
+
+ 'the template is the reference bearer of this row and carries no such block. '
|
|
412
|
+
+ 'Fix: restore the block in the template, or correct the row.');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Who this repository is, as it declares it. NOT the directory name: inside
|
|
416
|
+
// the image the row now runs in, every service lies in `/app`
|
|
417
|
+
// (`serviceIdentity.js`).
|
|
418
|
+
const identity = readIdentity(serviceRoot);
|
|
419
|
+
if (identity.problem !== undefined) {
|
|
420
|
+
if (identity.undecided !== null) {
|
|
421
|
+
say(`the "${row.block}" block could not be compared — ${identity.undecided}`, IDENTITY_FILE);
|
|
422
|
+
}
|
|
423
|
+
return findings;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const [containerName] = others[0];
|
|
427
|
+
const serviceName = identity.params.service_name;
|
|
428
|
+
const mine = blockLines(text, row.block);
|
|
429
|
+
|
|
430
|
+
if (mine.length === 0) {
|
|
431
|
+
say(`carries no "${row.block}" block — the template ships one and every ${row.path} repeats it, `
|
|
432
|
+
+ 'apart from what the runner shares with its service');
|
|
433
|
+
return findings;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const difference = firstDifference(
|
|
437
|
+
normalizeRunnerBlock({ lines: mine, containerName, serviceName }),
|
|
438
|
+
normalizeRunnerBlock({ lines: reference, containerName: CONTAINER_PLACEHOLDER, serviceName: SERVICE_PLACEHOLDER })
|
|
439
|
+
);
|
|
440
|
+
if (difference !== null) {
|
|
441
|
+
say(`the "${row.block}" block differs from the template at block line ${difference.line}: `
|
|
442
|
+
+ `"${difference.text}" (compared without ${SHARED_DECLARATIONS.join(', ')}, which the rows above `
|
|
443
|
+
+ 'compare against the service this runner tests)');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return findings;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* A document's level-2 headings, in file order — the shape of a document, with
|
|
452
|
+
* none of its prose in it.
|
|
453
|
+
*
|
|
454
|
+
* Level 2 and no deeper on purpose: `##` is the section an operator navigates
|
|
455
|
+
* by, and the template's own `###` sub-headings are the way ONE service happens
|
|
456
|
+
* to have written a section. A rule that demanded those would be prescribing
|
|
457
|
+
* prose, which the `generated` class explicitly does not do (confirmation
|
|
458
|
+
* `biz-service-manifest` 001 §2: `skeleton_only` compares headings and required
|
|
459
|
+
* sections, not prose).
|
|
460
|
+
*
|
|
461
|
+
* @param {string} text the document
|
|
462
|
+
* @returns {string[]}
|
|
463
|
+
*/
|
|
464
|
+
function headingsOf(text) {
|
|
465
|
+
return text.split('\n')
|
|
466
|
+
.filter((line) => /^## +\S/.test(line))
|
|
467
|
+
.map((line) => line.replace(/^##\s+/, '').trim());
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The reference document with this repository's parameters in it.
|
|
472
|
+
*
|
|
473
|
+
* The parameters are the repository's DECLARED identity (`serviceIdentity.js`);
|
|
474
|
+
* a repository that cannot say who it is gets the fail-fast that module writes,
|
|
475
|
+
* which is what a generator run must see. The check below asks the same question
|
|
476
|
+
* without throwing, because a run reports every row before it stops.
|
|
477
|
+
*
|
|
478
|
+
* @param {{row: object, serviceRoot: string, workspaceRoot: string}} args
|
|
479
|
+
* @returns {string}
|
|
480
|
+
*/
|
|
481
|
+
function renderedReference({ row, serviceRoot, workspaceRoot }) {
|
|
482
|
+
return renderText(readReferencedFile({ from: row.from, workspaceRoot }), requireIdentity(serviceRoot));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The skeleton of a document: its header, its title and its sections — and not
|
|
487
|
+
* one sentence of what any of them says.
|
|
488
|
+
*
|
|
489
|
+
* This is what the generator CREATES a missing installation document from. It
|
|
490
|
+
* deliberately writes no prose: what this service's installation actually
|
|
491
|
+
* requires is the service's own fact, and a generated paragraph describing a
|
|
492
|
+
* different service is exactly the sentence nobody re-derives
|
|
493
|
+
* (`.claude/rules/doc-code-binding.md` §1).
|
|
494
|
+
*
|
|
495
|
+
* @param {string} reference the rendered reference document
|
|
496
|
+
* @returns {string}
|
|
497
|
+
*/
|
|
498
|
+
function skeletonOf(reference) {
|
|
499
|
+
const lines = reference.split('\n');
|
|
500
|
+
const titleAt = lines.findIndex((line) => /^# +\S/.test(line));
|
|
501
|
+
if (titleAt === -1) {
|
|
502
|
+
throw new Error('[ManifestFiles] Reference document has no title - a skeleton is its header, its title '
|
|
503
|
+
+ 'and its sections, and this document declares no "# " line. '
|
|
504
|
+
+ 'Fix: restore the title in the template document.');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const header = lines.slice(0, titleAt).filter((line) => line.trim() !== '');
|
|
508
|
+
const sections = headingsOf(reference)
|
|
509
|
+
.map((heading) => `## ${heading}\n\nTODO — this section is this service's own.`)
|
|
510
|
+
.join('\n\n');
|
|
511
|
+
return `${[...header, '', lines[titleAt], '', sections].join('\n')}\n`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const templateSkeleton = Object.freeze({
|
|
515
|
+
scope: 'bearer',
|
|
516
|
+
requires: Object.freeze(['path', 'from', 'skeleton_only']),
|
|
517
|
+
|
|
518
|
+
...READS_ITS_REFERENCE_ONLY,
|
|
519
|
+
|
|
520
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
521
|
+
const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
522
|
+
const text = readServiceFile(serviceRoot, row.path);
|
|
523
|
+
|
|
524
|
+
// An absent document is NOT this row's finding. The installation contract
|
|
525
|
+
// raises SETUP_DOCS for exactly that, and `G-SETUP` puts it on the table
|
|
526
|
+
// with its own fix; saying it twice would give one defect two owners
|
|
527
|
+
// (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
528
|
+
if (text === null) return [];
|
|
529
|
+
|
|
530
|
+
// The document is rendered for THIS repository, so it cannot be compared
|
|
531
|
+
// before the repository has said who it is (`serviceIdentity.js`).
|
|
532
|
+
const identity = readIdentity(serviceRoot);
|
|
533
|
+
if (identity.problem !== undefined) {
|
|
534
|
+
if (identity.undecided === null) return [];
|
|
535
|
+
return [{
|
|
536
|
+
where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: IDENTITY_FILE }),
|
|
537
|
+
what: `the sections of ${row.path} could not be compared — ${identity.undecided}`
|
|
538
|
+
}];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const reference = renderedReference({ row, serviceRoot, workspaceRoot });
|
|
542
|
+
const missing = headingsOf(reference).filter((heading) => !headingsOf(text).includes(heading));
|
|
543
|
+
if (missing.length === 0) return [];
|
|
544
|
+
|
|
545
|
+
return [{
|
|
546
|
+
where,
|
|
547
|
+
what: `has no section ${missing.map((heading) => `"${heading}"`).join(', ')} — `
|
|
548
|
+
+ `${referenceOwner(row.from)} declares it and an operator installs from these headings`
|
|
549
|
+
}];
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
/** The block a sync writes the platform's ignore entries into, so a reader can tell them apart. */
|
|
555
|
+
const IGNORE_BLOCK = 'oa-ignore v1';
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The entries a `.gitignore`-shaped file DECLARES: every non-blank line that is
|
|
559
|
+
* not a comment, trimmed. Order is not read, and neither are the comments — a
|
|
560
|
+
* repository groups its own lines however it likes.
|
|
561
|
+
*
|
|
562
|
+
* @param {string} text the file
|
|
563
|
+
* @returns {string[]} in the order they appear, duplicates kept out
|
|
564
|
+
*/
|
|
565
|
+
function ignoreEntries(text) {
|
|
566
|
+
const seen = [];
|
|
567
|
+
for (const line of text.split('\n')) {
|
|
568
|
+
const entry = line.trim();
|
|
569
|
+
if (entry === '' || entry.startsWith('#')) continue;
|
|
570
|
+
if (!seen.includes(entry)) seen.push(entry);
|
|
571
|
+
}
|
|
572
|
+
return seen;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The file with the generated block taken out, markers included. What is left is
|
|
577
|
+
* the repository's own — which is the half a sync never rewrites.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} text the file
|
|
580
|
+
* @param {string} name the block name
|
|
581
|
+
* @returns {string}
|
|
582
|
+
*/
|
|
583
|
+
function withoutBlock(text, name) {
|
|
584
|
+
const block = blockLines(text, name);
|
|
585
|
+
if (block.length === 0) return text;
|
|
586
|
+
const lines = text.split('\n');
|
|
587
|
+
const start = lines.indexOf(block[0]);
|
|
588
|
+
const kept = [...lines.slice(0, start), ...lines.slice(start + block.length)];
|
|
589
|
+
return kept.join('\n').replace(/\n{3,}$/, '\n');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** The block a sync writes the platform's build-context exclusions into. */
|
|
593
|
+
const DOCKERIGNORE_BLOCK = 'oa-dockerignore v1';
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The one exclusion `.gitignore` cannot declare, because git never has to say it
|
|
597
|
+
* about itself: `.git`. Without it a LOCAL production build copies the whole
|
|
598
|
+
* history into the image, and the image stops being what a clean checkout would
|
|
599
|
+
* have produced — which is the criterion the whole row is measured by.
|
|
600
|
+
*/
|
|
601
|
+
const DOCKER_IMPLICIT = Object.freeze(['.git']);
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The exclusions a `.dockerignore` must carry, DERIVED from the very declaration
|
|
605
|
+
* the `.gitignore` row reads. One list, two consumers
|
|
606
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern): a second
|
|
607
|
+
* hand-written list is the drift this row exists to prevent.
|
|
608
|
+
*
|
|
609
|
+
* The translation is not a copy, because the two formats do not mean the same
|
|
610
|
+
* thing by the same line. A `.gitignore` pattern with no slash matches at EVERY
|
|
611
|
+
* depth; a `.dockerignore` pattern is matched against the path from the context
|
|
612
|
+
* root, so `*.log` there catches `debug.log` and not `logs/debug.log`. Measured
|
|
613
|
+
* by BIZ-hello on 2026-09-11: a local production image of hello came to 6.6 GB,
|
|
614
|
+
* 5.2 GB of it `logs/`, and carried `config/env-active/*.env` with DB_PASSWORD,
|
|
615
|
+
* JWT_SECRET, the MinIO keys and RABBITMQ_URL. So every slash-less entry is
|
|
616
|
+
* emitted in both shapes, and an entry that already carries a slash is anchored
|
|
617
|
+
* in both formats alike and travels unchanged.
|
|
618
|
+
*
|
|
619
|
+
* A negation keeps its `!` in front of the pattern rather than inside it, and
|
|
620
|
+
* keeps its place in the order — in both formats a later line wins.
|
|
621
|
+
*
|
|
622
|
+
* What the derivation does NOT do is exclude anything `.gitignore` does not:
|
|
623
|
+
* `tests/` is the measured trap on that side, because Tier-1 of the boot reads
|
|
624
|
+
* `tests/cookbooks` and an image without it fails at phase 0.2.
|
|
625
|
+
*
|
|
626
|
+
* @param {string} gitignoreText the declaration both rows read
|
|
627
|
+
* @returns {string[]}
|
|
628
|
+
*/
|
|
629
|
+
function dockerignoreEntries(gitignoreText) {
|
|
630
|
+
const entries = [];
|
|
631
|
+
const add = (entry) => { if (!entries.includes(entry)) entries.push(entry); };
|
|
632
|
+
|
|
633
|
+
for (const declared of ignoreEntries(gitignoreText)) {
|
|
634
|
+
const negated = declared.startsWith('!');
|
|
635
|
+
const pattern = (negated ? declared.slice(1) : declared).replace(/\/+$/, '');
|
|
636
|
+
if (pattern === '') continue;
|
|
637
|
+
const mark = (value) => (negated ? `!${value}` : value);
|
|
638
|
+
|
|
639
|
+
add(mark(pattern));
|
|
640
|
+
if (!pattern.includes('/')) add(mark(`**/${pattern}`));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
for (const entry of DOCKER_IMPLICIT) add(entry);
|
|
644
|
+
return entries;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The `contains` class: a file whose platform ENTRIES must all be there, and
|
|
649
|
+
* whose every other line is the repository's own.
|
|
650
|
+
*
|
|
651
|
+
* The class exists because `.gitignore` is neither of the other two. It cannot
|
|
652
|
+
* be `identical` — a repository ignores its own scratch directories, and
|
|
653
|
+
* measured 2026-09-10 the eight services carry nine different files. It is not
|
|
654
|
+
* `generated` either — nothing renders it from a declaration. What must be true
|
|
655
|
+
* of it is a SET: every path the platform WRITES into a repository is a path git
|
|
656
|
+
* must not see. `ci/deployability.json` is the measured case — `oa-validate` and
|
|
657
|
+
* the pre-push hook of confirmation 006 both write it, and in four of the eight
|
|
658
|
+
* repositories every run left it untracked.
|
|
659
|
+
*
|
|
660
|
+
* A narrower pattern does not satisfy the entry it narrows, and that is not
|
|
661
|
+
* pedantry: `config/env-active/*.env` (property's line) leaves every non-`.env`
|
|
662
|
+
* file in a directory of live secrets committable.
|
|
663
|
+
*/
|
|
664
|
+
const templateEntries = Object.freeze({
|
|
665
|
+
scope: 'bearer',
|
|
666
|
+
requires: Object.freeze(['path']),
|
|
667
|
+
...READS_ITS_REFERENCE_ONLY,
|
|
668
|
+
|
|
669
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
670
|
+
const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
671
|
+
const reference = ignoreEntries(readReferencedFile({ from: row.from, workspaceRoot }));
|
|
672
|
+
const text = readServiceFile(serviceRoot, row.path);
|
|
673
|
+
|
|
674
|
+
// ONE finding, not one per entry: the repository has no such file, and the
|
|
675
|
+
// fix is the same single command whichever entry a reader looks at.
|
|
676
|
+
if (text === null) {
|
|
677
|
+
return [{
|
|
678
|
+
where,
|
|
679
|
+
what: `the repository carries no ${row.path} — every path this platform writes into it `
|
|
680
|
+
+ `(${reference.join(', ')}) is committable`
|
|
681
|
+
}];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const declared = ignoreEntries(text);
|
|
685
|
+
return reference
|
|
686
|
+
.filter((entry) => !declared.includes(entry))
|
|
687
|
+
.map((entry) => ({
|
|
688
|
+
where,
|
|
689
|
+
what: `does not ignore ${JSON.stringify(entry)}, which ${referenceOwner(row.from)} declares — `
|
|
690
|
+
+ 'a path this platform writes into the repository, so every run leaves it in git status'
|
|
691
|
+
}));
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* `F-DOCKERIGNORE` — what a LOCAL production build must not copy into the image.
|
|
697
|
+
*
|
|
698
|
+
* The same `contains` shape as `F-GITIGNORE`, over the same declaration, and for
|
|
699
|
+
* the same reason one step further along: `Dockerfile` line `COPY . .` takes
|
|
700
|
+
* everything `.dockerignore` does not exclude, and being ignored by git excludes
|
|
701
|
+
* nothing at all. A CI image is clean by accident — a fresh checkout has no
|
|
702
|
+
* ignored file to copy — so the defect is invisible exactly where it is built:
|
|
703
|
+
* on a developer's machine.
|
|
704
|
+
*/
|
|
705
|
+
const dockerignoreEntriesCheck = Object.freeze({
|
|
706
|
+
scope: 'bearer',
|
|
707
|
+
requires: Object.freeze(['path']),
|
|
708
|
+
...READS_ITS_REFERENCE_ONLY,
|
|
709
|
+
|
|
710
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
711
|
+
const where = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
712
|
+
const reference = dockerignoreEntries(readReferencedFile({ from: row.from, workspaceRoot }));
|
|
713
|
+
const text = readServiceFile(serviceRoot, row.path);
|
|
714
|
+
|
|
715
|
+
if (text === null) {
|
|
716
|
+
return [{
|
|
717
|
+
where,
|
|
718
|
+
what: `the repository carries no ${row.path} — a local production build copies the whole working `
|
|
719
|
+
+ 'tree into the image, live secrets in config/env-active/ and the log directory with them'
|
|
720
|
+
}];
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const declared = ignoreEntries(text);
|
|
724
|
+
return reference
|
|
725
|
+
.filter((entry) => !declared.includes(entry))
|
|
726
|
+
.map((entry) => ({
|
|
727
|
+
where,
|
|
728
|
+
what: `does not exclude ${JSON.stringify(entry)}, derived from ${referenceOwner(row.from)} — `
|
|
729
|
+
+ 'a local production build copies it into the image, where a clean checkout would not have'
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
module.exports = {
|
|
735
|
+
checks: [
|
|
736
|
+
{ name: 'template-block', check: templateBlock },
|
|
737
|
+
{ name: 'template-file', check: templateFile },
|
|
738
|
+
{ name: 'template-render', check: templateRender },
|
|
739
|
+
{ name: 'delimited-block-render', check: delimitedBlockRender },
|
|
740
|
+
{ name: 'compose-runner', check: composeRunner },
|
|
741
|
+
{ name: 'template-skeleton', check: templateSkeleton },
|
|
742
|
+
{ name: 'template-entries', check: templateEntries },
|
|
743
|
+
{ name: 'dockerignore-entries', check: dockerignoreEntriesCheck }
|
|
744
|
+
],
|
|
745
|
+
blockLines,
|
|
746
|
+
headingsOf,
|
|
747
|
+
skeletonOf,
|
|
748
|
+
renderedReference,
|
|
749
|
+
ignoreEntries,
|
|
750
|
+
dockerignoreEntries,
|
|
751
|
+
withoutBlock,
|
|
752
|
+
IGNORE_BLOCK,
|
|
753
|
+
DOCKERIGNORE_BLOCK
|
|
754
|
+
};
|