@onlineapps/conn-orch-validator 12.1.1 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +607 -0
  2. package/README.md +126 -19
  3. package/manifests/biz-service.manifest.json +15 -2
  4. package/manifests/library.manifest.json +4 -4
  5. package/package.json +11 -3
  6. package/src/CookbookTestRunner.js +275 -105
  7. package/src/CookbookTestUtils.js +79 -68
  8. package/src/ServiceReadinessValidator.js +42 -52
  9. package/src/ValidationOrchestrator.js +65 -44
  10. package/src/cli/biz-ci-gate.js +2 -2
  11. package/src/cli/oa-sync-template.js +97 -47
  12. package/src/cli/oa-validate.js +44 -10
  13. package/src/helpers/README.md +6 -6
  14. package/src/helpers/createServiceReadinessTests.js +87 -33
  15. package/src/index.js +14 -5
  16. package/src/lint/scripts/lintScripts.js +11 -4
  17. package/src/manifest/checks/libraryContext.js +6 -3
  18. package/src/manifest/checks/libraryDocs.js +174 -4
  19. package/src/manifest/checks/libraryTests.js +200 -19
  20. package/src/manifest/checks/scriptHeaders.js +6 -13
  21. package/src/manifest/checks/serviceConfig.js +36 -16
  22. package/src/manifest/checks/serviceConnectors.js +180 -2
  23. package/src/manifest/checks/serviceDb.js +0 -3
  24. package/src/manifest/checks/serviceScripts.js +3 -20
  25. package/src/manifest/runManifest.js +90 -13
  26. package/src/manifest/workspaceRoot.js +133 -4
  27. package/src/mocks/MockMQClient.js +2 -2
  28. package/src/sync/docsRegion.js +2 -2
  29. package/src/sync/readmeFile.js +30 -0
  30. package/src/sync/readmeLocation.js +2 -12
  31. package/src/sync/readmePointer.js +10 -4
  32. package/src/sync/serviceTemplate.js +9 -11
  33. package/src/sync/sharedEnv.js +59 -3
  34. package/src/sync/uniformFiles.js +81 -8
  35. package/src/utils/bizCiGateContract.js +2 -2
  36. package/src/utils/connectorContract.js +54 -2
  37. package/src/utils/cookbookFormat.js +25 -115
  38. package/src/utils/dbAccountGrants.js +5 -3
  39. package/src/utils/deployContract.js +153 -28
  40. package/src/utils/envContract.js +2 -2
  41. package/src/utils/handlerRef.js +8 -10
  42. package/src/utils/integrationRun.js +1 -1
  43. package/src/utils/operationsDocumentRules.js +242 -0
  44. package/src/utils/operationsRules.js +157 -0
  45. package/src/utils/resolveHeaders.js +12 -1
  46. package/src/utils/setupDatabase.js +1 -1
  47. package/src/utils/stepFailure.js +3 -3
  48. package/src/utils/stepReferences.js +28 -87
  49. package/src/utils/throwawaySchema.js +1 -1
  50. package/src/utils/yamlTopLevel.js +105 -0
  51. package/src/validators/ServiceStructureValidator.js +67 -152
  52. package/templates/business-service/.gitlab-ci.yml +203 -37
  53. package/templates/business-service/README.md +7 -4
  54. package/templates/business-service/config/env-templates/shared.env +1 -0
  55. package/templates/business-service/docs/80-setup/INSTALL.md +31 -3
  56. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +5 -2
  57. package/templates/business-service/src/config/index.js +15 -0
  58. package/TESTING_STRATEGY.md +0 -92
  59. package/jest.config.js +0 -37
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Which top-level key of a YAML document owns a line — the ONE definition of
5
+ * that question in this package.
6
+ *
7
+ * Two modules ask it, for two different purposes, and both used to answer it
8
+ * themselves: `src/sync/serviceTemplate.js` (which keys the platform block
9
+ * declares, and whose spans the sync replaces) and `src/utils/deployContract.js`
10
+ * (which JOB a `git reset --hard` sits in, so R2 can refuse a guard standing in
11
+ * another one). Two regexes for one fact, with two tolerances and no note that
12
+ * they differed: `.claude/rules/change-discipline.md` § One rail per concern.
13
+ *
14
+ * Column 0 is the whole rule, and it is enough for the files this reads: a
15
+ * `.gitlab-ci.yml` job is a top-level key and everything it owns is indented
16
+ * under it. Text in, positions out — nothing is re-serialized, so a comment and
17
+ * a quoting style survive (`.claude/rules/automation-gates.md` §1 requirement 3).
18
+ *
19
+ * The two readings differ in ONE thing, and it is a named predicate over this
20
+ * one regex rather than a second regex:
21
+ *
22
+ * `topLevelKeys` every key, hidden ones included. A hidden key
23
+ * (`.oa-uniform:`) is a declaration GitLab never runs
24
+ * as a job, but it is still a key, and a reader
25
+ * asking "which key is this line under" has to place
26
+ * it somewhere truthful.
27
+ * `rewritableTopLevelKeys` hidden keys dropped. This is the reading a
28
+ * GENERATOR may act on: `replaceTopLevelKeys`
29
+ * DELETES the spans it is given, and a hidden key a
30
+ * service wrote itself is not the platform's to
31
+ * delete (tests/unit/manifestCiBlock.test.js, G-CI
32
+ * over a pipeline that predates the markers).
33
+ */
34
+
35
+ /**
36
+ * A top-level mapping key: a line that starts in column 0 and ends its key with
37
+ * a colon. The leading dot is optional, because a hidden key is a key.
38
+ *
39
+ * The key's characters are what excludes the lines that are not keys: a list
40
+ * item (`- if: …`), a comment (`# build:`), an indented key, and anything with a
41
+ * space in it cannot match, so no `.gitlab-ci.yml` construct is mistaken for one.
42
+ */
43
+ const TOP_LEVEL_KEY = /^(\.?[A-Za-z_][A-Za-z0-9_.-]*):/gm;
44
+
45
+ /** What `topLevelKeyAt` answers for text that precedes the first key. */
46
+ const NO_TOP_LEVEL_KEY = '(before the first key)';
47
+
48
+ /** True for a hidden key — GitLab runs nothing whose name starts with a dot. */
49
+ function isHiddenKey(name) {
50
+ return name.startsWith('.');
51
+ }
52
+
53
+ /**
54
+ * Every top-level key of the document, in file order.
55
+ *
56
+ * @param {string} text the document
57
+ * @returns {Array<{name: string, at: number, line: number}>} `at` is the offset
58
+ * of the key in `text`, `line` its 0-based index in `text.split('\n')`.
59
+ */
60
+ function topLevelKeys(text) {
61
+ if (typeof text !== 'string') {
62
+ throw new Error('[YamlTopLevel] Missing document text - Expected the YAML as a string. '
63
+ + 'Fix: pass the file contents, not a path and not an array of lines.');
64
+ }
65
+ const keys = [];
66
+ TOP_LEVEL_KEY.lastIndex = 0;
67
+ let scanned = 0;
68
+ let line = 0;
69
+ let hit;
70
+ while ((hit = TOP_LEVEL_KEY.exec(text)) !== null) {
71
+ for (let i = scanned; i < hit.index; i += 1) if (text[i] === '\n') line += 1;
72
+ scanned = hit.index;
73
+ keys.push({ name: hit[1], at: hit.index, line });
74
+ }
75
+ return keys;
76
+ }
77
+
78
+ /**
79
+ * The top-level keys a generator may rewrite: every key except the hidden ones.
80
+ *
81
+ * @param {string} text the document
82
+ * @returns {Array<{name: string, at: number, line: number}>}
83
+ */
84
+ function rewritableTopLevelKeys(text) {
85
+ return topLevelKeys(text).filter((key) => !isHiddenKey(key.name));
86
+ }
87
+
88
+ /**
89
+ * The key an offset sits under — for a command in a `.gitlab-ci.yml`, the job
90
+ * whose shell runs it.
91
+ *
92
+ * @param {Array<{name: string, at: number}>} keys from `topLevelKeys`
93
+ * @param {number} offset an offset into the same document
94
+ * @returns {string} the key's name, or `NO_TOP_LEVEL_KEY`
95
+ */
96
+ function topLevelKeyAt(keys, offset) {
97
+ let name = NO_TOP_LEVEL_KEY;
98
+ for (const key of keys) {
99
+ if (key.at > offset) break;
100
+ name = key.name;
101
+ }
102
+ return name;
103
+ }
104
+
105
+ module.exports = { topLevelKeys, rewritableTopLevelKeys, topLevelKeyAt, NO_TOP_LEVEL_KEY, isHiddenKey };
@@ -17,7 +17,8 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
- const { HANDLER_REF_PATTERN } = require('../utils/handlerRef');
20
+ const { operationsRuleRecords } = require('../utils/operationsRules');
21
+ const { operationsDocumentFindings, documentFindingRecord } = require('../utils/operationsDocumentRules');
21
22
 
