@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +597 -0
  2. package/README.md +126 -19
  3. package/manifests/biz-service.manifest.json +15 -2
  4. package/manifests/library.manifest.json +4 -4
  5. package/package.json +11 -3
  6. package/src/CookbookTestRunner.js +275 -105
  7. package/src/CookbookTestUtils.js +79 -68
  8. package/src/ServiceReadinessValidator.js +42 -52
  9. package/src/ValidationOrchestrator.js +65 -44
  10. package/src/cli/biz-ci-gate.js +2 -2
  11. package/src/cli/oa-sync-template.js +97 -47
  12. package/src/cli/oa-validate.js +44 -10
  13. package/src/helpers/README.md +6 -6
  14. package/src/helpers/createServiceReadinessTests.js +87 -33
  15. package/src/index.js +14 -5
  16. package/src/lint/scripts/lintScripts.js +11 -4
  17. package/src/manifest/checks/libraryContext.js +6 -3
  18. package/src/manifest/checks/libraryDocs.js +174 -4
  19. package/src/manifest/checks/libraryTests.js +200 -19
  20. package/src/manifest/checks/scriptHeaders.js +6 -13
  21. package/src/manifest/checks/serviceConfig.js +36 -16
  22. package/src/manifest/checks/serviceConnectors.js +180 -2
  23. package/src/manifest/checks/serviceDb.js +0 -3
  24. package/src/manifest/checks/serviceScripts.js +3 -20
  25. package/src/manifest/runManifest.js +90 -13
  26. package/src/manifest/workspaceRoot.js +133 -4
  27. package/src/mocks/MockMQClient.js +2 -2
  28. package/src/sync/docsRegion.js +2 -2
  29. package/src/sync/readmeFile.js +30 -0
  30. package/src/sync/readmeLocation.js +2 -12
  31. package/src/sync/readmePointer.js +10 -4
  32. package/src/sync/serviceTemplate.js +9 -11
  33. package/src/sync/sharedEnv.js +59 -3
  34. package/src/sync/uniformFiles.js +81 -8
  35. package/src/utils/bizCiGateContract.js +2 -2
  36. package/src/utils/connectorContract.js +54 -2
  37. package/src/utils/cookbookFormat.js +25 -115
  38. package/src/utils/dbAccountGrants.js +5 -3
  39. package/src/utils/deployContract.js +153 -28
  40. package/src/utils/envContract.js +2 -2
  41. package/src/utils/handlerRef.js +8 -10
  42. package/src/utils/integrationRun.js +1 -1
  43. package/src/utils/operationsDocumentRules.js +242 -0
  44. package/src/utils/operationsRules.js +157 -0
  45. package/src/utils/resolveHeaders.js +12 -1
  46. package/src/utils/setupDatabase.js +1 -1
  47. package/src/utils/stepFailure.js +3 -3
  48. package/src/utils/stepReferences.js +28 -87
  49. package/src/utils/throwawaySchema.js +1 -1
  50. package/src/utils/yamlTopLevel.js +105 -0
  51. package/src/validators/ServiceStructureValidator.js +67 -152
  52. package/templates/business-service/README.md +3 -2
  53. package/templates/business-service/config/env-templates/shared.env +1 -0
  54. package/templates/business-service/src/config/index.js +15 -0
  55. package/TESTING_STRATEGY.md +0 -92
  56. 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 { verifyConnectorDeclarations, CONFIG_PATH, CONTRACT_PATH } = require('../../utils/connectorContract');
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
- module.exports = { name: 'connector-contract', check: connectorContract };
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
- if (row.only_with !== undefined && !hasSuite(serviceRoot, row.only_with)) return [];
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 { resolveWorkspacePath, canonicalRoot, describeWorkspaceFix } = require('./workspaceRoot');
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({ manifest, serviceRoot = null, workspaceRoot = null, checkRegistry = CHECK_REGISTRY }) {
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
- const answer = readAnswer(check.run({ row, block, serviceRoot: root, workspaceRoot }));
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().map((bearer) => readAnswer(check.run({
423
- row, block, serviceRoot: bearer.dir, workspaceRoot
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
- return readAnswer(check.run({ row, block, serviceRoot: root, workspaceRoot }));
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
- const relative = path.relative(workspaceRoot, root);
494
- if (relative === '') return () => true;
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
- /** The prefix every manifest path uses for the api checkout. */
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 workspace a given tree belongs to: the nearest ancestor carrying the
206
- * marker under the name the prefix writes. This answers a DIFFERENT question
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 (fs.existsSync(path.join(current, ...WORKSPACE_MARKER.split('/')))) return current;
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:2286`: a delivery already settled (marked
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:2313`:
159
+ * `RabbitMQClient.nack` (`transports/rabbitmqClient.js`):
160
160
  *
161
161
  * const requeue = options.requeue !== undefined ? options.requeue : true;
162
162
  *
@@ -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 };