@onlineapps/conn-orch-validator 9.0.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +83 -9
  3. package/docs/DESIGN.md +21 -7
  4. package/manifests/biz-service.manifest.json +28 -5
  5. package/package.json +2 -2
  6. package/src/CookbookTestRunner.js +84 -16
  7. package/src/ValidationOrchestrator.js +73 -20
  8. package/src/cli/biz-ci-gate.js +28 -14
  9. package/src/cli/oa-sync-template.js +23 -8
  10. package/src/cli/oa-validate.js +7 -1
  11. package/src/index.js +21 -13
  12. package/src/lint/scripts/lintScripts.js +65 -18
  13. package/src/manifest/checks/composeRunnerBlock.js +37 -20
  14. package/src/manifest/checks/discoveryOrphan.js +2 -1
  15. package/src/manifest/checks/docsLintBridge.js +79 -21
  16. package/src/manifest/checks/gitTracked.js +12 -1
  17. package/src/manifest/checks/libraryPackage.js +3 -1
  18. package/src/manifest/checks/libraryWorkspace.js +18 -3
  19. package/src/manifest/checks/readmeRegion.js +9 -1
  20. package/src/manifest/checks/serviceConfig.js +29 -12
  21. package/src/manifest/checks/serviceFiles.js +34 -7
  22. package/src/manifest/checks/serviceIdentityRows.js +3 -1
  23. package/src/manifest/checks/serviceRuntime.js +3 -1
  24. package/src/manifest/discovery.js +25 -7
  25. package/src/manifest/runManifest.js +58 -7
  26. package/src/manifest/workspaceRoot.js +91 -5
  27. package/src/sync/serviceTemplate.js +76 -7
  28. package/src/sync/sharedEnv.js +11 -4
  29. package/src/sync/uniformFiles.js +91 -21
  30. package/src/utils/bizCiGateContract.js +25 -1
  31. package/src/utils/installContract.js +46 -5
  32. package/src/utils/libCompat.js +39 -19
  33. package/src/utils/preValidation.js +56 -11
  34. package/src/utils/stepFailure.js +106 -19
  35. package/src/utils/testCoverageContract.js +60 -2
  36. package/src/utils/throwawaySchema.js +92 -7
  37. package/src/validatorIdentity.js +31 -0
  38. package/src/validators/ServiceStructureValidator.js +41 -15
  39. package/src/validators/ValidationProofGenerator.js +73 -34
  40. package/templates/business-service/.dockerignore +9 -1
  41. package/templates/business-service/.gitlab-ci.yml +91 -25
  42. package/templates/business-service/README.md +14 -5
  43. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
  44. package/templates/business-service/config/env-templates/shared.env +7 -1
  45. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +1 -1
  46. package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
  47. package/templates/business-service/jest.config.js +9 -1
  48. package/templates/business-service/package.json.template +1 -1
  49. package/src/mocks/MockStorage.js +0 -188
@@ -30,12 +30,22 @@
30
30
  * biz repositories: 47 `@see` lines under their scripts directories, of which 4
31
31
  * open with the `api/` prefix.
32
32
  *
33
- * Two scopes, two rules. S001-S008 read the HEADER of every script under
34
- * `scripts/`. S009 reads the COMMENTS of every test under `tests/scripts/` and
35
- * nothing else in them: a `<path>.<ext>:<line>` citation in prose has no
36
- * mechanism keeping it true (`doc-code-binding.md` §1), while the same shape on
37
- * a CODE line is a fixture assertion whose target the test creates itself and
38
- * which therefore cannot rot.
33
+ * Two scopes, three rules. S001-S008 read the HEADER of every script under
34
+ * `scripts/`. S009 and S010 read the tests, and they read opposite halves of
35
+ * them. S009 reads the COMMENTS and nothing else: a `<path>.<ext>:<line>`
36
+ * citation in prose has no mechanism keeping it true (`doc-code-binding.md`
37
+ * §1), while the same shape on a CODE line is a fixture assertion whose target
38
+ * the test creates itself and which therefore cannot rot. S010 reads the CODE
39
+ * lines and nothing else: an inverted command is exempt from `errexit`, so an
40
+ * assertion written that way asserts nothing unless it happens to be the last
41
+ * command of the body.
42
+ *
43
+ * The test scope is `tests/`, not the one directory S009 was born in. A rule
44
+ * that reads `tests/scripts/` and not `tests/scripts-docker/` beside it has a
45
+ * hole nobody can see: the run reports `0 finding(s)` and never says which
46
+ * files it did not open. Widened in d.482 (2026-09-15) together with S010, over
47
+ * a scope measured clean for both rules first — no baseline
48
+ * (`automation-gates.md` §3).
39
49
  *
