@onlineapps/conn-orch-validator 7.0.0 → 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 +2558 -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 +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,272 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The rows a library's own `package.json` decides, without looking anywhere else:
|
|
5
|
+
* the entry point, the Node major, the test script, the dependency specifiers,
|
|
6
|
+
* and the two duties that belong to one category each (`core` exports and
|
|
7
|
+
* dependencies, `runtime` resource clients).
|
|
8
|
+
*
|
|
9
|
+
* Every check here is `scope: 'service'` — it needs the package directory and
|
|
10
|
+
* nothing more — except `library-engines`, which resolves the platform major
|
|
11
|
+
* through the row's `from:` reference and is therefore workspace-scoped.
|
|
12
|
+
*
|
|
13
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md §11
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const { resolveFromValue } = require('../discovery');
|
|
20
|
+
const {
|
|
21
|
+
readPackage, whereOf, appliesTo, allDeps, scopedDeps, nodeMajorOf
|
|
22
|
+
} = require('./libraryContext');
|
|
23
|
+
|
|
24
|
+
/** The sections that answer "what does this package pull into a runtime". */
|
|
25
|
+
const RUNTIME_SECTIONS = Object.freeze(['dependencies']);
|
|
26
|
+
|
|
27
|
+
/** The sections `scripts/ci/verify-manifest-pins.mjs` compares against the SSOT. */
|
|
28
|
+
const PINNED_SECTIONS = Object.freeze(['dependencies', 'devDependencies']);
|
|
29
|
+
|
|
30
|
+
/** A specifier that is not one exact version, within the scope the SSOT owns. */
|
|
31
|
+
const RANGE_PREFIX = /^[\^~]/;
|
|
32
|
+
|
|
33
|
+
/** The specifiers that are a range rather than one version. */
|
|
34
|
+
const FLOATING = Object.freeze(['latest', '*']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every section npm resolves a dependency from. A `file:` in ANY of them is the
|
|
38
|
+
* same defect, so the row reads all four — while a RANGE is only judged in the
|
|
39
|
+
* two the pins row reads, and only inside the scope the SSOT owns.
|
|
40
|
+
*/
|
|
41
|
+
const ALL_DEPENDENCY_SECTIONS = Object.freeze([
|
|
42
|
+
'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** A specifier resolved from a path on the machine that wrote it. */
|
|
46
|
+
const isFileReference = (spec) => spec.startsWith('file:');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Shared preamble: read the package, and decide whether the row applies at all.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ block: object, serviceRoot: string|null, workspaceRoot: string|null }} params
|
|
52
|
+
* @param {string} scope the calling check's scope, which decides what `where` is relative to
|
|
53
|
+
* @returns {{ pkg: object, where: (relative?: string) => string }|null} null when the row does not apply
|
|
54
|
+
*/
|
|
55
|
+
function contextFor({ block, serviceRoot, workspaceRoot }, scope = 'service') {
|
|
56
|
+
const { json } = readPackage(serviceRoot);
|
|
57
|
+
if (json === null) return null;
|
|
58
|
+
if (!appliesTo(block, json)) return null;
|
|
59
|
+
return {
|
|
60
|
+
pkg: json,
|
|
61
|
+
where: (relative = 'package.json') => whereOf({ scope, serviceRoot, workspaceRoot, relative })
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const libraryMain = Object.freeze({
|
|
66
|
+
scope: 'service',
|
|
67
|
+
requires: Object.freeze([]),
|
|
68
|
+
|
|
69
|
+
run({ block, serviceRoot, workspaceRoot }) {
|
|
70
|
+
const { json } = readPackage(serviceRoot);
|
|
71
|
+
const where = (relative = 'package.json') => whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative });
|
|
72
|
+
|
|
73
|
+
if (json === null) {
|
|
74
|
+
return [{ where: where(), what: 'package.json is absent — nothing declares this directory a package' }];
|
|
75
|
+
}
|
|
76
|
+
if (!appliesTo(block, json)) return [];
|
|
77
|
+
|
|
78
|
+
if (typeof json.main !== 'string' || json.main.length === 0) {
|
|
79
|
+
return [{ where: where(), what: 'declares no "main" — a consumer\'s require() has no entry point to land on' }];
|
|
80
|
+
}
|
|
81
|
+
const target = path.join(serviceRoot, ...json.main.split('/'));
|
|
82
|
+
if (!fs.existsSync(target)) {
|
|
83
|
+
return [{ where: where(), what: `"main" points at ${json.main}, which does not exist in the package` }];
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const libraryEngines = Object.freeze({
|
|
90
|
+
scope: 'bearer',
|
|
91
|
+
requires: Object.freeze(['from']),
|
|
92
|
+
|
|
93
|
+
describeNotRun({ row }) {
|
|
94
|
+
return `the workspace root is not reachable, so ${row.from.path} cannot be read`;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
run({ row, block, serviceRoot, workspaceRoot }) {
|
|
98
|
+
const context = contextFor({ block, serviceRoot, workspaceRoot }, 'bearer');
|
|
99
|
+
if (context === null) return [];
|
|
100
|
+
|
|
101
|
+
const platform = nodeMajorOf(resolveFromValue({ from: row.from, workspaceRoot }));
|
|
102
|
+
if (platform === null) {
|
|
103
|
+
throw new Error(`[LibraryManifest] Platform Node major unreadable - ${row.from.path} carries no number. `
|
|
104
|
+
+ 'Fix: repair the file; it is the single owner of the major every package is measured against.');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const declared = context.pkg.engines && context.pkg.engines.node;
|
|
108
|
+
if (typeof declared !== 'string' || declared.length === 0) {
|
|
109
|
+
return [{
|
|
110
|
+
where: context.where(),
|
|
111
|
+
what: `declares no engines.node — the platform runs Node ${platform}`
|
|
112
|
+
}];
|
|
113
|
+
}
|
|
114
|
+
const major = nodeMajorOf(declared);
|
|
115
|
+
if (major !== platform) {
|
|
116
|
+
return [{
|
|
117
|
+
where: context.where(),
|
|
118
|
+
what: `engines.node is "${declared}" (major ${major}) but the platform major is ${platform}`
|
|
119
|
+
}];
|
|
120
|
+
}
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const libraryTestScript = Object.freeze({
|
|
126
|
+
scope: 'service',
|
|
127
|
+
requires: Object.freeze([]),
|
|
128
|
+
|
|
129
|
+
run(params) {
|
|
130
|
+
const context = contextFor(params);
|
|
131
|
+
if (context === null) return [];
|
|
132
|
+
const script = context.pkg.scripts && context.pkg.scripts.test;
|
|
133
|
+
if (typeof script === 'string' && script.length > 0) return [];
|
|
134
|
+
return [{
|
|
135
|
+
where: context.where(),
|
|
136
|
+
what: 'declares no scripts.test — the publish path has no run to make'
|
|
137
|
+
}];
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Two rules under one row, and the difference between them is who owns the
|
|
143
|
+
* version.
|
|
144
|
+
*
|
|
145
|
+
* * a RANGE (`^`, `~`, `latest`, `*`) is wrong for an `@onlineapps` package,
|
|
146
|
+
* because `api/config/libraries.json` owns that version and a floating range
|
|
147
|
+
* installs different code on different days. Outside that scope nothing here
|
|
148
|
+
* owns the version, so a caret on `jest` is not this row's business;
|
|
149
|
+
* * a `file:` is wrong for ANY package, in any section: it resolves to a path
|
|
150
|
+
* on the machine that wrote it and never reaches a published tarball
|
|
151
|
+
* (`.claude/rules/architecture-principles.md` § Shared Packages).
|
|
152
|
+
*
|
|
153
|
+
* The second half is d.224d. It was `@onlineapps`-only until then — measured by
|
|
154
|
+
* d.224c: `lodash = file:../lodash` in `devDependencies` passed the uniform, so
|
|
155
|
+
* the publish gate had to keep rule G2 of its own for exactly that case, which
|
|
156
|
+
* is one concern on two rails (`change-discipline.md` § One rail per concern).
|
|
157
|
+
* With the row reading every package, G2 is gone and the gate reads this row.
|
|
158
|
+
*/
|
|
159
|
+
const libraryDepRange = Object.freeze({
|
|
160
|
+
scope: 'service',
|
|
161
|
+
requires: Object.freeze([]),
|
|
162
|
+
|
|
163
|
+
run(params) {
|
|
164
|
+
const context = contextFor(params);
|
|
165
|
+
if (context === null) return [];
|
|
166
|
+
|
|
167
|
+
const files = allDeps(context.pkg, ALL_DEPENDENCY_SECTIONS)
|
|
168
|
+
.filter(({ spec }) => isFileReference(spec))
|
|
169
|
+
.map(({ name, spec, section }) => ({
|
|
170
|
+
where: context.where(),
|
|
171
|
+
what: `${section} points ${name} at "${spec}" — a file: reference resolves to a path on one machine `
|
|
172
|
+
+ 'and never reaches a published package'
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
// A `file:` on an @onlineapps package is already above; reported twice it
|
|
176
|
+
// would be one defect with two sentences and two fixes.
|
|
177
|
+
const ranges = scopedDeps(context.pkg, PINNED_SECTIONS)
|
|
178
|
+
.filter(({ spec }) => !isFileReference(spec))
|
|
179
|
+
.filter(({ spec }) => RANGE_PREFIX.test(spec) || FLOATING.includes(spec))
|
|
180
|
+
.map(({ name, spec, section }) => ({
|
|
181
|
+
where: context.where(),
|
|
182
|
+
what: `${section} pins ${name} at "${spec}", which is not one exact version`
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
return [...files, ...ranges];
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const libraryCoreDeps = Object.freeze({
|
|
190
|
+
scope: 'service',
|
|
191
|
+
requires: Object.freeze([]),
|
|
192
|
+
|
|
193
|
+
run(params) {
|
|
194
|
+
const context = contextFor(params);
|
|
195
|
+
if (context === null) return [];
|
|
196
|
+
|
|
197
|
+
return scopedDeps(context.pkg, RUNTIME_SECTIONS).map(({ name }) => ({
|
|
198
|
+
where: context.where(),
|
|
199
|
+
what: `depends on ${name} — a core package has no @onlineapps dependency at all`
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const libraryCoreExports = Object.freeze({
|
|
205
|
+
scope: 'service',
|
|
206
|
+
requires: Object.freeze([]),
|
|
207
|
+
|
|
208
|
+
run(params) {
|
|
209
|
+
const context = contextFor(params);
|
|
210
|
+
if (context === null) return [];
|
|
211
|
+
if (context.pkg.exports !== undefined) return [];
|
|
212
|
+
|
|
213
|
+
const main = context.pkg.main;
|
|
214
|
+
if (typeof main !== 'string' || main.length === 0) return [];
|
|
215
|
+
|
|
216
|
+
const target = path.join(params.serviceRoot, ...main.split('/'));
|
|
217
|
+
if (!fs.existsSync(target)) return [];
|
|
218
|
+
|
|
219
|
+
let loaded;
|
|
220
|
+
try {
|
|
221
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
222
|
+
loaded = require(target);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return [{
|
|
225
|
+
where: context.where(main),
|
|
226
|
+
what: `"main" does not load: ${error.message.split('\n')[0]} `
|
|
227
|
+
+ '(run npm install in the package if a dependency is missing, then re-run)'
|
|
228
|
+
}];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const named = loaded !== null && typeof loaded === 'object' && Object.keys(loaded).length > 0;
|
|
232
|
+
if (named || typeof loaded === 'function') return [];
|
|
233
|
+
return [{
|
|
234
|
+
where: context.where(main),
|
|
235
|
+
what: 'declares no "exports" and "main" exports no named symbol — the contract of an L1 package is not explicit'
|
|
236
|
+
}];
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const libraryRuntimeClient = Object.freeze({
|
|
241
|
+
scope: 'service',
|
|
242
|
+
requires: Object.freeze(['forbidden_packages']),
|
|
243
|
+
|
|
244
|
+
run(params) {
|
|
245
|
+
const context = contextFor(params);
|
|
246
|
+
if (context === null) return [];
|
|
247
|
+
|
|
248
|
+
const declared = context.pkg.dependencies || {};
|
|
249
|
+
return params.row.forbidden_packages
|
|
250
|
+
.filter((name) => Object.prototype.hasOwnProperty.call(declared, name))
|
|
251
|
+
.map((name) => ({
|
|
252
|
+
where: context.where(),
|
|
253
|
+
what: `depends on ${name} directly — storage and database access has one unified rail`
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
module.exports = {
|
|
259
|
+
checks: [
|
|
260
|
+
{ name: 'library-main', check: libraryMain },
|
|
261
|
+
{ name: 'library-engines', check: libraryEngines },
|
|
262
|
+
{ name: 'library-test-script', check: libraryTestScript },
|
|
263
|
+
{ name: 'library-dep-range', check: libraryDepRange },
|
|
264
|
+
{ name: 'library-core-deps', check: libraryCoreDeps },
|
|
265
|
+
{ name: 'library-core-exports', check: libraryCoreExports },
|
|
266
|
+
{ name: 'library-runtime-client', check: libraryRuntimeClient }
|
|
267
|
+
],
|
|
268
|
+
contextFor,
|
|
269
|
+
PINNED_SECTIONS,
|
|
270
|
+
RUNTIME_SECTIONS,
|
|
271
|
+
ALL_DEPENDENCY_SECTIONS
|
|
272
|
+
};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `library-source-env` — a connector receives its dependencies, it does not go
|
|
5
|
+
* looking for them.
|
|
6
|
+
*
|
|
7
|
+
* `process.env` inside `src/` is the measurable form of the hidden input
|
|
8
|
+
* dependency injection exists to remove (architecture-principles §1, §8). One
|
|
9
|
+
* place is exempt and the row names it: the config module, whose whole job is to
|
|
10
|
+
* turn the environment into an explicit object the constructor is handed.
|
|
11
|
+
*
|
|
12
|
+
* The row states that place as a list, because the module is a concept and not a
|
|
13
|
+
* shape: a package big enough for a `src/config/` directory and one small enough
|
|
14
|
+
* for a single `src/config.js` are wearing the same uniform, and the duty is the
|
|
15
|
+
* same for both (lead decision, `api/shared/TODO.md` §0.2b-15). An entry ending
|
|
16
|
+
* in `/` exempts a directory, any other entry exempts exactly that one file.
|
|
17
|
+
*
|
|
18
|
+
* The question is asked of CODE. A module that documents the duty it obeys —
|
|
19
|
+
* "this module reads no `process.env`" — is the duty kept, and reporting it is
|
|
20
|
+
* the check calling a sentence a defect. Measured: `conn-base-db`'s
|
|
21
|
+
* `src/createSequelize.js` was reported for exactly that sentence in its own
|
|
22
|
+
* header. So the file is read as source and its prose is blanked before the
|
|
23
|
+
* search: comments, the text of string and template literals, and
|
|
24
|
+
* regular-expression literals. What a template literal interpolates is code
|
|
25
|
+
* again, because `${process.env.X}` really does read the environment.
|
|
26
|
+
*
|
|
27
|
+
* @see .claude/rules/architecture-principles.md §1
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
const { readPackage, whereOf, appliesTo } = require('./libraryContext');
|
|
34
|
+
|
|
35
|
+
/** Where the search starts. Only shipped source is read; tests are not the rule. */
|
|
36
|
+
const SOURCE_DIR = 'src';
|
|
37
|
+
|
|
38
|
+
/** The one hidden input this row is about. */
|
|
39
|
+
const ENV_READ = /process\.env\b/;
|
|
40
|
+
|
|
41
|
+
/** Characters that may open an identifier — and so may continue one. */
|
|
42
|
+
const IDENTIFIER = /[A-Za-z0-9_$]/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* After one of these, a `/` divides; after anything else it opens a regular
|
|
46
|
+
* expression. Keywords are the exception the other way round: `return /x/` is a
|
|
47
|
+
* regular expression even though `return` ends in an identifier character.
|
|
48
|
+
*/
|
|
49
|
+
const DIVISION_AFTER = new Set([')', ']', '}', "'", '"', '`']);
|
|
50
|
+
const KEYWORD_BEFORE_REGEX = new Set([
|
|
51
|
+
'return', 'typeof', 'instanceof', 'in', 'of', 'case', 'delete',
|
|
52
|
+
'void', 'new', 'do', 'else', 'yield', 'await', 'throw'
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The source with every run of prose replaced by spaces of the same length, so
|
|
57
|
+
* a textual search answers a question about code and nothing else.
|
|
58
|
+
*
|
|
59
|
+
* Newlines are kept, so a position in the result is the same position in the
|
|
60
|
+
* file. Delimiters that carry meaning for the reader of the result — the quotes
|
|
61
|
+
* around a blanked string, the `${` and `}` of a live interpolation — are kept;
|
|
62
|
+
* a comment and a regular-expression literal go entirely, because a surviving
|
|
63
|
+
* `/` would read as division.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} source the file, verbatim
|
|
66
|
+
* @returns {string} the same length, prose blanked out
|
|
67
|
+
*/
|
|
68
|
+
function codeOnly(source) {
|
|
69
|
+
const out = source.split('');
|
|
70
|
+
const blank = (from, to) => {
|
|
71
|
+
for (let k = from; k < to && k < out.length; k += 1) if (out[k] !== '\n') out[k] = ' ';
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Nested contexts: template text and the code inside its interpolations. */
|
|
75
|
+
const frames = [{ template: false, braces: 0 }];
|
|
76
|
+
let previous = '';
|
|
77
|
+
let word = '';
|
|
78
|
+
let i = 0;
|
|
79
|
+
|
|
80
|
+
const remember = (character) => {
|
|
81
|
+
if (character === ' ' || character === '\t' || character === '\n' || character === '\r') return;
|
|
82
|
+
previous = character;
|
|
83
|
+
word = IDENTIFIER.test(character) ? word + character : '';
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
while (i < source.length) {
|
|
87
|
+
const frame = frames[frames.length - 1];
|
|
88
|
+
const character = source[i];
|
|
89
|
+
const next = source[i + 1];
|
|
90
|
+
|
|
91
|
+
if (frame.template) {
|
|
92
|
+
if (character === '\\') {
|
|
93
|
+
blank(i, i + 2);
|
|
94
|
+
i += 2;
|
|
95
|
+
} else if (character === '`') {
|
|
96
|
+
frames.pop();
|
|
97
|
+
previous = '`';
|
|
98
|
+
word = '';
|
|
99
|
+
i += 1;
|
|
100
|
+
} else if (character === '$' && next === '{') {
|
|
101
|
+
frames.push({ template: false, braces: 0 });
|
|
102
|
+
previous = '{';
|
|
103
|
+
word = '';
|
|
104
|
+
i += 2;
|
|
105
|
+
} else {
|
|
106
|
+
blank(i, i + 1);
|
|
107
|
+
i += 1;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (character === '/' && next === '/') {
|
|
113
|
+
let end = i;
|
|
114
|
+
while (end < source.length && source[end] !== '\n') end += 1;
|
|
115
|
+
blank(i, end);
|
|
116
|
+
i = end;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (character === '/' && next === '*') {
|
|
121
|
+
let end = i + 2;
|
|
122
|
+
while (end < source.length && !(source[end] === '*' && source[end + 1] === '/')) end += 1;
|
|
123
|
+
end = Math.min(end + 2, source.length);
|
|
124
|
+
blank(i, end);
|
|
125
|
+
i = end;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (character === "'" || character === '"') {
|
|
130
|
+
let end = i + 1;
|
|
131
|
+
while (end < source.length && source[end] !== character && source[end] !== '\n') {
|
|
132
|
+
end += source[end] === '\\' ? 2 : 1;
|
|
133
|
+
}
|
|
134
|
+
blank(i + 1, Math.min(end, source.length));
|
|
135
|
+
i = Math.min(end, source.length) + 1;
|
|
136
|
+
remember(character);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (character === '`') {
|
|
141
|
+
frames.push({ template: true, braces: 0 });
|
|
142
|
+
remember(character);
|
|
143
|
+
i += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (character === '/') {
|
|
148
|
+
const end = endOfRegex(source, i);
|
|
149
|
+
if (end !== -1) {
|
|
150
|
+
blank(i, end);
|
|
151
|
+
i = end;
|
|
152
|
+
previous = '/';
|
|
153
|
+
word = '';
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (character === '{') frame.braces += 1;
|
|
159
|
+
if (character === '}') {
|
|
160
|
+
if (frame.braces === 0 && frames.length > 1) {
|
|
161
|
+
frames.pop();
|
|
162
|
+
previous = '}';
|
|
163
|
+
word = '';
|
|
164
|
+
i += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
frame.braces -= 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
remember(character);
|
|
171
|
+
i += 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return out.join('');
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Where the regular-expression literal starting at `start` ends, flags
|
|
178
|
+
* included — or `-1` when that `/` is a division sign, or opens nothing that
|
|
179
|
+
* closes on the same line.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} text the file
|
|
182
|
+
* @param {number} start index of the `/`
|
|
183
|
+
* @returns {number} the index one past the literal, or -1
|
|
184
|
+
*/
|
|
185
|
+
function endOfRegex(text, start) {
|
|
186
|
+
if (DIVISION_AFTER.has(previous)) return -1;
|
|
187
|
+
if (IDENTIFIER.test(previous) && !KEYWORD_BEFORE_REGEX.has(word)) return -1;
|
|
188
|
+
|
|
189
|
+
let k = start + 1;
|
|
190
|
+
let inClass = false;
|
|
191
|
+
while (k < text.length && text[k] !== '\n') {
|
|
192
|
+
const character = text[k];
|
|
193
|
+
if (character === '\\') {
|
|
194
|
+
k += 2;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (character === '[') inClass = true;
|
|
198
|
+
else if (character === ']') inClass = false;
|
|
199
|
+
else if (character === '/' && !inClass) {
|
|
200
|
+
k += 1;
|
|
201
|
+
while (k < text.length && /[a-z]/.test(text[k])) k += 1;
|
|
202
|
+
return k;
|
|
203
|
+
}
|
|
204
|
+
k += 1;
|
|
205
|
+
}
|
|
206
|
+
return -1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Every `.js` file under `dir`, as paths relative to `root`, `/`-separated.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} root the package directory
|
|
214
|
+
* @param {string} dir absolute directory to walk
|
|
215
|
+
* @returns {string[]}
|
|
216
|
+
*/
|
|
217
|
+
function sourceFiles(root, dir) {
|
|
218
|
+
let entries;
|
|
219
|
+
try {
|
|
220
|
+
entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
221
|
+
} catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const found = [];
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
const absolute = path.join(dir, entry.name);
|
|
228
|
+
if (entry.isDirectory()) {
|
|
229
|
+
if (entry.name === 'node_modules') continue;
|
|
230
|
+
found.push(...sourceFiles(root, absolute));
|
|
231
|
+
} else if (entry.name.endsWith('.js')) {
|
|
232
|
+
found.push(path.relative(root, absolute).split(path.sep).join('/'));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return found;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Is this file part of the config module the row exempts?
|
|
240
|
+
*
|
|
241
|
+
* @param {string} relative the file, relative to the package root, `/`-separated
|
|
242
|
+
* @param {string[]} allowed the row's exemptions — a trailing `/` means a directory
|
|
243
|
+
* @returns {boolean}
|
|
244
|
+
*/
|
|
245
|
+
function isExempt(relative, allowed) {
|
|
246
|
+
return allowed.some((entry) => (entry.endsWith('/') ? relative.startsWith(entry) : relative === entry));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const librarySourceEnv = Object.freeze({
|
|
250
|
+
scope: 'service',
|
|
251
|
+
requires: Object.freeze(['allowed']),
|
|
252
|
+
|
|
253
|
+
run({ row, block, serviceRoot, workspaceRoot }) {
|
|
254
|
+
const { json } = readPackage(serviceRoot);
|
|
255
|
+
if (json === null || !appliesTo(block, json)) return [];
|
|
256
|
+
|
|
257
|
+
const allowed = row.allowed;
|
|
258
|
+
|
|
259
|
+
return sourceFiles(serviceRoot, path.join(serviceRoot, SOURCE_DIR))
|
|
260
|
+
.filter((relative) => !isExempt(relative, allowed))
|
|
261
|
+
.filter((relative) => ENV_READ.test(codeOnly(fs.readFileSync(path.join(serviceRoot, relative), 'utf8'))))
|
|
262
|
+
.map((relative) => ({
|
|
263
|
+
where: whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative }),
|
|
264
|
+
what: `reads process.env outside ${allowed.join(' and ')} — the value belongs in the constructor`
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
checks: [{ name: 'library-source-env', check: librarySourceEnv }],
|
|
271
|
+
codeOnly,
|
|
272
|
+
sourceFiles,
|
|
273
|
+
isExempt
|
|
274
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two rows about tests: that the package HAS a unit tier, and that the tier
|
|
5
|
+
* does not travel to the consumer inside the tarball.
|
|
6
|
+
*
|
|
7
|
+
* `library-pack-tests` measures the EFFECT, not the file. Twenty packages keep
|
|
8
|
+
* tests out with `.npmignore` and eight with a `files` allowlist that simply
|
|
9
|
+
* never names them (measured over `api/shared` 2026-09-09); a row checking for
|
|
10
|
+
* `.npmignore` would report eight packages that are already correct, and would
|
|
11
|
+
* miss the ninth that has the file and lists nothing. So the check reproduces
|
|
12
|
+
* npm's own precedence — `files` allowlist first, then `.npmignore`, then
|
|
13
|
+
* `.gitignore` — and asks one question: does `tests/` end up in the tarball.
|
|
14
|
+
*
|
|
15
|
+
* @see .claude/rules/architecture-principles.md § Shared Packages
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const { readPackage, readText, whereOf, appliesTo } = require('./libraryContext');
|
|
22
|
+
|
|
23
|
+
/** The directory both rows are about. */
|
|
24
|
+
const TEST_DIR = 'tests';
|
|
25
|
+
|
|
26
|
+
/** The unit tier, and the two suffixes a test file wears in this workspace. */
|
|
27
|
+
const UNIT_DIR = path.join('tests', 'unit');
|
|
28
|
+
const TEST_FILE = /\.(test|spec)\.js$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Does an ignore-file line take `tests/` out of the tarball? npm reads these
|
|
32
|
+
* with gitignore semantics; the shapes that actually occur are the bare name,
|
|
33
|
+
* the anchored name, and the name with a trailing slash or glob.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} body the contents of .npmignore or .gitignore
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function ignoresTests(body) {
|
|
39
|
+
return body
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
|
43
|
+
.some((line) => /^\/?tests(\/(\*\*?)?)?$/.test(line));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Does a `files` allowlist entry let `tests/` in? An entry is a path or a glob;
|
|
48
|
+
* it reaches the directory only when its first segment is the directory itself.
|
|
49
|
+
*
|
|
50
|
+
* @param {string[]} files the allowlist
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function allowsTests(files) {
|
|
54
|
+
return files.some((entry) => String(entry).replace(/^\.?\//, '').split('/')[0] === TEST_DIR);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const libraryTests = Object.freeze({
|
|
58
|
+
scope: 'service',
|
|
59
|
+
requires: Object.freeze([]),
|
|
60
|
+
|
|
61
|
+
run({ block, serviceRoot, workspaceRoot }) {
|
|
62
|
+
const { json } = readPackage(serviceRoot);
|
|
63
|
+
if (json === null || !appliesTo(block, json)) return [];
|
|
64
|
+
|
|
65
|
+
const where = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: UNIT_DIR.split(path.sep).join('/') });
|
|
66
|
+
const dir = path.join(serviceRoot, UNIT_DIR);
|
|
67
|
+
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = fs.readdirSync(dir);
|
|
71
|
+
} catch {
|
|
72
|
+
return [{ where, what: 'the unit tier is absent — no production code without a test' }];
|
|
73
|
+
}
|
|
74
|
+
if (!entries.some((name) => TEST_FILE.test(name))) {
|
|
75
|
+
return [{ where, what: `the unit tier holds no test file (${entries.length} entr(y|ies), none matching *.test.js)` }];
|
|
76
|
+
}
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const libraryPackTests = Object.freeze({
|
|
82
|
+
scope: 'service',
|
|
83
|
+
requires: Object.freeze([]),
|
|
84
|
+
|
|
85
|
+
run({ block, serviceRoot, workspaceRoot }) {
|
|
86
|
+
const { json } = readPackage(serviceRoot);
|
|
87
|
+
if (json === null || !appliesTo(block, json)) return [];
|
|
88
|
+
|
|
89
|
+
const where = whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: 'package.json' });
|
|
90
|
+
if (!fs.existsSync(path.join(serviceRoot, TEST_DIR))) return [];
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(json.files)) {
|
|
93
|
+
if (!allowsTests(json.files)) return [];
|
|
94
|
+
return [{ where, what: `the files allowlist names ${TEST_DIR} — the tier would ship to every consumer` }];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const name of ['.npmignore', '.gitignore']) {
|
|
98
|
+
const body = readText(path.join(serviceRoot, name));
|
|
99
|
+
if (body === null) continue;
|
|
100
|
+
if (ignoresTests(body)) return [];
|
|
101
|
+
return [{
|
|
102
|
+
where: whereOf({ scope: 'service', serviceRoot, workspaceRoot, relative: name }),
|
|
103
|
+
what: `does not exclude ${TEST_DIR}/, and no files allowlist does either — the tier would ship to every consumer`
|
|
104
|
+
}];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return [{
|
|
108
|
+
where,
|
|
109
|
+
what: `no files allowlist, no .npmignore, no .gitignore — ${TEST_DIR}/ would ship to every consumer`
|
|
110
|
+
}];
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
checks: [
|
|
116
|
+
{ name: 'library-tests', check: libraryTests },
|
|
117
|
+
{ name: 'library-pack-tests', check: libraryPackTests }
|
|
118
|
+
],
|
|
119
|
+
ignoresTests,
|
|
120
|
+
allowsTests
|
|
121
|
+
};
|