@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.
- package/CHANGELOG.md +2582 -2
- package/README.md +1038 -4
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +408 -101
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +10 -35
- package/src/ValidationOrchestrator.js +219 -71
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +2 -1
- package/src/helpers/createServiceReadinessTests.js +60 -4
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +140 -9
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +2 -1
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +409 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +22 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +123 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- 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
|
+
};
|