40
50
  * @see api/docs/standards/SCRIPTS-STANDARD.md
41
51
  * @see api/docs/governance/confirmations/biz-service-manifest.md
@@ -54,8 +64,8 @@ const SHELL_VOCAB = new Set(['bash>=4', 'bash>=3.2', 'sh', 'node>=18', 'node>=24
54
64
  /** The directory whose scripts carry the header. */
55
65
  const SCRIPT_SCOPE = 'scripts';
56
66
 
57
- /** The directory whose COMMENTS S009 reads, and nothing else in it. */
58
- const CITATION_SCOPE = 'tests/scripts';
67
+ /** The directory whose tests S009 and S010 read, each one half of them. */
68
+ const CITATION_SCOPE = 'tests';
59
69
 
60
70
  /**
61
71
  * The directories whose files are libraries rather than entry points.
@@ -111,6 +121,27 @@ const LINE_CITATION = /[A-Za-z0-9_/.-]+\.(?:js|mjs|cjs|ts|mts|sh|bash|bats|yml|j
111
121
  /** A comment line of a shell-family test. */
112
122
  const COMMENT_LINE = /^\s*#/;
113
123
 
124
+ /**
125
+ * An assertion written as an inverted command, in the two shapes bats carries.
126
+ *
127
+ * Measured on bats 1.13.0: under `set -e` a command whose exit code is inverted
128
+ * is exempt from errexit, so `! cmd` in the middle of a test body fails nothing
129
+ * — the test stays green whatever `cmd` returns. Only the LAST command of the
130
+ * body decides the verdict, which is why the defect survives review: the line
131
+ * reads like an assertion and is one exactly when it happens to sit last.
132
+ *
133
+ * The rule does not try to tell the last command from the others. It reports
134
+ * every occurrence, because the position is a property of the file's current
135
+ * shape rather than of the assertion's intent: a line inserted below turns a
136
+ * working assertion into an inert one with no edit to the assertion itself.
137
+ * INFRA rewrote all 32 occurrences this platform carried before the rule landed
138
+ * (`572488c4`, `6100477e`, `d212d3c6`), so the stricter reading costs nothing.
139
+ *
140
+ * `if ! cmd; then … fi` is NOT this defect and matches neither pattern: there
141
+ * the `if` consumes the exit code on purpose, which is the shape the fix uses.
142
+ */
143
+ const INERT_ASSERTION = Object.freeze([/^\s*!\s/, /\|\|\s*!\s/]);
144
+
114
145
  /** The prefix a citation of the api checkout opens with, as every document writes it. */
115
146
  const API_PREFIX = 'api/';
116
147
 
@@ -265,22 +296,38 @@ function lintScripts({ root, apiRoot = null }) {
265
296
  );
266
297
  }
267
298
 
268
- // S009: a comment under tests/scripts/ cites a symbol or a literal string, and
269
- // a covered defect cites the commit hash — never a line number.
299
+ // S009 on the comment lines, S010 on the code lines. One pass, because they
300
+ // partition the same file: a line is prose or it is a command, never both.
270
301
  for (const relative of citationScanned) {
271
302
  const lines = fs.readFileSync(path.join(root, ...relative.split('/')), 'utf8').split('\n');
272
303
  for (let n = 0; n < lines.length; n += 1) {
273
- if (!COMMENT_LINE.test(lines[n])) continue;
274
- const hit = lines[n].match(LINE_CITATION);
275
- if (hit === null) continue;
304
+ // S009: a comment cites a symbol or a literal string, and a covered
305
+ // defect cites the commit hash — never a line number.
306
+ if (COMMENT_LINE.test(lines[n])) {
307
+ const hit = lines[n].match(LINE_CITATION);
308
+ if (hit === null) continue;
309
+ findings.push({
310
+ id: 'S009',
311
+ file: relative,
312
+ line: n + 1,
313
+ message: `Comment cites a line number - "${hit[0]}". A line number has no mechanism keeping it true: `
314
+ + 'the file moves and the number stays (doc-code-binding.md §1). Fix: cite a symbol or a literal '
315
+ + 'string; a covered defect cites the commit hash that introduced or repaired it '
316
+ + "(git log -S'<string>' --format=%h -- <file>) - DOC-STANDARD rule 11."
317
+ });
318
+ continue;
319
+ }
320
+
321
+ // S010: an assertion whose exit code is inverted is exempt from errexit.
322
+ if (!INERT_ASSERTION.some((shape) => shape.test(lines[n]))) continue;
276
323
  findings.push({
277
- id: 'S009',
324
+ id: 'S010',
278
325
  file: relative,
279
326
  line: n + 1,
280
- message: `Comment cites a line number - "${hit[0]}". A line number has no mechanism keeping it true: `
281
- + 'the file moves and the number stays (doc-code-binding.md §1). Fix: cite a symbol or a literal '
282
- + 'string; a covered defect cites the commit hash that introduced or repaired it '
283
- + "(git log -S'<string>' --format=%h -- <file>) - DOC-STANDARD rule 11."
327
+ message: `Inert assertion - "${lines[n].trim()}". Under set -e a command with an inverted exit code `
328
+ + 'is exempt from errexit, so it fails nothing unless it is the last command of the test body. '
329
+ + 'Fix: if <what must not hold>; then echo "[ctx] problem - found: …" >&2; return 1; fi '
330
+ + '— SCRIPTS-STANDARD.md § Enforcement'
284
331
  });
285
332
  }
286
333
  }
@@ -24,19 +24,24 @@
24
24
  * (here) — one concern, one rail (`.claude/rules/change-discipline.md` § One rail
25
25
  * per concern). Everything else in the block is compared byte for byte.
26
26
  *
27
- * The service's own identity is the other normalization: the runner's key is
28
- * `<container>_tests`, and the container name is the one thing that is per
29
- * repository by construction. It is substituted back to the template's
30
- * placeholders before the comparison, so the block is read as the template
31
- * wrote it.
27
+ * The service's own identity is the other half, and it is settled in ONE
28
+ * direction: the reference is RENDERED with this repository's names and the
29
+ * result compared against the file, never the file read back into placeholders
30
+ * (d.531). The runner's key is `<container>_tests` and the container name is per
31
+ * repository by construction, so a render is exact; reading a name back is not,
32
+ * because a name is also an ordinary word — for `api_biz/meta` it rewrote the
33
+ * template's own citation of that path and reported a conformant block as
34
+ * drifted. `renderRunnerIdentity` below is that one direction, and both the
35
+ * check and the generator go through it.
32
36
  *
33
37
  * ## The same definition renders
34
38
  *
35
- * `renderRunnerBlock` performs exactly the inverse: the template's block with
36
- * this repository's identity in it and this repository's three shared
37
- * declarations grafted in. That is what makes `npx oa-sync-template
38
- * docker-compose.yml` produce a block the check then finds clean — a generator
39
- * whose output its own check rejects is the defect this module exists to avoid.
39
+ * `renderRunnerBlock` is the generator's half: the same identity render, plus
40
+ * this repository's three shared declarations grafted in. That is what makes
41
+ * `npx oa-sync-template docker-compose.yml` produce a block the check then finds
42
+ * clean — a generator whose output its own check rejects is the defect this
43
+ * module exists to avoid, and it is measured over a service called `meta`
44
+ * (`tests/unit/manifestServiceFiles.test.js`).
40
45
  *
41
46
  * @see api/docs/governance/confirmations/biz-service-manifest.md §2, §3.2
42
47
  * @see api/docs/governance/confirmations/biz-test-container.md
@@ -156,8 +161,22 @@ function stripDeclarations(lines, keys) {
156
161
  }
157
162
 
158
163
  /**
159
- * A repository's runner block as the template wrote it: its identity replaced by
160
- * the placeholders, and the three shared declarations removed.
164
+ * The template's block with one repository's identity in it — the ONE direction
165
+ * this module substitutes (d.531).
166
+ *
167
+ * The other direction, reading a repository's block back into placeholders, was
168
+ * a second rail under the same rule and it could not be made right: a
169
+ * substitution of the service's NAME rewrites every occurrence of that string,
170
+ * including one in the template's own prose. Measured over `api_biz/meta`: the
171
+ * block cites `api_biz/meta/docker-compose.yml` as the file the memory numbers
172
+ * were taken in, so that service's conformant block was normalized into
173
+ * `api_biz/__SERVICE_NAME__/docker-compose.yml` and reported as drifted, while
174
+ * `oa-sync-template docker-compose.yml --check` — which renders — called the
175
+ * same file unchanged.
176
+ *
177
+ * A placeholder is a name nothing else can be, so rendering is total; a name is
178
+ * a word, and reading it back is a guess. One rail, and it is this one
179
+ * (`.claude/rules/change-discipline.md` § One rail per concern).
161
180
  *
162
181
  * The container name is substituted BEFORE the service name on purpose: the
163
182
  * container name normally contains the service name (`api_service_converter`
@@ -167,10 +186,10 @@ function stripDeclarations(lines, keys) {
167
186
  * @param {{lines: string[], containerName: string, serviceName: string}} args
168
187
  * @returns {string[]}
169
188
  */
170
- function normalizeRunnerBlock({ lines, containerName, serviceName }) {
171
- return stripDeclarations(lines, SHARED_DECLARATIONS).map((line) => line
172
- .split(containerName).join(CONTAINER_PLACEHOLDER)
173
- .split(serviceName).join(SERVICE_PLACEHOLDER));
189
+ function renderRunnerIdentity({ lines, containerName, serviceName }) {
190
+ return lines.map((line) => line
191
+ .split(CONTAINER_PLACEHOLDER).join(containerName)
192
+ .split(SERVICE_PLACEHOLDER).join(serviceName));
174
193
  }
175
194
 
176
195
  /**
@@ -188,9 +207,7 @@ function normalizeRunnerBlock({ lines, containerName, serviceName }) {
188
207
  * @returns {string[]} the block's lines, markers included
189
208
  */
190
209
  function renderRunnerBlock({ reference, composeText, containerName, serviceName }) {
191
- let rendered = reference.map((line) => line
192
- .split(CONTAINER_PLACEHOLDER).join(containerName)
193
- .split(SERVICE_PLACEHOLDER).join(serviceName));
210
+ let rendered = renderRunnerIdentity({ lines: reference, containerName, serviceName });
194
211
 
195
212
  const service = serviceLines(composeText, containerName);
196
213
 
@@ -217,6 +234,6 @@ module.exports = {
217
234
  serviceLines,
218
235
  replaceServiceNode,
219
236
  stripDeclarations,
220
- normalizeRunnerBlock,
237
+ renderRunnerIdentity,
221
238
  renderRunnerBlock
222
239
  };
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  const { discoverBearers, rootOfPattern } = require('../discovery');
21
+ const { describeWorkspaceFix } = require('../workspaceRoot');
21
22
 
22
23
  const check = Object.freeze({
23
24
  scope: 'workspace',
@@ -31,7 +32,7 @@ const check = Object.freeze({
31
32
  /** What the run prints instead of a verdict when the workspace is unreachable. */
32
33
  describeNotRun({ block }) {
33
34
  const owner = block && block.from ? block.from.path : 'the referenced SSOT';
34
- return `the workspace root is not reachable, so ${owner} cannot be read`;
35
+ return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
35
36
  },
36
37
 
37
38
  /**
@@ -54,7 +54,7 @@ const path = require('path');
54
54
  const { spawnSync } = require('child_process');
55
55
 
56
56
  const { whereOf } = require('./libraryContext');
57
- const { resolveWorkspacePath } = require('../workspaceRoot');
57
+ const { resolveWorkspacePath, describeWorkspaceFix, AS_THE_REASON_NAMES } = require('../workspaceRoot');
58
58
 
59
59
  /** The documentation lint, by the path the manifest writes every api path in. */
60
60
  const LINT_SCRIPT = 'api/scripts/ci/lint-biz-docs.mjs';
@@ -210,6 +210,55 @@ const ERROR_SEVERITY = 'error';
210
210
  */
211
211
  const errorGraded = (findings) => findings.filter((finding) => finding.severity === ERROR_SEVERITY);
212
212
 
213
+ /**
214
+ * The NOT RUN sentence of a row the linter could not decide — the rules it could
215
+ * not evaluate, the linter's own reason for the first of them, and the command
216
+ * that turns this checkout into one where the question has an answer.
217
+ *
218
+ * Three things, and each has exactly one owner. The rule ids and the reason are
219
+ * the LINTER'S: this module cites the tool, it does not restate it, which is the
220
+ * whole point of the bridge (docblock at the top of this file). The remedy is
221
+ * `workspaceRoot.js` § describeWorkspaceFix, shared with
222
+ * `runManifest.js` § describeMissingRoots — the other place a row goes NOT RUN
223
+ * because a sibling was not there. Until d.516 this end of it stopped at the
224
+ * linter's reason: measured 2026-09-15 over a `git archive HEAD` export placed
225
+ * beside no `api_biz`, the run printed
226
+ *
227
+ * NOT RUN D-PORT — F002:http-ports could not be decided — api_biz/*&#47;docker-compose.yml
228
+ * lives in a sibling checkout this run does not have, so the probe could not
229
+ * be evaluated
230
+ *
231
+ * while `U-ORPHAN`, in the SAME run and about the SAME absent directory, named
232
+ * the command. True, and nothing the reader could act on
233
+ * (`.claude/rules/automation-gates.md` §1 requirement 4).
234
+ *
235
+ * What the fix asks the reader to carry is left as the phrase pointing back at
236
+ * the reason, rather than a root resolved here. The linter's skipped channel
237
+ * names the absent THING — a probe target, a scan root, a manifest — and it
238
+ * carries no structured root to read (`api/scripts/ci/lint-biz-docs.mjs`
239
+ * § markSkipped: `{ rule, reason }` and nothing else). Deriving one would mean
240
+ * parsing that sentence, which is the private dialect between row and tool this
241
+ * bridge exists to avoid; naming the siblings here instead would make this
242
+ * module a second owner of the lint's own probe configuration.
243
+ *
244
+ * Collapsed into one reason per row: a checkout without siblings makes a dozen
245
+ * bans undecidable at once, and twelve identical sentences say nothing the first
246
+ * one does not.
247
+ *
248
+ * @param {Array<{rule: string, reason: string}>} undecided at least one entry
249
+ * @returns {string}
250
+ */
251
+ function describeUndecided(undecided) {
252
+ if (!Array.isArray(undecided) || undecided.length === 0) {
253
+ throw new Error('[DocsLintBridge] Undecided rules are required - describeUndecided() was given '
254
+ + `${JSON.stringify(undecided)}, and a row with nothing undecided is not NOT RUN. `
255
+ + 'Fix: call it only when the linter reported at least one rule it could not evaluate.');
256
+ }
257
+
258
+ return `${undecided.map((entry) => entry.rule).join(', ')} could not be decided — `
259
+ + `${undecided[0].reason}. ${describeWorkspaceFix(AS_THE_REASON_NAMES)}`;
260
+ }
261
+
213
262
  /**
214
263
  * Which row owns a lint finding: the one whose citation is the most specific.
215
264
  *
@@ -250,7 +299,11 @@ function claimantOf(ruleId, rows) {
250
299
  * @returns {{findings: Array<object>, skipped: Array<object>}|{problem: string}}
251
300
  */
252
301
  function lintOnce({ lintScript, docsDir, treeConfig }) {
253
- const key = [lintScript, docsDir, treeConfig].join('');
302
+ // `\0` as the escape, never the raw byte: written literally it makes the
303
+ // whole file binary to grep, diff and every reader that asks what changed
304
+ // (measured 2026-09-16 — `grep -n` answered "Binary file … matches" and
305
+ // printed nothing). The value is identical; the source stays readable.
306
+ const key = [lintScript, docsDir, treeConfig].join('\0');
254
307
  if (answers.has(key)) return answers.get(key);
255
308
 
256
309
  const answer = runLint({ lintScript, docsDir, treeConfig });
@@ -394,15 +447,31 @@ function askLint({ block, serviceRoot, workspaceRoot }) {
394
447
  return { answer };
395
448
  }
396
449
 
450
+ /**
451
+ * The absence BOTH documentation rows answer, in one sentence.
452
+ *
453
+ * `describeMissingRoots` answers "the workspace is there and the root is not";
454
+ * this one answers "there is no workspace at all". Until d.516b it stopped at
455
+ * the absence — the defect `.claude/rules/automation-gates.md` §1 requirement 4
456
+ * names, one channel over from the one d.516 closed. Until d.523 the sentence
457
+ * was then written TWICE, once per row, byte for byte: two rails for one
458
+ * concern (`.claude/rules/change-discipline.md` § One rail per concern), and
459
+ * the shape in which the first of the two would one day be improved alone.
460
+ *
461
+ * @returns {string}
462
+ */
463
+ function describeLintNotRun() {
464
+ return `the workspace root is not reachable, so ${LINT_SCRIPT} cannot be run. `
465
+ + describeWorkspaceFix(LINT_SCRIPT);
466
+ }
467
+
397
468
  const docsLint = Object.freeze({
398
469
  scope: 'bearer',
399
470
  requires: Object.freeze(['rule']),
400
471
 
401
472
  requiresSiblings: () => [LINT_DIR],
402
473
 
403
- describeNotRun() {
404
- return `the workspace root is not reachable, so ${LINT_SCRIPT} cannot be run`;
405
- },
474
+ describeNotRun: describeLintNotRun,
406
475
 
407
476
  /**
408
477
  * @param {{ row: object, block: object, serviceRoot: string, workspaceRoot: string }} params
@@ -446,20 +515,15 @@ const docsLint = Object.freeze({
446
515
  // is `answer.problem` above, and the absent tree config before it, both of
447
516
  // which stay findings with a fix somebody can carry out.
448
517
  //
449
- // Collapsed into one reason per row: a checkout without siblings makes a
450
- // dozen bans undecidable at once, and twelve identical sentences say nothing
451
- // the first one does not.
518
+ // The sentence itself is `describeUndecided` above — shared with `D-LINT`,
519
+ // which reports the same absence about the rules no row claims.
452
520
  const undecided = answer.skipped
453
521
  .filter((entry) => citations.some((citation) => covers(citation, entry.rule)))
454
522
  .filter((entry) => claimantOf(entry.rule, rows) === row.id);
455
523
 
456
524
  if (undecided.length === 0) return found;
457
525
 
458
- return {
459
- findings: found,
460
- notRun: `${undecided.map((entry) => entry.rule).join(', ')} could not be decided — `
461
- + `${undecided[0].reason}`
462
- };
526
+ return { findings: found, notRun: describeUndecided(undecided) };
463
527
  }
464
528
  });
465
529
 
@@ -497,9 +561,7 @@ const docsLintClean = Object.freeze({
497
561
 
498
562
  requiresSiblings: () => [LINT_DIR],
499
563
 
500
- describeNotRun() {
501
- return `the workspace root is not reachable, so ${LINT_SCRIPT} cannot be run`;
502
- },
564
+ describeNotRun: describeLintNotRun,
503
565
 
504
566
  /**
505
567
  * @param {{ block: object, serviceRoot: string, workspaceRoot: string }} params
@@ -531,11 +593,7 @@ const docsLintClean = Object.freeze({
531
593
  const undecided = answer.skipped.filter((entry) => claimantOf(entry.rule, rows) === null);
532
594
  if (undecided.length === 0) return found;
533
595
 
534
- return {
535
- findings: found,
536
- notRun: `${undecided.map((entry) => entry.rule).join(', ')} could not be decided — `
537
- + `${undecided[0].reason}`
538
- };
596
+ return { findings: found, notRun: describeUndecided(undecided) };
539
597
  }
540
598
  });
541
599
 
@@ -165,8 +165,19 @@ const check = Object.freeze({
165
165
  if (listed === null) {
166
166
  return {
167
167
  findings: [],
168
+ // The remedy of this row is NOT the one every other NOT RUN of this
169
+ // package carries. `workspaceRoot.js` § describeWorkspaceFix names
170
+ // `--workspace`, which points at a tree of checkouts; no value of it
171
+ // turns an export into a checkout, and this row asks git, not the
172
+ // workspace. So the sentence is this row's own — one remedy, one owner,
173
+ // and neither borrowed from the other
174
+ // (`.claude/rules/change-discipline.md` § One rail per concern).
175
+ // Until d.518 it stopped at the absence, which is the dead end
176
+ // `.claude/rules/automation-gates.md` §1 requirement 4 forbids.
168
177
  notRun: 'this tree is not a git checkout (git ls-files could not answer), so what a clone '
169
- + 'would receive cannot be read — a container and an exported tarball are in exactly this state'
178
+ + 'would receive cannot be read — a container and an exported tarball are in exactly this state. '
179
+ + 'Fix: run this row over a git checkout — a clone of the repository, never an export, a tarball '
180
+ + 'or a built image.'
170
181
  };
171
182
  }
172
183
 
@@ -17,6 +17,7 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
 
19
19
  const { resolveFromValue } = require('../discovery');
20
+ const { describeWorkspaceFix } = require('../workspaceRoot');
20
21
  const {
21
22
  readPackage, whereOf, appliesTo, allDeps, scopedDeps, nodeMajorOf
22
23
  } = require('./libraryContext');
@@ -91,7 +92,8 @@ const libraryEngines = Object.freeze({
91
92
  requires: Object.freeze(['from']),
92
93
 
93
94
  describeNotRun({ row }) {
94
- return `the workspace root is not reachable, so ${row.from.path} cannot be read`;
95
+ return `the workspace root is not reachable, so ${row.from.path} cannot be read. `
96
+ + describeWorkspaceFix(row.from.path);
95
97
  },
96
98
 
97
99
  run({ row, block, serviceRoot, workspaceRoot }) {
@@ -28,7 +28,7 @@ const path = require('path');
28
28
  const {
29
29
  discoverBearers, resolveFromReference, resolveFromMap, expandPattern, rootOfPattern
30
30
  } = require('../discovery');
31
- const { resolveWorkspacePath } = require('../workspaceRoot');
31
+ const { resolveWorkspacePath, describeWorkspaceFix, AS_THE_REASON_NAMES } = require('../workspaceRoot');
32
32
  const {
33
33
  readJson, readPackage, whereOf, declaredCategory, appliesTo, scopedDeps, SCOPE
34
34
  } = require('./libraryContext');
@@ -39,8 +39,23 @@ const RUNTIME_SECTIONS = Object.freeze(['dependencies']);
39
39
  /** The sections `scripts/ci/verify-manifest-pins.mjs` compares against the SSOT. */
40
40
  const PINNED_SECTIONS = Object.freeze(['dependencies', 'devDependencies']);
41
41
 
42
- /** Why a workspace-scoped row prints nothing instead of a verdict. */
43
- const notRunBecause = (what) => `the workspace root is not reachable, so ${what} cannot be read`;
42
+ /**
43
+ * Why a workspace-scoped row prints nothing instead of a verdict — and, since
44
+ * d.518, what to run so that it can.
45
+ *
46
+ * Five rows of the library uniform are reported through this one line
47
+ * (`U-ORPHAN`, `U-MISMATCH`, `L-PINS`, `L-CONSUMER`, `L-TOOLING`), so the
48
+ * command belongs here rather than at each of them: one remedy, one sentence,
49
+ * one owner (`workspaceRoot.js` § describeWorkspaceFix,
50
+ * `.claude/rules/change-discipline.md` § One rail per concern). Until d.518 all
51
+ * five stopped at the absence — true, and nothing a reader of a CI log can act
52
+ * on (`.claude/rules/automation-gates.md` §1 requirement 4).
53
+ *
54
+ * WHAT the checkout has to carry is already named by `what`, immediately before
55
+ * the fix, so the fix points back at it instead of repeating it in other words.
56
+ */
57
+ const notRunBecause = (what) => `the workspace root is not reachable, so ${what} cannot be read. `
58
+ + describeWorkspaceFix(AS_THE_REASON_NAMES);
44
59
 
45
60
  /**
46
61
  * Every package the uniform's discovery pattern finds, indexed by the name its
@@ -32,6 +32,7 @@ const {
32
32
  readReadme, serviceRegion, libraryRegion, PACKAGE_IN_WORKSPACE
33
33
  } = require('../../sync/readmeLocation');
34
34
  const { whereOf, declaredCategory, readPackage } = require('./libraryContext');
35
+ const { describeWorkspaceFix, AS_THE_REASON_NAMES } = require('../workspaceRoot');
35
36
 
36
37
  /**
37
38
  * The first line at which the region on disk stops being the rendered one, and
@@ -102,8 +103,15 @@ const libraryReadmeRegion = Object.freeze({
102
103
  // holds a copy of this package.
103
104
  requiresSiblings: () => (PACKAGE_IN_WORKSPACE === null ? [] : [PACKAGE_IN_WORKSPACE]),
104
105
 
106
+ // The absence, and then the command that removes it, from the one owner of
107
+ // that command (`workspaceRoot.js` § describeWorkspaceFix). What the checkout
108
+ // must carry is named by the sentence in front of it, so the fix points back
109
+ // at it rather than saying it a second time in other words — which is what
110
+ // `AS_THE_REASON_NAMES` is for. Until d.518 this ended at the absence
111
+ // (`.claude/rules/automation-gates.md` §1 requirement 4).
105
112
  describeNotRun() {
106
- return 'the workspace root is not reachable, so the manifest the region links at cannot be located';
113
+ return 'the workspace root is not reachable, so the manifest the region links at cannot be located. '
114
+ + describeWorkspaceFix(AS_THE_REASON_NAMES);
107
115
  },
108
116
 
109
117
  run({ row, serviceRoot, workspaceRoot }) {
@@ -25,9 +25,10 @@
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
27
 
28
- const { readReferencedFile, isPackageReference, referenceOwner } = require('../discovery');
28
+ const { resolveFromDocument, isPackageReference, referenceOwner } = require('../discovery');
29
+ const { describeWorkspaceFix } = require('../workspaceRoot');
29
30
  const { whereOf } = require('./libraryContext');
30
- const { diffAgainst } = require('../../sync/sharedEnv');
31
+ const { diffAgainst, renderSharedEnv } = require('../../sync/sharedEnv');
31
32
  const { loadAndValidateIntegrationContract } = require('../../utils/bizCiGateContract');
32
33
 
33
34
  /** The generated platform file every service carries a copy of. */
@@ -333,14 +334,29 @@ const envTemplates = Object.freeze({
333
334
  });
334
335
 
335
336
  /**
336
- * `shared.env` is rendered from `api/config/shared-env.json`, and this row reads
337
- * the RENDER the package carries (d.229): the platform manifest stays the owner
338
- * of the key set, `templates/business-service/config/env-templates/shared.env`
339
- * is its output, and `sharedEnvTemplate.test.js` is what keeps the two equal.
340
- * Reading the render rather than the manifest is what lets the row run inside a
341
- * service container, where `api/config/` is not there at all — and that is the
342
- * only place a service's own `shared.env` is ever wrong for a reader who cannot
343
- * fix it from the workspace.
337
+ * `shared.env` is generated, never written, and this row reads the file that
338
+ * OWNS the key set — `api/config/shared-env.json` in the workspace — through the
339
+ * same renderer `npx oa-sync-template shared-env` writes with (`src/sync/sharedEnv.js`).
340
+ *
341
+ * Until d.536 it read the render this package carries (d.229), so the verdict
342
+ * depended on the day the validator was published rather than on the SSOT: the
343
+ * three services synced after the 9.0.0 publish were told "10 line(s) differ" by
344
+ * a row whose own fix command had just written the file, because the SSOT had
345
+ * gained `LOG_MAX_SIZE_BYTES`, `LOG_MAX_FILES` and a comment since. One fact
346
+ * cannot be read off two files (`.claude/rules/change-discipline.md` § One rail
347
+ * per concern), and a gate whose answer moves with a publication date is not
348
+ * predictable (`.claude/rules/automation-gates.md` §1.1).
349
+ *
350
+ * The reason d.229 gave for the packaged copy — "a service container has no
351
+ * `api/config/`" — no longer buys anything the row needs: the deploy job the
352
+ * uniform runs in clones the api checkout (confirmation `biz-service-manifest`
353
+ * 008), and where the workspace really is out of reach the row says NOT RUN and
354
+ * names the command, which is what every other workspace-reading row does. A
355
+ * silent pass measured against yesterday's key set is the false guarantee
356
+ * `automation-gates.md` §5 calls a defect.
357
+ *
358
+ * `templates/business-service/config/env-templates/shared.env` stays as the
359
+ * scaffold's own output, and `sharedEnvTemplate.test.js` stays the gate on it.
344
360
  */
345
361
  const sharedEnvGenerated = Object.freeze({
346
362
  scope: 'bearer',
@@ -349,7 +365,8 @@ const sharedEnvGenerated = Object.freeze({
349
365
  needsWorkspace: ({ row }) => !isPackageReference(row.from),
350
366
 
351
367
  describeNotRun({ row }) {
352
- return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
368
+ const owner = referenceOwner(row.from);
369
+ return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
353
370
  },
354
371
 
355
372
  run({ row, serviceRoot, workspaceRoot }) {
@@ -360,7 +377,7 @@ const sharedEnvGenerated = Object.freeze({
360
377
  return [{ where, what: `absent — it is rendered from ${owner}` }];
361
378
  }
362
379
 
363
- const rendered = readReferencedFile({ from: row.from, workspaceRoot });
380
+ const rendered = renderSharedEnv(resolveFromDocument({ from: row.from, workspaceRoot }));
364
381
  if (rendered === text) return [];
365
382
 
366
383
  const differing = diffAgainst(rendered, text).split('\n').filter((line) => /^[-+]/.test(line)).length - 2;