@onlineapps/conn-orch-validator 6.0.1 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +2591 -2
  2. package/README.md +1075 -7
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +422 -104
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +78 -42
  10. package/src/ValidationOrchestrator.js +298 -75
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +12 -2
  16. package/src/helpers/createServiceReadinessTests.js +75 -6
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +213 -13
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +21 -20
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +290 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -1,10 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Deploy contract checks R1-R7 for a biz service repository.
4
+ * Deploy contract checks for a biz service repository.
5
5
  *
6
6
  * Requirements: api/docs/operations/biz-rework-deployment-requirements.md
7
7
  * Database rules: ADR 0006 (api/docs/biz/80-decisions/)
8
+ * R8 permits and their reasons: api/docs/standards/tenant-allocation.md § Enforcement
8
9
  *
9
10
  * R1 the production compose pins its image immutably
10
11
  * R2 the deploy resets to origin/production rather than merging
@@ -14,6 +15,11 @@
14
15
  * R7 the CI database engine is the one the service actually talks to
15
16
  * R8 tenant / workspace identity has exactly one source
16
17
  *
18
+ * R6 is absent on purpose: the biz x infra library-compatibility gate lives in
19
+ * utils/libCompat.js (`verify-lib-compat`), a different command with a different
20
+ * input. The scope every message prints is rendered from DEPLOY_CONTRACT_SCOPE
21
+ * below, so it can neither omit a requirement nor claim one that is elsewhere.
22
+ *
17
23
  * Pure module: reads the repository, returns a structured result. It renders
18
24
  * nothing and exits nothing — the CLI owns presentation, this owns the rules.
19
25
  * That split is what lets the api-side cross-repo audit reuse the same logic
@@ -26,6 +32,41 @@
26
32
  const fs = require('fs');
27
33
  const path = require('path');
28
34
 
35
+ /**
36
+ * The requirements this module raises, and the ONLY place their extent is
37
+ * stated. Every message naming the scope renders it from here.
38
+ *
39
+ * The list had been written by hand into four messages, which then stopped one
40
+ * requirement short for months while R8 was already running: the gate announced
41
+ * less coverage than it enforced, and a reader had no way to tell. `add()` below refuses an
42
+ * id that is not on this list, so the two cannot drift apart again.
43
+ */
44
+ const DEPLOY_CONTRACT_REQUIREMENTS = Object.freeze(['R1', 'R2', 'R3', 'R4', 'R5', 'R7', 'R8']);
45
+
46
+ /**
47
+ * Render the requirement ids as a scope: consecutive ids collapse into a range,
48
+ * a gap stays visible ("R1-R5, R7-R8"). The gap is the point — writing "R1-R8"
49
+ * would claim R6, which this module does not check.
50
+ *
51
+ * @param {ReadonlyArray<string>} ids requirement ids, ascending
52
+ * @returns {string}
53
+ */
54
+ function formatRequirementScope(ids) {
55
+ const runs = [];
56
+ for (const id of ids) {
57
+ const number = Number(id.slice(1));
58
+ const open = runs[runs.length - 1];
59
+ if (open && number === open.end + 1) open.end = number;
60
+ else runs.push({ start: number, end: number });
61
+ }
62
+ return runs
63
+ .map(({ start, end }) => (start === end ? `R${start}` : `R${start}-R${end}`))
64
+ .join(', ');
65
+ }
66
+
67
+ /** "R1-R5, R7-R8" — what the CLI prints, derived, never typed. */
68
+ const DEPLOY_CONTRACT_SCOPE = formatRequirementScope(DEPLOY_CONTRACT_REQUIREMENTS);
69
+
29
70
  const IDENTIFIER_ONLY = /^[A-Za-z_][A-Za-z0-9_]*$/;
30
71
  const REQUIRED_MARKER = /[@:]\$\{[A-Za-z_][A-Za-z0-9_]*:\?[^}]*\}$/;
31
72
  const BARE_VARIABLE = /\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}$/;
