@onlineapps/conn-orch-validator 12.2.0 → 13.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 +597 -0
- package/README.md +126 -19
- package/manifests/biz-service.manifest.json +15 -2
- package/manifests/library.manifest.json +4 -4
- package/package.json +11 -3
- package/src/CookbookTestRunner.js +275 -105
- package/src/CookbookTestUtils.js +79 -68
- package/src/ServiceReadinessValidator.js +42 -52
- package/src/ValidationOrchestrator.js +65 -44
- package/src/cli/biz-ci-gate.js +2 -2
- package/src/cli/oa-sync-template.js +97 -47
- package/src/cli/oa-validate.js +44 -10
- package/src/helpers/README.md +6 -6
- package/src/helpers/createServiceReadinessTests.js +87 -33
- package/src/index.js +14 -5
- package/src/lint/scripts/lintScripts.js +11 -4
- package/src/manifest/checks/libraryContext.js +6 -3
- package/src/manifest/checks/libraryDocs.js +174 -4
- package/src/manifest/checks/libraryTests.js +200 -19
- package/src/manifest/checks/scriptHeaders.js +6 -13
- package/src/manifest/checks/serviceConfig.js +36 -16
- package/src/manifest/checks/serviceConnectors.js +180 -2
- package/src/manifest/checks/serviceDb.js +0 -3
- package/src/manifest/checks/serviceScripts.js +3 -20
- package/src/manifest/runManifest.js +90 -13
- package/src/manifest/workspaceRoot.js +133 -4
- package/src/mocks/MockMQClient.js +2 -2
- package/src/sync/docsRegion.js +2 -2
- package/src/sync/readmeFile.js +30 -0
- package/src/sync/readmeLocation.js +2 -12
- package/src/sync/readmePointer.js +10 -4
- package/src/sync/serviceTemplate.js +9 -11
- package/src/sync/sharedEnv.js +59 -3
- package/src/sync/uniformFiles.js +81 -8
- package/src/utils/bizCiGateContract.js +2 -2
- package/src/utils/connectorContract.js +54 -2
- package/src/utils/cookbookFormat.js +25 -115
- package/src/utils/dbAccountGrants.js +5 -3
- package/src/utils/deployContract.js +153 -28
- package/src/utils/envContract.js +2 -2
- package/src/utils/handlerRef.js +8 -10
- package/src/utils/integrationRun.js +1 -1
- package/src/utils/operationsDocumentRules.js +242 -0
- package/src/utils/operationsRules.js +157 -0
- package/src/utils/resolveHeaders.js +12 -1
- package/src/utils/setupDatabase.js +1 -1
- package/src/utils/stepFailure.js +3 -3
- package/src/utils/stepReferences.js +28 -87
- package/src/utils/throwawaySchema.js +1 -1
- package/src/utils/yamlTopLevel.js +105 -0
- package/src/validators/ServiceStructureValidator.js +67 -152
- package/templates/business-service/README.md +3 -2
- package/templates/business-service/config/env-templates/shared.env +1 -0
- package/templates/business-service/src/config/index.js +15 -0
- package/TESTING_STRATEGY.md +0 -92
- package/jest.config.js +0 -37
|
@@ -30,7 +30,10 @@
|
|
|
30
30
|
const fs = require('fs');
|
|
31
31
|
const path = require('path');
|
|
32
32
|
|
|
33
|
-
const {
|
|
33
|
+
const {
|
|
34
|
+
verifyConnectorDeclarations, CONNECTORS, CONFIG_PATH, CONTRACT_PATH
|
|
35
|
+
} = require('../../utils/connectorContract');
|
|
36
|
+
const { blockLines } = require('./serviceFiles');
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* The two declarations, or the reason they could not be read.
|
|
@@ -78,4 +81,179 @@ const connectorContract = Object.freeze({
|
|
|
78
81
|
}
|
|
79
82
|
});
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
/**
|
|
85
|
+
* `C-CI-CONNECTOR-ENV` — the job that runs the suite sets the names the declared
|
|
86
|
+
* connectors are opened with.
|
|
87
|
+
*
|
|
88
|
+
* `C-CONNECTORS` above asks whether the two DECLARATIONS agree. This row asks
|
|
89
|
+
* whether the one environment those declarations are exercised in every day
|
|
90
|
+
* carries what they need. Measured 2026-09-18: three emailer tests failed in CI
|
|
91
|
+
* on `[RuntimeConfig] Missing environment variable - MINIO_USE_SSL`, a name no
|
|
92
|
+
* line of the service reads — `@onlineapps/conn-base-storage` resolves it,
|
|
93
|
+
* `required: true`, with no default. So `ci:gate:contract` could not see it
|
|
94
|
+
* (`utils/envContract.js` puts `node_modules` deliberately out of scope) and
|
|
95
|
+
* `ci:gate:env` emits six `OA_CI_*` names and measures nothing.
|
|
96
|
+
*
|
|
97
|
+
* Locally the same suite passes: the one-shot runner loads
|
|
98
|
+
* `config/env-templates/shared.env` through `env_file`, and that file — one
|
|
99
|
+
* generated copy of `api/config/shared-env.json`, held by `G-SHARED-ENV` —
|
|
100
|
+
* carries the key. The CI job's `variables:` is the SECOND delivery of the same
|
|
101
|
+
* set, copied by hand in eight repositories, and nothing compared it with the
|
|
102
|
+
* first. Owner decision `biz-service-manifest` 013, variant A.
|
|
103
|
+
*
|
|
104
|
+
* ## What it reads, and what it leaves alone
|
|
105
|
+
*
|
|
106
|
+
* The `variables:` of ONE job, named by the row, and only OUTSIDE the `oa-ci v1`
|
|
107
|
+
* block: that block is the platform's, `G-CI` renders it from the template byte
|
|
108
|
+
* for byte, and a value a service wrote there would not survive the next sync.
|
|
109
|
+
* The same boundary `D-DB-CI-ACCOUNT` reads, for the same reason, and reading a
|
|
110
|
+
* REGION rather than the whole file is what keeps both clear of the defect that
|
|
111
|
+
* had the whole-file row over `.gitlab-ci.yml` withdrawn after three days.
|
|
112
|
+
*
|
|
113
|
+
* It asks WHICH names that job sets and deliberately not WHETHER a repository
|
|
114
|
+
* runs one: "add a test job" is another fix, and one row with two fixes is two
|
|
115
|
+
* mechanisms under one name (`automation-gates.md` §1.2). A repository with no
|
|
116
|
+
* `.gitlab-ci.yml` at all is `G-CI`'s finding. Both silences are stated here
|
|
117
|
+
* because a boundary nobody states is read as coverage (`automation-gates.md` §5).
|
|
118
|
+
*
|
|
119
|
+
* It cannot see GitLab's project-level variables, which live outside the
|
|
120
|
+
* repository by design — so the names it measures are the ones with a platform
|
|
121
|
+
* value in `api/config/shared-env.json`, which belong in the file, never a
|
|
122
|
+
* secret.
|
|
123
|
+
*
|
|
124
|
+
* The set of names is NOT in the row: it is `CONNECTORS[<connector>].env`
|
|
125
|
+
* (`utils/connectorContract.js`), measured against the library that resolves
|
|
126
|
+
* them. A list copied into the manifest would be a second owner of one fact
|
|
127
|
+
* (`manifestShape.js` § OWNED_STRING_ARRAY_KEYS).
|
|
128
|
+
*
|
|
129
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/** `KEY: value` as a YAML mapping line reads it, with the indent that places it. */
|
|
133
|
+
const MAPPING = /^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/;
|
|
134
|
+
const COMMENT = /^\s*#/;
|
|
135
|
+
const BLANK = /^\s*$/;
|
|
136
|
+
|
|
137
|
+
/** The indent a line carries, or null when the line declares nothing. */
|
|
138
|
+
function indentOf(line) {
|
|
139
|
+
if (COMMENT.test(line) || BLANK.test(line)) return null;
|
|
140
|
+
return line.length - line.trimStart().length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The names the job's OWN `variables:` block sets.
|
|
145
|
+
*
|
|
146
|
+
* Indentation is the whole of the reading, because the measured file carries a
|
|
147
|
+
* SECOND `variables:` two levels deeper: the MinIO sidecar under `services:`
|
|
148
|
+
* sets `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` for its own container. A scan
|
|
149
|
+
* collecting every `KEY:` under any `variables:` would read that container's
|
|
150
|
+
* environment as the job's and call a job green that sets nothing.
|
|
151
|
+
*
|
|
152
|
+
* @param {string[]} lines the file, split
|
|
153
|
+
* @param {number} jobAt index of the job's own key line
|
|
154
|
+
* @param {(index: number) => boolean} inBlock whether that line belongs to the generated block
|
|
155
|
+
* @returns {{names: Set<string>, at: number}} the names, and the line to point a finding at
|
|
156
|
+
*/
|
|
157
|
+
function jobVariables(lines, jobAt, inBlock) {
|
|
158
|
+
const names = new Set();
|
|
159
|
+
const jobIndent = indentOf(lines[jobAt]);
|
|
160
|
+
|
|
161
|
+
let bodyIndent = null;
|
|
162
|
+
let variablesAt = -1;
|
|
163
|
+
|
|
164
|
+
for (let index = jobAt + 1; index < lines.length; index += 1) {
|
|
165
|
+
const indent = indentOf(lines[index]);
|
|
166
|
+
if (indent === null) continue;
|
|
167
|
+
if (indent <= jobIndent) break; // the next job — this one's body has ended
|
|
168
|
+
if (bodyIndent === null) bodyIndent = indent;
|
|
169
|
+
if (indent !== bodyIndent) continue; // deeper: a sidecar, a script, a rules list
|
|
170
|
+
|
|
171
|
+
const mapping = lines[index].match(MAPPING);
|
|
172
|
+
if (mapping !== null && mapping[2] === 'variables') {
|
|
173
|
+
variablesAt = index;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (variablesAt === -1) return { names, at: jobAt };
|
|
179
|
+
|
|
180
|
+
for (let index = variablesAt + 1; index < lines.length; index += 1) {
|
|
181
|
+
if (inBlock(index)) break;
|
|
182
|
+
const indent = indentOf(lines[index]);
|
|
183
|
+
if (indent === null) continue;
|
|
184
|
+
if (indent <= bodyIndent) break;
|
|
185
|
+
|
|
186
|
+
const mapping = lines[index].match(MAPPING);
|
|
187
|
+
if (mapping !== null) names.add(mapping[2]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { names, at: variablesAt };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The generated copy of the platform's shared key set, as it sits in the repository. */
|
|
194
|
+
const SHARED_TEMPLATE = 'config/env-templates/shared.env';
|
|
195
|
+
|
|
196
|
+
/** Does the platform own a value for this name? Read from the generated copy, never printed. */
|
|
197
|
+
function platformOwns(serviceRoot, key) {
|
|
198
|
+
const file = path.join(serviceRoot, ...SHARED_TEMPLATE.split('/'));
|
|
199
|
+
if (!fs.existsSync(file)) return false;
|
|
200
|
+
return fs.readFileSync(file, 'utf8').split('\n').some((line) => line.startsWith(`${key}=`));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const ciConnectorEnv = Object.freeze({
|
|
204
|
+
scope: 'service',
|
|
205
|
+
requires: Object.freeze(['path', 'block', 'job']),
|
|
206
|
+
|
|
207
|
+
run({ row, serviceRoot }) {
|
|
208
|
+
const declarations = readDeclarations(serviceRoot);
|
|
209
|
+
if (declarations === null) return [];
|
|
210
|
+
|
|
211
|
+
const required = Object.entries(CONNECTORS)
|
|
212
|
+
.filter(([name]) => declarations.requiredConnectors[name] === true);
|
|
213
|
+
if (required.length === 0) return [];
|
|
214
|
+
|
|
215
|
+
const file = path.join(serviceRoot, ...row.path.split('/'));
|
|
216
|
+
// Whether the file is there at all is `G-CI`'s finding, with its own fix.
|
|
217
|
+
if (!fs.existsSync(file)) return [];
|
|
218
|
+
|
|
219
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
220
|
+
const lines = text.split('\n');
|
|
221
|
+
const block = blockLines(text, row.block);
|
|
222
|
+
const blockStart = block.length === 0 ? -1 : lines.indexOf(block[0]);
|
|
223
|
+
const inBlock = (index) => blockStart !== -1 && index >= blockStart && index < blockStart + block.length;
|
|
224
|
+
|
|
225
|
+
const jobKey = new RegExp(`^(\\s*)${row.job.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*(#.*)?$`);
|
|
226
|
+
const jobAt = lines.findIndex((line, index) => !inBlock(index) && jobKey.test(line));
|
|
227
|
+
// Which names that job sets is this row's question; whether the repository
|
|
228
|
+
// runs one at all is another fix and therefore another row.
|
|
229
|
+
if (jobAt === -1) return [];
|
|
230
|
+
|
|
231
|
+
const { names, at } = jobVariables(lines, jobAt, inBlock);
|
|
232
|
+
|
|
233
|
+
const findings = [];
|
|
234
|
+
for (const [connector, spec] of required) {
|
|
235
|
+
for (const key of spec.env) {
|
|
236
|
+
if (names.has(key)) continue;
|
|
237
|
+
findings.push({
|
|
238
|
+
where: `${row.path}:${at + 1}`,
|
|
239
|
+
what: `requiredConnectors.${connector} is true and the ${row.job} job declares no ${key} — the `
|
|
240
|
+
+ "connector's library resolves that name with no default, so the suite meets it inside the "
|
|
241
|
+
+ 'connector, where the cause is harder to see'
|
|
242
|
+
+ (platformOwns(serviceRoot, key)
|
|
243
|
+
? ` (the value is the one ${SHARED_TEMPLATE} declares for this key)`
|
|
244
|
+
: ` (${SHARED_TEMPLATE} declares no value for this key, so the job declares the one its own `
|
|
245
|
+
+ 'sidecar uses)')
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return findings;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
checks: [
|
|
256
|
+
{ name: 'connector-contract', check: connectorContract },
|
|
257
|
+
{ name: 'ci-connector-env', check: ciConnectorEnv }
|
|
258
|
+
]
|
|
259
|
+
};
|
|
@@ -43,9 +43,6 @@ const { blockLines } = require('./serviceFiles');
|
|
|
43
43
|
const CONTRACT_PATH = 'config/service/integration-contract.json';
|
|
44
44
|
const MIGRATIONS_DIR = 'migrations';
|
|
45
45
|
|
|
46
|
-
/** The document an operator runs the SQL package from; the contract §3 requires it. */
|
|
47
|
-
const MIGRATIONS_README = 'migrations/README.md';
|
|
48
|
-
|
|
49
46
|
/** Where a service keeps its env templates, and the one that is not its own. */
|
|
50
47
|
const ENV_TEMPLATE_DIR = 'config/env-templates';
|
|
51
48
|
const SHARED_ENV = 'shared.env';
|
|
@@ -86,31 +86,14 @@ function binSpelling(body, bin) {
|
|
|
86
86
|
return [bin, ...tokens.slice(2)].join(' ');
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/**
|
|
90
|
-
* Does this repository have an integration suite to run?
|
|
91
|
-
*
|
|
92
|
-
* The question decides whether `test:integration:container` is owed at all: a
|
|
93
|
-
* script pointing at an absent suite is the dead declaration
|
|
94
|
-
* `change-discipline.md` § "Removing something removes its declaration"
|
|
95
|
-
* forbids, and jest exits 1 on a path that matches no test — so requiring it
|
|
96
|
-
* unconditionally would ship a red script to every new service.
|
|
97
|
-
*
|
|
98
|
-
* @param {string} serviceRoot repository root
|
|
99
|
-
* @param {string} relative the suite directory the row names
|
|
100
|
-
* @returns {boolean}
|
|
101
|
-
*/
|
|
102
|
-
function hasSuite(serviceRoot, relative) {
|
|
103
|
-
const dir = path.join(serviceRoot, ...relative.split('/'));
|
|
104
|
-
return fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length > 0;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
89
|
const scriptBody = Object.freeze({
|
|
108
90
|
scope: 'service',
|
|
109
91
|
requires: Object.freeze(['name', 'body']),
|
|
110
92
|
|
|
111
93
|
run({ row, serviceRoot }) {
|
|
112
|
-
|
|
113
|
-
|
|
94
|
+
// `only_with` sem nepatří a od d.838 tu není: podmíněnost je vlastnost
|
|
95
|
+
// ŘÁDKU a vyhodnocuje ji engine pro všechny druhy řádků
|
|
96
|
+
// (`manifest/runManifest.js`, `rowApplies`).
|
|
114
97
|
const { scripts, present } = readScripts(serviceRoot);
|
|
115
98
|
if (!present) return [{ where: 'package.json', what: 'absent — nothing declares this directory a service' }];
|
|
116
99
|
|
|
@@ -19,7 +19,9 @@ 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 {
|
|
22
|
+
const {
|
|
23
|
+
resolveWorkspacePath, canonicalRoot, describeWorkspaceFix, workspaceRelativeOf
|
|
24
|
+
} = require('./workspaceRoot');
|
|
23
25
|
const { CHECK_REGISTRY } = require('./checks');
|
|
24
26
|
|
|
25
27
|
/** The three scopes, by the name the runner branches on. */
|
|
@@ -100,19 +102,51 @@ const SCOPE_BEARER = 'bearer';
|
|
|
100
102
|
* for eight services nobody looked at.
|
|
101
103
|
*/
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The shape a target version must have: `X.Y.Z` with an optional prerelease —
|
|
107
|
+
* what `npm publish` accepts as a version and what a CHANGELOG heading names.
|
|
108
|
+
*/
|
|
109
|
+
const TARGET_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Is this a version a run may be judged AS? The one owner of the question: the
|
|
113
|
+
* engine asks it at entry, and the CLI asks it while parsing `--as-version`, so a
|
|
114
|
+
* malformed value is refused before anything runs and with the same rule
|
|
115
|
+
* (`change-discipline.md` § One rail per concern).
|
|
116
|
+
*
|
|
117
|
+
* @param {*} value
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
function isTargetVersion(value) {
|
|
121
|
+
return typeof value === 'string' && TARGET_VERSION.test(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
/**
|
|
104
125
|
* @param {object} params
|
|
105
126
|
* @param {object} params.manifest parsed manifest (loadManifest)
|
|
106
127
|
* @param {string|null} [params.serviceRoot] the repository being checked; null = workspace mode
|
|
107
128
|
* @param {string|null} params.workspaceRoot resolved workspace root, or null
|
|
108
129
|
* @param {object} [params.checkRegistry] injected registry; defaults to the packaged one
|
|
130
|
+
* @param {string|null} [params.asVersion] the version being PUBLISHED, when it is not yet
|
|
131
|
+
* in package.json — a publish dry run (`scripts/ci/lib/prepublishGate.js`, `--as-version`).
|
|
132
|
+
* Handed to every check as `asVersion`; a check that judges a version reads it
|
|
133
|
+
* (`checks/libraryDocs.js` § libraryChangelogEntry), the others ignore it. null = the
|
|
134
|
+
* run judges the tree as it stands.
|
|
109
135
|
* @returns {{ uniform: string, mode: string, serviceRoot: string|null, workspaceRoot: string|null,
|
|
110
136
|
* findings: Array<object>, notRun: Array<{id: string, reason: string}>, ok: boolean,
|
|
111
137
|
* blockingSeverities: string[], verdict: {blocked: string, clear: string} }}
|
|
112
138
|
*/
|
|
113
|
-
function runManifest({
|
|
139
|
+
function runManifest({
|
|
140
|
+
manifest, serviceRoot = null, workspaceRoot = null, checkRegistry = CHECK_REGISTRY, asVersion = null
|
|
141
|
+
}) {
|
|
114
142
|
const mode = serviceRoot === null || serviceRoot === undefined ? 'workspace' : 'service';
|
|
115
143
|
|
|
144
|
+
if (asVersion !== null && !isTargetVersion(asVersion)) {
|
|
145
|
+
throw new Error(`[Manifest] Target version is not a version - asVersion ${JSON.stringify(asVersion)} `
|
|
146
|
+
+ 'is not X.Y.Z or X.Y.Z-<prerelease>. Fix: pass the version being published, e.g. "1.2.4", '
|
|
147
|
+
+ 'or null to judge package.json as it stands.');
|
|
148
|
+
}
|
|
149
|
+
|
|
116
150
|
if (mode === 'workspace' && workspaceRoot === null) {
|
|
117
151
|
throw new Error('[Manifest] A run without a service root needs a workspace root - runManifest() got '
|
|
118
152
|
+ 'neither, so it would check nothing. Fix: pass serviceRoot to check one repository, '
|
|
@@ -226,7 +260,7 @@ function runManifest({ manifest, serviceRoot = null, workspaceRoot = null, check
|
|
|
226
260
|
}
|
|
227
261
|
|
|
228
262
|
const raised = rowFindings({
|
|
229
|
-
check, row, block, mode, root, workspaceRoot: workspace, bearers, underServiceRoot
|
|
263
|
+
check, row, block, mode, root, workspaceRoot: workspace, bearers, underServiceRoot, asVersion
|
|
230
264
|
});
|
|
231
265
|
|
|
232
266
|
// A check may also report that it could not DECIDE the row — not because
|
|
@@ -400,6 +434,40 @@ function readAnswer(returned) {
|
|
|
400
434
|
};
|
|
401
435
|
}
|
|
402
436
|
|
|
437
|
+
/** A row that answers nothing: neither a finding nor a reason it could not look. */
|
|
438
|
+
const SILENT = Object.freeze({ findings: Object.freeze([]), notRun: null });
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Does this row apply to this bearer at all?
|
|
442
|
+
*
|
|
443
|
+
* `only_with: <relative path>` says the row is owed only where that path carries
|
|
444
|
+
* something — the suite `S-INT-C` names, the config file a future row will name.
|
|
445
|
+
* A directory that exists and is EMPTY does not count: an empty `tests/` is not
|
|
446
|
+
* a suite, and requiring the script that runs it would ship a red command to
|
|
447
|
+
* every new service (the reason the condition exists at all, d.482).
|
|
448
|
+
*
|
|
449
|
+
* The question is asked HERE, for every kind of row, because it is a property of
|
|
450
|
+
* the ROW, not of one check: until d.838 only `scriptBody` consulted it, so the
|
|
451
|
+
* same word in a config row would have stood in the manifest and decided
|
|
452
|
+
* nothing — a rule visible in the declaration and absent from the run
|
|
453
|
+
* (`automation-gates.md` §5). One place, every scope
|
|
454
|
+
* (`change-discipline.md` § One rail per concern).
|
|
455
|
+
*
|
|
456
|
+
* @param {object} row the manifest row
|
|
457
|
+
* @param {string} bearerRoot the root the row is measured against
|
|
458
|
+
* @returns {boolean}
|
|
459
|
+
*/
|
|
460
|
+
function rowApplies(row, bearerRoot) {
|
|
461
|
+
if (row.only_with === undefined) return true;
|
|
462
|
+
|
|
463
|
+
const target = path.join(bearerRoot, ...row.only_with.split('/'));
|
|
464
|
+
if (!fs.existsSync(target)) return false;
|
|
465
|
+
|
|
466
|
+
return fs.statSync(target).isDirectory()
|
|
467
|
+
? fs.readdirSync(target).length > 0
|
|
468
|
+
: true;
|
|
469
|
+
}
|
|
470
|
+
|
|
403
471
|
/**
|
|
404
472
|
* Run one row's check wherever its scope says it belongs, and return what it
|
|
405
473
|
* raised. The three branches are the three scopes and nothing else — a caller
|
|
@@ -409,9 +477,10 @@ function readAnswer(returned) {
|
|
|
409
477
|
* @param {object} params the check, its row and block, and the run's roots
|
|
410
478
|
* @returns {{findings: Array<{where: string, what: string}>, notRun: string|null}}
|
|
411
479
|
*/
|
|
412
|
-
function rowFindings({ check, row, block, mode, root, workspaceRoot, bearers, underServiceRoot }) {
|
|
480
|
+
function rowFindings({ check, row, block, mode, root, workspaceRoot, bearers, underServiceRoot, asVersion }) {
|
|
413
481
|
if (check.scope === SCOPE_WORKSPACE) {
|
|
414
|
-
|
|
482
|
+
if (!rowApplies(row, root)) return SILENT;
|
|
483
|
+
const answer = readAnswer(check.run({ row, block, serviceRoot: root, workspaceRoot, asVersion }));
|
|
415
484
|
return { ...answer, findings: answer.findings.filter(underServiceRoot) };
|
|
416
485
|
}
|
|
417
486
|
|
|
@@ -419,9 +488,11 @@ function rowFindings({ check, row, block, mode, root, workspaceRoot, bearers, un
|
|
|
419
488
|
// One row, several bearers: the findings add up, and the row is NOT RUN as
|
|
420
489
|
// soon as ONE bearer could not be decided — the run did not reach every
|
|
421
490
|
// bearer of it, and saying otherwise is the silence this reports.
|
|
422
|
-
const answers = bearers()
|
|
423
|
-
row,
|
|
424
|
-
|
|
491
|
+
const answers = bearers()
|
|
492
|
+
.filter((bearer) => rowApplies(row, bearer.dir))
|
|
493
|
+
.map((bearer) => readAnswer(check.run({
|
|
494
|
+
row, block, serviceRoot: bearer.dir, workspaceRoot, asVersion
|
|
495
|
+
})));
|
|
425
496
|
const undecided = answers.find((answer) => answer.notRun !== null);
|
|
426
497
|
return {
|
|
427
498
|
findings: answers.flatMap((answer) => answer.findings),
|
|
@@ -429,7 +500,9 @@ function rowFindings({ check, row, block, mode, root, workspaceRoot, bearers, un
|
|
|
429
500
|
};
|
|
430
501
|
}
|
|
431
502
|
|
|
432
|
-
|
|
503
|
+
if (!rowApplies(row, root)) return SILENT;
|
|
504
|
+
|
|
505
|
+
return readAnswer(check.run({ row, block, serviceRoot: root, workspaceRoot, asVersion }));
|
|
433
506
|
}
|
|
434
507
|
|
|
435
508
|
/**
|
|
@@ -490,11 +563,15 @@ function buildScopeFilter({ mode, root, workspaceRoot }) {
|
|
|
490
563
|
// NOT RUN, not filtered.
|
|
491
564
|
if (mode === 'workspace' || workspaceRoot === null) return () => true;
|
|
492
565
|
|
|
493
|
-
|
|
494
|
-
|
|
566
|
+
// The SAME spelling `whereOf` gave the findings this filters, from the one
|
|
567
|
+
// owner of it (`workspaceRoot.js` § workspaceRelativeOf). Subtracting the
|
|
568
|
+
// workspace root here and writing `api/…` there would make a service-mode run
|
|
569
|
+
// in a checkout not named `api` — which every CI checkout is — drop every
|
|
570
|
+
// workspace finding about the service it was asked about.
|
|
571
|
+
const prefix = workspaceRelativeOf(workspaceRoot, root);
|
|
572
|
+
if (prefix === '') return () => true;
|
|
495
573
|
|
|
496
|
-
const prefix = relative.split(path.sep).join('/');
|
|
497
574
|
return (finding) => finding.where === prefix || finding.where.startsWith(`${prefix}/`);
|
|
498
575
|
}
|
|
499
576
|
|
|
500
|
-
module.exports = { runManifest, incompleteRows };
|
|
577
|
+
module.exports = { runManifest, incompleteRows, isTargetVersion };
|
|
@@ -70,7 +70,13 @@
|
|
|
70
70
|
const fs = require('fs');
|
|
71
71
|
const path = require('path');
|
|
72
72
|
|
|
73
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* The prefix every manifest path uses for the api checkout, and the one owner of
|
|
75
|
+
* that convention for this package: the lint that resolves an `api/…` citation,
|
|
76
|
+
* the row that reports such a citation NOT RUN and the README pointer all read
|
|
77
|
+
* this export rather than writing the word again
|
|
78
|
+
* (`api/.claude/rules/single-source-of-truth.md`).
|
|
79
|
+
*/
|
|
74
80
|
const API_PREFIX = 'api';
|
|
75
81
|
|
|
76
82
|
/** What identifies an api checkout, whatever the checkout is named. */
|
|
@@ -202,8 +208,117 @@ function resolveWorkspacePath(workspaceRoot, relative) {
|
|
|
202
208
|
}
|
|
203
209
|
|
|
204
210
|
/**
|
|
205
|
-
* The
|
|
206
|
-
*
|
|
211
|
+
* The inverse of `resolveWorkspacePath`: an absolute path written back in the
|
|
212
|
+
* form the manifest, the rows and their findings write it — `api/…` for
|
|
213
|
+
* anything inside the api checkout, whatever that checkout is NAMED, and
|
|
214
|
+
* workspace-relative for everything else.
|
|
215
|
+
*
|
|
216
|
+
* It is the same rule `scripts/ci/lint-biz-docs.mjs` § workspaceRelativeOf
|
|
217
|
+
* already applies to its own findings, and it exists here for the same measured
|
|
218
|
+
* reason. Subtracting the workspace root yields the checkout's DIRECTORY NAME,
|
|
219
|
+
* and GitLab CI checks this repository out under the project name: measured
|
|
220
|
+
* 2026-09-18 in the CI image over a tree named `infra-mono` (job
|
|
221
|
+
* `test-unit-aggregate` 16581767451), the documentation row reported
|
|
222
|
+
* `infra-mono/shared/connector/conn-orch-validator/…/deployment.md:3` — a path
|
|
223
|
+
* that exists under no name the reader can paste anywhere
|
|
224
|
+
* (`.claude/rules/automation-gates.md` §1 requirement 4), from a run whose
|
|
225
|
+
* finding was real.
|
|
226
|
+
*
|
|
227
|
+
* One owner, two readers, and they must agree: `checks/libraryContext.js`
|
|
228
|
+
* § whereOf writes the `where` of a finding, and `runManifest.js`
|
|
229
|
+
* § buildScopeFilter decides which workspace findings a service-mode run keeps
|
|
230
|
+
* by comparing that same string against this same path. Two spellings of one
|
|
231
|
+
* path would make a service-mode run in CI drop every workspace finding about
|
|
232
|
+
* itself (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
233
|
+
*
|
|
234
|
+
* @param {string} workspaceRoot
|
|
235
|
+
* @param {string} absolute a path inside the workspace, or inside its api checkout
|
|
236
|
+
* @returns {string} a `/`-separated workspace-relative path, `''` for the
|
|
237
|
+
* workspace root itself and a `..`-leading path for anything outside it
|
|
238
|
+
*/
|
|
239
|
+
function workspaceRelativeOf(workspaceRoot, absolute) {
|
|
240
|
+
if (typeof workspaceRoot !== 'string' || workspaceRoot.length === 0) {
|
|
241
|
+
throw new Error('[ManifestWorkspace] Workspace root is required - workspaceRelativeOf() got '
|
|
242
|
+
+ `${JSON.stringify(workspaceRoot)}. Fix: resolve it first (resolveWorkspaceRoot).`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const target = path.resolve(absolute);
|
|
246
|
+
const apiRoot = apiCheckoutOf(workspaceRoot);
|
|
247
|
+
if (apiRoot !== null) {
|
|
248
|
+
// Both spellings, or a run below the system temp answers about the symlink
|
|
249
|
+
// instead of the directory (§ canonicalRoot).
|
|
250
|
+
const inside = path.relative(canonicalRoot(apiRoot), target);
|
|
251
|
+
if (inside === '') return API_PREFIX;
|
|
252
|
+
if (!inside.startsWith('..') && !path.isAbsolute(inside)) {
|
|
253
|
+
return [API_PREFIX, ...inside.split(path.sep)].join('/');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return path.relative(path.resolve(workspaceRoot), target).split(path.sep).join('/');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The checkout a tree lies in: the nearest directory, the tree itself included,
|
|
261
|
+
* that carries `API_MARKER` — or `null` when none above it does. It is read by
|
|
262
|
+
* `requirePackageInApiCheckout` alone, to name the checkout whose validator
|
|
263
|
+
* measures a package this one does not speak for.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} tree
|
|
266
|
+
* @returns {string|null}
|
|
267
|
+
*/
|
|
268
|
+
function checkoutAbove(tree) {
|
|
269
|
+
let current = path.resolve(tree);
|
|
270
|
+
for (;;) {
|
|
271
|
+
if (carriesMarker(current)) return current;
|
|
272
|
+
const parent = path.dirname(current);
|
|
273
|
+
if (parent === current) return null;
|
|
274
|
+
current = parent;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* A library run measures ONE package against the api checkout of the workspace
|
|
280
|
+
* it resolved — the checkout this copy speaks for (§ apiCheckoutOf). Every row
|
|
281
|
+
* that renders a link or writes a `where` does so against that checkout, so a
|
|
282
|
+
* package lying outside it is judged against the wrong tree. Measured 2026-09-26
|
|
283
|
+
* (d.938): the validator of `api/` over the same package in a sibling worktree
|
|
284
|
+
* reported `L-README-REGION` with a fix that would have rewritten the README to
|
|
285
|
+
* point into the other checkout, while that checkout's own validator found
|
|
286
|
+
* nothing. The run stops before any row and names the command that measures the
|
|
287
|
+
* package from the checkout carrying it (d.940).
|
|
288
|
+
*
|
|
289
|
+
* @param {string} workspaceRoot the resolved workspace root of the run
|
|
290
|
+
* @param {string} packageRoot the canonical package directory the run is about
|
|
291
|
+
* @returns {void}
|
|
292
|
+
* @throws {Error} when the package lies outside that workspace's api checkout
|
|
293
|
+
*/
|
|
294
|
+
function requirePackageInApiCheckout(workspaceRoot, packageRoot) {
|
|
295
|
+
const apiRoot = apiCheckoutOf(workspaceRoot);
|
|
296
|
+
if (apiRoot === null) {
|
|
297
|
+
throw new Error(`[ManifestWorkspace] Workspace root holds no api checkout - ${path.resolve(workspaceRoot)} `
|
|
298
|
+
+ `carries no directory with ${API_MARKER}, so no checkout can measure ${packageRoot}. `
|
|
299
|
+
+ 'Fix: pass --workspace <root> holding the api checkout (under any name).');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const speaksFor = canonicalRoot(apiRoot);
|
|
303
|
+
const inside = path.relative(speaksFor, canonicalRoot(packageRoot));
|
|
304
|
+
const outside = inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside);
|
|
305
|
+
if (!outside) return;
|
|
306
|
+
|
|
307
|
+
const carrier = checkoutAbove(packageRoot);
|
|
308
|
+
if (carrier === null) {
|
|
309
|
+
throw new Error(`[ManifestWorkspace] ${packageRoot} lies outside the checkout this validator speaks for `
|
|
310
|
+
+ `(${speaksFor}) - and no directory above it carries ${API_MARKER}, so no checkout carries it. `
|
|
311
|
+
+ 'Fix: run oa-validate from the checkout that carries the package sources.');
|
|
312
|
+
}
|
|
313
|
+
const cli = path.join(carrier, ...PACKAGE_LOCATION, path.basename(PACKAGE_ROOT), 'src', 'cli', 'oa-validate.js');
|
|
314
|
+
throw new Error(`[ManifestWorkspace] ${packageRoot} lies outside the checkout this validator speaks for `
|
|
315
|
+
+ `(${speaksFor}) - Fix: run oa-validate from the checkout that carries the package: `
|
|
316
|
+
+ `node ${cli} --library ${packageRoot}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The workspace a given tree belongs to: the nearest ancestor that carries an
|
|
321
|
+
* api checkout. This answers a DIFFERENT question
|
|
207
322
|
* from the one above — not "which SSOT does this package speak for" but "which
|
|
208
323
|
* workspace is this tree part of" — and it is the question a run that writes
|
|
209
324
|
* INTO a tree has to ask (`oa-sync-template --target <service>`, and the
|
|
@@ -211,13 +326,24 @@ function resolveWorkspacePath(workspaceRoot, relative) {
|
|
|
211
326
|
* constructed with). A caller states which question it is asking by passing
|
|
212
327
|
* `startDir` or leaving it out; neither is a default of the other.
|
|
213
328
|
*
|
|
329
|
+
* WHAT makes an ancestor the workspace is `apiCheckoutOf`, not the literal
|
|
330
|
+
* string `api/config/services.json`, and the difference is the whole of the
|
|
331
|
+
* header above: the checkout is named after the GitLab project, so joining the
|
|
332
|
+
* literal onto every ancestor finds nothing in CI. Measured 2026-09-18 over a
|
|
333
|
+
* checkout named `infra-mono` (`tests/scripts/readme-uniform-pointer.bats:241`):
|
|
334
|
+
* `oa-sync-template readme-uniform` got `null` for a tree lying inside the very
|
|
335
|
+
* workspace it was run in, and labelled the file it wrote with an absolute path
|
|
336
|
+
* — a name that means something different on every machine. One rule for "this
|
|
337
|
+
* directory carries an api checkout", read by the three questions below and by
|
|
338
|
+
* this one (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
339
|
+
*
|
|
214
340
|
* @param {string} startDir
|
|
215
341
|
* @returns {string|null}
|
|
216
342
|
*/
|
|
217
343
|
function workspaceAbove(startDir) {
|
|
218
344
|
let current = path.resolve(startDir);
|
|
219
345
|
for (;;) {
|
|
220
|
-
if (
|
|
346
|
+
if (apiCheckoutOf(current) !== null) return current;
|
|
221
347
|
const parent = path.dirname(current);
|
|
222
348
|
if (parent === current) return null;
|
|
223
349
|
current = parent;
|
|
@@ -318,10 +444,13 @@ module.exports = {
|
|
|
318
444
|
canonicalRoot,
|
|
319
445
|
workspaceAbove,
|
|
320
446
|
resolveWorkspacePath,
|
|
447
|
+
workspaceRelativeOf,
|
|
321
448
|
describeWorkspaceFix,
|
|
322
449
|
apiCheckoutOf,
|
|
450
|
+
requirePackageInApiCheckout,
|
|
323
451
|
API_CHECKOUT_ROOT,
|
|
324
452
|
API_MARKER,
|
|
453
|
+
API_PREFIX,
|
|
325
454
|
AS_THE_REASON_NAMES,
|
|
326
455
|
PACKAGE_ROOT,
|
|
327
456
|
WORKSPACE_MARKER
|
|
@@ -131,7 +131,7 @@ class MockMQClient {
|
|
|
131
131
|
* Acknowledge message.
|
|
132
132
|
*
|
|
133
133
|
* Signature and idempotence per `BaseClient.ack(msg)` → transport
|
|
134
|
-
* `rabbitmqClient.js:
|
|
134
|
+
* `RabbitMQClient.ack` (`transports/rabbitmqClient.js`): a delivery already settled (marked
|
|
135
135
|
* `_mqProcessed`) is silently skipped rather than settled twice.
|
|
136
136
|
*
|
|
137
137
|
* @param {Object} message - Broker message object
|
|
@@ -156,7 +156,7 @@ class MockMQClient {
|
|
|
156
156
|
* Negative-acknowledge a message.
|
|
157
157
|
*
|
|
158
158
|
* Signature and semantics per `BaseClient.nack(msg, options)` → transport
|
|
159
|
-
* `rabbitmqClient.js:
|
|
159
|
+
* `RabbitMQClient.nack` (`transports/rabbitmqClient.js`):
|
|
160
160
|
*
|
|
161
161
|
* const requeue = options.requeue !== undefined ? options.requeue : true;
|
|
162
162
|
*
|
package/src/sync/docsRegion.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* The generated regions of the documentation tree.
|
|
5
5
|
*
|
|
6
6
|
* Confirmation `biz-service-manifest` 002 §16.2: the list of required files,
|
|
7
|
-
* scripts, limits, categories and duties that `docs/biz/60-templates/…`,
|
|
8
|
-
* `docs/guides/library-publishing-process.md` and `docs/guides/DEVELOPMENT.md`
|
|
7
|
+
* scripts, limits, categories and duties that `api/docs/biz/60-templates/…`,
|
|
8
|
+
* `api/docs/guides/library-publishing-process.md` and `api/docs/guides/DEVELOPMENT.md`
|
|
9
9
|
* copy by hand becomes a generated region fed by the manifests, and `--check`
|
|
10
10
|
* fails the run when a region is stale. Three documents were measured copying
|
|
11
11
|
* the same repository tree, and two copying the same script table; a copy is
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The name of the file a uniform pointer is a region of — one declaration for
|
|
5
|
+
* the whole package.
|
|
6
|
+
*
|
|
7
|
+
* It is a module of its own, and the shape of the package is what decided that.
|
|
8
|
+
* Four places wrote the string by hand until d.739b: the module that decides
|
|
9
|
+
* WHERE a pointer lives (`readmeLocation.js`), the one that renders the region
|
|
10
|
+
* (`readmePointer.js`), the row that judges a library's README
|
|
11
|
+
* (`manifest/checks/libraryDocs.js`) and the sync command. Neither of the two
|
|
12
|
+
* modules that already know about READMEs could hold it for the others:
|
|
13
|
+
*
|
|
14
|
+
* - `readmeLocation.js` cannot be the owner `readmePointer.js` imports from,
|
|
15
|
+
* because `readmeLocation.js` already requires `./readmePointer` — the
|
|
16
|
+
* second import would close a cycle;
|
|
17
|
+
* - `readmePointer.js` cannot take the name from its caller instead, because
|
|
18
|
+
* that changes `applyUniformRegion`'s signature, which `src/cli/oa-sync-template.js`
|
|
19
|
+
* and `src/sync/uniformFiles.js` read from outside that pair.
|
|
20
|
+
*
|
|
21
|
+
* A module that owns the one constant and requires nothing is what neither
|
|
22
|
+
* problem touches, and it keeps `readmeLocation.js`'s own sentence true: the
|
|
23
|
+
* renderer knows no path of its own (`.claude/rules/change-discipline.md`
|
|
24
|
+
* § One rail per concern).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The file the uniform pointer lives in, for both bearer kinds. */
|
|
28
|
+
const README_FILE = 'README.md';
|
|
29
|
+
|
|
30
|
+
module.exports = { README_FILE };
|