@onlineapps/conn-orch-validator 6.0.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2591 -2
- package/README.md +1075 -7
- 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 +422 -104
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +78 -42
- package/src/ValidationOrchestrator.js +298 -75
- 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 +12 -2
- package/src/helpers/createServiceReadinessTests.js +75 -6
- 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 +213 -13
- 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 +21 -20
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -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 +4 -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 +101 -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,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `git-tracked` — a file this uniform REQUIRES must reach a clean clone.
|
|
5
|
+
*
|
|
6
|
+
* Finding 19 of the controller (`api/shared/TODO.md`), on the owner's words about
|
|
7
|
+
* `.gitignore`: "biz služba by neměla mít právo ignorovat nic závažného, co
|
|
8
|
+
* uniforma vyžaduje". It is not a theory. Measured 2026-09-10 on a copy of
|
|
9
|
+
* converter outside the repository: with `init.sh` and `jest.config.js` added to
|
|
10
|
+
* `.gitignore` and still on disk, `F-INIT` and `F-JEST` reported NOTHING —
|
|
11
|
+
* because every file row reads the WORKING TREE. Delete the same two files, the
|
|
12
|
+
* state a clean clone and the production image are actually in, and both rows
|
|
13
|
+
* fire and the service is NOT DEPLOYABLE.
|
|
14
|
+
*
|
|
15
|
+
* Developer green, CI and image red. It is the same class as the pathspec-commit
|
|
16
|
+
* trap the workspace has already paid for once
|
|
17
|
+
* (`.claude/rules/shared-working-tree.md` § Past untracked souborů), one layer
|
|
18
|
+
* down: what git will hand to the next reader is not what the checker looked at.
|
|
19
|
+
*
|
|
20
|
+
* WHICH PATHS. The ones the manifest already names — `files.identical`,
|
|
21
|
+
* `files.contains` and `files.generated` — read from the block this row sits in,
|
|
22
|
+
* so there is NO second list here and adding a file row extends this one too
|
|
23
|
+
* (confirmation `biz-service-manifest` 004 point 2). `files.forbidden` is
|
|
24
|
+
* deliberately excluded: demanding that a banned file be tracked would be a
|
|
25
|
+
* contradiction. So is `files.own`, whose whole meaning is that the service
|
|
26
|
+
* decides.
|
|
27
|
+
*
|
|
28
|
+
* A named path that is a DIRECTORY covers every file under it. `scripts/` is the
|
|
29
|
+
* case that makes the difference: `S-SCRIPTS` lints the headers of the scripts
|
|
30
|
+
* it finds on disk, and a script the clone never receives is linted here and
|
|
31
|
+
* missing there.
|
|
32
|
+
*
|
|
33
|
+
* TRACKED is the question, not IGNORED. A tracked file matching a `.gitignore`
|
|
34
|
+
* line is harmless — git ignores the rule for it — while an untracked file is
|
|
35
|
+
* gone from the clone whether a rule covers it or not. `git check-ignore` is
|
|
36
|
+
* asked only about the files that already failed, because that is where it turns
|
|
37
|
+
* "this is missing" into "delete this line".
|
|
38
|
+
*
|
|
39
|
+
* OUTSIDE A GIT CHECKOUT the row is NOT RUN with that reason — never a pass. A
|
|
40
|
+
* service container and an exported tarball carry no `.git`, and answering "all
|
|
41
|
+
* tracked" there would be the false guarantee `automation-gates.md` §5 names.
|
|
42
|
+
*
|
|
43
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md §2
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
const { execFileSync } = require('child_process');
|
|
49
|
+
|
|
50
|
+
/** Never walked: neither is part of a repository's declared shape. */
|
|
51
|
+
const NEVER_WALKED = Object.freeze(['node_modules', '.git']);
|
|
52
|
+
|
|
53
|
+
/** The file classes whose rows REQUIRE a path to be there. */
|
|
54
|
+
const REQUIRING_CLASSES = Object.freeze(['identical', 'contains', 'generated']);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run one git command in the repository, or return null when git itself could
|
|
58
|
+
* not answer. Nothing here guesses: the caller turns a null into NOT RUN.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} serviceRoot
|
|
61
|
+
* @param {string[]} args
|
|
62
|
+
* @param {string} [input]
|
|
63
|
+
* @returns {string|null}
|
|
64
|
+
*/
|
|
65
|
+
function git(serviceRoot, args, input = undefined) {
|
|
66
|
+
try {
|
|
67
|
+
return execFileSync('git', ['-C', serviceRoot, ...args], {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
input,
|
|
70
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// check-ignore exits 1 when nothing matched, which is an ANSWER.
|
|
74
|
+
if (error.status === 1 && typeof error.stdout === 'string') return error.stdout;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Does the path exist under EXACTLY this spelling?
|
|
81
|
+
*
|
|
82
|
+
* `fs.existsSync` is the wrong question on macOS and on Windows: the filesystem
|
|
83
|
+
* is case-insensitive, git's index is not. Measured 2026-09-11 against property,
|
|
84
|
+
* whose setup documents are committed as `docs/80-setup/install.md` while the row
|
|
85
|
+
* `G-SETUP-INSTALL` names `docs/80-setup/INSTALL.md` — `existsSync` said yes,
|
|
86
|
+
* `git ls-files` said no, and this row reported two untracked files that are
|
|
87
|
+
* tracked. A finding nobody can act on is the defect `automation-gates.md` §5
|
|
88
|
+
* names, and here it would also hide the real one: a name a row does not match
|
|
89
|
+
* is ABSENT, which is the file row's own finding and not this one's.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} serviceRoot
|
|
92
|
+
* @param {string[]} segments the path, split
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function existsExactly(serviceRoot, segments) {
|
|
96
|
+
let dir = serviceRoot;
|
|
97
|
+
for (const [index, segment] of segments.entries()) {
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const entry = entries.find((candidate) => candidate.name === segment);
|
|
105
|
+
if (entry === undefined) return false;
|
|
106
|
+
if (index < segments.length - 1 && !entry.isDirectory()) return false;
|
|
107
|
+
dir = path.join(dir, segment);
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every file under `relative`, repository-relative, `/`-separated. A path naming
|
|
114
|
+
* one file answers with itself; one naming a directory answers with its tree.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} serviceRoot
|
|
117
|
+
* @param {string} relative as the row writes it, with or without a trailing slash
|
|
118
|
+
* @returns {string[]}
|
|
119
|
+
*/
|
|
120
|
+
function filesUnder(serviceRoot, relative) {
|
|
121
|
+
const trimmed = relative.replace(/\/+$/, '');
|
|
122
|
+
const absolute = path.join(serviceRoot, ...trimmed.split('/'));
|
|
123
|
+
if (!existsExactly(serviceRoot, trimmed.split('/'))) return [];
|
|
124
|
+
if (fs.statSync(absolute).isFile()) return [trimmed];
|
|
125
|
+
|
|
126
|
+
const found = [];
|
|
127
|
+
const descend = (dir, prefix) => {
|
|
128
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
129
|
+
if (NEVER_WALKED.includes(entry.name)) continue;
|
|
130
|
+
const next = `${prefix}/${entry.name}`;
|
|
131
|
+
if (entry.isDirectory()) descend(path.join(dir, entry.name), next);
|
|
132
|
+
else if (entry.isFile()) found.push(next);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
descend(absolute, trimmed);
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The paths the manifest requires, from the block this row belongs to.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} block the `files` object
|
|
143
|
+
* @returns {string[]}
|
|
144
|
+
*/
|
|
145
|
+
function requiredPaths(block) {
|
|
146
|
+
const paths = new Set();
|
|
147
|
+
for (const name of REQUIRING_CLASSES) {
|
|
148
|
+
for (const row of Array.isArray(block[name]) ? block[name] : []) {
|
|
149
|
+
if (typeof row.path === 'string' && row.path.length > 0) paths.add(row.path);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return [...paths].sort();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const check = Object.freeze({
|
|
156
|
+
scope: 'service',
|
|
157
|
+
requires: Object.freeze([]),
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {{ block: object, serviceRoot: string }} params
|
|
161
|
+
* @returns {{findings: Array<{where: string, what: string}>, notRun: string|null}}
|
|
162
|
+
*/
|
|
163
|
+
run({ block, serviceRoot }) {
|
|
164
|
+
const listed = git(serviceRoot, ['ls-files', '-z']);
|
|
165
|
+
if (listed === null) {
|
|
166
|
+
return {
|
|
167
|
+
findings: [],
|
|
168
|
+
notRun: 'this tree is not a git checkout (git ls-files could not answer), so what a clone '
|
|
169
|
+
+ 'would receive cannot be read — a container and an exported tarball are in exactly this state'
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const tracked = new Set(listed.split('\0').filter((entry) => entry.length > 0));
|
|
174
|
+
|
|
175
|
+
const candidates = requiredPaths(block).flatMap((relative) => filesUnder(serviceRoot, relative)
|
|
176
|
+
.map((file) => ({ file, declared: relative })));
|
|
177
|
+
|
|
178
|
+
const missing = candidates.filter(({ file }) => !tracked.has(file));
|
|
179
|
+
if (missing.length === 0) return { findings: [], notRun: null };
|
|
180
|
+
|
|
181
|
+
// Only now, and only for those: which of them a .gitignore rule covers is
|
|
182
|
+
// what turns the finding into an edit the reader can make.
|
|
183
|
+
const answered = git(serviceRoot, ['check-ignore', '--stdin', '-z'], missing.map(({ file }) => file).join('\0'));
|
|
184
|
+
const ignored = new Set((answered === null ? '' : answered).split('\0').filter((entry) => entry.length > 0));
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
findings: missing.map(({ file, declared }) => ({
|
|
188
|
+
where: file,
|
|
189
|
+
what: ignored.has(file)
|
|
190
|
+
? `git does not track it and .gitignore covers it — the uniform requires ${declared}, so a clone, `
|
|
191
|
+
+ 'a CI checkout and the production image get a repository without this file while this working '
|
|
192
|
+
+ 'tree reports it present'
|
|
193
|
+
: `git does not track it — the uniform requires ${declared}, so a clone, a CI checkout and the `
|
|
194
|
+
+ 'production image get a repository without this file while this working tree reports it present'
|
|
195
|
+
})),
|
|
196
|
+
notRun: null
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// `existsExactly` is exported for the one other place that asks whether a file
|
|
202
|
+
// exists under the name the contract spells (installContract.js) — one rail for
|
|
203
|
+
// the case-insensitive-filesystem trap, not a second copy of it.
|
|
204
|
+
module.exports = { name: 'git-tracked', check, existsExactly };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The registry of checks a manifest row may name.
|
|
5
|
+
*
|
|
6
|
+
* A row is a DECLARATION (`id`, `severity`, `owner`, `fix`, `doc`) plus the name
|
|
7
|
+
* of the check that decides it. The check is the code. Keeping the two apart is
|
|
8
|
+
* what makes the manifest extensible by one row and keeps the rules of service
|
|
9
|
+
* and library shape in one place instead of two.
|
|
10
|
+
*
|
|
11
|
+
* Every entry states:
|
|
12
|
+
* - `scope` — one of the three words `manifestShape.js` § CHECK_SCOPES
|
|
13
|
+
* defines: `service` (the bearer's root is enough), `workspace`
|
|
14
|
+
* (the check reports about the workspace itself) or `bearer`
|
|
15
|
+
* (the check reports about one bearer and needs the workspace
|
|
16
|
+
* only to read its `from:` reference — so it runs once per
|
|
17
|
+
* discovered bearer in a workspace run). The last two are NOT
|
|
18
|
+
* RUN where the workspace is unreachable;
|
|
19
|
+
* - `requires` — the row fields the check reads, verified by the shape check
|
|
20
|
+
* before anything runs, so a check never sees a missing param;
|
|
21
|
+
* - `requiresSiblings({ row, block })` — OPTIONAL; the workspace-relative
|
|
22
|
+
* directories this check will walk. The runner verifies they
|
|
23
|
+
* exist BEFORE running it and reports NOT RUN otherwise, so a
|
|
24
|
+
* check that walks an absent directory never reports the empty
|
|
25
|
+
* set as an answer. Derive them from the row or the block
|
|
26
|
+
* (`rootOfPattern`), never restate a name the row already
|
|
27
|
+
* carries;
|
|
28
|
+
* - `run({ row, block, serviceRoot, workspaceRoot })` — returns
|
|
29
|
+
* `[{ where, what }]`, one per finding; the runner adds the
|
|
30
|
+
* row's own id, severity, fix, owner and doc.
|
|
31
|
+
*
|
|
32
|
+
* This is the bridge to the checkers this package already has, and the reason
|
|
33
|
+
* they are not rewritten: a row such as
|
|
34
|
+
* `{ "id": "R-PORTS", "check": "deploy-contract", "requirement": "R4", … }`
|
|
35
|
+
* needs one registry entry that calls `verifyDeployContract(serviceRoot)` and
|
|
36
|
+
* keeps the violations of that requirement — no rule moves into the manifest,
|
|
37
|
+
* the manifest only names it (confirmation biz-service-manifest 004 point 4).
|
|
38
|
+
* The rows citing the existing contracts land with d.210.
|
|
39
|
+
*
|
|
40
|
+
* A module registers either one check (`{ name, check }`) or several
|
|
41
|
+
* (`{ checks: [{ name, check }, …] }`); grouping the rows of one concern in one
|
|
42
|
+
* file is what keeps the library family readable.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const fileAbsent = require('./fileAbsent');
|
|
46
|
+
const gitTracked = require('./gitTracked');
|
|
47
|
+
const discoveryOrphan = require('./discoveryOrphan');
|
|
48
|
+
const contractBridge = require('./contractBridge');
|
|
49
|
+
const docsLintBridge = require('./docsLintBridge');
|
|
50
|
+
const serviceFiles = require('./serviceFiles');
|
|
51
|
+
const serviceScripts = require('./serviceScripts');
|
|
52
|
+
const scriptHeaders = require('./scriptHeaders');
|
|
53
|
+
const serviceConfig = require('./serviceConfig');
|
|
54
|
+
const serviceConnectors = require('./serviceConnectors');
|
|
55
|
+
const serviceIdentityRows = require('./serviceIdentityRows');
|
|
56
|
+
const serviceRuntime = require('./serviceRuntime');
|
|
57
|
+
const serviceDb = require('./serviceDb');
|
|
58
|
+
const libraryPackage = require('./libraryPackage');
|
|
59
|
+
const libraryTests = require('./libraryTests');
|
|
60
|
+
const libraryDocs = require('./libraryDocs');
|
|
61
|
+
const librarySource = require('./librarySource');
|
|
62
|
+
const libraryWorkspace = require('./libraryWorkspace');
|
|
63
|
+
const readmeRegion = require('./readmeRegion');
|
|
64
|
+
const { CHECK_SCOPES } = require('../manifestShape');
|
|
65
|
+
|
|
66
|
+
const MODULES = Object.freeze([
|
|
67
|
+
fileAbsent,
|
|
68
|
+
gitTracked,
|
|
69
|
+
discoveryOrphan,
|
|
70
|
+
contractBridge,
|
|
71
|
+
docsLintBridge,
|
|
72
|
+
serviceFiles,
|
|
73
|
+
serviceScripts,
|
|
74
|
+
scriptHeaders,
|
|
75
|
+
serviceConfig,
|
|
76
|
+
serviceConnectors,
|
|
77
|
+
serviceIdentityRows,
|
|
78
|
+
serviceRuntime,
|
|
79
|
+
serviceDb,
|
|
80
|
+
libraryPackage,
|
|
81
|
+
libraryTests,
|
|
82
|
+
libraryDocs,
|
|
83
|
+
librarySource,
|
|
84
|
+
libraryWorkspace,
|
|
85
|
+
readmeRegion
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const registry = {};
|
|
89
|
+
for (const module of MODULES) {
|
|
90
|
+
const entries = Array.isArray(module.checks) ? module.checks : [{ name: module.name, check: module.check }];
|
|
91
|
+
for (const { name, check } of entries) {
|
|
92
|
+
// Fail-fast at registration, not at the first run that needs it: a check
|
|
93
|
+
// whose scope the runner does not know would be placed nowhere, and the
|
|
94
|
+
// report of a run that placed it nowhere is indistinguishable from the
|
|
95
|
+
// report of a run in which it found nothing (`automation-gates.md` §5).
|
|
96
|
+
if (!CHECK_SCOPES.includes(check.scope)) {
|
|
97
|
+
throw new Error(`[CheckRegistry] Check declares an unknown scope - "${name}" states `
|
|
98
|
+
+ `${JSON.stringify(check.scope)}. `
|
|
99
|
+
+ `Fix: declare one of ${CHECK_SCOPES.join(', ')} (see manifestShape.js § CHECK_SCOPES).`);
|
|
100
|
+
}
|
|
101
|
+
if (registry[name] !== undefined) {
|
|
102
|
+
throw new Error(`[CheckRegistry] Check name registered twice - "${name}". `
|
|
103
|
+
+ 'Fix: a finding is named by its row and decided by exactly one check; rename one of them.');
|
|
104
|
+
}
|
|
105
|
+
registry[name] = check;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const CHECK_REGISTRY = Object.freeze(registry);
|
|
110
|
+
|
|
111
|
+
module.exports = { CHECK_REGISTRY };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What every library row needs before it can decide anything: the package's own
|
|
5
|
+
* `package.json`, the category it declares, and a `where` a human can act on.
|
|
6
|
+
*
|
|
7
|
+
* This module holds no rule. Each rule is one check module plus the manifest row
|
|
8
|
+
* that declares it; keeping the reading here is what stops five checks from
|
|
9
|
+
* parsing the same file five different ways.
|
|
10
|
+
*
|
|
11
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md §11
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const { CHECK_SCOPES } = require('../manifestShape');
|
|
18
|
+
|
|
19
|
+
/** The scopes whose `where` is read from the workspace root — every one but `service`. */
|
|
20
|
+
const WORKSPACE_RELATIVE_SCOPES = Object.freeze(CHECK_SCOPES.filter((scope) => scope !== 'service'));
|
|
21
|
+
|
|
22
|
+
/** The scope that answers about ONE bearer, and can do so without a workspace (d.229). */
|
|
23
|
+
const BEARER_SCOPE = 'bearer';
|
|
24
|
+
|
|
25
|
+
/** The scope prefix of every package this platform owns. */
|
|
26
|
+
const SCOPE = '@onlineapps/';
|
|
27
|
+
|
|
28
|
+
/** Where a category-scoped duty section says which category it belongs to. */
|
|
29
|
+
const ALL_CATEGORIES = '*';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read a JSON file, or `null` when it is absent. A file that IS there but is not
|
|
33
|
+
* JSON stops the run: that is damage, and hiding it behind a `null` would make a
|
|
34
|
+
* broken package look conformant.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} file absolute path
|
|
37
|
+
* @returns {object|null}
|
|
38
|
+
*/
|
|
39
|
+
function readJson(file) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(raw);
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
throw new Error(`[LibraryManifest] File is not valid JSON - ${file}. `
|
|
50
|
+
+ 'Fix: repair the file; every library row reads it as the package declaration.', { cause });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read a text file, or `null` when it is absent.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} file absolute path
|
|
58
|
+
* @returns {string|null}
|
|
59
|
+
*/
|
|
60
|
+
function readText(file) {
|
|
61
|
+
try {
|
|
62
|
+
return fs.readFileSync(file, 'utf8');
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The package declaration of the library being checked.
|
|
70
|
+
*
|
|
71
|
+
* A workspace-mode run has no package root at all (`runManifest.js` § The two
|
|
72
|
+
* modes): the whole-set rows still run, and the per-package ones find nothing to
|
|
73
|
+
* read and raise nothing. That is not a pass being hidden — in that mode those
|
|
74
|
+
* rows have no bearer to judge, and every bearer gets its own run.
|
|
75
|
+
*
|
|
76
|
+
* @param {string|null} serviceRoot the package directory, or null in workspace mode
|
|
77
|
+
* @returns {{ json: object|null, file: string|null }}
|
|
78
|
+
*/
|
|
79
|
+
function readPackage(serviceRoot) {
|
|
80
|
+
if (typeof serviceRoot !== 'string' || serviceRoot.length === 0) return { json: null, file: null };
|
|
81
|
+
const file = path.join(serviceRoot, 'package.json');
|
|
82
|
+
return { json: readJson(file), file };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Where a finding points, as a path a human can paste into an editor.
|
|
87
|
+
*
|
|
88
|
+
* The reference point is the check's own scope, and `runManifest` depends on it:
|
|
89
|
+
* a `scope: service` check reports relative to the package root, a
|
|
90
|
+
* `scope: workspace` check relative to the workspace root, because that is what
|
|
91
|
+
* lets a service-mode run keep the workspace findings that are about THIS
|
|
92
|
+
* package and drop its neighbours' (`runManifest.js` § The two modes).
|
|
93
|
+
*
|
|
94
|
+
* A `scope: bearer` check places its finding the same way a workspace one does,
|
|
95
|
+
* and for a reason of its own: the SAME row answers for several bearers in a
|
|
96
|
+
* workspace run, so a path relative to "the package" would name four files
|
|
97
|
+
* `package.json` and tell nobody which package is drifting.
|
|
98
|
+
*
|
|
99
|
+
* That reason has a limit, and since d.229 a run reaches it: a bearer row whose
|
|
100
|
+
* reference is a file this package carries runs with NO workspace at all — in a
|
|
101
|
+
* service container there is none. There is then exactly one bearer, nothing to
|
|
102
|
+
* tell apart, and no root to be relative TO, so the finding names the file the
|
|
103
|
+
* way the repository does. The same holds for a bearer root that lies outside
|
|
104
|
+
* the workspace given: `../../elsewhere/init.sh` names the file no better than
|
|
105
|
+
* `init.sh` and reads as a defect of the run.
|
|
106
|
+
*
|
|
107
|
+
* A `scope: workspace` check keeps the strict rule — it reports ABOUT the
|
|
108
|
+
* workspace, so without one it must not report at all.
|
|
109
|
+
*
|
|
110
|
+
* @param {{ scope: string, serviceRoot: string, workspaceRoot: string|null, relative?: string }} params
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
function whereOf({ scope, serviceRoot, workspaceRoot, relative = '' }) {
|
|
114
|
+
if (scope === 'service') return relative;
|
|
115
|
+
if (!WORKSPACE_RELATIVE_SCOPES.includes(scope)) {
|
|
116
|
+
throw new Error(`[LibraryManifest] Unknown check scope - whereOf({ scope }) got ${JSON.stringify(scope)}. `
|
|
117
|
+
+ `Fix: pass one of ${CHECK_SCOPES.join(', ')}, the scopes the check registry knows.`);
|
|
118
|
+
}
|
|
119
|
+
const placeable = workspaceRoot !== null && workspaceRoot !== undefined;
|
|
120
|
+
if (!placeable && scope !== BEARER_SCOPE) {
|
|
121
|
+
throw new Error(`[LibraryManifest] ${scope}-scoped finding without a workspace root - whereOf() cannot `
|
|
122
|
+
+ 'place it. Fix: such a check runs only when the workspace root resolved.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const dir = placeable
|
|
126
|
+
? path.relative(workspaceRoot, path.resolve(serviceRoot)).split(path.sep).join('/')
|
|
127
|
+
: '';
|
|
128
|
+
if (dir === '' || dir.startsWith('..')) return relative;
|
|
129
|
+
return relative ? `${dir}/${relative}` : dir;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The category the package DECLARES. It is the one fact about a library that
|
|
134
|
+
* cannot be derived — the intent — so its absence is a finding of `U-MISMATCH`
|
|
135
|
+
* and never a default (confirmation `biz-service-manifest` 005 point 3).
|
|
136
|
+
*
|
|
137
|
+
* @param {object|null} pkg parsed package.json
|
|
138
|
+
* @returns {string|null}
|
|
139
|
+
*/
|
|
140
|
+
function declaredCategory(pkg) {
|
|
141
|
+
const declared = pkg && pkg.oa ? pkg.oa.category : undefined;
|
|
142
|
+
return typeof declared === 'string' && declared.length > 0 ? declared : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Does this duty section apply to this package? `applies_to: "*"` is every
|
|
147
|
+
* library; a category name applies to the package that declares that category.
|
|
148
|
+
*
|
|
149
|
+
* A package with NO declaration matches no category section. It is not silently
|
|
150
|
+
* excused: `U-MISMATCH` fires on the same run with severity `publish`, so the
|
|
151
|
+
* package is blocked before any category duty could have mattered.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} block the duty section the row lives in
|
|
154
|
+
* @param {object|null} pkg parsed package.json
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
function appliesTo(block, pkg) {
|
|
158
|
+
const target = block && block.applies_to;
|
|
159
|
+
if (target === ALL_CATEGORIES) return true;
|
|
160
|
+
return declaredCategory(pkg) === target;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Every entry of the sections asked for, whoever owns the package.
|
|
165
|
+
*
|
|
166
|
+
* @param {object|null} pkg parsed package.json
|
|
167
|
+
* @param {string[]} sections which dependency sections to read
|
|
168
|
+
* @returns {Array<{name: string, spec: string, section: string}>}
|
|
169
|
+
*/
|
|
170
|
+
function allDeps(pkg, sections) {
|
|
171
|
+
const found = [];
|
|
172
|
+
for (const section of sections) {
|
|
173
|
+
const block = pkg && pkg[section];
|
|
174
|
+
if (!block || typeof block !== 'object') continue;
|
|
175
|
+
for (const [name, spec] of Object.entries(block)) found.push({ name, spec: String(spec), section });
|
|
176
|
+
}
|
|
177
|
+
return found;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The `@onlineapps/*` entries of the sections asked for.
|
|
182
|
+
*
|
|
183
|
+
* `dependencies` alone answers "what does this package pull into a runtime" —
|
|
184
|
+
* the layer question. `dependencies` + `devDependencies` answers "which versions
|
|
185
|
+
* does this package pin", which is the question `scripts/ci/verify-manifest-pins.mjs`
|
|
186
|
+
* asks over the same files, and the two rows agree on the sections deliberately.
|
|
187
|
+
*
|
|
188
|
+
* The scope is what the SSOT owns: `api/config/libraries.json` decides the
|
|
189
|
+
* version of an `@onlineapps` package and of no other, so a range on a
|
|
190
|
+
* third-party dependency is a judgement no row here can make. `file:` is the
|
|
191
|
+
* exception, and it is not scoped at all — see `libraryPackage.js`
|
|
192
|
+
* § `libraryDepRange`.
|
|
193
|
+
*
|
|
194
|
+
* @param {object|null} pkg parsed package.json
|
|
195
|
+
* @param {string[]} sections which dependency sections to read
|
|
196
|
+
* @returns {Array<{name: string, spec: string, section: string}>}
|
|
197
|
+
*/
|
|
198
|
+
function scopedDeps(pkg, sections) {
|
|
199
|
+
return allDeps(pkg, sections).filter(({ name }) => name.startsWith(SCOPE));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The major of a Node version range, whatever shape it is written in
|
|
204
|
+
* (`24`, `^24`, `>=24.0.0 <25`, `24.x`).
|
|
205
|
+
*
|
|
206
|
+
* @param {string} range
|
|
207
|
+
* @returns {number|null}
|
|
208
|
+
*/
|
|
209
|
+
function nodeMajorOf(range) {
|
|
210
|
+
const match = /(\d+)/.exec(String(range));
|
|
211
|
+
return match === null ? null : Number(match[1]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
SCOPE,
|
|
216
|
+
ALL_CATEGORIES,
|
|
217
|
+
readJson,
|
|
218
|
+
readText,
|
|
219
|
+
readPackage,
|
|
220
|
+
whereOf,
|
|
221
|
+
declaredCategory,
|
|
222
|
+
appliesTo,
|
|
223
|
+
allDeps,
|
|
224
|
+
scopedDeps,
|
|
225
|
+
nodeMajorOf
|
|
226
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two documentation rows: a file the uniform requires must be there, and the
|
|
5
|
+
* README must carry the node header that makes its ownership a machine answer.
|
|
6
|
+
*
|
|
7
|
+
* `file-present` is the mirror of `file-absent` and takes the same one parameter,
|
|
8
|
+
* so a future "this uniform requires X" needs a row and no code.
|
|
9
|
+
*
|
|
10
|
+
* @see api/docs/standards/INFRA-DOC-STANDARD.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { readPackage, readText, whereOf, appliesTo } = require('./libraryContext');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The node header, in the blockquote shape the infra standard fixes. `Owns:` must
|
|
20
|
+
* carry a value - an empty label names no fact class and answers nothing. `Parent:`
|
|
21
|
+
* is neither required nor forbidden: a library's parent is the rendered category
|
|
22
|
+
* line, and a hand-written copy of it would be a second owner of one fact.
|
|
23
|
+
*/
|
|
24
|
+
const OWNS = /^>[ \t]*Owns:[ \t]*\S/m;
|
|
25
|
+
const STATUS = /^>[ \t]*Status:[ \t]*(\S+)/m;
|
|
26
|
+
const STATUS_VALUES = Object.freeze(['current', 'draft', 'archived']);
|
|
27
|
+
|
|
28
|
+
const filePresent = Object.freeze({
|
|
29
|
+
scope: 'service',
|
|
30
|
+
requires: Object.freeze(['path']),
|
|
31
|
+
|
|
32
|
+
run({ row, block, serviceRoot, workspaceRoot }) {
|
|
33
|
+
const { json } = readPackage(serviceRoot);
|
|
34
|
+
if (json !== null && !appliesTo(block, json)) return [];
|
|
35
|
+
|
|
36
|
+
const where = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: row.path });
|
|
37
|
+
if (fs.existsSync(path.join(serviceRoot, ...row.path.split('/')))) return [];
|
|
38
|
+
|
|
39
|
+
const because = row.why ? `: ${row.why}` : '';
|
|
40
|
+
return [{ where, what: `file absent — this uniform requires it${because}` }];
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const libraryReadme = Object.freeze({
|
|
45
|
+
scope: 'service',
|
|
46
|
+
requires: Object.freeze([]),
|
|
47
|
+
|
|
48
|
+
run({ block, serviceRoot, workspaceRoot }) {
|
|
49
|
+
const { json } = readPackage(serviceRoot);
|
|
50
|
+
if (json === null || !appliesTo(block, json)) return [];
|
|
51
|
+
|
|
52
|
+
const where = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: 'README.md' });
|
|
53
|
+
const body = readText(path.join(serviceRoot, 'README.md'));
|
|
54
|
+
if (body === null) return [{ where, what: 'README.md is absent — the package documents nothing' }];
|
|
55
|
+
|
|
56
|
+
const problems = [];
|
|
57
|
+
if (!OWNS.test(body)) problems.push('no "> Owns:" line');
|
|
58
|
+
|
|
59
|
+
const status = STATUS.exec(body);
|
|
60
|
+
if (status === null) problems.push('no "> Status:" line');
|
|
61
|
+
else if (!STATUS_VALUES.includes(status[1])) {
|
|
62
|
+
problems.push(`Status "${status[1]}" is not one of ${STATUS_VALUES.join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (problems.length === 0) return [];
|
|
66
|
+
return [{ where, what: `the node header does not hold: ${problems.join('; ')}` }];
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
checks: [
|
|
72
|
+
{ name: 'file-present', check: filePresent },
|
|
73
|
+
{ name: 'library-readme', check: libraryReadme }
|
|
74
|
+
]
|
|
75
|
+
};
|