@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +2558 -2
  2. package/README.md +1038 -4
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +140 -9
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +2 -1
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +290 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -0,0 +1,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
+ };