22
23
  /**
23
24
  * Read a configuration file from the ONE place a service may keep it:
@@ -130,7 +131,7 @@ function readFile(root, rel) {
130
131
  * The body is `[^{}]*?`, NOT `[\s\S]*?`: a lazy any-character body starts at the
131
132
  * nearest preceding `const {` and swallows whole lines to reach the right
132
133
  * `require`, which dropped the first imported name. Measured on
133
- * `api_biz/meta/src/handlers/persons.js:10-11`, where an unrelated `models`
134
+ * the imports of `api_biz/meta/src/handlers/persons.js`, where an unrelated `models`
134
135
  * destructuring sits directly above the service-common one.
135
136
  *
136
137
  * @returns {string[]}
@@ -279,7 +280,7 @@ const STANDARD_LEVELS = [
279
280
  // infrastructureGate, health, validation, mq, registry, monitoring, state,
280
281
  // secrets and heartbeat — not tenantContext; `createTenantContextMiddleware`
281
282
  // was deleted on 2026-09-05 together with the runtime-defaults key
282
- // (docs/governance/confirmations/wrapper-tenant-middleware.md). A
283
+ // (api/docs/governance/confirmations/wrapper-tenant-middleware.md). A
283
284
  // level enforcing a dead declaration is the false guarantee automation-gates.md
284
285
  // §5 names, so the level is GONE rather than emptied — an empty level would
285
286
  // pass for every service while asserting nothing.
@@ -423,7 +424,7 @@ class ServiceStructureValidator {
423
424
  ? `Standard ${nextLevel.level} (${nextLevel.name}): ${check.message}`
424
425
  : `Standard ${nextLevel.level} (${nextLevel.name}): missing ${check.message}`,
425
426
  fix: check.fix
426
- || `Implement ${check.message} to reach standard ${nextLevel.level}. See docs/biz/60-templates/service-template.md`
427
+ || `Implement ${check.message} to reach standard ${nextLevel.level}. See api/docs/biz/60-templates/service-template.md`
427
428
  });
428
429
  }
429
430
  }
@@ -510,7 +511,7 @@ class ServiceStructureValidator {
510
511
  type: 'MISSING_CONFIG',
511
512
  path: 'config/service/config.json',
512
513
  message: 'Service configuration missing: config/service/config.json',
513
- fix: 'Create config.json with service metadata. See: /docs/biz/60-templates/service-template.md'
514
+ fix: 'Create config.json with service metadata. See: api/docs/biz/60-templates/service-template.md'
514
515
  });
515
516
  } else {
516
517
  try {
@@ -533,7 +534,7 @@ class ServiceStructureValidator {
533
534
  type: 'MISSING_OPERATIONS',
534
535
  path: 'config/service/operations.json',
535
536
  message: 'Operations specification missing: config/service/operations.json',
536
- fix: 'Create operations.json. See: /docs/biz/30-operations/schema-v3.md'
537
+ fix: 'Create operations.json. See: api/docs/biz/30-operations/schema-v3.md'
537
538
  });
538
539
  } else {
539
540
  try {
@@ -552,40 +553,42 @@ class ServiceStructureValidator {
552
553
  }
553
554
 
554
555
  /**
555
- * Validate config.json structure
556
- * Note: service.version is now read from package.json (Single Source of Truth)
556
+ * What step 1 still says about `config.json`: nothing about WHICH KEYS it
557
+ * carries. That question has one owner, the manifest row `C-SERVICE`, and
558
+ * this step is the structural half — the file is there, it reads, it parses.
559
+ *
560
+ * Until d.726 a `requiredFields = ['service.name']` lived here, with a
561
+ * message and a fix of its own, beside a row that demands the same key and
562
+ * two more. The four questions the removal has to answer
563
+ * (`.claude/rules/change-discipline.md` § Removing something removes its
564
+ * declaration):
565
+ *
566
+ * how it came to be — step 1 predates the manifest. When it was written it
567
+ * WAS the only reader of `config.json`, so the key list had to live
568
+ * somewhere and this was the only somewhere there was.
569
+ *
570
+ * which part of the concept carried it — service shape, and that part still
571
+ * stands. What moved is where it is declared: confirmation
572
+ * `biz-service-manifest` §2 makes the manifest the place a rule of service
573
+ * shape is written, with an id, an owner, a severity, a fix and a doc
574
+ * pointer. d.465 already moved boot step 2 onto it.
575
+ *
576
+ * why there were two — nobody removed the first when the second arrived.
577
+ *
578
+ * is the replacement MORE conceptual — yes, and measurably so. `C-SERVICE`
579
+ * demands three keys, not one, and each because grep found the reader that
580
+ * fails without it (`manifest/checks/serviceConfig.js`,
581
+ * REQUIRED_SERVICE_KEYS). The list here had one key and no reader named.
582
+ *
583
+ * And the two rails did not merely overlap: measured d.726, step 1 failing on
584
+ * `name` FAIL-FASTED the orchestrator, so step 2 never ran and a missing
585
+ * `workspaceScoped` — which the wrapper refuses to boot without — was not
586
+ * reported at all until `name` was fixed. The narrower rail was hiding the
587
+ * conceptual one.
588
+ *
589
+ * Note: service.version is read from package.json (Single Source of Truth).
557
590
  */
