@onlineapps/conn-orch-validator 12.1.1 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +607 -0
- package/README.md +126 -19
- package/manifests/biz-service.manifest.json +15 -2
- package/manifests/library.manifest.json +4 -4
- package/package.json +11 -3
- package/src/CookbookTestRunner.js +275 -105
- package/src/CookbookTestUtils.js +79 -68
- package/src/ServiceReadinessValidator.js +42 -52
- package/src/ValidationOrchestrator.js +65 -44
- package/src/cli/biz-ci-gate.js +2 -2
- package/src/cli/oa-sync-template.js +97 -47
- package/src/cli/oa-validate.js +44 -10
- package/src/helpers/README.md +6 -6
- package/src/helpers/createServiceReadinessTests.js +87 -33
- package/src/index.js +14 -5
- package/src/lint/scripts/lintScripts.js +11 -4
- package/src/manifest/checks/libraryContext.js +6 -3
- package/src/manifest/checks/libraryDocs.js +174 -4
- package/src/manifest/checks/libraryTests.js +200 -19
- package/src/manifest/checks/scriptHeaders.js +6 -13
- package/src/manifest/checks/serviceConfig.js +36 -16
- package/src/manifest/checks/serviceConnectors.js +180 -2
- package/src/manifest/checks/serviceDb.js +0 -3
- package/src/manifest/checks/serviceScripts.js +3 -20
- package/src/manifest/runManifest.js +90 -13
- package/src/manifest/workspaceRoot.js +133 -4
- package/src/mocks/MockMQClient.js +2 -2
- package/src/sync/docsRegion.js +2 -2
- package/src/sync/readmeFile.js +30 -0
- package/src/sync/readmeLocation.js +2 -12
- package/src/sync/readmePointer.js +10 -4
- package/src/sync/serviceTemplate.js +9 -11
- package/src/sync/sharedEnv.js +59 -3
- package/src/sync/uniformFiles.js +81 -8
- package/src/utils/bizCiGateContract.js +2 -2
- package/src/utils/connectorContract.js +54 -2
- package/src/utils/cookbookFormat.js +25 -115
- package/src/utils/dbAccountGrants.js +5 -3
- package/src/utils/deployContract.js +153 -28
- package/src/utils/envContract.js +2 -2
- package/src/utils/handlerRef.js +8 -10
- package/src/utils/integrationRun.js +1 -1
- package/src/utils/operationsDocumentRules.js +242 -0
- package/src/utils/operationsRules.js +157 -0
- package/src/utils/resolveHeaders.js +12 -1
- package/src/utils/setupDatabase.js +1 -1
- package/src/utils/stepFailure.js +3 -3
- package/src/utils/stepReferences.js +28 -87
- package/src/utils/throwawaySchema.js +1 -1
- package/src/utils/yamlTopLevel.js +105 -0
- package/src/validators/ServiceStructureValidator.js +67 -152
- package/templates/business-service/.gitlab-ci.yml +203 -37
- package/templates/business-service/README.md +7 -4
- package/templates/business-service/config/env-templates/shared.env +1 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +31 -3
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +5 -2
- package/templates/business-service/src/config/index.js +15 -0
- package/TESTING_STRATEGY.md +0 -92
- package/jest.config.js +0 -37
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
const fs = require('fs');
|
|
55
55
|
const path = require('path');
|
|
56
56
|
|
|
57
|
+
const { API_PREFIX } = require('../../manifest/workspaceRoot');
|
|
58
|
+
|
|
57
59
|
/** The five header fields, in the order SCRIPTS-STANDARD §1 writes them. */
|
|
58
60
|
const ORDER = Object.freeze(['Owns', 'Usage', 'Shell', 'Exit', 'Status']);
|
|
59
61
|
|
|
@@ -142,8 +144,13 @@ const COMMENT_LINE = /^\s*#/;
|
|
|
142
144
|
*/
|
|
143
145
|
const INERT_ASSERTION = Object.freeze([/^\s*!\s/, /\|\|\s*!\s/]);
|
|
144
146
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
+
/**
|
|
148
|
+
* The prefix a citation of the api checkout opens with, as every document writes
|
|
149
|
+
* it: the checkout's conventional name from its one owner
|
|
150
|
+
* (`src/manifest/workspaceRoot.js` § `API_PREFIX`), and the separator that makes
|
|
151
|
+
* it a path head rather than a word a file name could start with.
|
|
152
|
+
*/
|
|
153
|
+
const API_CITATION_PREFIX = `${API_PREFIX}/`;
|
|
147
154
|
|
|
148
155
|
/**
|
|
149
156
|
* Every file under `dir` whose name matches, depth first, as `/`-separated
|
|
@@ -175,9 +182,9 @@ function walk(dir, root, matches) {
|
|
|
175
182
|
* @returns {string|null} the absolute path to test, or null when unresolvable here
|
|
176
183
|
*/
|
|
177
184
|
function resolveSeeTarget({ target, root, apiRoot }) {
|
|
178
|
-
if (!target.startsWith(
|
|
185
|
+
if (!target.startsWith(API_CITATION_PREFIX)) return path.join(root, target);
|
|
179
186
|
if (apiRoot === null) return null;
|
|
180
|
-
return path.join(apiRoot, target.slice(
|
|
187
|
+
return path.join(apiRoot, target.slice(API_CITATION_PREFIX.length));
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
/**
|
|
@@ -15,6 +15,7 @@ const fs = require('fs');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
|
|
17
17
|
const { CHECK_SCOPES } = require('../manifestShape');
|
|
18
|
+
const { workspaceRelativeOf } = require('../workspaceRoot');
|
|
18
19
|
|
|
19
20
|
/** The scopes whose `where` is read from the workspace root — every one but `service`. */
|
|
20
21
|
const WORKSPACE_RELATIVE_SCOPES = Object.freeze(CHECK_SCOPES.filter((scope) => scope !== 'service'));
|
|
@@ -107,6 +108,10 @@ function readPackage(serviceRoot) {
|
|
|
107
108
|
* A `scope: workspace` check keeps the strict rule — it reports ABOUT the
|
|
108
109
|
* workspace, so without one it must not report at all.
|
|
109
110
|
*
|
|
111
|
+
* How the path is SPELLED is not this function's to decide: a bearer inside the
|
|
112
|
+
* api checkout is written `api/…` whatever that checkout is named, and
|
|
113
|
+
* `workspaceRoot.js` § workspaceRelativeOf owns that rule for every reader of it.
|
|
114
|
+
*
|
|
110
115
|
* @param {{ scope: string, serviceRoot: string, workspaceRoot: string|null, relative?: string }} params
|
|
111
116
|
* @returns {string}
|
|
112
117
|
*/
|
|
@@ -122,9 +127,7 @@ function whereOf({ scope, serviceRoot, workspaceRoot, relative = '' }) {
|
|
|
122
127
|
+ 'place it. Fix: such a check runs only when the workspace root resolved.');
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
const dir = placeable
|
|
126
|
-
? path.relative(workspaceRoot, path.resolve(serviceRoot)).split(path.sep).join('/')
|
|
127
|
-
: '';
|
|
130
|
+
const dir = placeable ? workspaceRelativeOf(workspaceRoot, serviceRoot) : '';
|
|
128
131
|
if (dir === '' || dir.startsWith('..')) return relative;
|
|
129
132
|
return relative ? `${dir}/${relative}` : dir;
|
|
130
133
|
}
|
|
@@ -14,6 +14,7 @@ const fs = require('fs');
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
|
|
16
16
|
const { readPackage, readText, whereOf, appliesTo } = require('./libraryContext');
|
|
17
|
+
const { README_FILE } = require('../../sync/readmeFile');
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The node header, in the blockquote shape the infra standard fixes. `Owns:` must
|
|
@@ -25,6 +26,32 @@ const OWNS = /^>[ \t]*Owns:[ \t]*\S/m;
|
|
|
25
26
|
const STATUS = /^>[ \t]*Status:[ \t]*(\S+)/m;
|
|
26
27
|
const STATUS_VALUES = Object.freeze(['current', 'draft', 'archived']);
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* A package version written into a README by hand.
|
|
31
|
+
*
|
|
32
|
+
* A version is a DESCRIPTIVE fact, and a descriptive fact written by hand is a
|
|
33
|
+
* lie with a commencement date (`.claude/rules/doc-code-binding.md` §1). It has
|
|
34
|
+
* ONE owner, `package.json`, and the publish path reads it from there. Measured
|
|
35
|
+
* 2026-09-20: `error-handler-core`'s README footer said `Version: 1.0.0` while
|
|
36
|
+
* the package was `3.0.0` — two majors stale, and no mechanism on the platform
|
|
37
|
+
* said a word.
|
|
38
|
+
*
|
|
39
|
+
* Two shapes, and both are narrow ON PURPOSE:
|
|
40
|
+
* - `Version: 1.2.3` — the digits must follow the label directly, so a
|
|
41
|
+
* configuration example (`serviceVersion: '1.0.0'`, quoted) does not light
|
|
42
|
+
* up. A README teaching an API shows values, and a gate that fires on the
|
|
43
|
+
* teaching is a gate people learn to work around;
|
|
44
|
+
* - a standalone `v1.2.3` — the footer spelling, bounded on both sides, so
|
|
45
|
+
* `@onlineapps/lib-v2` and prose like "serverv1.2.3" are not matches.
|
|
46
|
+
* A dotted triple with no label at all is NOT matched: `docs/biz/…` paths,
|
|
47
|
+
* dates and dependency tables carry those, and the false finding would cost more
|
|
48
|
+
* than the fact is worth.
|
|
49
|
+
*/
|
|
50
|
+
const README_VERSION_SHAPES = Object.freeze([
|
|
51
|
+
/[Vv]ersion:[ \t]*\d+\.\d+\.\d+/,
|
|
52
|
+
/(^|[\s(*_])v\d+\.\d+\.\d+([\s)*_.,]|$)/m
|
|
53
|
+
]);
|
|
54
|
+
|
|
28
55
|
const filePresent = Object.freeze({
|
|
29
56
|
scope: 'service',
|
|
30
57
|
requires: Object.freeze(['path']),
|
|
@@ -49,9 +76,13 @@ const libraryReadme = Object.freeze({
|
|
|
49
76
|
const { json } = readPackage(serviceRoot);
|
|
50
77
|
if (json === null || !appliesTo(block, json)) return [];
|
|
51
78
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
79
|
+
// The file name is `readmeFile.js`'s fact, not this row's: row `L-README`
|
|
80
|
+
// declares no `path` (unlike `L-README-REGION` one row down), so there is
|
|
81
|
+
// nothing here to read it off, and a literal would be the package's fifth
|
|
82
|
+
// copy of one name (`.claude/rules/change-discipline.md` § One rail).
|
|
83
|
+
const where = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: README_FILE });
|
|
84
|
+
const body = readText(path.join(serviceRoot, README_FILE));
|
|
85
|
+
if (body === null) return [{ where, what: `${README_FILE} is absent — the package documents nothing` }];
|
|
55
86
|
|
|
56
87
|
const problems = [];
|
|
57
88
|
if (!OWNS.test(body)) problems.push('no "> Owns:" line');
|
|
@@ -62,14 +93,153 @@ const libraryReadme = Object.freeze({
|
|
|
62
93
|
problems.push(`Status "${status[1]}" is not one of ${STATUS_VALUES.join(', ')}`);
|
|
63
94
|
}
|
|
64
95
|
|
|
96
|
+
for (const shape of README_VERSION_SHAPES) {
|
|
97
|
+
const written = shape.exec(body);
|
|
98
|
+
if (written !== null) {
|
|
99
|
+
problems.push(`writes the package version by hand ("${written[0].trim()}") — package.json owns it`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
if (problems.length === 0) return [];
|
|
66
104
|
return [{ where, what: `the node header does not hold: ${problems.join('; ')}` }];
|
|
67
105
|
}
|
|
68
106
|
});
|
|
69
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A version heading of a CHANGELOG, in either shape this tree writes:
|
|
110
|
+
* `## [1.0.3] — 2026-09-15`, `## [Unreleased]`, or the bare `## 1.0.0`.
|
|
111
|
+
*
|
|
112
|
+
* The shape is NOT what this row is about, so both are read. Demanding brackets
|
|
113
|
+
* would smuggle a second rule — a formatting one — into a row whose `why` is
|
|
114
|
+
* about content, and a rule nobody decided is not a rule a gate may enforce.
|
|
115
|
+
*/
|
|
116
|
+
const CHANGELOG_HEADING = /^##[ \t]+(\S.*?)[ \t]*$/;
|
|
117
|
+
/** A prerelease version (`1.2.3-rc.4`): its entry may still stand under `[Unreleased]`. */
|
|
118
|
+
const PRERELEASE = /^\d+\.\d+\.\d+-[0-9A-Za-z.]+$/;
|
|
119
|
+
const BRACKETED_NAME = /^\[([^\]]+)\]/;
|
|
120
|
+
/** ` — `, ` – ` or ` - ` separating the version from the date behind it. */
|
|
121
|
+
const NAME_AND_DATE = /[ \t]+[—–-][ \t]+/;
|
|
122
|
+
|
|
123
|
+
/** @returns {string|null} the version a heading line names, or `null` if it is not one */
|
|
124
|
+
function headingName(line) {
|
|
125
|
+
const heading = CHANGELOG_HEADING.exec(line);
|
|
126
|
+
if (heading === null) return null;
|
|
127
|
+
|
|
128
|
+
const bracketed = BRACKETED_NAME.exec(heading[1]);
|
|
129
|
+
if (bracketed !== null) return bracketed[1].trim();
|
|
130
|
+
|
|
131
|
+
return heading[1].split(NAME_AND_DATE)[0].trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The sections of a CHANGELOG in file order: `{ name, body }`, where `body` is
|
|
136
|
+
* every line up to the next version heading.
|
|
137
|
+
*/
|
|
138
|
+
function changelogSections(text) {
|
|
139
|
+
const sections = [];
|
|
140
|
+
let current = null;
|
|
141
|
+
|
|
142
|
+
for (const line of text.split('\n')) {
|
|
143
|
+
const name = headingName(line);
|
|
144
|
+
if (name !== null) {
|
|
145
|
+
if (current !== null) sections.push(current);
|
|
146
|
+
current = { name, body: [] };
|
|
147
|
+
} else if (current !== null) {
|
|
148
|
+
current.body.push(line);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (current !== null) sections.push(current);
|
|
152
|
+
|
|
153
|
+
return sections;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether a section SAYS anything. One non-blank line that is not itself a
|
|
158
|
+
* sub-heading is enough — a pin-only release writes a sentence about the pin and
|
|
159
|
+
* no `### Changed` above it, and that sentence IS the entry. A lone `### Changed`
|
|
160
|
+
* with nothing under it is not.
|
|
161
|
+
*/
|
|
162
|
+
function hasContent(section) {
|
|
163
|
+
return section.body.some((line) => {
|
|
164
|
+
const trimmed = line.trim();
|
|
165
|
+
return trimmed !== '' && !/^#{1,6}[ \t]/.test(trimmed);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `L-CHANGELOG` measures what its `why` says: not that the file exists, but that
|
|
171
|
+
* the version being published has an entry, and that the entry is not an empty
|
|
172
|
+
* heading.
|
|
173
|
+
*
|
|
174
|
+
* `file-present` was the check here until d.731b, and a file carrying a bare
|
|
175
|
+
* `## [2.0.1] — 2026-09-15` with nothing under it passed it — the version was
|
|
176
|
+
* still one nobody can reason about afterwards, which is the row's own reason
|
|
177
|
+
* for existing. Measured 2026-09-20 over the 28 platform packages: three were in
|
|
178
|
+
* exactly that state while the row was green (`automation-gates.md` §5 — a
|
|
179
|
+
* mechanism that checks less than it announces is a false guarantee). The row is
|
|
180
|
+
* sharpened only now, after those three were written (§3 — a gate lands with
|
|
181
|
+
* compliance, never ahead of it).
|
|
182
|
+
*
|
|
183
|
+
* A prerelease is the one case where the entry may live elsewhere: an rc wave
|
|
184
|
+
* publishes `X.Y.Z-rc.N` without moving the `[Unreleased]` body into a section of
|
|
185
|
+
* its own (`.claude/rules/architecture-principles.md` § Release channels), so a
|
|
186
|
+
* non-empty `[Unreleased]` answers for it.
|
|
187
|
+
*
|
|
188
|
+
* WHICH VERSION IS JUDGED — two legitimate questions, not a value and its fallback
|
|
189
|
+
* (d.1040). A publish asks about the version it is ABOUT to write: the gate's dry
|
|
190
|
+
* run hands it as `asVersion` (`oa-validate --as-version`), because package.json
|
|
191
|
+
* still carries the old one until the wave bumps it — judged by that, the row let a
|
|
192
|
+
* missing `## [NEW]` through and refused a package for an old version nobody was
|
|
193
|
+
* publishing (d.1033, d.1038). An audit of the tree as it stands asks about the
|
|
194
|
+
* version package.json names, and passes no `asVersion`. The finding says which of
|
|
195
|
+
* the two it answered.
|
|
196
|
+
*/
|
|
197
|
+
const libraryChangelogEntry = Object.freeze({
|
|
198
|
+
scope: 'service',
|
|
199
|
+
requires: Object.freeze(['path']),
|
|
200
|
+
|
|
201
|
+
run({ row, block, serviceRoot, workspaceRoot, asVersion = null }) {
|
|
202
|
+
const { json } = readPackage(serviceRoot);
|
|
203
|
+
if (json === null || !appliesTo(block, json)) return [];
|
|
204
|
+
|
|
205
|
+
const where = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: row.path });
|
|
206
|
+
const body = readText(path.join(serviceRoot, ...row.path.split('/')));
|
|
207
|
+
if (body === null) {
|
|
208
|
+
const because = row.why ? `: ${row.why}` : '';
|
|
209
|
+
return [{ where, what: `file absent — this uniform requires it${because}` }];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const version = asVersion === null ? json.version : asVersion;
|
|
213
|
+
if (typeof version !== 'string' || version.trim() === '') {
|
|
214
|
+
return [{ where, what: 'package.json names no version, so there is no entry to look for' }];
|
|
215
|
+
}
|
|
216
|
+
// Where the judged version came from, said only for a publish, so nobody reads
|
|
217
|
+
// the finding against package.json.
|
|
218
|
+
const source = asVersion === null ? null : `given by --as-version; package.json says ${json.version}`;
|
|
219
|
+
|
|
220
|
+
const sections = changelogSections(body);
|
|
221
|
+
const own = sections.find((section) => section.name === version);
|
|
222
|
+
|
|
223
|
+
if (own !== undefined) {
|
|
224
|
+
if (hasContent(own)) return [];
|
|
225
|
+
const which = source === null ? version : `${version} (${source})`;
|
|
226
|
+
return [{ where, what: `the entry for ${which} is an empty heading — the published version says nothing` }];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (PRERELEASE.test(version)) {
|
|
230
|
+
const unreleased = sections.find((section) => section.name === 'Unreleased');
|
|
231
|
+
if (unreleased !== undefined && hasContent(unreleased)) return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const named = source === null ? version : `${version}, ${source}`;
|
|
235
|
+
return [{ where, what: `no entry for the version being published (${named})` }];
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
70
239
|
module.exports = {
|
|
71
240
|
checks: [
|
|
72
241
|
{ name: 'file-present', check: filePresent },
|
|
73
|
-
{ name: 'library-readme', check: libraryReadme }
|
|
242
|
+
{ name: 'library-readme', check: libraryReadme },
|
|
243
|
+
{ name: 'library-changelog-entry', check: libraryChangelogEntry }
|
|
74
244
|
]
|
|
75
245
|
};
|
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
* miss the ninth that has the file and lists nothing. So the check reproduces
|
|
12
12
|
* npm's own precedence — `files` allowlist first, then `.npmignore`, then
|
|
13
13
|
* `.gitignore` — and asks one question: does `tests/` end up in the tarball.
|
|
14
|
+
* An ignore file is read as npm walks it, negations included (d.1006,
|
|
15
|
+
* `testsShippedBy`), not as a list of excluding shapes.
|
|
16
|
+
*
|
|
17
|
+
* WHICH `tests/`: the package's own tier, at the package root. A `tests/` under
|
|
18
|
+
* `templates/**` is not a tier of the package but content of a template it ships
|
|
19
|
+
* on purpose — `templates/business-service/tests/**` is what `oa-sync-template
|
|
20
|
+
* --new` renders into a new service — so it must travel, and this row never
|
|
21
|
+
* looks at it (d.1002).
|
|
14
22
|
*
|
|
15
23
|
* @see .claude/rules/architecture-principles.md § Shared Packages
|
|
16
24
|
*/
|
|
@@ -28,30 +36,196 @@ const UNIT_DIR = path.join('tests', 'unit');
|
|
|
28
36
|
const TEST_FILE = /\.(test|spec)\.js$/;
|
|
29
37
|
|
|
30
38
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
39
|
+
* One line of an ignore file, read the way npm reads it (`ignore-walk`, gitignore
|
|
40
|
+
* syntax): a leading `!` negates, a trailing `/` matches directories only, a
|
|
41
|
+
* pattern with a `/` in it is anchored at the package root, and one without is
|
|
42
|
+
* matched against the last path segment at any depth.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} line a trimmed, non-comment line
|
|
45
|
+
* @param {number} index its position in the file — the last matching line wins
|
|
46
|
+
* @returns {{index: number, line: string, negate: boolean, dirOnly: boolean, anchored: boolean, segments: string[]}}
|
|
47
|
+
*/
|
|
48
|
+
function parseIgnoreLine(line, index) {
|
|
49
|
+
const negate = line.startsWith('!');
|
|
50
|
+
let pattern = negate ? line.slice(1) : line;
|
|
51
|
+
const dirOnly = pattern.endsWith('/');
|
|
52
|
+
if (dirOnly) pattern = pattern.slice(0, -1);
|
|
53
|
+
const anchored = pattern.includes('/');
|
|
54
|
+
pattern = pattern.replace(/^\//, '');
|
|
55
|
+
return { index, line, negate, dirOnly, anchored, segments: pattern.split('/') };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Do `patternSegments` match all of `pathSegments`? `**` matches any number of
|
|
60
|
+
* segments, except as the LAST segment, where it matches at least one — `tests/**`
|
|
61
|
+
* is everything inside `tests`, not `tests` itself.
|
|
62
|
+
*
|
|
63
|
+
* @param {string[]} patternSegments
|
|
64
|
+
* @param {string[]} pathSegments
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
function segmentsMatch(patternSegments, pathSegments) {
|
|
68
|
+
if (patternSegments.length === 0) return pathSegments.length === 0;
|
|
69
|
+
const [head, ...rest] = patternSegments;
|
|
70
|
+
if (head === '**') {
|
|
71
|
+
const from = rest.length === 0 ? 1 : 0;
|
|
72
|
+
for (let skip = from; skip <= pathSegments.length; skip += 1) {
|
|
73
|
+
if (segmentsMatch(rest, pathSegments.slice(skip))) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (pathSegments.length === 0 || !segmentMatches(head, pathSegments[0])) return false;
|
|
78
|
+
return segmentsMatch(rest, pathSegments.slice(1));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Could an anchored pattern match something strictly INSIDE the directory
|
|
83
|
+
* `dirSegments`? This is the question npm asks before it walks into a directory an
|
|
84
|
+
* earlier line excluded: a later `!` pattern that starts with the directory's name
|
|
85
|
+
* — or with `**` — reopens it, and a pattern without a `/` never does (measured,
|
|
86
|
+
* d.1006: `/tests/` then `!*.js` ships nothing, `/tests/` then `!**\/*.test.js`
|
|
87
|
+
* ships the tier).
|
|
88
|
+
*
|
|
89
|
+
* @param {string[]} patternSegments
|
|
90
|
+
* @param {string[]} dirSegments
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
function couldMatchInside(patternSegments, dirSegments) {
|
|
94
|
+
if (patternSegments.length === 0) return false;
|
|
95
|
+
const [head, ...rest] = patternSegments;
|
|
96
|
+
if (head === '**') return true;
|
|
97
|
+
if (dirSegments.length === 0) return true;
|
|
98
|
+
if (!segmentMatches(head, dirSegments[0])) return false;
|
|
99
|
+
return couldMatchInside(rest, dirSegments.slice(1));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Does `rule` match the path `segments` (a directory when `isDir`)?
|
|
34
104
|
*
|
|
35
|
-
* @param {string} body the contents of .npmignore or .gitignore
|
|
36
105
|
* @returns {boolean}
|
|
37
106
|
*/
|
|
38
|
-
function
|
|
39
|
-
return
|
|
107
|
+
function ruleMatches(rule, segments, isDir) {
|
|
108
|
+
if (rule.dirOnly && !isDir) return false;
|
|
109
|
+
if (!rule.anchored) return segmentMatches(rule.segments[0], segments[segments.length - 1]);
|
|
110
|
+
return segmentsMatch(rule.segments, segments);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The last rule matching the path, or `null` — the last matching line wins. */
|
|
114
|
+
function lastMatch(rules, segments, isDir) {
|
|
115
|
+
let found = null;
|
|
116
|
+
for (const rule of rules) {
|
|
117
|
+
if (ruleMatches(rule, segments, isDir)) found = rule;
|
|
118
|
+
}
|
|
119
|
+
return found;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Which of the tier's files an ignore file lets into the tarball, reproducing
|
|
124
|
+
* npm's walk (d.1006, measured against `npm pack --dry-run --json`, npm 11):
|
|
125
|
+
* every directory on the way down is judged by the last line matching it; an
|
|
126
|
+
* EXCLUDED directory is walked into anyway when a later `!` line could match
|
|
127
|
+
* inside it (`couldMatchInside`), and is otherwise dropped whole; a file is
|
|
128
|
+
* judged by the last line matching it, and ships when none excludes it.
|
|
129
|
+
*
|
|
130
|
+
* Until d.1006 the row read only the excluding shapes and none of the negations,
|
|
131
|
+
* so `/tests/` followed by `!tests/**` — which npm packs — passed as excluded.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} body the contents of .npmignore or .gitignore
|
|
134
|
+
* @param {string[]} testFiles the tier's files, relative, `/`-separated (`tests/unit/a.test.js`)
|
|
135
|
+
* @returns {{shipped: string[], reincludedBy: string|null}} what ships, and the
|
|
136
|
+
* `!` line that brought the tier back after a line had excluded it
|
|
137
|
+
*/
|
|
138
|
+
function testsShippedBy(body, testFiles) {
|
|
139
|
+
const rules = body
|
|
40
140
|
.split('\n')
|
|
41
141
|
.map((line) => line.trim())
|
|
42
142
|
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
|
43
|
-
.
|
|
143
|
+
.map(parseIgnoreLine);
|
|
144
|
+
|
|
145
|
+
const shipped = [];
|
|
146
|
+
let reincludedBy = null;
|
|
147
|
+
for (const file of testFiles) {
|
|
148
|
+
const segments = file.split('/');
|
|
149
|
+
let dropped = false;
|
|
150
|
+
for (let depth = 1; depth < segments.length && !dropped; depth += 1) {
|
|
151
|
+
const dir = segments.slice(0, depth);
|
|
152
|
+
const last = lastMatch(rules, dir, true);
|
|
153
|
+
if (last === null) continue;
|
|
154
|
+
if (last.negate) {
|
|
155
|
+
reincludedBy = reincludedBy || last.line;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const opener = rules.find((rule) => rule.negate && rule.index > last.index && rule.anchored
|
|
159
|
+
&& couldMatchInside(rule.segments, dir));
|
|
160
|
+
if (opener === undefined) dropped = true;
|
|
161
|
+
else reincludedBy = reincludedBy || opener.line;
|
|
162
|
+
}
|
|
163
|
+
if (dropped) continue;
|
|
164
|
+
const last = lastMatch(rules, segments, false);
|
|
165
|
+
if (last !== null && !last.negate) continue;
|
|
166
|
+
if (last !== null) reincludedBy = reincludedBy || last.line;
|
|
167
|
+
shipped.push(file);
|
|
168
|
+
}
|
|
169
|
+
return { shipped, reincludedBy };
|
|
44
170
|
}
|
|
45
171
|
|
|
46
172
|
/**
|
|
47
|
-
*
|
|
48
|
-
* it reaches the directory only when its first segment is the directory itself.
|
|
173
|
+
* Every file under the tier, relative to the package root, `/`-separated.
|
|
49
174
|
*
|
|
50
|
-
* @param {string
|
|
175
|
+
* @param {string} serviceRoot
|
|
176
|
+
* @returns {string[]}
|
|
177
|
+
*/
|
|
178
|
+
function listTestFiles(serviceRoot) {
|
|
179
|
+
const out = [];
|
|
180
|
+
const walk = (relative) => {
|
|
181
|
+
for (const entry of fs.readdirSync(path.join(serviceRoot, relative), { withFileTypes: true })) {
|
|
182
|
+
const child = `${relative}/${entry.name}`;
|
|
183
|
+
if (entry.isDirectory()) walk(child);
|
|
184
|
+
else out.push(child);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
walk(TEST_DIR);
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Does ONE segment of an allowlist glob match `name`? `*` and `?` stay inside a
|
|
193
|
+
* segment, as npm's globbing has them; `**` matches any segment.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} segment one `/`-separated part of an allowlist entry
|
|
196
|
+
* @param {string} name the directory name asked about
|
|
51
197
|
* @returns {boolean}
|
|
52
198
|
*/
|
|
53
|
-
function
|
|
54
|
-
|
|
199
|
+
function segmentMatches(segment, name) {
|
|
200
|
+
if (segment === '**') return true;
|
|
201
|
+
const pattern = segment
|
|
202
|
+
.split('')
|
|
203
|
+
.map((ch) => {
|
|
204
|
+
if (ch === '*') return '[^/]*';
|
|
205
|
+
if (ch === '?') return '[^/]';
|
|
206
|
+
return ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
207
|
+
})
|
|
208
|
+
.join('');
|
|
209
|
+
return new RegExp(`^${pattern}$`).test(name);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The allowlist entry that lets `tests/` in, or `null`. An entry reaches the
|
|
214
|
+
* directory when its FIRST segment matches `tests` — the name itself, a glob
|
|
215
|
+
* such as `*` or `t*`, or `**`, which matches at any depth. Until d.1002 only the
|
|
216
|
+
* literal name counted, so `["**\/*.js"]` and `["*"]` shipped the tier (measured
|
|
217
|
+
* with `npm pack --dry-run`) and the row said nothing. A glob confined to the
|
|
218
|
+
* root (`*.js`) cannot match a directory and does not reach it.
|
|
219
|
+
*
|
|
220
|
+
* @param {string[]} files the allowlist
|
|
221
|
+
* @returns {string|null}
|
|
222
|
+
*/
|
|
223
|
+
function allowlistEntryReachingTests(files) {
|
|
224
|
+
const reaching = files.find((entry) => {
|
|
225
|
+
const firstSegment = String(entry).replace(/^\.?\//, '').split('/')[0];
|
|
226
|
+
return segmentMatches(firstSegment, TEST_DIR);
|
|
227
|
+
});
|
|
228
|
+
return reaching === undefined ? null : String(reaching);
|
|
55
229
|
}
|
|
56
230
|
|
|
57
231
|
const libraryTests = Object.freeze({
|
|
@@ -90,16 +264,25 @@ const libraryPackTests = Object.freeze({
|
|
|
90
264
|
if (!fs.existsSync(path.join(serviceRoot, TEST_DIR))) return [];
|
|
91
265
|
|
|
92
266
|
if (Array.isArray(json.files)) {
|
|
93
|
-
|
|
94
|
-
|
|
267
|
+
const entry = allowlistEntryReachingTests(json.files);
|
|
268
|
+
if (entry === null) return [];
|
|
269
|
+
return [{ where, what: `the files allowlist entry ${JSON.stringify(entry)} reaches ${TEST_DIR} — the tier would ship to every consumer` }];
|
|
95
270
|
}
|
|
96
271
|
|
|
97
272
|
for (const name of ['.npmignore', '.gitignore']) {
|
|
98
273
|
const body = readText(path.join(serviceRoot, name));
|
|
99
274
|
if (body === null) continue;
|
|
100
|
-
|
|
275
|
+
const { shipped, reincludedBy } = testsShippedBy(body, listTestFiles(serviceRoot));
|
|
276
|
+
if (shipped.length === 0) return [];
|
|
277
|
+
const ignoreFile = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: name });
|
|
278
|
+
if (reincludedBy !== null) {
|
|
279
|
+
return [{
|
|
280
|
+
where: ignoreFile,
|
|
281
|
+
what: `excludes ${TEST_DIR}/, then re-includes it with ${JSON.stringify(reincludedBy)} — the tier would ship to every consumer`
|
|
282
|
+
}];
|
|
283
|
+
}
|
|
101
284
|
return [{
|
|
102
|
-
where:
|
|
285
|
+
where: ignoreFile,
|
|
103
286
|
what: `does not exclude ${TEST_DIR}/, and no files allowlist does either — the tier would ship to every consumer`
|
|
104
287
|
}];
|
|
105
288
|
}
|
|
@@ -115,7 +298,5 @@ module.exports = {
|
|
|
115
298
|
checks: [
|
|
116
299
|
{ name: 'library-tests', check: libraryTests },
|
|
117
300
|
{ name: 'library-pack-tests', check: libraryPackTests }
|
|
118
|
-
]
|
|
119
|
-
ignoresTests,
|
|
120
|
-
allowsTests
|
|
301
|
+
]
|
|
121
302
|
};
|
|
@@ -31,15 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
const fs = require('fs');
|
|
33
33
|
|
|
34
|
-
const { lintScripts
|
|
35
|
-
const { resolveWorkspacePath,
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The api checkout, by the name the workspace marker already spells. Written
|
|
39
|
-
* once, from the marker, so "which directory is the api one" has one owner
|
|
40
|
-
* (`api/.claude/rules/single-source-of-truth.md`).
|
|
41
|
-
*/
|
|
42
|
-
const API_CHECKOUT = WORKSPACE_MARKER.split('/')[0];
|
|
34
|
+
const { lintScripts } = require('../../lint/scripts/lintScripts');
|
|
35
|
+
const { resolveWorkspacePath, API_PREFIX } = require('../workspaceRoot');
|
|
43
36
|
|
|
44
37
|
/**
|
|
45
38
|
* Where the citations of one repository can be resolved, or null.
|
|
@@ -49,7 +42,7 @@ const API_CHECKOUT = WORKSPACE_MARKER.split('/')[0];
|
|
|
49
42
|
*/
|
|
50
43
|
function apiCheckout(workspaceRoot) {
|
|
51
44
|
if (workspaceRoot === null || workspaceRoot === undefined) return null;
|
|
52
|
-
const target = resolveWorkspacePath(workspaceRoot,
|
|
45
|
+
const target = resolveWorkspacePath(workspaceRoot, API_PREFIX);
|
|
53
46
|
return fs.existsSync(target) && fs.statSync(target).isDirectory() ? target : null;
|
|
54
47
|
}
|
|
55
48
|
|
|
@@ -69,11 +62,11 @@ const scriptsLint = Object.freeze({
|
|
|
69
62
|
|
|
70
63
|
return {
|
|
71
64
|
findings,
|
|
72
|
-
notRun: `${result.unresolved.length} @see target(s) name the ${
|
|
65
|
+
notRun: `${result.unresolved.length} @see target(s) name the ${API_PREFIX} checkout, which this run `
|
|
73
66
|
+ `cannot reach: ${result.unresolved.join(', ')}. Run the uniform from a workspace that carries `
|
|
74
|
-
+ `${
|
|
67
|
+
+ `${API_PREFIX}/.`
|
|
75
68
|
};
|
|
76
69
|
}
|
|
77
70
|
});
|
|
78
71
|
|
|
79
|
-
module.exports = { checks: [{ name: 'scripts-lint', check: scriptsLint }]
|
|
72
|
+
module.exports = { checks: [{ name: 'scripts-lint', check: scriptsLint }] };
|
|
@@ -3,16 +3,22 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The files a service is configured by.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* started — and
|
|
12
|
-
* declared (003 §20: an `Enforced by` cell names a row
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
6
|
+
* Three of these rows ARE the boot's step 2: `ValidationOrchestrator` names them
|
|
7
|
+
* in `CONFIG_STEP_ROWS` and reports what they found. The split between step 2
|
|
8
|
+
* and step 7 is stated at that symbol and nowhere else, this comment included.
|
|
9
|
+
*
|
|
10
|
+
* The rows are asked outside a boot as well — `npx oa-validate` in CI, in the
|
|
11
|
+
* image build, in a repository nobody has started — and the manifest is where a
|
|
12
|
+
* rule of service shape is declared (003 §20: an `Enforced by` cell names a row
|
|
13
|
+
* id).
|
|
14
|
+
*
|
|
15
|
+
* Until d.720 this paragraph said something else: that step 2 kept hand-written
|
|
16
|
+
* sentences of its own, that the rows therefore "overlap deliberately" with it,
|
|
17
|
+
* and that unifying the two was work still owed and "recorded for the lead". The
|
|
18
|
+
* unification had already landed — step 2 runs the manifest — so the sentence
|
|
19
|
+
* outlived what it described, and a promise of work that was done read as a gap
|
|
20
|
+
* that was open (`.claude/rules/doc-code-binding.md` §5). Kept as a line about
|
|
21
|
+
* THIS file's own history, which is the only thing a comment here can keep true.
|
|
16
22
|
*
|
|
17
23
|
* `G-SHARED-ENV` is a different kind: `shared.env` is GENERATED from the
|
|
18
24
|
* platform manifest `api/config/shared-env.json` (003 §18), so the row does not
|
|
@@ -30,6 +36,8 @@ const { describeWorkspaceFix } = require('../workspaceRoot');
|
|
|
30
36
|
const { whereOf } = require('./libraryContext');
|
|
31
37
|
const { diffAgainst, renderSharedEnv } = require('../../sync/sharedEnv');
|
|
32
38
|
const { loadAndValidateIntegrationContract } = require('../../utils/bizCiGateContract');
|
|
39
|
+
const { operationsRuleSentences } = require('../../utils/operationsRules');
|
|
40
|
+
const { operationsDocumentFindings } = require('../../utils/operationsDocumentRules');
|
|
33
41
|
|
|
34
42
|
/** The generated platform file every service carries a copy of. */
|
|
35
43
|
const SHARED_ENV = 'shared.env';
|
|
@@ -281,13 +289,25 @@ const configOperations = Object.freeze({
|
|
|
281
289
|
if (read.absent) return [{ where: row.path, what: 'absent — this uniform requires it' }];
|
|
282
290
|
if (read.broken) return [{ where: row.path, what: `is not valid JSON — ${read.broken}` }];
|
|
283
291
|
|
|
292
|
+
// The rules about the document — `schema_version`, and an operations map
|
|
293
|
+
// with nothing in it — are not decided here either: they are
|
|
294
|
+
// `src/utils/operationsDocumentRules.js`, the one definition every rail of
|
|
295
|
+
// this package asks. The row's `what` carries the problem; the row's own
|
|
296
|
+
// `fix` column carries the remedy, so the sentence is not repeated into it.
|
|
297
|
+
const documentFindings = operationsDocumentFindings(read.json)
|
|
298
|
+
.map((finding) => ({ where: row.path, what: `${finding.field}: ${finding.what}` }));
|
|
299
|
+
if (documentFindings.length > 0) return documentFindings;
|
|
300
|
+
|
|
284
301
|
const operations = read.json.operations;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
302
|
+
|
|
303
|
+
// What each declared operation must BE is not decided here: it is decided by
|
|
304
|
+
// `@onlineapps/service-validator-core`, which mirrors the rules the Registry
|
|
305
|
+
// applies on registration (owner decision
|
|
306
|
+
// `api/docs/governance/confirmations/operations-schema-mirror.md` 001). The
|
|
307
|
+
// row asks that one definition, so `oa-validate` refuses outside a boot
|
|
308
|
+
// exactly what a registration refuses inside one.
|
|
309
|
+
return operationsRuleSentences(operations).sentences
|
|
310
|
+
.map((sentence) => ({ where: row.path, what: sentence }));
|
|
291
311
|
}
|
|
292
312
|
});
|
|
293
313
|
|