@@ -285,9 +326,10 @@ function checkDatabaseEngine(serviceRoot, ci, add) {
285
326
  // src/, scripts/ no literal — production code and operational
286
327
  // scripts take identity from ctx / arguments / env.
287
328
  // tests/integration/ must take the namespace from the repo's shared
288
- // test-namespace module. A positive rule, because the
289
- // literal hides behind any name a test invents
290
- // (`const T = 100`) and pattern-hunting loses.
329
+ // test-namespace module, UNLESS it owns its database
330
+ // (see the third permit below). A positive rule,
331
+ // because the literal hides behind any name a test
332
+ // invents (`const T = 100`) and pattern-hunting loses.
291
333
  // config/env-templates/ only shared.env may name the runner namespace.
292
334
  // tests/unit/ exempt — pure in-memory mocks reach no database,
293
335
  // so a literal there is not a safety boundary, and
@@ -302,16 +344,37 @@ const LITERAL_NAMESPACE = /\b(tenant_id|workspace_id)\s*(?::|={1,3})\s*\d+/;
302
344
  const LITERAL_NAMESPACE_CONST =
303
345
  /\b(?:const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*(?:tenant|workspace)[A-Za-z0-9_$]*\s*=\s*\d+/i;
304
346
 
347
+ /** `CREATE DATABASE \`x\``, `create schema x` — the file builds its own schema. */
348
+ const BUILDS_OWN_DATABASE = /\bcreate\s+(?:database|schema)\b/i;
349
+ /** `process.env.DB_NAME = …` and `process.env['DB_NAME'] = …`, assignment only. */
350
+ const REDIRECTS_DB_NAME =
351
+ /\bprocess\.env\s*(?:\.DB_NAME|\[\s*(['"])DB_NAME\1\s*\])\s*=(?!=)/;
352
+
353
+ /** `require('@onlineapps/conn-orch-validator')` — the package, not a path inside it. */
354
+ const REQUIRES_VALIDATOR_PACKAGE =
355
+ /\brequire\(\s*(['"])@onlineapps\/conn-orch-validator\1\s*\)/;
356
+ /** The throwaway-schema build, under the name the package index exports it by. */
357
+ const THROWAWAY_SCHEMA_HELPER = /\bcreateThrowawaySchema\b/;
358
+
305
359
  const SCANNED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.json']);
306
360
  const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'coverage', 'dist', 'build']);
307
361
 
362
+ /** A call to either library helper: the own namespace, or the foreign one. */
363
+ const NAMESPACE_HELPER_CALL = /\bget(?:Foreign)?TestNamespace\s*\(/;
364
+
308
365
  /** True for the repo's shared test-namespace module, whatever it is named. */
309
366
  function isNamespaceModule(relativePath) {
310
367
  return /(^|\/)(test-?namespace)[^/]*\.js$/i.test(relativePath);
311
368
  }
312
369
 
313
370
  /**
314
- * True when the file actually CALLS the shared helper, on a line that executes.
371
+ * True when the file actually CALLS one of the shared helpers, on a line that
372
+ * executes — getTestNamespace() for the namespace the test owns,
373
+ * getForeignTestNamespace() for the one it must not see rows from. Both come
374
+ * from the library and both return an allowed environment class, so both are
375
+ * the fix R8 asks for; a test that needs the second one otherwise falls back to
376
+ * a literal (biz-hello asserted against the live customer tenant 101) or to
377
+ * arithmetic (99 + 1 = 100, LIVE).
315
378
  *
316
379
  * The permit used to be a whole-file text match, so a comment saying "TODO:
317
380
  * switch to getTestNamespace()" excused the file from R8 entirely — a rule that
@@ -322,7 +385,12 @@ function isNamespaceModule(relativePath) {
322
385
  function callsNamespaceHelper(text) {
323
386
  return text
324
387
  .split('\n')
325
- .some((line) => !isCommentLine(line) && /\bgetTestNamespace\s*\(/.test(line));
388
+ .some((line) => !isCommentLine(line) && NAMESPACE_HELPER_CALL.test(line));
389
+ }
390
+
391
+ /** True when `pattern` matches on a line that executes, not one that describes. */
392
+ function matchesOnExecutingLine(text, pattern) {
393
+ return text.split('\n').some((line) => !isCommentLine(line) && pattern.test(line));
326
394
  }
327
395
 
328
396
  /** A literal inside a comment is prose — it cannot reach a database. */
@@ -331,6 +399,77 @@ function isCommentLine(line) {
331
399
  return trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
332
400
  }
333
401
 
402
+ /**
403
+ * The other half of the same distinction. A comment is prose because of WHERE
404
+ * it sits; an error message is prose because of WHAT IT SAYS.
405
+ *
406
+ * "Inside quotes" alone is NOT the test, and must never become it: a query is
407
+ * a string too, and a query is exactly what reaches a database —
408
+ * `SELECT 1 FROM t WHERE tenant_id = 100` has to keep failing. What separates
409
+ * the two is the verb. Prose makes a CLAIM about the identifier ("workspace_id
410
+ * = 0 IS NOT a value"); code assigns a VALUE to it, and nothing follows the
411
+ * number but syntax.
412
+ *
413
+ * So the permit is granted only when the number is followed, inside the same
414
+ * string literal, by a finite verb from the closed list below. Everything else
415
+ * — end of string, `,`, `)`, ` AND `, ` order by ` — stays reported. The list
416
+ * is deliberately short and enumerated rather than clever: a gate whose verdict
417
+ * cannot be predicted from reading it is not a gate (`automation-gates.md` §1).
418
+ *
419
+ * Both conditions fail CLOSED. A string that spans several lines looks to this
420
+ * line-local scanner like no string at all, so its literal keeps its report.
421
+ */
422
+ const PROSE_PREDICATE =
423
+ /^[\s,;:—-]*\b(is|are|was|were|has|have|had|means|meant|carries|carry|cannot|must|never|does|do|would|will|remains|stays)\b/i;
424
+
425
+ /**
426
+ * The end offset of the CLOSED string literal containing `index`, or -1 when
427
+ * that position is code. Line-local and escape-aware; a quote opened on an
428
+ * earlier line is invisible here, which is the safe direction (no permit).
429
+ */
430
+ function enclosingStringEnd(line, index) {
431
+ let quote = null;
432
+ let start = -1;
433
+ for (let i = 0; i < line.length; i += 1) {
434
+ const ch = line[i];
435
+ if (quote !== null && ch === '\\') { i += 1; continue; }
436
+ if (quote === null) {
437
+ if (ch === "'" || ch === '"' || ch === '`') { quote = ch; start = i; }
438
+ } else if (ch === quote) {
439
+ if (index > start && index < i) return i;
440
+ quote = null;
441
+ start = -1;
442
+ }
443
+ }
444
+ return -1;
445
+ }
446
+
447
+ /**
448
+ * True when the literal at `index` is quoted inside an English sentence that
449
+ * talks ABOUT the identifier, rather than handing it a value.
450
+ */
451
+ function isProseLiteral(line, index, matchText) {
452
+ const end = enclosingStringEnd(line, index);
453
+ if (end === -1) return false;
454
+ return PROSE_PREDICATE.test(line.slice(index + matchText.length, end));
455
+ }
456
+
457
+ /**
458
+ * A tenant / workspace identity frozen into this line of executable code.
459
+ *
460
+ * One rail for both scan sites (src+scripts, and the shared test-namespace
461
+ * module), so the two permits — comment, prose — cannot drift apart.
462
+ */
463
+ function freezesNamespaceIdentity(line) {
464
+ if (isCommentLine(line)) return false;
465
+ for (const pattern of [LITERAL_NAMESPACE, LITERAL_NAMESPACE_CONST]) {
466
+ const match = pattern.exec(line);
467
+ if (!match) continue;
468
+ if (!isProseLiteral(line, match.index, match[0])) return true;
469
+ }
470
+ return false;
471
+ }
472
+
334
473
  function walkFiles(dir, base, out) {
335
474
  let entries;
336
475
  try {
@@ -384,8 +523,7 @@ function checkNamespaceLiterals(serviceRoot, add) {
384
523
  if (text === null) continue;
385
524
  const lines = text.split('\n');
386
525
  for (let i = 0; i < lines.length; i += 1) {
387
- if (isCommentLine(lines[i])) continue;
388
- if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
526
+ if (!freezesNamespaceIdentity(lines[i])) continue;
389
527
  add('R8', `${file.relative}:${i + 1} freezes a tenant / workspace id into code:\n`
390
528
  + ` ${lines[i].trim()}\n`
391
529
  + ' Fix: take it from ctx (handlers), from a CLI argument (scripts),\n'
@@ -409,8 +547,7 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
409
547
  if (isNamespaceModule(file.relative)) {
410
548
  const lines = text.split('\n');
411
549
  for (let i = 0; i < lines.length; i += 1) {
412
- if (isCommentLine(lines[i])) continue;
413
- if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
550
+ if (!freezesNamespaceIdentity(lines[i])) continue;
414
551
  add('R8', `${file.relative}:${i + 1} freezes the test namespace into a literal:\n`
415
552
  + ` ${lines[i].trim()}\n`
416
553
  + ' This module exists so the namespace has one source; that source must\n'
@@ -426,6 +563,61 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
426
563
  // that itself reads the platform env (checked above).
427
564
  if (callsNamespaceHelper(text)) continue;
428
565
  if (/require\([^)]*test-?namespace[^)]*\)/i.test(text)) continue;
566
+
567
+ // Third permit — the file builds its throwaway through the library.
568
+ //
569
+ // `utils/throwawaySchema.js` is the one build for every DB-owning service:
570
+ // it refuses the schema the service declares, drops and recreates the
571
+ // throwaway it was given, names that schema on every statement, and strips
572
+ // the schema selects out of the migrations so a `USE oagen_<service>` line
573
+ // written for the install runner cannot redirect them. Where those
574
+ // statements land is therefore decided by code, not inferred from two
575
+ // strings — which is the whole difference between this permit and the one
576
+ // below it.
577
+ //
578
+ // Both halves are required and both on a line that executes: the package
579
+ // import AND the helper. A repo-local module that happens to export the same
580
+ // name earns nothing, because what is being trusted here is this package's
581
+ // build and nothing else (`automation-gates.md` §5 — a rule a mention
582
+ // satisfies checks nothing).
583
+ if (matchesOnExecutingLine(text, REQUIRES_VALIDATOR_PACKAGE)
584
+ && matchesOnExecutingLine(text, THROWAWAY_SCHEMA_HELPER)) {
585
+ continue;
586
+ }
587
+
588
+ // Fourth permit — the file owns its database.
589
+ //
590
+ // The shared-namespace rule assumes the test writes into the service's live
591
+ // schema, where the namespace IS the safety boundary. A test that builds a
592
+ // throwaway database and drops it targets no shared namespace, so neither
593
+ // permit above means anything for it: the assertion "my identity does not
594
+ // collide with the test namespace" cannot fail, because getTestNamespace()
595
+ // only ever returns a value from ALLOWED_TENANT_CLASSES. A requirement whose
596
+ // only available compliance is an assertion that cannot fail documents no
597
+ // fix at all, and `automation-gates.md` §3 forbids reporting such a state.
598
+ //
599
+ // What decides where those queries land is DB_NAME, so that is what the
600
+ // permit asks for — positively, like the rest of R8, and not as a deny-list
601
+ // of the live database name: naming one database to avoid protects that one
602
+ // and nothing else (api/docs/standards/tenant-allocation.md § Enforcement).
603
+ // Both halves must be earned on a line that executes; a rule a mention
604
+ // satisfies checks nothing (`automation-gates.md` §5).
605
+ if (matchesOnExecutingLine(text, BUILDS_OWN_DATABASE)) {
606
+ if (matchesOnExecutingLine(text, REDIRECTS_DB_NAME)) continue;
607
+ add('R8', `${file.relative} creates its own database but never redirects DB_NAME to it.\n`
608
+ + ' The schema it builds is therefore not the schema its queries reach:\n'
609
+ + ' the service config still points at the live database, so the test\n'
610
+ + ' writes there while appearing to own a throwaway.\n'
611
+ + ' Expected: a file that builds its own schema also directs the service\n'
612
+ + ' at it, so nothing it runs can land in live data.\n'
613
+ + " Fix: set process.env.DB_NAME to that schema before the first require\n"
614
+ + ' of the service\'s database config; or, if the file does target the\n'
615
+ + ' shared namespace after all, take it from the repo\'s\n'
616
+ + ' tests/integration/testNamespace.js instead.\n'
617
+ + ' Rule: api/docs/standards/tenant-allocation.md § Enforcement');
618
+ continue;
619
+ }
620
+
429
621
  add('R8', `${file.relative} names a tenant / workspace without taking it from the\n`
430
622
  + ' shared test-namespace module.\n'
431
623
  + ' An integration test writes into a real database, so the namespace it\n'
@@ -436,7 +628,7 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
436
628
  }
437
629
 
438
630
  /**
439
- * Verify one biz repository against R1-R8.
631
+ * Verify one biz repository against the deploy contract.
440
632
  *
441
633
  * @param {string} serviceRoot absolute path to the biz repo
442
634
  * @returns {{service: string, ok: boolean, violations: Array<{requirement: string, message: string}>}}
@@ -451,7 +643,15 @@ function verifyDeployContract(serviceRoot) {
451
643
  }
452
644
 
453
645
  const violations = [];
454
- const add = (requirement, message) => violations.push({ requirement, message });
646
+ const add = (requirement, message) => {
647
+ if (!DEPLOY_CONTRACT_REQUIREMENTS.includes(requirement)) {
648
+ throw new Error(`[DeployContract] Unknown requirement "${requirement}" - Expected one of `
649
+ + `${DEPLOY_CONTRACT_REQUIREMENTS.join(', ')}. Fix: add the id to `
650
+ + 'DEPLOY_CONTRACT_REQUIREMENTS in utils/deployContract.js, so the scope every message '
651
+ + 'reports stays this module\'s own list.');
652
+ }
653
+ violations.push({ requirement, message });
654
+ };
455
655
 
456
656
  const compose = readIfExists(path.join(serviceRoot, 'docker-compose.production.yml'));
457
657
  if (compose === null) {
@@ -485,4 +685,4 @@ function verifyDeployContract(serviceRoot) {
485
685
  };
486
686
  }
487
687
 
488
- module.exports = { verifyDeployContract };
688
+ module.exports = { verifyDeployContract, DEPLOY_CONTRACT_REQUIREMENTS, DEPLOY_CONTRACT_SCOPE };
@@ -331,6 +331,52 @@ function collectEnvReads({ serviceRoot }) {
331
331
  return { reads, dynamicSites, filesScanned: files.length };
332
332
  }
333
333
 
334
+ /**
335
+ * Principle 4: the completeness gate validates its own input before it judges.
336
+ *
337
+ * Both arguments used to be read on trust. Handed the loader envelope
338
+ * (`{serviceRoot, contractPath, contract}`) in place of the normalized contract,
339
+ * the scan found neither `requiredConnectors` nor `env`, so it compared the
340
+ * repository against an EMPTY declaration with no connector coverage — and over
341
+ * a repository whose reads are all M1-covered it answered `ok: true`. That is a
342
+ * PASS printed over a declaration the gate never read, which is the false
343
+ * guarantee automation-gates.md §5 calls a defect of the same severity as a
344
+ * wrong result (BIZ-pdfgen, 2026-08-28).
345
+ *
346
+ * The shape is not restated here. `requiredConnectors` is judged against the
347
+ * connector set this module already reads for M2 coverage, and the env block
348
+ * against `normalizeEnvDeclaration` (below, in verifyEnvCompleteness) — one
349
+ * definition each, no second copy to drift.
350
+ */
351
+ function assertNormalizedContract(contract) {
352
+ if (!contract || typeof contract !== 'object' || Array.isArray(contract)) {
353
+ throw new Error('[EnvContract] Invalid contract - Expected the normalized integration contract object, '
354
+ + `got ${Array.isArray(contract) ? 'an array' : typeof contract}. `
355
+ + 'Fix: pass loadAndValidateIntegrationContract(serviceRoot).contract, not the envelope around it.');
356
+ }
357
+
358
+ const connectors = contract.requiredConnectors;
359
+ const missing = !connectors || typeof connectors !== 'object' || Array.isArray(connectors)
360
+ ? Object.keys(CONNECTORS)
361
+ : Object.keys(CONNECTORS).filter((name) => typeof connectors[name] !== 'boolean');
362
+
363
+ if (missing.length > 0) {
364
+ throw new Error('[EnvContract] Invalid contract - requiredConnectors does not declare a boolean for '
365
+ + `${missing.join(', ')}, so this is not a normalized integration contract. `
366
+ + 'Fix: pass loadAndValidateIntegrationContract(serviceRoot).contract — the loader envelope it is '
367
+ + 'wrapped in carries the contract under .contract, and checking against the envelope reads an '
368
+ + 'empty declaration as a satisfied one.');
369
+ }
370
+ }
371
+
372
+ /** The scan reads the repository off disk, so the root has to be a path. */
373
+ function assertScannableServiceRoot(serviceRoot) {
374
+ if (typeof serviceRoot !== 'string' || serviceRoot.trim() === '') {
375
+ throw new Error('[EnvContract] Invalid serviceRoot - Expected a non-empty path to the repository root, '
376
+ + `got ${typeof serviceRoot}. Fix: pass loadAndValidateIntegrationContract(serviceRoot).serviceRoot.`);
377
+ }
378
+ }
379
+
334
380
  /**
335
381
  * COMPLETENESS: every environment name the repository visibly reads is either
336
382
  * declared in the env block or covered by M1/M2.
@@ -343,14 +389,24 @@ function collectEnvReads({ serviceRoot }) {
343
389
  * dynamicSites: Array<{file: string, line: number}>}}
344
390
  */
345
391
  function verifyEnvCompleteness({ serviceRoot, contract }) {
392
+ assertScannableServiceRoot(serviceRoot);
393
+ assertNormalizedContract(contract);
394
+
346
395
  const coverage = collectEnvCoverage({
347
396
  serviceRoot,
348
397
  requiredConnectors: contract.requiredConnectors
349
398
  });
350
399
 
400
+ // The declaration is re-derived through the function that DEFINES it rather
401
+ // than read field by field: an env block that never went through
402
+ // normalizeEnvDeclaration is refused here instead of being silently counted
403
+ // as whatever `?.[listKey] ?? []` happens to find in it. A normalized block
404
+ // round-trips unchanged — same repository, same coverage, same result.
405
+ const declaration = normalizeEnvDeclaration(contract.env, { coverage });
406
+
351
407
  const declared = new Set();
352
408
  for (const listKey of ENV_LIST_KEYS) {
353
- for (const item of contract.env?.[listKey] ?? []) declared.add(item.name);
409
+ for (const item of declaration?.[listKey] ?? []) declared.add(item.name);
354
410
  }
355
411
 
356
412
  const { reads, dynamicSites, filesScanned } = collectEnvReads({ serviceRoot });
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What a v3 handler reference IS, and what resolving one MEANS — one definition
5
+ * for the whole package, and the one the wrapper can adopt.
6
+ *
7
+ * Before this module the rule lived in five places. Four of them were copies of
8
+ * the same regex literal checking only the shape (`ServiceReadinessValidator`,
9
+ * `ValidationOrchestrator`, `ServiceStructureValidator`,
10
+ * `helpers/createServiceReadinessTests`); the fifth,
11
+ * `CookbookTestRunner.resolveOperation`, resolved a ref for real and did it
12
+ * more weakly than production: it split on `#` without counting the separators,
13
+ * so `a#b#c` was silently truncated, and it joined the path onto
14
+ * `<serviceRoot>/src` with no containment check, so `../outside#run` loaded and
15
+ * ran a module from outside the service source root. A second implementation of
16
+ * one concern is a defect by default (`change-discipline.md` § One rail per
17
+ * concern), and the weaker one had been the one dispatching cookbooks.
18
+ *
19
+ * The two exported functions are deliberately separate, and the split is what
20
+ * lets `service-wrapper`'s `HandlerLoader` take this over as an internal swap
21
+ * rather than an API change:
22
+ *
23
+ * - `parseHandlerRef` states the DECLARED FORM. `handlers/<path>#<export>` is
24
+ * owned by api/docs/biz/30-operations/schema-v3.md § handler, and every
25
+ * shape check in this package now reads `HANDLER_REF_PATTERN` instead of
26
+ * restating it.
27
+ * - `resolveHandlerModule` states RESOLUTION: containment inside
28
+ * `<serviceRoot>/src`, `require`, and an export that is a function. It does
29
+ * NOT demand the declared form, because `HandlerLoader.resolve()` accepts
30
+ * any relative path inside the source root today — narrowing that is a
31
+ * contract decision for the owner (`confirmation-triggers.md` §1.2), never
32
+ * a side effect of sharing code.
33
+ *
34
+ * Layer: L3 (Orchestration). Filesystem + require only, no cross-imports.
35
+ *
36
+ * @see api/docs/biz/10-invocation/handler-dispatch.md § `HandlerRegistry` — resolution rules
37
+ * @see api/docs/biz/30-operations/schema-v3.md § handler
38
+ */
39
+
40
+ const path = require('path');
41
+
42
+ /**
43
+ * The declared form of a handler ref — the single literal in this package.
44
+ * The path charset carries no `.`, which is why a conformant ref can express
45
+ * neither a file extension nor a `..` traversal.
46
+ */
47
+ const HANDLER_REF_PATTERN = /^handlers\/[a-zA-Z0-9_/-]+#[a-zA-Z_][a-zA-Z0-9_]*$/;
48
+
49
+ /** How the ref is printed back to whoever has to fix it. */
50
+ function printRef(value) {
51
+ return typeof value === 'string' ? `"${value}"` : String(value);
52
+ }
53
+
54
+ /**
55
+ * The arity rule, shared by both exported functions: exactly one `#`, both
56
+ * halves non-empty. Kept private so there is one place that counts separators.
57
+ *
58
+ * @param {string} handlerRef
59
+ * @returns {{relPath: string, exportName: string}}
60
+ */
61
+ function splitHandlerRef(handlerRef) {
62
+ if (typeof handlerRef !== 'string' || handlerRef.length === 0) {
63
+ throw new Error(
64
+ `[HandlerRef] handlerRef must be a non-empty string - Got: ${printRef(handlerRef)}. `
65
+ + 'Fix: declare handler as "handlers/<path>#<exportName>" in config/service/operations.json.'
66
+ );
67
+ }
68
+
69
+ const segments = handlerRef.split('#');
70
+ if (segments.length !== 2) {
71
+ throw new Error(
72
+ `[HandlerRef] handlerRef contains ${segments.length - 1} '#' separators - Expected exactly `
73
+ + `one, as "handlers/<path>#<exportName>". Got: '${handlerRef}'. `
74
+ + 'Fix: correct the handler ref in config/service/operations.json.'
75
+ );
76
+ }
77
+
78
+ const [relPath, exportName] = segments;
79
+ if (!relPath || !exportName) {
80
+ throw new Error(
81
+ '[HandlerRef] handlerRef has an empty half - Expected "<relative/path>#<exportName>" '
82
+ + `with both sides non-empty. Got: '${handlerRef}'. `
83
+ + 'Fix: correct the handler ref in config/service/operations.json.'
84
+ );
85
+ }
86
+
87
+ return { relPath, exportName };
88
+ }
89
+
90
+ /**
91
+ * Parse a handler ref against the DECLARED FORM.
92
+ *
93
+ * @param {string} handlerRef - as written in `config/service/operations.json`
94
+ * @returns {{relPath: string, exportName: string}} path relative to `src/`, and the named export
95
+ * @throws {Error} when the ref is absent, malformed, or outside the declared form
96
+ */
97
+ function parseHandlerRef(handlerRef) {
98
+ const parts = splitHandlerRef(handlerRef);
99
+
100
+ if (!HANDLER_REF_PATTERN.test(handlerRef)) {
101
+ throw new Error(
102
+ '[HandlerRef] handlerRef does not match the declared form - Expected '
103
+ + '"handlers/<path>#<exportName>" (path relative to src/, no file extension). '
104
+ + `Got: '${handlerRef}'. Fix: correct the handler ref in config/service/operations.json `
105
+ + '- see api/docs/biz/30-operations/schema-v3.md § handler.'
106
+ );
107
+ }
108
+
109
+ return parts;
110
+ }
111
+
112
+ /**
113
+ * Resolve a handler ref to the exported function, the way the runtime does.
114
+ *
115
+ * The containment check runs on the RESOLVED location, not on the spelling of
116
+ * the reference, so `..` segments and an absolute path are both caught; the
117
+ * prefix carries the separator so a prefix-named sibling (`src-evil` beside
118
+ * `src`) cannot pass as a child. It is lexical — a symlink inside the source
119
+ * root pointing out of it is not detected, exactly as in `HandlerLoader`.
120
+ *
121
+ * @param {Object} args
122
+ * @param {string} args.serviceRoot - absolute path to the service root (holds `src/`)
123
+ * @param {string} args.handlerRef - `<relative/path>#<exportName>`
124
+ * @returns {{modulePath: string, exportName: string, handler: Function}}
125
+ * @throws {Error} when the ref escapes the source root, the module does not
126
+ * load, or the named export is not a function
127
+ */
128
+ function resolveHandlerModule({ serviceRoot, handlerRef } = {}) {
129
+ if (typeof serviceRoot !== 'string' || serviceRoot.length === 0) {
130
+ throw new Error(
131
+ '[HandlerRef] serviceRoot is required - Expected an absolute path to the service root '
132
+ + `(the directory holding src/). Got: ${serviceRoot}.`
133
+ );
134
+ }
135
+ if (!path.isAbsolute(serviceRoot)) {
136
+ throw new Error(
137
+ `[HandlerRef] serviceRoot must be absolute - Got: ${serviceRoot}. `
138
+ + 'Fix: pass the resolved service root (path.resolve of the directory holding src/).'
139
+ );
140
+ }
141
+
142
+ const { relPath, exportName } = splitHandlerRef(handlerRef);
143
+
144
+ const baseDir = path.resolve(serviceRoot, 'src');
145
+ const baseDirPrefix = path.join(baseDir, path.sep);
146
+ const absModule = path.resolve(baseDir, relPath);
147
+ if (!absModule.startsWith(baseDirPrefix)) {
148
+ throw new Error(
149
+ `[HandlerRef] handlerRef escapes the service source root - '${handlerRef}' resolves to `
150
+ + `'${absModule}', outside '${baseDir}'. Fix: use a path inside the service source root `
151
+ + '(no ".." segments, not absolute).'
152
+ );
153
+ }
154
+
155
+ let modulePath;
156
+ let mod;
157
+ try {
158
+ modulePath = require.resolve(absModule);
159
+ mod = require(modulePath);
160
+ } catch (error) {
161
+ throw new Error(
162
+ `[HandlerRef] Failed to require '${absModule}' resolved from '${handlerRef}' - `
163
+ + 'Expected a loadable module at that path. Fix: ship the module in the service, or '
164
+ + `correct the handler ref in config/service/operations.json. Cause: ${error.message}`
165
+ );
166
+ }
167
+
168
+ const handler = mod[exportName];
169
+ if (typeof handler !== 'function') {
170
+ throw new Error(
171
+ `[HandlerRef] Export '${exportName}' is not a function in '${modulePath}' - Expected the `
172
+ + `module to expose '${exportName}' as a function (the ref '${handlerRef}' names it). `
173
+ + `Fix: make '${exportName}' a function in that module, or correct the handler ref in `
174
+ + 'config/service/operations.json.'
175
+ );
176
+ }
177
+
178
+ return { modulePath, exportName, handler };
179
+ }
180
+
181
+ module.exports = { HANDLER_REF_PATTERN, parseHandlerRef, resolveHandlerModule };