@onlineapps/conn-orch-validator 7.0.0 → 8.1.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 +2582 -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 +409 -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 +22 -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 +123 -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
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WHERE a README's uniform pointer points, for each of the two bearer kinds —
5
+ * and therefore WHICH region that README should carry.
6
+ *
7
+ * `readmePointer.js` renders the region and knows no path of its own; this
8
+ * module is the half that touches the disk. It exists because since d.215e the
9
+ * region has TWO readers — the generator (`oa-sync-template readme-uniform`) and
10
+ * the conformance rows `G-README` / `L-README-REGION` — and a link computed two
11
+ * ways would be a region that is stale by construction on one of the two
12
+ * (`.claude/rules/change-discipline.md` § One rail per concern).
13
+ *
14
+ * The two kinds differ, and the difference is not a preference:
15
+ *
16
+ * * a SERVICE repository is its own checkout, so its region links at the
17
+ * manifest as the repository reaches it after `npm install` — the copy the
18
+ * pin decides and the one `oa-validate` reads there. Nothing above the
19
+ * repository is consulted, which is what lets the row answer in a container;
20
+ * * a LIBRARY lies beside this package in one checkout, so its region links at
21
+ * the manifest AS IT LIES THERE, found through the manifest's own discovery
22
+ * rather than through a path anybody typed.
23
+ *
24
+ * @see api/docs/governance/confirmations/biz-service-manifest.md § Confirmation 20260909-biz-service-manifest-005
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const { loadManifest, DEFAULT_MANIFEST_PATH, LIBRARY_MANIFEST_PATH } = require('../manifest/loadManifest');
31
+ const {
32
+ API_CHECKOUT_ROOT, PACKAGE_ROOT: OWN_ROOT, resolveWorkspacePath
33
+ } = require('../manifest/workspaceRoot');
34
+ const { KINDS, renderUniformRegion } = require('./readmePointer');
35
+
36
+ /** The file the pointer is a region of. */
37
+ const README_FILE = 'README.md';
38
+
39
+ /**
40
+ * This package, by the name a consumer installs it under — read when a link
41
+ * needs it, never at load time. Only the biz-service pointer names the package
42
+ * (its link goes through node_modules); a library pointer links inside the
43
+ * checkout and needs no name. A copy of the engine that carries src/ and
44
+ * manifests/ but no package.json (the pre-push hook's fixture repo is one) can
45
+ * therefore judge a library without this file, and a service run that lacks it
46
+ * fails here with the cause named, not with a module-load stack trace.
47
+ *
48
+ * @returns {string}
49
+ */
50
+ function selfName() {
51
+ const pkgPath = path.join(__dirname, '..', '..', 'package.json');
52
+ if (!fs.existsSync(pkgPath)) {
53
+ throw new Error(`[ReadmeLocation] This copy of the engine carries no package.json - ${pkgPath} is missing, `
54
+ + 'so the biz-service pointer cannot say under which name a bearer installs the manifest. '
55
+ + 'Fix: run the engine from an installed package or a full checkout of api/shared/connector/conn-orch-validator.');
56
+ }
57
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).name;
58
+ }
59
+
60
+ /** This package's own root, from this file's place in it. */
61
+ const PACKAGE_ROOT = path.join(__dirname, '..', '..');
62
+
63
+ /** Where each manifest sits inside this package — derived, never typed. */
64
+ const SERVICE_MANIFEST_IN_PACKAGE = path.relative(PACKAGE_ROOT, DEFAULT_MANIFEST_PATH);
65
+ const LIBRARY_MANIFEST_IN_PACKAGE = path.relative(PACKAGE_ROOT, LIBRARY_MANIFEST_PATH);
66
+
67
+ /**
68
+ * The service uniform's manifest as the service repository reaches it.
69
+ *
70
+ * @param {string} serviceRoot repository root
71
+ * @returns {string} absolute path
72
+ */
73
+ function serviceManifestPath(serviceRoot) {
74
+ return path.join(serviceRoot, 'node_modules', ...selfName().split('/'), SERVICE_MANIFEST_IN_PACKAGE);
75
+ }
76
+
77
+ /**
78
+ * Where this package lies inside its own workspace, workspace-relative — or null
79
+ * when it lies in no checkout at all (an installed copy, a service container).
80
+ *
81
+ * It is what a row declares through `requiresSiblings`, so a run over a
82
+ * workspace that carries no copy of this package is reported NOT RUN by the
83
+ * runner rather than failing inside the render: a library README's link points
84
+ * at the manifest AS IT LIES IN THAT CHECKOUT, and where there is no copy there
85
+ * is no link — which is a question the run could not answer, never a package
86
+ * that passed (`.claude/rules/automation-gates.md` §5).
87
+ */
88
+ const PACKAGE_IN_WORKSPACE = API_CHECKOUT_ROOT === null
89
+ ? null
90
+ : path.relative(path.dirname(API_CHECKOUT_ROOT), OWN_ROOT).split(path.sep).join('/');
91
+
92
+ /**
93
+ * The manifest as it lies in the workspace being read, whichever of the two it
94
+ * is.
95
+ *
96
+ * The link a README carries has to resolve for a reader of THAT checkout, so it
97
+ * cannot point at an installed copy under `node_modules`. The position is this
98
+ * package's own, workspace-relative — the same string `requiresSiblings`
99
+ * declares, so what a row says it needs and what this function reads are one
100
+ * fact, and a workspace missing it is reported NOT RUN by the runner rather than
101
+ * throwing in the middle of a render.
102
+ *
103
+ * @param {{workspaceRoot: string, relativeInPackage?: string}} params
104
+ * @returns {string} absolute path to the manifest inside that workspace
105
+ */
106
+ function manifestInWorkspace({ workspaceRoot, relativeInPackage = LIBRARY_MANIFEST_IN_PACKAGE }) {
107
+ if (PACKAGE_IN_WORKSPACE === null) {
108
+ throw new Error(`[ReadmeLocation] This copy of ${selfName()} lies in no checkout - the region links at the `
109
+ + 'manifest as it lies in the repository being read, and an installed copy under node_modules is not '
110
+ + 'that. Fix: run from the checkout that carries the package sources.');
111
+ }
112
+
113
+ const dir = resolveWorkspacePath(workspaceRoot, PACKAGE_IN_WORKSPACE);
114
+ if (!fs.existsSync(dir)) {
115
+ throw new Error(`[ReadmeLocation] Workspace holds no ${selfName()} - the region links at the manifest as `
116
+ + `it lies in the repository being read, and ${workspaceRoot} carries no ${PACKAGE_IN_WORKSPACE}. `
117
+ + 'Fix: run over the workspace whose api/shared/ holds that package.');
118
+ }
119
+ return path.join(dir, relativeInPackage);
120
+ }
121
+
122
+ /**
123
+ * A repository's README, or null when there is none.
124
+ *
125
+ * @param {string} dir repository or package root
126
+ * @returns {string|null}
127
+ */
128
+ function readReadme(dir) {
129
+ const file = path.join(dir, README_FILE);
130
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return null;
131
+ return fs.readFileSync(file, 'utf8');
132
+ }
133
+
134
+ /**
135
+ * The region a SERVICE repository's README should carry, rendered from the
136
+ * packaged manifest. Nothing above the repository is read, which is what lets
137
+ * the row that compares it answer inside a container.
138
+ *
139
+ * @param {string} serviceRoot repository root
140
+ * @returns {string} the region, markers included
141
+ */
142
+ function serviceRegion(serviceRoot) {
143
+ return renderUniformRegion({
144
+ kind: KINDS.service,
145
+ manifest: loadManifest(DEFAULT_MANIFEST_PATH),
146
+ packageDir: serviceRoot,
147
+ manifestPath: serviceManifestPath(serviceRoot)
148
+ });
149
+ }
150
+
151
+ /**
152
+ * The region a LIBRARY's README should carry. The manifest it links at is the
153
+ * copy lying in the same checkout.
154
+ *
155
+ * @param {{packageDir: string, pkg: object, workspaceRoot: string, manifest?: object}} params
156
+ * `manifest` is passed by a caller that has already read it, so a run over 28
157
+ * packages parses it once.
158
+ * @returns {string} the region, markers included
159
+ */
160
+ function libraryRegion({ packageDir, pkg, workspaceRoot, manifest }) {
161
+ return renderUniformRegion({
162
+ kind: KINDS.library,
163
+ manifest: manifest === undefined ? loadManifest(LIBRARY_MANIFEST_PATH) : manifest,
164
+ pkg,
165
+ packageDir,
166
+ manifestPath: manifestInWorkspace({ workspaceRoot })
167
+ });
168
+ }
169
+
170
+ module.exports = {
171
+ README_FILE,
172
+ selfName,
173
+ PACKAGE_ROOT,
174
+ PACKAGE_IN_WORKSPACE,
175
+ SERVICE_MANIFEST_IN_PACKAGE,
176
+ LIBRARY_MANIFEST_IN_PACKAGE,
177
+ serviceManifestPath,
178
+ manifestInWorkspace,
179
+ serviceRegion,
180
+ libraryRegion,
181
+ readReadme
182
+ };
@@ -0,0 +1,477 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The rendered uniform pointer of a repository README.
5
+ *
6
+ * Confirmation `biz-service-manifest` 005 point 2: discoverability is ONE
7
+ * rendered pointer per repository — a generated region fed by the manifest and
8
+ * the pin, never typed, rendering which paths of the repository fall under which
9
+ * duty section. A stale region fails `--check`. Individual files carry no
10
+ * uniform line; their uniform is their location (005 point 1).
11
+ *
12
+ * The confirmation names three bearer kinds in one sentence — `library/<category>`,
13
+ * `biz-service`, `infra-service` — so the module is parameterised by kind rather
14
+ * than copied per kind: a second renderer would be two rails under one rule
15
+ * (`.claude/rules/change-discipline.md` § One rail per concern). What actually
16
+ * differs is declared as data in `SPECS`: the region id, the command that
17
+ * regenerates it, the manifest a failed check names, whether the README must
18
+ * carry a node header, and the body renderer.
19
+ *
20
+ * Everything in a region is read from a manifest (and, for a library, from the
21
+ * one thing a package declares). Nothing is written by hand, which is the whole
22
+ * point: the section names and the row ids are descriptive facts, and a
23
+ * hand-kept copy of them in 28 READMEs would rot the day a row moved
24
+ * (`.claude/rules/doc-code-binding.md` §1).
25
+ *
26
+ * Deliberately absent from every region: a date, a package version, a count of
27
+ * anything. The version a README is checked against IS the pin on this package
28
+ * (004 — the manifest ships inside it), so a version line would be a second
29
+ * owner of that fact, and a date would make two runs render different bytes,
30
+ * which is exactly what stops a generator from being a gate. Equally absent: a
31
+ * row's `why` and `fix`. Those are prose with an owner, and the region is not it
32
+ * (the shape d.216 established and d.226 repeated — a region of ids and links,
33
+ * never of paragraphs).
34
+ *
35
+ * Pure functions: nothing here reads `process.env`, opens a file or knows a path
36
+ * of its own. The caller loads the manifest, reads the README and writes it
37
+ * (`.claude/rules/architecture-principles.md` §1).
38
+ *
39
+ * @see api/docs/governance/confirmations/biz-service-manifest.md § Confirmation 20260909-biz-service-manifest-005
40
+ */
41
+
42
+ const path = require('path');
43
+
44
+ const { markersFor, replaceRegion, extractRegion: extractBetween, checkRegion } = require('./generatedRegion');
45
+
46
+ /** The bearer kinds this package renders a pointer for. */
47
+ const KINDS = Object.freeze({ library: 'library', service: 'service' });
48
+
49
+ /** `applies_to` of the section every library wears, whatever it is for. */
50
+ const ALL_CATEGORIES = '*';
51
+
52
+ /**
53
+ * The top-level block naming which repositories wear the uniform. Its rows are
54
+ * the sentence above the duty sections, so they are not ALSO a section: one fact
55
+ * reaches the reader once (`api/.claude/rules/single-source-of-truth.md`, applied
56
+ * to the rendered text).
57
+ */
58
+ const DISCOVERY_SECTION = 'discovery';
59
+
60
+ /** The file every error of this module is about; the prefix is generatedRegion.js's own. */
61
+ const WHERE = Object.freeze({ file: 'README.md' });
62
+
63
+ const code = (value) => `\`${value}\``;
64
+
65
+ /**
66
+ * The category the package DECLARES. It is the one fact about a library that
67
+ * cannot be derived — the intent — so its absence is reported, never defaulted
68
+ * (005 point 3). A service declares nothing of the sort: the uniform it wears
69
+ * follows from its location, which is why the service body takes no package.
70
+ *
71
+ * @param {object|null} pkg parsed package.json
72
+ * @returns {string|null}
73
+ */
74
+ function declaredCategory(pkg) {
75
+ const declared = pkg && pkg.oa ? pkg.oa.category : undefined;
76
+ return typeof declared === 'string' && declared.length > 0 ? declared : null;
77
+ }
78
+
79
+ function categoriesOf(manifest) {
80
+ const categories = manifest && manifest.discovery && manifest.discovery.library
81
+ ? manifest.discovery.library.categories
82
+ : undefined;
83
+ if (categories === null || typeof categories !== 'object' || Array.isArray(categories)) {
84
+ throw new Error('[ReadmePointer] Manifest declares no discovery.library.categories - the pointer names '
85
+ + 'the category the manifest knows and never invents one. '
86
+ + 'Fix: repair manifests/library.manifest.json.');
87
+ }
88
+ return categories;
89
+ }
90
+
91
+ function dutiesOf(manifest) {
92
+ const duties = manifest ? manifest.duties : undefined;
93
+ if (duties === null || typeof duties !== 'object' || Array.isArray(duties)) {
94
+ throw new Error('[ReadmePointer] Manifest declares no duties - the region renders which duty sections '
95
+ + 'apply to the package. Fix: repair manifests/library.manifest.json.');
96
+ }
97
+ return duties;
98
+ }
99
+
100
+ /**
101
+ * The link target as the two paths compute it, `/`-separated and relative, with
102
+ * `./` in front when it does not already start with a `.` — a bare
103
+ * `manifests/…` reads as a path, `./manifests/…` reads as a link.
104
+ */
105
+ function linkTo({ packageDir, manifestPath }) {
106
+ if (typeof packageDir !== 'string' || packageDir.length === 0
107
+ || typeof manifestPath !== 'string' || manifestPath.length === 0) {
108
+ throw new Error('[ReadmePointer] packageDir and manifestPath are required - the link is computed from '
109
+ + 'them and never written by hand. Fix: pass both absolute paths.');
110
+ }
111
+ const relative = path.relative(packageDir, manifestPath).split(path.sep).join('/');
112
+ return relative.startsWith('.') ? relative : `./${relative}`;
113
+ }
114
+
115
+ /* ------------------------------------------------------------------- bodies */
116
+
117
+ /**
118
+ * The library body: the pointer line, then the duty sections that apply to this
119
+ * package's declared category, each with the ids of its rows in manifest order.
120
+ */
121
+ function renderLibraryBody({ manifest, pkg, link }) {
122
+ const categories = categoriesOf(manifest);
123
+ const duties = dutiesOf(manifest);
124
+
125
+ const category = declaredCategory(pkg);
126
+ if (category === null) {
127
+ throw new Error('[ReadmePointer] Package declares no "oa"."category" - the layer a package is meant to '
128
+ + 'sit in cannot be derived from anything else, so the pointer cannot be rendered. '
129
+ + 'Fix: declare "oa": { "category": "<core|connector|orchestration|runtime|tooling>" } in package.json.');
130
+ }
131
+ if (!Object.prototype.hasOwnProperty.call(categories, category)) {
132
+ throw new Error(`[ReadmePointer] Unknown category "${category}" - the manifest knows `
133
+ + `${Object.keys(categories).join(', ')}. `
134
+ + 'Fix: declare one of those in package.json, or add the category to manifests/library.manifest.json.');
135
+ }
136
+
137
+ const sections = Object.entries(duties)
138
+ .filter(([, block]) => block && (block.applies_to === ALL_CATEGORIES || block.applies_to === category))
139
+ .map(([name, block]) => {
140
+ const rows = Array.isArray(block.rows) ? block.rows : [];
141
+ if (rows.length === 0) {
142
+ throw new Error(`[ReadmePointer] Duty section "${name}" has no rows - a section with nothing to `
143
+ + 'check is not a duty. Fix: repair manifests/library.manifest.json.');
144
+ }
145
+ return `- ${code(name)}: ${rows.map((row) => row.id).join(', ')}`;
146
+ });
147
+
148
+ return [
149
+ `Uniform: [library/${category}](${link})`,
150
+ '',
151
+ 'Duty sections that apply:',
152
+ '',
153
+ ...sections
154
+ ];
155
+ }
156
+
157
+ /**
158
+ * Every row of a manifest, in declaration order, each carrying the top-level
159
+ * section that holds it and the array it sits in.
160
+ *
161
+ * Read rather than enumerated: a row added to the manifest reaches every README
162
+ * without this file being edited, which is the difference between a generated
163
+ * region and a hand-kept list (`.claude/rules/doc-code-binding.md` §1).
164
+ *
165
+ * @returns {Array<{row: object, section: string, group: string|null}>}
166
+ */
167
+ function rowsOf(manifest) {
168
+ const found = [];
169
+ const walk = (node, section, group) => {
170
+ if (Array.isArray(node)) {
171
+ for (const item of node) {
172
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string') {
173
+ found.push({ row: item, section, group });
174
+ } else walk(item, section, group);
175
+ }
176
+ return;
177
+ }
178
+ if (node !== null && typeof node === 'object') {
179
+ for (const [key, value] of Object.entries(node)) {
180
+ walk(value, section, Array.isArray(value) ? key : group);
181
+ }
182
+ }
183
+ };
184
+ for (const [key, value] of Object.entries(manifest)) walk(value, key, null);
185
+ return found;
186
+ }
187
+
188
+ /**
189
+ * The path that OWNS a row's value, when the row takes one from elsewhere (004).
190
+ *
191
+ * A reference into this package (d.229) is rendered as the reader of THIS
192
+ * repository reaches it: under `node_modules`, beside the manifest the pointer
193
+ * links at. The prefix is not typed here — it is the link the caller computed,
194
+ * with the manifest's own position inside the package taken off it — so one
195
+ * fact (where this package lies, from here) has one owner.
196
+ *
197
+ * @param {object} row the manifest row
198
+ * @param {{link: string, source: string}} where the region's link and the manifest's path in the package
199
+ * @returns {string|null}
200
+ */
201
+ function ownerOf(row, { link, source } = {}) {
202
+ const from = row.from;
203
+ if (from === null || typeof from !== 'object') return null;
204
+ if (typeof from.package === 'string' && from.package.length > 0) {
205
+ return `${packageRootOf({ link, source })}/${from.package}`;
206
+ }
207
+ return typeof from.path === 'string' ? from.path : null;
208
+ }
209
+
210
+ /**
211
+ * Where this package lies, as the README's own link already says it: the link
212
+ * minus the manifest's position inside the package.
213
+ *
214
+ * @param {{link: string, source: string}} params
215
+ * @returns {string}
216
+ */
217
+ function packageRootOf({ link, source } = {}) {
218
+ if (typeof link !== 'string' || typeof source !== 'string' || !link.endsWith(`/${source}`)) {
219
+ throw new Error(`[ReadmePointer] The manifest link does not end in ${JSON.stringify(source)} - a row `
220
+ + 'referencing a file this package carries is rendered relative to where the link says the package '
221
+ + `lies, and ${JSON.stringify(link)} does not say. `
222
+ + 'Fix: pass the link this kind\'s manifest was rendered with.');
223
+ }
224
+ return link.slice(0, -`/${source}`.length);
225
+ }
226
+
227
+ /**
228
+ * The discovery sentence: which repositories wear this uniform, and who says
229
+ * they exist. Both halves are the discovery block's own — the pattern it
230
+ * matches and the SSOT it is bound to — so the sentence carries no name this
231
+ * file knows.
232
+ */
233
+ function discoverySentence({ manifest }) {
234
+ const block = manifest[DISCOVERY_SECTION] ? manifest[DISCOVERY_SECTION][manifest.uniform] : undefined;
235
+ if (block === null || typeof block !== 'object' || typeof block.pattern !== 'string'
236
+ || ownerOf(block) === null || !Array.isArray(block.rows)) {
237
+ throw new Error(`[ReadmePointer] Manifest "${manifest.uniform}" names no discovery block with a pattern, `
238
+ + 'a from: reference and rows - the pointer says which repositories wear the uniform and never '
239
+ + 'invents that list. Fix: repair manifests/biz-service.manifest.json.');
240
+ }
241
+ const rows = block.rows.map((row) => code(row.id)).join(', ');
242
+ return `Every ${code(block.pattern)} that ${code(ownerOf(block))} lists wears it (${rows}).`;
243
+ }
244
+
245
+ /**
246
+ * The service body: the pointer line, who wears the uniform, the row ids grouped
247
+ * by the section that declares them, and which path falls under which row.
248
+ *
249
+ * The paths are the rows' own `path` values — 005 point 2's "which paths of the
250
+ * repository fall under which duty section" — and a row taking its value from
251
+ * elsewhere renders the OWNER of that value, never the value (004:
252
+ * "`api/.nvmrc`" stays true when the platform moves to the next major, "Node 24"
253
+ * does not).
254
+ *
255
+ * A FORBIDDEN row names its path like every other row. Until d.215e it did not,
256
+ * and the reason was not about the reader: `api/tests/scripts/add-service.bats`
257
+ * grepped the WHOLE template for `hooks/pre-commit`, so a region mentioning the
258
+ * retired path failed a test whose subject is whether `init.sh` still INSTALLS
259
+ * the hook. The test now asks that of the files that could install it, which is
260
+ * what it always meant, and the region stopped bending around it — a generated
261
+ * list that omits a row's path because of where the text lands is a list that
262
+ * says less than the manifest does (`.claude/rules/doc-code-binding.md` §1).
263
+ */
264
+ function renderServiceBody({ manifest, link, source }) {
265
+ const rows = rowsOf(manifest);
266
+ if (rows.length === 0) {
267
+ throw new Error('[ReadmePointer] Manifest declares no rows - a uniform that checks nothing is not a '
268
+ + 'uniform. Fix: repair manifests/biz-service.manifest.json.');
269
+ }
270
+
271
+ const sections = new Map();
272
+ const owned = new Map();
273
+
274
+ for (const { row, section } of rows) {
275
+ if (section !== DISCOVERY_SECTION) {
276
+ if (!sections.has(section)) sections.set(section, []);
277
+ sections.get(section).push(row.id);
278
+ }
279
+ if (typeof row.path !== 'string') continue;
280
+ if (!owned.has(row.path)) owned.set(row.path, []);
281
+ const from = ownerOf(row, { link, source });
282
+ owned.get(row.path).push(from === null ? row.id : `${row.id} (from ${code(from)})`);
283
+ }
284
+
285
+ const pathLines = [...owned.keys()].sort()
286
+ .map((relative) => `- ${code(relative)}: ${owned.get(relative).join(', ')}`);
287
+
288
+ return [
289
+ `Uniform: [${manifest.uniform}](${link})`,
290
+ '',
291
+ discoverySentence({ manifest }),
292
+ '',
293
+ 'Duty sections that apply:',
294
+ '',
295
+ ...[...sections.entries()].map(([name, ids]) => `- ${code(name)}: ${ids.join(', ')}`),
296
+ '',
297
+ 'Paths a duty owns:',
298
+ '',
299
+ ...pathLines
300
+ ];
301
+ }
302
+
303
+ /* -------------------------------------------------------------------- kinds */
304
+
305
+ /**
306
+ * What each bearer kind fixes. The marker shape is
307
+ * `api/scripts/ci/sync-biz-facts.mjs` (`BEGIN`/`END GENERATED`), taken over
308
+ * verbatim apart from the id and the command, so the workspace has ONE shape of
309
+ * generated region rather than a second dialect of the same idea. Finding,
310
+ * replacing, extracting and diffing a region is `generatedRegion.js`, shared
311
+ * with `docsRegion.js`.
312
+ *
313
+ * `header_required` is the one placement decision the kinds do not share. A
314
+ * library README must open with the node header — d.213b gave all 28 packages
315
+ * one, and row `L-README` of the library manifest requires it — so its absence
316
+ * means the run was pointed at something that is not a library README, and the
317
+ * region is not guessed at. Nothing requires a header of a SERVICE README (no
318
+ * row does), so there the pointer simply takes the first line of the file, which
319
+ * is what 005 point 2 asks for.
320
+ *
321
+ * The regenerate command differs for the same reason `--all` does: libraries all
322
+ * live in one checkout, and a service repository is its own.
323
+ */
324
+ const SPECS = Object.freeze({
325
+ [KINDS.library]: Object.freeze({
326
+ region_id: 'library-uniform',
327
+ regenerate: 'npx oa-sync-template readme-uniform --all',
328
+ source: 'manifests/library.manifest.json',
329
+ header_required: true,
330
+ body: renderLibraryBody
331
+ }),
332
+ [KINDS.service]: Object.freeze({
333
+ region_id: 'biz-service-uniform',
334
+ regenerate: 'npx oa-sync-template readme-uniform --target .',
335
+ source: 'manifests/biz-service.manifest.json',
336
+ header_required: false,
337
+ body: renderServiceBody
338
+ })
339
+ });
340
+
341
+ function specOf(kind) {
342
+ if (!Object.prototype.hasOwnProperty.call(SPECS, kind)) {
343
+ throw new Error(`[ReadmePointer] Unknown bearer kind "${kind}" - this package renders `
344
+ + `${Object.keys(SPECS).join(', ')}. `
345
+ + 'Fix: name one of those, or declare the kind here before a README asks for it.');
346
+ }
347
+ return SPECS[kind];
348
+ }
349
+
350
+ /**
351
+ * The two marker lines of one kind's region.
352
+ *
353
+ * @param {string} kind one of KINDS
354
+ * @returns {{begin: string, end: string}}
355
+ */
356
+ function markersOf(kind) {
357
+ const spec = specOf(kind);
358
+ return markersFor(spec.region_id, spec.regenerate);
359
+ }
360
+
361
+ /**
362
+ * The region text the manifest describes, markers included. LF endings, no
363
+ * trailing newline — the caller places it in the file.
364
+ *
365
+ * @param {{kind: string, manifest: object, pkg?: object, packageDir: string, manifestPath: string}} params
366
+ * `packageDir` and `manifestPath` are what the link is computed from; the
367
+ * signature carries them because a pure function cannot look them up. `pkg` is
368
+ * read by the library kind only — a service declares no category.
369
+ * @returns {string}
370
+ */
371
+ function renderUniformRegion({ kind, manifest, pkg, packageDir, manifestPath } = {}) {
372
+ const spec = specOf(kind);
373
+ const markers = markersOf(kind);
374
+ const link = linkTo({ packageDir, manifestPath });
375
+
376
+ return [markers.begin, ...spec.body({ manifest, pkg, link, source: spec.source }), markers.end].join('\n');
377
+ }
378
+
379
+ /**
380
+ * Where the node header ends: the run of `>` lines the file opens with, followed
381
+ * by one blank line (`api/docs/standards/INFRA-DOC-STANDARD.md`; L-README checks
382
+ * the same block). The region goes immediately after that blank line, so the
383
+ * header keeps the first lines and the pointer still precedes the heading.
384
+ *
385
+ * A file with no header is refused for a kind that requires one, and takes the
386
+ * region on its first line for a kind that does not (see `SPECS`).
387
+ *
388
+ * @returns {number} index of the first line the region may be inserted before
389
+ */
390
+ function headerEnd(lines, { required }) {
391
+ if (lines.length === 0 || !lines[0].startsWith('>')) {
392
+ if (!required) return 0;
393
+ throw new Error('[ReadmePointer] README has no node header - the file must open with the '
394
+ + '"> Status:" / "> Owns:" block, and the region is placed after it. '
395
+ + 'Fix: give README.md the node header (api/docs/standards/INFRA-DOC-STANDARD.md).');
396
+ }
397
+
398
+ let index = 0;
399
+ while (index < lines.length && lines[index].startsWith('>')) index += 1;
400
+
401
+ if (lines[index] !== '') {
402
+ throw new Error('[ReadmePointer] Node header is not followed by a blank line - the region is placed '
403
+ + `after it and the shape has to be unambiguous, but line ${index + 1} is `
404
+ + `${JSON.stringify(lines[index] === undefined ? null : lines[index])}. `
405
+ + 'Fix: leave one blank line between the "> " block and the rest of README.md.');
406
+ }
407
+ return index + 1;
408
+ }
409
+
410
+ /**
411
+ * The README with the region in it: replaced where one already exists, inserted
412
+ * where none does. Everything outside the markers is returned byte-identical — a
413
+ * README is mostly prose nobody generated.
414
+ *
415
+ * @param {string} readmeText the file's current content
416
+ * @param {string} regionText what `renderUniformRegion` produced
417
+ * @param {{kind: string}} params which kind's markers this is
418
+ * @returns {string}
419
+ */
420
+ function applyUniformRegion(readmeText, regionText, { kind } = {}) {
421
+ const spec = specOf(kind);
422
+ if (typeof readmeText !== 'string' || typeof regionText !== 'string') {
423
+ throw new Error('[ReadmePointer] applyUniformRegion(readmeText, regionText) needs two strings - got '
424
+ + `${typeof readmeText} and ${typeof regionText}. Fix: read the README before applying the region.`);
425
+ }
426
+
427
+ const replaced = replaceRegion(readmeText, markersOf(kind), regionText, WHERE);
428
+ if (replaced !== null) return replaced;
429
+
430
+ const lines = readmeText.split('\n');
431
+ const insertAt = headerEnd(lines, { required: spec.header_required });
432
+ return [...lines.slice(0, insertAt), regionText, '', ...lines.slice(insertAt)].join('\n');
433
+ }
434
+
435
+ /**
436
+ * The region on disk, or null when the file carries none of this kind's.
437
+ */
438
+ function extractRegion(readmeText, { kind } = {}) {
439
+ return extractBetween(readmeText, markersOf(kind));
440
+ }
441
+
442
+ /**
443
+ * Whether the README's region is what the manifest renders, and — when it is not
444
+ * — the lines that differ, so the failure names the drift instead of announcing
445
+ * it (`.claude/rules/automation-gates.md` §1 requirement 4).
446
+ *
447
+ * Only the region is compared. Prose outside the markers is nobody's generated
448
+ * output, and a check that failed on it would fight every documentation edit.
449
+ *
450
+ * The diff is a plain expected/actual block rather than the prefix-suffix
451
+ * trimming `sharedEnv.js` does over a 40-key env file: a region is short enough
452
+ * that trimming it hides more than it saves.
453
+ *
454
+ * @param {string} readmeText
455
+ * @param {string} regionText
456
+ * @param {{kind: string}} params
457
+ * @returns {{ok: boolean, diff: string}} `diff` is '' exactly when `ok` is true
458
+ */
459
+ function checkUniformRegion(readmeText, regionText, { kind } = {}) {
460
+ const spec = specOf(kind);
461
+ if (typeof readmeText !== 'string' || typeof regionText !== 'string') {
462
+ throw new Error('[ReadmePointer] checkUniformRegion(readmeText, regionText) needs two strings - got '
463
+ + `${typeof readmeText} and ${typeof regionText}. Fix: read the README before checking it.`);
464
+ }
465
+
466
+ return checkRegion({ text: readmeText, markers: markersOf(kind), regionText, source: spec.source });
467
+ }
468
+
469
+ module.exports = {
470
+ KINDS,
471
+ markersOf,
472
+ renderUniformRegion,
473
+ applyUniformRegion,
474
+ checkUniformRegion,
475
+ extractRegion,
476
+ declaredCategory
477
+ };