@onlineapps/conn-orch-validator 9.0.0 → 11.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 (58) hide show
  1. package/CHANGELOG.md +546 -0
  2. package/README.md +337 -19
  3. package/docs/DESIGN.md +32 -9
  4. package/manifests/biz-service.manifest.json +56 -6
  5. package/package.json +3 -2
  6. package/src/CookbookTestRunner.js +134 -22
  7. package/src/ValidationOrchestrator.js +312 -73
  8. package/src/cli/biz-ci-gate.js +191 -15
  9. package/src/cli/oa-sync-template.js +23 -8
  10. package/src/cli/oa-validate.js +70 -2
  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 +14 -28
  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/serviceDb.js +176 -7
  22. package/src/manifest/checks/serviceFiles.js +34 -7
  23. package/src/manifest/checks/serviceIdentityRows.js +3 -1
  24. package/src/manifest/checks/serviceRuntime.js +126 -1
  25. package/src/manifest/discovery.js +25 -7
  26. package/src/manifest/gitCheckout.js +84 -0
  27. package/src/manifest/runManifest.js +58 -7
  28. package/src/manifest/workspaceRoot.js +91 -5
  29. package/src/sync/serviceTemplate.js +76 -7
  30. package/src/sync/sharedEnv.js +11 -4
  31. package/src/sync/uniformFiles.js +91 -21
  32. package/src/utils/bizCiGateContract.js +25 -1
  33. package/src/utils/dbAccountGrants.js +126 -0
  34. package/src/utils/envContract.js +36 -6
  35. package/src/utils/envReads.js +102 -0
  36. package/src/utils/installContract.js +46 -5
  37. package/src/utils/libCompat.js +39 -19
  38. package/src/utils/preValidation.js +56 -11
  39. package/src/utils/stepFailure.js +106 -19
  40. package/src/utils/stepReferences.js +278 -0
  41. package/src/utils/testCoverageContract.js +60 -2
  42. package/src/utils/throwawaySchema.js +92 -7
  43. package/src/validatorIdentity.js +31 -0
  44. package/src/validators/ServiceStructureValidator.js +47 -15
  45. package/src/validators/ValidationProofGenerator.js +73 -34
  46. package/templates/business-service/.dockerignore +9 -1
  47. package/templates/business-service/.gitlab-ci.yml +199 -35
  48. package/templates/business-service/Dockerfile +49 -16
  49. package/templates/business-service/README.md +56 -9
  50. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
  51. package/templates/business-service/config/env-templates/shared.env +8 -2
  52. package/templates/business-service/docker-compose.production.yml +9 -0
  53. package/templates/business-service/docker-compose.yml +17 -0
  54. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +1 -1
  55. package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
  56. package/templates/business-service/jest.config.js +9 -1
  57. package/templates/business-service/package.json.template +1 -1
  58. package/src/mocks/MockStorage.js +0 -188
@@ -33,8 +33,11 @@ const {
33
33
  PLATFORM_TEST_FILE_PATTERN,
34
34
  } = require('../utils/testCoverageContract');
35
35
  const { runPreValidation } = require('../utils/preValidation');
36
+ const { describeRecordIdentity } = require('../utils/stepFailure');
36
37
  const { checkLibCompat, loadLibrarySet } = require('../utils/libCompat');
37
- const { buildSchema } = require('../utils/setupDatabase');
38
+ const { buildSchema, defaultExec } = require('../utils/setupDatabase');
39
+ const { databaseAccountSql, ACCOUNT_HOST } = require('../utils/dbAccountGrants');
40
+ const { readDeclaredAccount, ACCOUNT_KEY, ENV_TEMPLATE_DIR } = require('../manifest/checks/serviceDb');
38
41
 
