@onlineapps/conn-orch-validator 7.0.0 → 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 +2558 -2
  2. package/README.md +1038 -4
  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 +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  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 +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  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 +140 -9
  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 +2 -1
  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. */
@@ -495,6 +563,61 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
495
563
  // that itself reads the platform env (checked above).
496
564
  if (callsNamespaceHelper(text)) continue;
497
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
+
498
621
  add('R8', `${file.relative} names a tenant / workspace without taking it from the\n`
499
622
  + ' shared test-namespace module.\n'
500
623
  + ' An integration test writes into a real database, so the namespace it\n'
@@ -505,7 +628,7 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
505
628
  }
506
629
 
507
630
  /**
508
- * Verify one biz repository against R1-R8.
631
+ * Verify one biz repository against the deploy contract.
509
632
  *
510
633
  * @param {string} serviceRoot absolute path to the biz repo
511
634
  * @returns {{service: string, ok: boolean, violations: Array<{requirement: string, message: string}>}}
@@ -520,7 +643,15 @@ function verifyDeployContract(serviceRoot) {
520
643
  }
521
644
 
522
645
  const violations = [];
523
- 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
+ };
524
655
 
525
656
  const compose = readIfExists(path.join(serviceRoot, 'docker-compose.production.yml'));
526
657
  if (compose === null) {
@@ -554,4 +685,4 @@ function verifyDeployContract(serviceRoot) {
554
685
  };
555
686
  }
556
687
 
557
- 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 };