558
591
  validateConfigStructure(config) {
559
- // Required fields (version comes from package.json, not config.json).
560
- //
561
- // `service.port` was required here until 2026-08-30. ADR 0005 removed the
562
- // HTTP surface from biz containers, so nothing listens and the number has
563
- // no consumer — requiring it forced every repo to keep a declaration that
564
- // means nothing, which is what `change-discipline.md` § "Removing something
565
- // removes its declaration" exists to prevent. Absence is now correct;
566
- // presence is reported below as a warning, never as a failure.
567
- const requiredFields = [
568
- 'service.name'
569
- ];
570
-
571
- for (const field of requiredFields) {
572
- const parts = field.split('.');
573
- let value = config;
574
- for (const part of parts) {
575
- value = value?.[part];
576
- }
577
-
578
- if (!value) {
579
- this.errors.push({
580
- type: 'MISSING_CONFIG_FIELD',
581
- path: 'config/service/config.json',
582
- field: field,
583
- message: `Required field missing in config.json: ${field}`,
584
- fix: `Add "${field}" to config.json`
585
- });
586
- }
587
- }
588
-
589
592
  // A port that is still declared is a stale declaration, not a failure. Say
590
593
  // so out loud: `automation-gates.md` §5 — a check that stays silent about a
591
594
  // thing it can see is a false guarantee, and the eight services measured in
@@ -638,118 +641,19 @@ class ServiceStructureValidator {
638
641
  /**
639
642
  * Validate operations.json structure (v3 — handler registry dispatch).
640
643
  *
644
+ * Nothing about `operations.json` is decided in this method. The rules about
645
+ * the DOCUMENT (`schema_version`, an operations map with nothing in it) are
646
+ * `src/utils/operationsDocumentRules.js`; the rules about each OPERATION are
647
+ * `src/utils/operationsRules.js`, which asks
648
+ * `@onlineapps/service-validator-core` — the mirror of what the Registry
649
+ * refuses a registration on. Both used to be restated here, and both said
650
+ * something different from what the other rails said (d.465, d.465b).
651
+ *
641
652
  * @see api/docs/biz/30-operations/schema-v3.md § File shape
642
653
  */
643
654
  validateOperationsStructure(operations) {
644
- if (!operations.operations) {
645
- this.errors.push({
646
- type: 'INVALID_OPERATIONS_STRUCTURE',
647
- path: 'config/service/operations.json',
648
- message: 'operations.json must have "operations" key',
649
- fix: 'Wrap operations in {"operations": {...}} in config/service/operations.json'
650
- });
651
- return;
652
- }
653
-
654
- const ops = operations.operations;
655
- if (typeof ops !== 'object' || Array.isArray(ops)) {
656
- this.errors.push({
657
- type: 'INVALID_OPERATIONS_TYPE',
658
- path: 'config/service/operations.json',
659
- message: 'operations must be an object',
660
- fix: 'operations should be key-value pairs: {"operation-name": {...}}'
661
- });
662
- return;
663
- }
664
-
665
- if (operations.schema_version && operations.schema_version !== '3.0') {
666
- this.warnings.push({
667
- type: 'SCHEMA_VERSION_MISMATCH',
668
- path: 'config/service/operations.json',
669
- field: 'schema_version',
670
- value: operations.schema_version,
671
- message: `operations.json schema_version is "${operations.schema_version}" — expected "3.0"`,
672
- fix: 'Set schema_version to "3.0" in config/service/operations.json'
673
- });
674
- }
675
-
676
- if (Object.keys(ops).length === 0) {
677
- this.warnings.push({
678
- type: 'NO_OPERATIONS',
679
- path: 'config/service/operations.json',
680
- message: 'No operations defined',
681
- fix: 'Add at least one operation to operations.json'
682
- });
683
- return;
684
- }
685
-
686
- for (const [operationName, operationSpec] of Object.entries(ops)) {
687
- this.validateOperation(operationName, operationSpec);
688
- }
689
- }
690
-
691
- /**
692
- * Validate single operation structure (v3).
693
- * Required: handler, bundle_scope. Forbidden (v2): endpoint, method, path.
694
- *
695
- * @see api/docs/biz/30-operations/schema-v3.md § Per-operation keys
696
- */
697
- validateOperation(name, spec) {
698
- const requiredFields = ['handler', 'bundle_scope'];
699
-
700
- for (const field of requiredFields) {
701
- if (!spec[field]) {
702
- this.errors.push({
703
- type: 'MISSING_OPERATION_FIELD',
704
- path: 'config/service/operations.json',
705
- operation: name,
706
- field,
707
- message: `Operation "${name}" missing required field: ${field}`,
708
- fix: `Add "${field}" to operation "${name}" in config/service/operations.json`
709
- });
710
- }
711
- }
712
-
713
- if (spec.handler && !HANDLER_REF_PATTERN.test(spec.handler)) {
714
- this.errors.push({
715
- type: 'INVALID_HANDLER_REF',
716
- path: 'config/service/operations.json',
717
- operation: name,
718
- field: 'handler',
719
- value: spec.handler,
720
- message: `Handler must be in form 'handlers/<path>#<exportName>': ${spec.handler}`,
721
- fix: `Change handler to form 'handlers/v3/<file>#<exportName>'`
722
- });
723
- }
724
-
725
- const validScopes = ['platform', 'tenant', 'workspace'];
726
- if (spec.bundle_scope && !validScopes.includes(spec.bundle_scope)) {
727
- this.errors.push({
728
- type: 'INVALID_BUNDLE_SCOPE',
729
- path: 'config/service/operations.json',
730
- operation: name,
731
- field: 'bundle_scope',
732
- value: spec.bundle_scope,
733
- message: `Invalid bundle_scope: ${spec.bundle_scope}`,
734
- fix: `Use one of: ${validScopes.join(', ')}`
735
- });
736
- }
737
-
738
- // Reject v2 HTTP-routing fields (clean break per ARCHITECTURE_PRINCIPLES.md §11).
739
- const forbiddenV2Fields = ['endpoint', 'method', 'path'];
740
- for (const forbidden of forbiddenV2Fields) {
741
- if (forbidden in spec) {
742
- this.errors.push({
743
- type: 'V2_FIELD_PRESENT',
744
- path: 'config/service/operations.json',
745
- operation: name,
746
- field: forbidden,
747
- value: spec[forbidden],
748
- message: `Operation "${name}" has retired v2 field "${forbidden}" — not allowed in v3 schema`,
749
- fix: `Remove "${forbidden}" from operation "${name}" in config/service/operations.json — v3 dispatches via the handler registry, not by URL`
750
- });
751
- }
752
- }
655
+ this.errors.push(...operationsDocumentFindings(operations).map(documentFindingRecord));
656
+ this.errors.push(...operationsRuleRecords(operations && operations.operations).records);
753
657
  }
754
658
 
755
659
  /**
@@ -791,11 +695,22 @@ class ServiceStructureValidator {
791
695
  }
792
696
  }
793
697
 
794
- // Check for @onlineapps dependencies
698
+ // The @onlineapps packages a biz service cannot be without: the wrapper it
699
+ // boots through, and this validator, which its own gate runs.
700
+ //
701
+ // `@onlineapps/service-common` was a third entry and is not one any more.
702
+ // The v1.2 block at the top of this file records the decision it
703
+ // contradicted: `dep_service_common` and `business_error_usage` were
704
+ // RETIRED because the error hierarchy moved to the wrapper — so this file
705
+ // refused an import from that package up there and advised installing it
706
+ // down here. It stays a live package, which makes it a dependency a
707
+ // service adds when it needs what that package exports, never a
708
+ // recommendation for every service. See api/docs/biz/RETIRED-VOCABULARY.md,
709
+ // and tests/unit/serviceStructureRecommendedDeps.test.js for what was
710
+ // measured.
795
711
  const requiredDeps = [
796
712
  '@onlineapps/service-wrapper',
797
- '@onlineapps/conn-orch-validator',
798
- '@onlineapps/service-common'
713
+ '@onlineapps/conn-orch-validator'
799
714
  ];
800
715
 
801
716
  for (const dep of requiredDeps) {
@@ -844,7 +759,7 @@ class ServiceStructureValidator {
844
759
  type: 'MISSING_HANDLERS',
845
760
  path: 'src/handlers',
846
761
  message: 'Handlers directory missing: src/handlers/',
847
- fix: 'Create src/handlers/ with at least one v3 handler module. See: /docs/biz/60-templates/service-template.md'
762
+ fix: 'Create src/handlers/ with at least one v3 handler module. See: api/docs/biz/60-templates/service-template.md'
848
763
  });
849
764
  } else {
850
765
  this.info.push('✓ Found src/handlers/');
@@ -860,7 +775,7 @@ class ServiceStructureValidator {
860
775
  type: 'LEGACY_APP_JS',
861
776
  path: 'src/app.js',
862
777
  message: 'src/app.js is dead code post-ADR 0005 (bootstrap no longer reads it)',
863
- fix: 'Delete src/app.js; move any residual middleware into the wrapper adapter. See: /docs/biz/80-decisions/0005-no-http-in-biz-containers.md'
778
+ fix: 'Delete src/app.js; move any residual middleware into the wrapper adapter. See: api/docs/biz/80-decisions/0005-no-http-in-biz-containers.md'
864
779
  });
865
780
  }
866
781