39
42
  function parseArgs(argv) {
40
43
  const parsed = {
@@ -89,7 +92,7 @@ Commands:
89
92
  contract's closed vocabularies on every live migrations/**/*.sql
90
93
  (archive/ and superseded/ excluded), and each BASELINE manifest
91
94
  naming exactly the files the installer applies, in that order.
92
- run-prevalidation Run the service's cookbooks offline against mocked infrastructure
95
+ run-prevalidation Run the service's cookbooks in-process (no transport, real database)
93
96
  and write conn-runtime/validation-proof.json. Replaces the
94
97
  per-service scripts/run-pre-validation.js.
95
98
  verify-lib-compat Compare @onlineapps/* pins against the platform library SSOT (R6).
@@ -100,6 +103,10 @@ Commands:
100
103
  skipped; this reads the runner's own JSON report.
101
104
  emit-ci-env Emit connector-aware CI environment exports.
102
105
  wait-connectors Wait for required connector TCP endpoints.
106
+ setup-db-account Create this service's database account and grant it its own schemas.
107
+ The ONE step of a biz pipeline that runs under the database root
108
+ account, and the last one that does: every step after it connects
109
+ as the service, the way production does.
103
110
  setup-db Build the schema from the declared migrations (contract database block).
104
111
  run-setup Run optional connector setup commands from contract.
105
112
  write-summary Write normalized integration signal artifact JSON.
@@ -130,6 +137,18 @@ wait-connectors options:
130
137
  --attempts <number> Maximum connection attempts per connector (default: 60)
131
138
  --interval-ms <number> Delay between attempts in milliseconds (default: 1000)
132
139
 
140
+ setup-db-account environment:
141
+ DB_HOST, DB_PORT The database this job stands up.
142
+ CI_DB_ROOT_USER The administrative account of that database — the sidecar's
143
+ CI_DB_ROOT_PASSWORD root, under its OWN name. It is never taken from DB_USER:
144
+ DB_USER is who the job connects AS, and the two being one
145
+ value is the thing this command exists to end.
146
+ DB_USER The account every later step connects as. It must be the one
147
+ the repository declares in ${ENV_TEMPLATE_DIR}/<service>.env.
148
+ DB_PASSWORD The password that account is CREATED with and connects with.
149
+ A throwaway value of the job; the env template declares the
150
+ key, never the secret.
151
+
133
152
  write-summary options:
134
153
  --output <path> Summary artifact path. Default: <service-root>/ci/integration-signal.json
135
154
  --gate-verdict <pass|fail> Report a failure this summary cannot see for itself -
@@ -257,6 +276,146 @@ function reportInstallContract(result) {
257
276
  return false;
258
277
  }
259
278
 
279
+ /**
280
+ * Fail with the one shape `architecture-principles.md` §5 asks for, and the
281
+ * prefix every other step of this gate prints.
282
+ *
283
+ * @param {string} subject what was checked
284
+ * @param {string} problem what was found and what was expected
285
+ * @param {string} fix the command or edit that satisfies it
286
+ */
287
+ function failSetupDbAccount(subject, problem, fix) {
288
+ process.stderr.write(`[BizCiGate] FAIL setup-db-account — ${subject} — ${problem}\n`
289
+ + ` Fix: ${fix}\n`);
290
+ process.exit(1);
291
+ }
292
+
293
+ /**
294
+ * Create this service's database account, and grant it its own schemas.
295
+ *
296
+ * This is the ONE step of a biz pipeline that runs under the database root
297
+ * account, and the last one that does. Everything after it — `setup-db`, the
298
+ * integration tier, the cookbooks — connects as the service, which is what makes
299
+ * CI prove the migration set under the rights production actually has
300
+ * (`db-migrations-first-deploy` 001: "migrace nikdy pod rootem"). Until d.567
301
+ * seven pipelines set `DB_USER: "root"` and proved it under rights no production
302
+ * box grants.
303
+ *
304
+ * Three facts, three owners, none of them invented here:
305
+ * - the SCHEMA is `database.schema` of the integration contract, the same
306
+ * value `setup-db` builds;
307
+ * - the ACCOUNT is `DB_USER` of the repository's own env template — the
308
+ * declaration uniform row `D-DB-ACCOUNT` measures, read through the one
309
+ * function that knows where it lives;
310
+ * - the STATEMENTS are `utils/dbAccountGrants.js`, the same definition the
311
+ * production runbook installs from.
312
+ *
313
+ * The password is the job's own throwaway value (`DB_PASSWORD`), never the
314
+ * `CHANGE_ME` placeholder an env template carries: a template declares the key,
315
+ * not the secret.
316
+ */
317
+ function runSetupDbAccount(options) {
318
+ const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
319
+ const database = contractInfo.contract.database;
320
+
321
+ if (!database) {
322
+ // NOT APPLICABLE, not OK — the same distinction `setup-db` draws one
323
+ // function down, and for the same reason: a step that prints OK for work it
324
+ // never did is the false guarantee `automation-gates.md` §5 names.
325
+ process.stdout.write(`[BizCiGate] NOT APPLICABLE setup-db-account — ${contractInfo.contractPath} declares no `
326
+ + '"database" block, so this service has no schema and needs no account. Nothing was done, and nothing '
327
+ + 'was expected to be.\n');
328
+ return;
329
+ }
330
+
331
+ const serviceRoot = contractInfo.serviceRoot;
332
+ const { relative, declared } = readDeclaredAccount(serviceRoot);
333
+
334
+ if (relative === null || declared === null) {
335
+ failSetupDbAccount(ACCOUNT_KEY,
336
+ `this repository declares a database, and ${relative === null
337
+ ? `no env template of its own under ${ENV_TEMPLATE_DIR}/ declares ${ACCOUNT_KEY}`
338
+ : `${relative} declares no ${ACCOUNT_KEY}`} — the account is not this command's to invent`,
339
+ `declare ${ACCOUNT_KEY}=oagen_<service> in ${relative === null ? `${ENV_TEMPLATE_DIR}/<service>.env` : relative}`
340
+ + '; uniform row D-DB-ACCOUNT measures the same key.');
341
+ }
342
+
343
+ const connecting = process.env.DB_USER;
344
+ if (connecting !== declared) {
345
+ failSetupDbAccount('DB_USER',
346
+ `this job connects as ${JSON.stringify(connecting ?? null)} and the repository declares `
347
+ + `${JSON.stringify(declared)} in ${relative} — creating one account and using another would report `
348
+ + 'a success nothing consumes, and fail two steps later as an access denial',
349
+ `set DB_USER: "${declared}" in the job's variables, the value ${relative} declares.`);
350
+ }
351
+
352
+ const required = {
353
+ DB_HOST: process.env.DB_HOST,
354
+ CI_DB_ROOT_USER: process.env.CI_DB_ROOT_USER,
355
+ CI_DB_ROOT_PASSWORD: process.env.CI_DB_ROOT_PASSWORD,
356
+ DB_PASSWORD: process.env.DB_PASSWORD
357
+ };
358
+ const fixes = {
359
+ DB_HOST: 'set it in the job variables to the alias of the database service container.',
360
+ CI_DB_ROOT_USER: 'set it in the job variables to the administrative account of the database '
361
+ + 'sidecar (root). It is named separately from DB_USER on purpose: DB_USER is who the job '
362
+ + 'connects as afterwards.',
363
+ CI_DB_ROOT_PASSWORD: 'set it in the job variables to the sidecar\'s MARIADB_ROOT_PASSWORD. '
364
+ + 'The sidecar is thrown away with the job, so the value is a throwaway of the job too.',
365
+ DB_PASSWORD: 'set it in the job variables to a throwaway password; the account is CREATED with '
366
+ + 'it here and connects with it in every later step. The env template declares the key, never '
367
+ + 'the secret.'
368
+ };
369
+
370
+ for (const [name, value] of Object.entries(required)) {
371
+ if (value === undefined || value === '') {
372
+ failSetupDbAccount(name, 'not set, and this step cannot run without it', fixes[name]);
373
+ }
374
+ }
375
+
376
+ const connection = {
377
+ host: required.DB_HOST,
378
+ port: Number.parseInt(process.env.DB_PORT || '3306', 10),
379
+ user: required.CI_DB_ROOT_USER,
380
+ password: required.CI_DB_ROOT_PASSWORD,
381
+ requireTls: process.env.DB_REQUIRE_TLS === '1'
382
+ };
383
+
384
+ // Said before any statement runs, pass or fail — the way `verify-env-contract`
385
+ // prints its scope. A step that names nothing until it succeeds leaves the
386
+ // reader of a failed job log with a refused statement and no idea which
387
+ // account it was for.
388
+ process.stdout.write(`[BizCiGate] setup-db-account scope: '${declared}'@'${ACCOUNT_HOST}' on `
389
+ + `${connection.host}:${connection.port}, schema \`${database.schema}\` — the account declared in `
390
+ + `${relative}, created by ${JSON.stringify(connection.user)}\n`);
391
+
392
+ const statements = databaseAccountSql({
393
+ schema: database.schema,
394
+ account: declared,
395
+ password: required.DB_PASSWORD,
396
+ // The integration tier builds throwaway schemas beside the declared one
397
+ // (`utils/throwawaySchema.js`); production installs exactly one and gets no
398
+ // such grant. The difference is named here, at its one caller.
399
+ ciThrowaway: true
400
+ });
401
+
402
+ statements.forEach((sql, index) => {
403
+ const result = defaultExec({ connection, sql });
404
+ if (result.status !== 0) {
405
+ // The statement is identified by its ORDINAL, never quoted: the first one
406
+ // carries the account's password.
407
+ failSetupDbAccount(`statement ${index + 1} of ${statements.length}`,
408
+ `the database at ${connection.host}:${connection.port} refused it:\n ${result.stderr}`,
409
+ `check that ${JSON.stringify(connection.user)} is the administrative account of that database, `
410
+ + 'and that a mariadb/mysql client is on PATH in this job image.');
411
+ }
412
+ });
413
+
414
+ process.stdout.write(`[BizCiGate] OK setup-db-account — '${declared}'@'${ACCOUNT_HOST}': `
415
+ + `${statements.length} statement(s), granted \`${database.schema}\` and its throwaway namespace `
416
+ + `\`${database.schema}_*\`. Every step after this one connects as that account.\n`);
417
+ }
418
+
260
419
  /**
261
420
  * Build the schema the contract declares. One implementation for every service:
262
421
  * the repo declares WHAT, this decides HOW (docs/biz/00-model/uniformity-principle.md).
@@ -382,18 +541,6 @@ async function runVerifyContract(options) {
382
541
  }
383
542
 
384
543
 
385
- /**
386
- * Running cookbooks needs `tests/cookbooks/`, `operations.json` and the handler
387
- * modules — nothing else. This command used to resolve the service's own
388
- * `@onlineapps/service-wrapper`, run `ConfigLoader.loadAll()` and pass
389
- * `service.url` to the runner, which never read it (`this.serviceUrl` appears
390
- * nowhere in CookbookTestRunner). `ConfigLoader.loadAll` no longer returns that
391
- * field either — the owner removed `port` and `url` from the service shape
392
- * (docs/governance/confirmations/biz-service-port-url.md 001), so the value had
393
- * been `undefined` for every service on the platform. What the load still did
394
- * was make a readable config, an installed wrapper and a resolvable `${ENV}`
395
- * placeholder preconditions of a cookbook run that depends on none of them.
396
- */
397
544
  /**
398
545
  * End the process with this code, once everything written has left the buffers.
399
546
  *
@@ -434,6 +581,27 @@ function exitWhenFlushed(code) {
434
581
  done();
435
582
  }
436
583
 
584
+ /**
585
+ * What a cookbook run reads, and what it does NOT.
586
+ *
587
+ * It reads `tests/cookbooks/`, `config/service/operations.json` and the
588
+ * service's `package.json`, and it dispatches the service's own v3 handler
589
+ * modules IN THIS PROCESS. So it needs whatever those handlers need: a handler
590
+ * that reaches its schema opens the service's real pool here, which is the
591
+ * subject of `exitWhenFlushed` above. "Nothing else" is what this sentence used
592
+ * to say, and the paragraph below it said the opposite (d.523).
593
+ *
594
+ * What it does not do is load the service CONFIG. This command used to resolve
595
+ * the service's own `@onlineapps/service-wrapper`, run `ConfigLoader.loadAll()`
596
+ * and pass `service.url` to the runner, which never read it (`this.serviceUrl`
597
+ * appears nowhere in CookbookTestRunner). `ConfigLoader.loadAll` no longer
598
+ * returns that field either — the owner removed `port` and `url` from the
599
+ * service shape (docs/governance/confirmations/biz-service-port-url.md 001), so
600
+ * the value had been `undefined` for every service on the platform. What the
601
+ * load still did was make a readable config, an installed wrapper and a
602
+ * resolvable `${ENV}` placeholder preconditions of a cookbook run that depends
603
+ * on none of them.
604
+ */
437
605
  async function runRunPreValidation(options) {
438
606
  const outcome = await runPreValidation({
439
607
  serviceRoot: path.resolve(options.serviceRoot),
@@ -451,7 +619,11 @@ async function runRunPreValidation(options) {
451
619
 
452
620
  if (!outcome.ok) {
453
621
  for (const step of outcome.failedSteps) {
454
- process.stderr.write(`[BizCiGate] FAIL run-prevalidation — ${step.step_id}: ${step.error}\n`);
622
+ // The record names itself: `results.steps` carries two kinds and this
623
+ // line printed `step_id` for both, so a rejected cookbook read as
624
+ // `undefined` (d.523). The identity comes from the one owner of it,
625
+ // through `runPreValidation`'s outcome.
626
+ process.stderr.write(`[BizCiGate] FAIL run-prevalidation — ${describeRecordIdentity(step)}: ${step.error}\n`);
455
627
  for (const validationError of step.validationErrors) {
456
628
  process.stderr.write(`[BizCiGate] * ${validationError}\n`);
457
629
  }
@@ -689,6 +861,10 @@ async function main() {
689
861
  await runWaitConnectors(options);
690
862
  return;
691
863
  }
864
+ if (command === 'setup-db-account') {
865
+ runSetupDbAccount(options);
866
+ return;
867
+ }
692
868
  if (command === 'setup-db') {
693
869
  runSetupDb(options);
694
870
  return;
@@ -11,8 +11,9 @@
11
11
  * * bare paths - the uniform sync of confirmation 001 §3.2. It writes the
12
12
  * files of the manifest classes `identical` and `generated` from the very
13
13
  * `from:` reference those rows already point the conformance CHECK at, and
14
- * never touches a file of the `own` class. A row the manifest does not make
15
- * renderable is reported NOT RUN by name, never filled from a guess.
14
+ * never touches a file of the `own` class. A row that names no `from:`
15
+ * reference is not a row of this run — it is a requirement about the
16
+ * repository, and `npx oa-validate` is what answers it (d.507).
16
17
  * * `--new <name>` - the whole service tree, from the template that ships
17
18
  * inside this package (001 §2). It is what `api/scripts/add-service.sh
18
19
  * --scaffold` runs; that script keeps the platform facts (the registry
@@ -48,7 +49,7 @@ const {
48
49
  SSOT_PIN,
49
50
  TEMPLATE_ROOT
50
51
  } = require('../sync/serviceTemplate');
51
- const { planSync, uniformRows } = require('../sync/uniformFiles');
52
+ const { planSync, uniformRows, isSyncRow } = require('../sync/uniformFiles');
52
53
  const { loadManifest: loadUniformManifest, DEFAULT_MANIFEST_PATH } = require('../manifest/loadManifest');
53
54
  const {
54
55
  applyUniformRegion,
@@ -107,10 +108,11 @@ Usage:
107
108
  is: generated output, committed for reading (001 §5). --check is the gate.
108
109
 
109
110
  With bare paths (or none, meaning all of them) the run rewrites the files the
110
- manifest declares in the classes "identical" and "generated", from the same
111
- reference the conformance check reads. Files of the "own" class - src/**,
112
- tests/**, the content of docs/** - are never written. A row the manifest does
113
- not make renderable is printed NOT RUN with the reason.
111
+ manifest declares in the classes "identical" and "generated" that name a
112
+ "from" reference, from that same reference the conformance check reads. Files
113
+ of the "own" class - src/**, tests/**, the content of docs/** - are never
114
+ written. A row naming no reference is a requirement about the repository
115
+ rather than a file, and npx oa-validate <serviceRoot> is what answers it.
114
116
 
115
117
  --new <name> create a service tree from the packaged template
116
118
  --into <dir> where that tree is written; it must not exist yet
@@ -721,9 +723,22 @@ function runSync(options) {
721
723
  // The positionals are judged FIRST, against the manifest, and before anything
722
724
  // that touches the disk: a mistyped subcommand must be named as what it is,
723
725
  // not reported as a missing workspace three steps later.
724
- const declared = uniformRows(loadUniformManifest(DEFAULT_MANIFEST_PATH)).map((row) => row.path);
726
+ const rows = uniformRows(loadUniformManifest(DEFAULT_MANIFEST_PATH));
727
+ const declared = rows.filter(isSyncRow).map((row) => row.path);
725
728
  for (const wanted of options.paths) {
726
729
  if (declared.includes(wanted)) continue;
730
+
731
+ // A path the uniform DOES declare, as a requirement rather than as a file
732
+ // this run renders, is refused by what it is and not as a typo: the reader
733
+ // named a real row, and what they need is the run that answers it.
734
+ const requirement = rows.find((row) => row.path === wanted);
735
+ if (requirement !== undefined) {
736
+ throw new Error(`[oa-sync-template] "${wanted}" is declared by ${requirement.id}, which names no "from" `
737
+ + 'reference - it is a requirement about the repository, not a file this run renders. '
738
+ + 'Fix: check it with npx oa-validate <serviceRoot>; the paths this run writes are '
739
+ + `(${declared.join(', ')}).`);
740
+ }
741
+
727
742
  throw new Error(`[oa-sync-template] Unknown argument "${wanted}" - it is neither a subcommand this run `
728
743
  + `has (${SUBCOMMAND_NAMES.join(', ')}) nor a path the uniform declares (${declared.join(', ')}). `
729
744
  + 'Fix: name one of those, or leave the paths out to write them all.');
@@ -42,6 +42,7 @@ const { discoverBearers } = require('../manifest/discovery');
42
42
  const { collectRows } = require('../manifest/walk');
43
43
  const { CHECK_REGISTRY } = require('../manifest/checks');
44
44
  const { declaredCategory, readPackage } = require('../manifest/checks/libraryContext');
45
+ const { listEnvReads } = require('../utils/envReads');
45
46
 
46
47
  /** Exit code for a run that could not start at all — distinct from a finding. */
47
48
  const USAGE_EXIT = 2;
@@ -84,6 +85,7 @@ Usage:
84
85
  oa-validate --workspace <root> [--json]
85
86
  oa-validate --library <packageDir> [--workspace <root>] [--json]
86
87
  oa-validate --library --all [--workspace <root>] [--json]
88
+ oa-validate --env-reads [<serviceRoot>]
87
89
 
88
90
  Checks a repository against the uniform manifest shipped in
89
91
  @onlineapps/conn-orch-validator. The manifest version IS this package's
@@ -120,6 +122,15 @@ Options:
120
122
  → the rows that need it are reported NOT RUN, never as
121
123
  passing.
122
124
  --json Print the run as JSON instead of the table.
125
+ --env-reads Print the environment names this service reads — one per
126
+ line, sorted, nothing else — and exit 0. No table, no
127
+ banner, no verdict file: the output is read by a deploy
128
+ gate (mapfile), so it combines with no other mode. The set
129
+ is the one row C-ENV-READS measures against: the contract's
130
+ env block, the \${VAR} placeholders of config/service/*.json,
131
+ and the endpoint variables of the required connectors. A
132
+ name only the source reads is NOT here — that is a blocking
133
+ finding of C-ENV-READS with its own fix.
123
134
  --help Print this and exit 0.
124
135
 
125
136
  Output:
@@ -136,7 +147,9 @@ Exit codes:
136
147
  `;
137
148
 
138
149
  function parseArgs(argv) {
139
- const parsed = { serviceRoot: null, workspace: null, json: false, help: false, library: false, all: false };
150
+ const parsed = {
151
+ serviceRoot: null, workspace: null, json: false, help: false, library: false, all: false, envReads: false
152
+ };
140
153
  const args = [...argv];
141
154
 
142
155
  while (args.length > 0) {
@@ -149,6 +162,8 @@ function parseArgs(argv) {
149
162
  parsed.library = true;
150
163
  } else if (token === '--all') {
151
164
  parsed.all = true;
165
+ } else if (token === '--env-reads') {
166
+ parsed.envReads = true;
152
167
  } else if (token === '--workspace') {
153
168
  const value = args.shift();
154
169
  if (!value || value.startsWith('--')) {
@@ -166,6 +181,27 @@ function parseArgs(argv) {
166
181
  }
167
182
  }
168
183
 
184
+ // Not a verdict and not combinable with one: the gate reads stdout as a list
185
+ // of names, so a table, a JSON document or a NOT RUN banner printed beside
186
+ // them would reach it as environment variables. The option that WOULD be
187
+ // harmless — `--workspace` — is refused with the rest, because this answer
188
+ // comes from the service's own contract and a workspace root would suggest it
189
+ // does not (`automation-gates.md` §1 requirement 1).
190
+ if (parsed.envReads && !parsed.help) {
191
+ const combined = [
192
+ parsed.json ? '--json' : null,
193
+ parsed.library ? '--library' : null,
194
+ parsed.all ? '--all' : null,
195
+ parsed.workspace !== null ? '--workspace' : null
196
+ ].filter((token) => token !== null);
197
+
198
+ if (combined.length > 0) {
199
+ throw new Error(`[oa-validate] --env-reads cannot be combined with ${combined.join(', ')} - it prints `
200
+ + 'environment names for a machine to read, and every other mode prints a verdict into the same '
201
+ + 'stdout. Fix: run oa-validate --env-reads [<serviceRoot>] on its own.');
202
+ }
203
+ }
204
+
169
205
  if (parsed.all && !parsed.library) {
170
206
  throw new Error('[oa-validate] Option --all needs --library - a service is checked one repository at a time. '
171
207
  + 'Fix: oa-validate --library --all');
@@ -403,6 +439,29 @@ function runLibrary(options) {
403
439
  return blockingOf(report).length > 0 ? 1 : 0;
404
440
  }
405
441
 
442
+ /**
443
+ * `--env-reads`: the question a deploy gate asks before it judges a `.env`.
444
+ *
445
+ * Nothing but the names reaches stdout, and nothing is written to the tree: the
446
+ * caller is `mapfile -t reads < <(oa-validate --env-reads "$repo")` in
447
+ * `api/scripts/validate-env.sh`, which took `KEY=CHANGE_ME` in a key the biz
448
+ * image never opens for a reason to refuse a deploy. The set, and why it is not
449
+ * a grep for `process.env`, belong to `utils/envReads.js`.
450
+ *
451
+ * An empty answer is an answer, and it exits 0: a contract that declares no env
452
+ * block, uses no placeholder and requires no connector reads nothing this
453
+ * package can see. Whether that is true of the repository is row `C-ENV-READS`'s
454
+ * question, not this one's.
455
+ *
456
+ * @param {object} options parsed arguments
457
+ * @returns {number} exit code
458
+ */
459
+ function runEnvReads(options) {
460
+ const names = listEnvReads(options.serviceRoot === null ? process.cwd() : options.serviceRoot);
461
+ if (names.length > 0) process.stdout.write(`${names.join('\n')}\n`);
462
+ return 0;
463
+ }
464
+
406
465
  function main(argv) {
407
466
  const options = parseArgs(argv);
408
467
 
@@ -411,6 +470,8 @@ function main(argv) {
411
470
  return 0;
412
471
  }
413
472
 
473
+ if (options.envReads) return runEnvReads(options);
474
+
414
475
  if (options.library) return runLibrary(options);
415
476
 
416
477
  const manifest = loadManifest(DEFAULT_MANIFEST_PATH);
@@ -433,7 +494,13 @@ function main(argv) {
433
494
  }
434
495
  const serviceRoot = canonicalRoot(given);
435
496
 
436
- const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace });
497
+ // The workspace this SERVICE lies in, not the one this package lies in
498
+ // (d.540): the copy a service runs is the one it installed, and an installed
499
+ // copy is part of no checkout — `oa-validate .` inside a complete workspace
500
+ // printed NOT RESOLVED and reported every workspace row NOT RUN. The root is
501
+ // the one the caller named, never `process.cwd()`
502
+ // (`workspaceRoot.js` § The service a run is ABOUT).
503
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace, serviceRoot });
437
504
  const result = runManifest({ manifest, serviceRoot, workspaceRoot });
438
505
 
439
506
  process.stdout.write(options.json ? renderJson(result) : renderReport(result));
@@ -465,6 +532,7 @@ if (require.main === module) {
465
532
  module.exports = {
466
533
  main,
467
534
  parseArgs,
535
+ runEnvReads,
468
536
  runLibrary,
469
537
  checkOnePackage,
470
538
  checkEveryPackage,
package/src/index.js CHANGED
@@ -12,10 +12,6 @@
12
12
  * @see /api/docs/architecture/validator.md (validator architecture)
13
13
  */
14
14
 
15
- const MockMQClient = require('./mocks/MockMQClient');
16
- const MockRegistry = require('./mocks/MockRegistry');
17
- const MockStorage = require('./mocks/MockStorage');
18
-
19
15
  const CookbookTestUtils = require('./CookbookTestUtils');
20
16
  const { ServiceStructureValidator } = require('./validators/ServiceStructureValidator');
21
17
  const ValidationProofGenerator = require('./validators/ValidationProofGenerator');
@@ -34,11 +30,16 @@ const ValidationOrchestrator = require('./ValidationOrchestrator');
34
30
 
35
31
  const { createServiceReadinessTests } = require('./helpers/createServiceReadinessTests');
36
32
 
33
+ // No infrastructure doubles are published. `MockMQClient`, `MockRegistry` and
34
+ // `MockStorage` were the furniture of `mockInfrastructure`, retired in d.576:
35
+ // pre-validation dispatches a step in-process against the service's own v3
36
+ // handler, so there is no transport to stand in for, and the database a handler
37
+ // reaches is the real one. `MockRegistry` stays inside the package as the
38
+ // registry stand-in of `helpers/createServiceReadinessTests`, which is a
39
+ // different concern and keeps its own consumer; `MockMQClient` as the double of
40
+ // `tests/unit/RegistrationFlow.test.js`. `MockStorage` had neither, so d.583
41
+ // deleted the file as well as the export (`tests/unit/mockInfrastructureRetired.test.js`).
37
42
  module.exports = {
38
- get MockMQClient() { return MockMQClient; },
39
- get MockRegistry() { return MockRegistry; },
40
- get MockStorage() { return MockStorage; },
41
-
42
43
  get CookbookTestUtils() { return CookbookTestUtils; },
43
44
  get ServiceReadinessValidator() { return ServiceReadinessValidator; },
44
45
  get CookbookTestRunner() { return CookbookTestRunner; },
@@ -82,11 +83,18 @@ module.exports = {
82
83
  get parseHandlerRef() { return require('./utils/handlerRef').parseHandlerRef; },
83
84
  get resolveHandlerModule() { return require('./utils/handlerRef').resolveHandlerModule; },
84
85
 
86
+ // The other half of what a service's integration harness needs to build its
87
+ // throwaway schema: which .sql files make up the set, and in which order.
88
+ // `createThrowawaySchema` above resolves the plan itself; a harness that
89
+ // builds its own schema step by step reads the plan directly, and had to
90
+ // reach into `src/utils/setupDatabase` to get it — a path inside the package
91
+ // is not a contract, and every move of `src/utils/` would break a consumer
92
+ // that never agreed to one. Exported as the identical function, not a
93
+ // wrapper: one definition, one behaviour (`change-discipline.md` § One rail
94
+ // per concern).
95
+ get resolveMigrationPlan() { return require('./utils/setupDatabase').resolveMigrationPlan; },
96
+
85
97
  get createServiceReadinessTests() { return createServiceReadinessTests; },
86
98
  get BizCiGateContract() { return BizCiGateContract; },
87
- get IntegrationRun() { return require('./utils/integrationRun'); },
88
-
89
- createMockMQ: () => new MockMQClient(),
90
- createMockRegistry: () => new MockRegistry(),
91
- createMockStorage: () => new MockStorage()
99
+ get IntegrationRun() { return require('./utils/integrationRun'); }
92
100
  };
@@ -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
  }