@verdeect/kit 0.0.0-stage → 1.0.0-rc.1

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 (216) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +804 -2
  3. package/alerts/verdeect-kit.rules.yml +133 -0
  4. package/dist/cli/commands/crypto.d.ts +9 -0
  5. package/dist/cli/commands/crypto.js +87 -0
  6. package/dist/cli/commands/db.d.ts +11 -0
  7. package/dist/cli/commands/db.js +151 -0
  8. package/dist/cli/commands/openapi.d.ts +9 -0
  9. package/dist/cli/commands/openapi.js +144 -0
  10. package/dist/cli/commands/rabbitmq.d.ts +11 -0
  11. package/dist/cli/commands/rabbitmq.js +124 -0
  12. package/dist/cli/commands/redis.d.ts +11 -0
  13. package/dist/cli/commands/redis.js +119 -0
  14. package/dist/cli/main.d.ts +2 -0
  15. package/dist/cli/main.js +31 -0
  16. package/dist/config/cli-schemas.d.ts +29 -0
  17. package/dist/config/cli-schemas.js +15 -0
  18. package/dist/config/define-env.d.ts +21 -0
  19. package/dist/config/define-env.js +43 -0
  20. package/dist/config/fragments.d.ts +105 -0
  21. package/dist/config/fragments.js +86 -0
  22. package/dist/config/helpers.d.ts +26 -0
  23. package/dist/config/helpers.js +62 -0
  24. package/dist/config/index.d.ts +7 -0
  25. package/dist/config/index.js +7 -0
  26. package/dist/config/kit-config.module.d.ts +20 -0
  27. package/dist/config/kit-config.module.js +23 -0
  28. package/dist/config/parse-env.d.ts +14 -0
  29. package/dist/config/parse-env.js +28 -0
  30. package/dist/crypto/cipher.d.ts +61 -0
  31. package/dist/crypto/cipher.js +136 -0
  32. package/dist/crypto/index.d.ts +2 -0
  33. package/dist/crypto/index.js +2 -0
  34. package/dist/database/batches.d.ts +42 -0
  35. package/dist/database/batches.js +49 -0
  36. package/dist/database/db.d.ts +93 -0
  37. package/dist/database/db.js +234 -0
  38. package/dist/database/if-newer.d.ts +9 -0
  39. package/dist/database/if-newer.js +15 -0
  40. package/dist/database/index.d.ts +17 -0
  41. package/dist/database/index.js +13 -0
  42. package/dist/database/kit-database.module.d.ts +47 -0
  43. package/dist/database/kit-database.module.js +181 -0
  44. package/dist/database/migrations.d.ts +42 -0
  45. package/dist/database/migrations.js +142 -0
  46. package/dist/database/pg.d.ts +20 -0
  47. package/dist/database/pg.js +19 -0
  48. package/dist/database/prepare-database.d.ts +39 -0
  49. package/dist/database/prepare-database.js +75 -0
  50. package/dist/database/run-exclusive.d.ts +19 -0
  51. package/dist/database/run-exclusive.js +62 -0
  52. package/dist/database/tenant.d.ts +34 -0
  53. package/dist/database/tenant.js +28 -0
  54. package/dist/http/api/api.decorator.d.ts +18 -0
  55. package/dist/http/api/api.decorator.js +9 -0
  56. package/dist/http/api/openapi-document.d.ts +40 -0
  57. package/dist/http/api/openapi-document.js +452 -0
  58. package/dist/http/bootstrap-service.d.ts +33 -0
  59. package/dist/http/bootstrap-service.js +107 -0
  60. package/dist/http/create-http-app.d.ts +38 -0
  61. package/dist/http/create-http-app.js +137 -0
  62. package/dist/http/errors.d.ts +118 -0
  63. package/dist/http/errors.js +94 -0
  64. package/dist/http/index.d.ts +12 -0
  65. package/dist/http/index.js +8 -0
  66. package/dist/http/kit-exception.filter.d.ts +18 -0
  67. package/dist/http/kit-exception.filter.js +151 -0
  68. package/dist/http/kit-http-core.module.d.ts +19 -0
  69. package/dist/http/kit-http-core.module.js +122 -0
  70. package/dist/http/pagination.d.ts +30 -0
  71. package/dist/http/pagination.js +73 -0
  72. package/dist/http/zod-validation.pipe.d.ts +15 -0
  73. package/dist/http/zod-validation.pipe.js +47 -0
  74. package/dist/idempotency/idempotency.interceptor.d.ts +55 -0
  75. package/dist/idempotency/idempotency.interceptor.js +317 -0
  76. package/dist/idempotency/idempotent.decorator.d.ts +7 -0
  77. package/dist/idempotency/idempotent.decorator.js +16 -0
  78. package/dist/idempotency/index.d.ts +7 -0
  79. package/dist/idempotency/index.js +6 -0
  80. package/dist/idempotency/kit-idempotency.module.d.ts +47 -0
  81. package/dist/idempotency/kit-idempotency.module.js +173 -0
  82. package/dist/idempotency/peer.d.ts +1 -0
  83. package/dist/idempotency/peer.js +4 -0
  84. package/dist/idempotency/table.d.ts +203 -0
  85. package/dist/idempotency/table.js +86 -0
  86. package/dist/internal/cleanup-result.d.ts +7 -0
  87. package/dist/internal/cleanup-result.js +12 -0
  88. package/dist/internal/ids.d.ts +13 -0
  89. package/dist/internal/ids.js +41 -0
  90. package/dist/internal/metadata.d.ts +12 -0
  91. package/dist/internal/metadata.js +3 -0
  92. package/dist/internal/outbox-policy.d.ts +8 -0
  93. package/dist/internal/outbox-policy.js +10 -0
  94. package/dist/internal/peer.d.ts +16 -0
  95. package/dist/internal/peer.js +56 -0
  96. package/dist/internal/pg-errors.d.ts +21 -0
  97. package/dist/internal/pg-errors.js +115 -0
  98. package/dist/internal/preparsed-env.d.ts +4 -0
  99. package/dist/internal/preparsed-env.js +14 -0
  100. package/dist/internal/rabbitmq-definitions.d.ts +85 -0
  101. package/dist/internal/rabbitmq-definitions.js +73 -0
  102. package/dist/internal/redis-acl.d.ts +21 -0
  103. package/dist/internal/redis-acl.js +30 -0
  104. package/dist/internal/service-name.d.ts +4 -0
  105. package/dist/internal/service-name.js +10 -0
  106. package/dist/internal/sql-identifier.d.ts +5 -0
  107. package/dist/internal/sql-identifier.js +12 -0
  108. package/dist/internal/tokens.d.ts +10 -0
  109. package/dist/internal/tokens.js +12 -0
  110. package/dist/internal/tx-context.d.ts +5 -0
  111. package/dist/internal/tx-context.js +9 -0
  112. package/dist/messaging/cleanup.d.ts +49 -0
  113. package/dist/messaging/cleanup.js +129 -0
  114. package/dist/messaging/config.d.ts +43 -0
  115. package/dist/messaging/config.js +8 -0
  116. package/dist/messaging/define-event.d.ts +29 -0
  117. package/dist/messaging/define-event.js +38 -0
  118. package/dist/messaging/envelope.d.ts +66 -0
  119. package/dist/messaging/envelope.js +49 -0
  120. package/dist/messaging/errors.d.ts +45 -0
  121. package/dist/messaging/errors.js +63 -0
  122. package/dist/messaging/inbox.d.ts +4 -0
  123. package/dist/messaging/inbox.js +9 -0
  124. package/dist/messaging/index.d.ts +13 -0
  125. package/dist/messaging/index.js +10 -0
  126. package/dist/messaging/kit-messaging.module.d.ts +39 -0
  127. package/dist/messaging/kit-messaging.module.js +309 -0
  128. package/dist/messaging/outbox-relay.d.ts +67 -0
  129. package/dist/messaging/outbox-relay.js +260 -0
  130. package/dist/messaging/outbox-store.d.ts +75 -0
  131. package/dist/messaging/outbox-store.js +126 -0
  132. package/dist/messaging/peer.d.ts +1 -0
  133. package/dist/messaging/peer.js +5 -0
  134. package/dist/messaging/rabbit-connection.d.ts +144 -0
  135. package/dist/messaging/rabbit-connection.js +472 -0
  136. package/dist/messaging/redelivery.d.ts +6 -0
  137. package/dist/messaging/redelivery.js +17 -0
  138. package/dist/messaging/subscriber.d.ts +102 -0
  139. package/dist/messaging/subscriber.js +412 -0
  140. package/dist/messaging/tables.d.ts +315 -0
  141. package/dist/messaging/tables.js +72 -0
  142. package/dist/messaging/topology.d.ts +56 -0
  143. package/dist/messaging/topology.js +56 -0
  144. package/dist/observability/fail-startup.d.ts +14 -0
  145. package/dist/observability/fail-startup.js +51 -0
  146. package/dist/observability/health-registry.d.ts +34 -0
  147. package/dist/observability/health-registry.js +81 -0
  148. package/dist/observability/index.d.ts +16 -0
  149. package/dist/observability/index.js +10 -0
  150. package/dist/observability/lifecycle.d.ts +52 -0
  151. package/dist/observability/lifecycle.js +151 -0
  152. package/dist/observability/logger-options.d.ts +54 -0
  153. package/dist/observability/logger-options.js +91 -0
  154. package/dist/observability/metrics-server.d.ts +33 -0
  155. package/dist/observability/metrics-server.js +108 -0
  156. package/dist/observability/metrics.d.ts +30 -0
  157. package/dist/observability/metrics.js +53 -0
  158. package/dist/observability/redaction.d.ts +23 -0
  159. package/dist/observability/redaction.js +65 -0
  160. package/dist/observability/sanitize-error.d.ts +47 -0
  161. package/dist/observability/sanitize-error.js +194 -0
  162. package/dist/observability/sentry.d.ts +64 -0
  163. package/dist/observability/sentry.js +233 -0
  164. package/dist/redis/channels.d.ts +34 -0
  165. package/dist/redis/channels.js +128 -0
  166. package/dist/redis/client.d.ts +42 -0
  167. package/dist/redis/client.js +85 -0
  168. package/dist/redis/index.d.ts +7 -0
  169. package/dist/redis/index.js +7 -0
  170. package/dist/redis/kit-redis.module.d.ts +18 -0
  171. package/dist/redis/kit-redis.module.js +131 -0
  172. package/dist/redis/peer.d.ts +1 -0
  173. package/dist/redis/peer.js +4 -0
  174. package/dist/redis/rate-limiter.d.ts +49 -0
  175. package/dist/redis/rate-limiter.js +139 -0
  176. package/dist/testing/count-queries.d.ts +30 -0
  177. package/dist/testing/count-queries.js +58 -0
  178. package/dist/testing/create-test-app.d.ts +26 -0
  179. package/dist/testing/create-test-app.js +32 -0
  180. package/dist/testing/expect-matches-api.d.ts +8 -0
  181. package/dist/testing/expect-matches-api.js +49 -0
  182. package/dist/testing/expect-problem.d.ts +24 -0
  183. package/dist/testing/expect-problem.js +23 -0
  184. package/dist/testing/images.d.ts +10 -0
  185. package/dist/testing/images.js +14 -0
  186. package/dist/testing/index.d.ts +23 -0
  187. package/dist/testing/index.js +17 -0
  188. package/dist/testing/launch-command.d.ts +52 -0
  189. package/dist/testing/launch-command.js +510 -0
  190. package/dist/testing/outbox-coverage.d.ts +26 -0
  191. package/dist/testing/outbox-coverage.js +98 -0
  192. package/dist/testing/rabbitmq-admin.d.ts +81 -0
  193. package/dist/testing/rabbitmq-admin.js +146 -0
  194. package/dist/testing/redis-worker.d.ts +10 -0
  195. package/dist/testing/redis-worker.js +15 -0
  196. package/dist/testing/repo-conventions.d.ts +10 -0
  197. package/dist/testing/repo-conventions.js +524 -0
  198. package/dist/testing/repo-files.d.ts +113 -0
  199. package/dist/testing/repo-files.js +401 -0
  200. package/dist/testing/scratch-database.d.ts +19 -0
  201. package/dist/testing/scratch-database.js +75 -0
  202. package/dist/testing/start-test-stack.d.ts +85 -0
  203. package/dist/testing/start-test-stack.js +342 -0
  204. package/dist/testing/tcp-proxy.d.ts +40 -0
  205. package/dist/testing/tcp-proxy.js +164 -0
  206. package/dist/testing/tenant-isolation.d.ts +31 -0
  207. package/dist/testing/tenant-isolation.js +205 -0
  208. package/dist/testing/test-events.d.ts +37 -0
  209. package/dist/testing/test-events.js +150 -0
  210. package/dist/testing/timeout-scale.d.ts +4 -0
  211. package/dist/testing/timeout-scale.js +17 -0
  212. package/dist/testing/timing-guard.d.ts +22 -0
  213. package/dist/testing/timing-guard.js +54 -0
  214. package/dist/testing/worker-scope.d.ts +31 -0
  215. package/dist/testing/worker-scope.js +68 -0
  216. package/package.json +177 -4
@@ -0,0 +1,524 @@
1
+ // assertRepoConventions — один тест репозитория сервиса по правилам prd.md §3.8 v1.10 (AC-1.8, CE-1). Эталоны: образы — BFF
2
+ // test/image-tags.spec.ts и O test/support/images.spec.ts; compose как данные — P ac-6-1-production-compose.spec.ts и O
3
+ // test/support/compose-env.spec.ts. Правил --import, REPLICA_ID, stop_grace_period и «команда не распознана» в эталонах нет —
4
+ // они новые по PRD. Новое правило или ключ RENAMED_ENV_KEYS после 1.0.0 — мажор (G14).
5
+ // Единица проверки compose — слитый сервис (базовый файл ⊕ переопределение, !reset/!override по правилам Compose);
6
+ // команда запуска собирается по правилам Docker (ENTRYPOINT + CMD, стадии) и Compose (entrypoint: + command:).
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import semver from 'semver';
10
+ import { envBase } from '../config/index.js';
11
+ import { imageRepository, TEST_IMAGES } from './images.js';
12
+ import { analyzeLaunch, hasInstrumentImport, shellWords, } from './launch-command.js';
13
+ import { expandDefaults, imageTags, parseImageRef, readRepoFiles, RESET, } from './repo-files.js';
14
+ const RULE_ORDER = [
15
+ 'test-images',
16
+ 'replica-id',
17
+ 'stop-grace-period',
18
+ 'env-keys',
19
+ 'peer-versions',
20
+ 'instrument-import',
21
+ 'command-unrecognized',
22
+ 'redaction-imports',
23
+ ];
24
+ /** Старые имена ключей → новые (§3.8, явный список; расширение после 1.0.0 — мажор). Псевдонимов нет (P0). */
25
+ const RENAMED_ENV_KEYS = {
26
+ IDENTITY_PORT: 'PORT',
27
+ BFF_PORT: 'PORT',
28
+ DATABASE_MIGRATION_URL: 'DATABASE_MIGRATOR_URL',
29
+ SESSION_ENC_KEYS: 'ENCRYPTION_KEYS',
30
+ SIGNING_KEY_ENCRYPTION_KEYS: 'ENCRYPTION_KEYS',
31
+ CLIENT_SECRET_KEY: 'ENCRYPTION_KEYS',
32
+ IDENTITY_SECRETS: 'ENCRYPTION_KEYS',
33
+ };
34
+ const BASE_KEYS = Object.keys(envBase);
35
+ const DEFAULT_SHUTDOWN_MS = 10_000;
36
+ const DOCKER_DEFAULT_GRACE_MS = 10_000;
37
+ const REQUIRED_FORM = 'node --import ./dist/instrument.js dist/main.js';
38
+ /** Порядок поиска базового файла Compose в каталоге проекта: первый найденный — база для переопределений. */
39
+ const BASE_COMPOSE_ORDER = [
40
+ 'compose.yaml',
41
+ 'compose.yml',
42
+ 'docker-compose.yaml',
43
+ 'docker-compose.yml',
44
+ ];
45
+ const DURATION_UNITS_MS = {
46
+ ns: 1e-6,
47
+ us: 1e-3,
48
+ µs: 1e-3,
49
+ ms: 1,
50
+ s: 1000,
51
+ m: 60_000,
52
+ h: 3_600_000,
53
+ };
54
+ /** Peer-диапазоны пакета: путь верен и из src/testing, и из dist/testing. */
55
+ function kitPeerRanges() {
56
+ const path = fileURLToPath(new URL('../../package.json', import.meta.url));
57
+ const manifest = JSON.parse(readFileSync(path, 'utf8'));
58
+ return manifest.peerDependencies ?? {};
59
+ }
60
+ /** Базовый файл Compose репозитория — первый найденный по порядку поиска Compose; null — базового файла нет. */
61
+ export function baseComposeFile(files) {
62
+ const present = new Set(files.compose.map((service) => service.file));
63
+ return BASE_COMPOSE_ORDER.find((name) => present.has(name)) ?? null;
64
+ }
65
+ /** Поле переопределения: своё значение, `!reset` — умолчание (null), нет ключа — значение базового файла. */
66
+ function pick(own, ownFile, base, select) {
67
+ if (own === RESET) {
68
+ return null;
69
+ }
70
+ if (own !== undefined) {
71
+ return { value: own, file: ownFile };
72
+ }
73
+ const inherited = base === undefined ? undefined : select(base);
74
+ return inherited === undefined || inherited === RESET || base === undefined
75
+ ? null
76
+ : { value: inherited, file: base.file };
77
+ }
78
+ function mergeService(service, base) {
79
+ const inherit = base !== undefined && base !== service && service.envMode === 'merge';
80
+ const env = new Map(inherit ? base.env : []);
81
+ const envSource = new Map(inherit ? base.envSource : []);
82
+ for (const [key, value] of service.env) {
83
+ env.set(key, value);
84
+ envSource.set(key, service.envSource.get(key) ?? service.file);
85
+ }
86
+ const other = base === service ? undefined : base;
87
+ return {
88
+ file: service.file,
89
+ name: service.name,
90
+ env,
91
+ envSource,
92
+ stopGracePeriod: pick(service.stopGracePeriod, service.file, other, (s) => s.stopGracePeriod)?.value ??
93
+ null,
94
+ command: pick(service.command, service.file, other, (s) => s.command),
95
+ entrypoint: pick(service.entrypoint, service.file, other, (s) => s.entrypoint),
96
+ build: pick(service.build, service.file, other, (s) => s.build)?.value ?? null,
97
+ };
98
+ }
99
+ /** Стадия Dockerfile, которую собирает `build:` сервиса: `target` или последняя стадия. */
100
+ function buildStage(files, build) {
101
+ if (build === null) {
102
+ return undefined;
103
+ }
104
+ const stages = files.docker.filter((stage) => stage.file === build.dockerfile);
105
+ const target = build.target?.toLowerCase() ?? null;
106
+ return target === null
107
+ ? stages.find((stage) => stage.last)
108
+ : stages.find((stage) => stage.name === target);
109
+ }
110
+ /** Argv строки или массива Compose: подстановка `${VAR:-умолчание}`, `$$` — литерал `$`. */
111
+ function composeArgv(command) {
112
+ const words = typeof command === 'string' ? shellWords(expandDefaults(command)) : [...command];
113
+ return words
114
+ .map((word) => expandDefaults(word).replaceAll('$$', ''))
115
+ .map((word) =>
116
+ // `$$` — экранированный `$` Compose: до shell доходит переменная shell, её разбирает analyzeLaunch.
117
+ word.replaceAll('', '$'));
118
+ }
119
+ /** Argv инструкции Docker: exec-форма как есть, shell-форма — `/bin/sh -c "<текст>"`. */
120
+ function instructionArgv(instruction) {
121
+ return instruction.exec === null ? ['/bin/sh', '-c', instruction.shell] : [...instruction.exec];
122
+ }
123
+ /** Команда запуска стадии по правилам Docker; null — ни CMD, ни ENTRYPOINT. */
124
+ function stageLaunch(stage) {
125
+ const { entrypoint, cmd } = stage;
126
+ if (entrypoint === null) {
127
+ return cmd === null ? null : { kind: 'CMD', argv: instructionArgv(cmd) };
128
+ }
129
+ if (entrypoint.exec === null || cmd === null) {
130
+ // Shell-форма ENTRYPOINT отбрасывает CMD (как в Docker).
131
+ return { kind: 'ENTRYPOINT', argv: instructionArgv(entrypoint) };
132
+ }
133
+ return { kind: 'ENTRYPOINT + CMD', argv: [...entrypoint.exec, ...instructionArgv(cmd)] };
134
+ }
135
+ /**
136
+ * Команда сервиса Compose (§3.8 v1.10, B6): `entrypoint:` задан — CMD образа отбрасывается, `command:` — его
137
+ * аргументы; только `command:` — аргументы ENTRYPOINT стадии Dockerfile (если Dockerfile найден). Файл — где задана
138
+ * команда. null — сервис запускается командой Dockerfile.
139
+ */
140
+ function composeLaunch(files, merged) {
141
+ if (merged.entrypoint !== null) {
142
+ return {
143
+ argv: [
144
+ ...composeArgv(merged.entrypoint.value),
145
+ ...(merged.command === null ? [] : composeArgv(merged.command.value)),
146
+ ],
147
+ file: merged.entrypoint.file,
148
+ };
149
+ }
150
+ if (merged.command === null) {
151
+ return null;
152
+ }
153
+ const command = composeArgv(merged.command.value);
154
+ const entrypoint = buildStage(files, merged.build)?.entrypoint ?? null;
155
+ if (entrypoint === null) {
156
+ return { argv: command, file: merged.command.file };
157
+ }
158
+ if (entrypoint.exec === null) {
159
+ return { argv: instructionArgv(entrypoint), file: merged.command.file };
160
+ }
161
+ return { argv: [...entrypoint.exec, ...command], file: merged.command.file };
162
+ }
163
+ /**
164
+ * Вид сервиса (§3.8 v1.10): без ключей envBase (у слитого сервиса) — инфраструктура; подделка `*.main.ts`; одноразовое
165
+ * задание — своя или унаследованная команда без запуска приложения и без формы «команда не распознана»; иначе —
166
+ * приложение (в том числе без `command:` — запуск командой Dockerfile).
167
+ */
168
+ function checkedServices(files) {
169
+ const baseFile = baseComposeFile(files);
170
+ return files.compose.map((service) => {
171
+ const base = files.compose.find((other) => other.file === baseFile && other.name === service.name);
172
+ const merged = mergeService(service, base);
173
+ const launch = composeLaunch(files, merged);
174
+ const analysis = launch === null ? null : analyzeLaunch(launch.argv);
175
+ const isCandidate = BASE_KEYS.some((key) => merged.env.has(key));
176
+ let kind = 'app';
177
+ if (!isCandidate) {
178
+ kind = 'infra';
179
+ }
180
+ else if (analysis !== null && analysis.fake && analysis.launches.length === 0) {
181
+ kind = 'fake';
182
+ }
183
+ else if (analysis !== null &&
184
+ analysis.launches.length === 0 &&
185
+ analysis.unrecognized.length === 0) {
186
+ kind = 'one-shot';
187
+ }
188
+ return { merged, launch, analysis, kind };
189
+ });
190
+ }
191
+ /** Ожидаемый образ TEST_IMAGES по репозиторию (`postgres` → `postgres:17.11-alpine3.24`). */
192
+ const EXPECTED_BY_REPOSITORY = new Map(Object.values(TEST_IMAGES).map((image) => [imageRepository(image), image]));
193
+ /**
194
+ * `image:` compose — как значение (§3.8): репозиторий из TEST_IMAGES требует ровно тег TEST_IMAGES; без тега —
195
+ * `latest`; неразрешённая переменная в теге — «тег не определён». Образы вне TEST_IMAGES и образы, чей
196
+ * репозиторий не определяется (переменная), не проверяются.
197
+ */
198
+ function composeImage(service) {
199
+ if (service.image === null) {
200
+ return [];
201
+ }
202
+ const ref = parseImageRef(service.image);
203
+ const expected = ref.repository === null ? undefined : EXPECTED_BY_REPOSITORY.get(ref.repository);
204
+ if (expected === undefined || `${ref.repository}:${ref.tag}` === expected) {
205
+ return [];
206
+ }
207
+ const found = `${ref.repository}:${ref.tag}`;
208
+ const detail = ref.tagUnresolved
209
+ ? `сервис ${service.name} — образ ${found} — тег не определён, ожидается ${expected}`
210
+ : `сервис ${service.name} — образ ${found}, ожидается ${expected}`;
211
+ return [{ rule: 'test-images', file: service.file, detail }];
212
+ }
213
+ /** Образы в свободном тексте CI: тег учитывается, только если в нём есть точка или буква (`postgres:5432` — адрес). */
214
+ function ciImages(text, file) {
215
+ return [...EXPECTED_BY_REPOSITORY].flatMap(([repository, expected]) => imageTags(text, repository)
216
+ .filter((tag) => `${repository}:${tag}` !== expected)
217
+ .map((tag) => ({
218
+ rule: 'test-images',
219
+ file,
220
+ detail: `образ ${repository}:${tag}, ожидается ${expected}`,
221
+ })));
222
+ }
223
+ function testImages(files) {
224
+ return [
225
+ ...files.compose.flatMap(composeImage),
226
+ ...files.ci.flatMap((ci) => ciImages(ci.text, ci.file)),
227
+ ];
228
+ }
229
+ function replicaId(files) {
230
+ return files.compose
231
+ .filter((service) => service.env.has('REPLICA_ID'))
232
+ .map((service) => ({
233
+ rule: 'replica-id',
234
+ file: service.envSource.get('REPLICA_ID') ?? service.file,
235
+ detail: `сервис ${service.name} задаёт REPLICA_ID — не задавайте, умолчание — hostname()`,
236
+ }));
237
+ }
238
+ /** Длительность Docker в мс или null, если не распознана. */
239
+ function durationMs(text) {
240
+ const value = text.trim();
241
+ if (!/^(\d+(?:\.\d+)?(ns|us|µs|ms|s|m|h))+$/.test(value)) {
242
+ return null;
243
+ }
244
+ let total = 0;
245
+ for (const match of value.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) {
246
+ total += Number(match[1]) * (DURATION_UNITS_MS[match[2] ?? ''] ?? 0);
247
+ }
248
+ return total;
249
+ }
250
+ /**
251
+ * SHUTDOWN_TIMEOUT_MS слитого сервиса: целое, `${NAME:-N}` / `${NAME-N}` → N; ключа нет — умолчание envBase; иное
252
+ * (переменная без умолчания, текст) — null, «значение не определено».
253
+ */
254
+ function shutdownMs(merged) {
255
+ if (!merged.env.has('SHUTDOWN_TIMEOUT_MS')) {
256
+ return DEFAULT_SHUTDOWN_MS;
257
+ }
258
+ const raw = expandDefaults(merged.env.get('SHUTDOWN_TIMEOUT_MS') ?? '').trim();
259
+ return /^\d+$/.test(raw) ? Number(raw) : null;
260
+ }
261
+ function stopGracePeriod(services) {
262
+ return services
263
+ .filter((service) => service.kind === 'app')
264
+ .flatMap(({ merged }) => {
265
+ const declared = merged.stopGracePeriod;
266
+ const expanded = declared === null ? null : expandDefaults(declared);
267
+ const violation = (detail) => [
268
+ {
269
+ rule: 'stop-grace-period',
270
+ file: merged.file,
271
+ detail: `сервис ${merged.name} — ${detail}`,
272
+ },
273
+ ];
274
+ if (expanded?.includes('$') === true) {
275
+ return violation(`stop_grace_period «${declared ?? ''}» — значение не определено`);
276
+ }
277
+ const graceMs = expanded === null ? DOCKER_DEFAULT_GRACE_MS : durationMs(expanded);
278
+ if (graceMs === null) {
279
+ return violation(`stop_grace_period «${declared ?? ''}» не распознан`);
280
+ }
281
+ const shutdown = shutdownMs(merged);
282
+ if (shutdown === null) {
283
+ // Значение окружения в текст не попадает (NFR-4).
284
+ return violation('SHUTDOWN_TIMEOUT_MS — значение не определено или не целое число мс');
285
+ }
286
+ if (graceMs > shutdown) {
287
+ return [];
288
+ }
289
+ return violation(`stop_grace_period ${graceMs} мс ≤ SHUTDOWN_TIMEOUT_MS ${shutdown} мс (нужно 15s при 10000)`);
290
+ });
291
+ }
292
+ /** Ключи окружения — у слитого сервиса приложения или одноразового задания; файл — где ключ задан. */
293
+ function envKeys(services) {
294
+ return services
295
+ .filter((service) => service.kind === 'app' || service.kind === 'one-shot')
296
+ .flatMap(({ merged }) => Object.entries(RENAMED_ENV_KEYS)
297
+ .filter(([old]) => merged.env.has(old))
298
+ .map(([old, current]) => ({
299
+ rule: 'env-keys',
300
+ file: merged.envSource.get(old) ?? merged.file,
301
+ detail: `сервис ${merged.name} — ключ ${old}, используйте ${current}`,
302
+ })));
303
+ }
304
+ function kitManifests(files) {
305
+ return files.manifests.filter((manifest) => manifest.dependencies.has('@verdeect/kit'));
306
+ }
307
+ function peerVersions(files) {
308
+ const ranges = kitPeerRanges();
309
+ return kitManifests(files).flatMap((manifest) => Object.entries(ranges).flatMap(([name, range]) => {
310
+ const version = manifest.dependencies.get(name);
311
+ if (version === undefined) {
312
+ return [];
313
+ }
314
+ if (semver.valid(version) === null) {
315
+ return [
316
+ {
317
+ rule: 'peer-versions',
318
+ file: manifest.file,
319
+ detail: `${name} ${version} — нужна точная версия`,
320
+ },
321
+ ];
322
+ }
323
+ if (!semver.satisfies(version, range)) {
324
+ return [
325
+ {
326
+ rule: 'peer-versions',
327
+ file: manifest.file,
328
+ detail: `${name} ${version} вне диапазона ${range} пакета @verdeect/kit`,
329
+ },
330
+ ];
331
+ }
332
+ return [];
333
+ }));
334
+ }
335
+ /** Стадии Dockerfile, на которые ссылаются приложения и задания compose; Dockerfile без ссылок — последняя стадия. */
336
+ function referencedStages(files, services) {
337
+ const referenced = new Set();
338
+ const referencedFiles = new Set();
339
+ for (const { merged, kind } of services) {
340
+ if (merged.build !== null) {
341
+ referencedFiles.add(merged.build.dockerfile);
342
+ }
343
+ const stage = buildStage(files, merged.build);
344
+ if (stage !== undefined && (kind === 'app' || kind === 'one-shot')) {
345
+ referenced.add(stage);
346
+ }
347
+ }
348
+ for (const stage of files.docker) {
349
+ if (stage.last && !referencedFiles.has(stage.file)) {
350
+ referenced.add(stage);
351
+ }
352
+ }
353
+ return referenced;
354
+ }
355
+ /**
356
+ * Стадии, которыми запускаются приложения compose без своей команды (`command:`/`entrypoint:`): стадия `build:`; у
357
+ * приложения без `build:` (образ `image:`) — последние стадии Dockerfile, на которые нет ссылок из `build:`.
358
+ */
359
+ function appImageStages(files, services) {
360
+ const builtFiles = new Set(services.flatMap(({ merged }) => (merged.build === null ? [] : [merged.build.dockerfile])));
361
+ const imageStages = files.docker.filter((stage) => stage.last && !builtFiles.has(stage.file));
362
+ const stages = new Set();
363
+ for (const { merged, launch, kind } of services) {
364
+ if (kind !== 'app' || launch !== null) {
365
+ continue;
366
+ }
367
+ const stage = buildStage(files, merged.build);
368
+ const launched = merged.build === null ? imageStages : stage === undefined ? [] : [stage];
369
+ for (const item of launched) {
370
+ stages.add(item);
371
+ }
372
+ }
373
+ return stages;
374
+ }
375
+ const NO_LAUNCH = { launches: [], fake: false, unrecognized: [] };
376
+ function launchSources(files, services) {
377
+ const referenced = referencedStages(files, services);
378
+ const appStages = appImageStages(files, services);
379
+ const fromCompose = services.flatMap(({ merged, launch, analysis, kind }) => kind === 'app' && launch !== null && analysis !== null
380
+ ? [
381
+ {
382
+ file: launch.file,
383
+ source: `сервис ${merged.name}`,
384
+ analysis,
385
+ checkForms: true,
386
+ requiredLaunch: null,
387
+ },
388
+ ]
389
+ : []);
390
+ const fromDocker = files.docker.flatMap((stage) => {
391
+ const launch = stageLaunch(stage);
392
+ const required = appStages.has(stage);
393
+ if (launch === null) {
394
+ // Ни CMD, ни ENTRYPOINT у стадии приложения — запуск не найден.
395
+ return required
396
+ ? [
397
+ {
398
+ file: stage.file,
399
+ source: `стадия ${stage.name ?? '(без имени)'}`,
400
+ analysis: NO_LAUNCH,
401
+ checkForms: true,
402
+ requiredLaunch: '',
403
+ },
404
+ ]
405
+ : [];
406
+ }
407
+ return [
408
+ {
409
+ file: stage.file,
410
+ source: launch.kind,
411
+ analysis: analyzeLaunch(launch.argv),
412
+ checkForms: referenced.has(stage),
413
+ requiredLaunch: required ? launch.argv.join(' ') : null,
414
+ },
415
+ ];
416
+ });
417
+ const fromStart = kitManifests(files).flatMap((manifest) => manifest.start === null
418
+ ? []
419
+ : [
420
+ {
421
+ file: manifest.file,
422
+ source: 'scripts.start',
423
+ analysis: analyzeLaunch(manifest.start),
424
+ checkForms: true,
425
+ requiredLaunch: manifest.start,
426
+ },
427
+ ]);
428
+ return [...fromCompose, ...fromDocker, ...fromStart];
429
+ }
430
+ /** Запуск приложения без `--import …instrument` в аргументах node (NODE_OPTIONS не засчитывается, §3.8 v1.10). */
431
+ function instrumentImport(sources) {
432
+ return sources.flatMap(({ file, source, analysis }) => analysis.launches
433
+ .filter((launch) => !hasInstrumentImport(launch.tokens))
434
+ .map((launch) => ({
435
+ rule: 'instrument-import',
436
+ file,
437
+ detail: `${source} «${launch.command}» — нет --import …instrument`,
438
+ })));
439
+ }
440
+ /**
441
+ * Формы команды вне перечня §3.8 — «команда не распознана»; у источника с обязательным запуском (v1.11) нет ни
442
+ * запуска приложения, ни подделки, ни нераспознанной формы — «не найден запуск приложения».
443
+ */
444
+ function commandUnrecognized(sources) {
445
+ const violation = (file, source, command, form) => ({
446
+ rule: 'command-unrecognized',
447
+ file,
448
+ detail: `${source}${command === '' ? '' : ` «${command}»`} — команда не распознана: ${form}; нужна форма ${REQUIRED_FORM}`,
449
+ });
450
+ return sources
451
+ .filter((source) => source.checkForms)
452
+ .flatMap(({ file, source, analysis, requiredLaunch }) => {
453
+ const found = analysis.unrecognized.map(({ form, command }) => violation(file, source, command, form));
454
+ const missing = requiredLaunch !== null &&
455
+ found.length === 0 &&
456
+ analysis.launches.length === 0 &&
457
+ !analysis.fake;
458
+ return missing
459
+ ? [
460
+ violation(file, source, requiredLaunch, requiredLaunch === ''
461
+ ? 'не найден запуск приложения: нет CMD и ENTRYPOINT'
462
+ : `не найден запуск приложения: ${requiredLaunch.split(/\s+/)[0] ?? ''}…`),
463
+ ]
464
+ : found;
465
+ });
466
+ }
467
+ function stripComments(text) {
468
+ return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
469
+ }
470
+ const ALLOWED_REDACTION_IMPORT = /^import\s+type\s*\{[^}]*\}\s*from\s*(['"])@verdeect\/kit\/observability\1\s*;?$/;
471
+ function redactionImports(files) {
472
+ return files.redaction.flatMap(({ file, text }) => {
473
+ const source = stripComments(text);
474
+ const statements = [
475
+ ...source.matchAll(/\bimport\b[\s\S]*?(?:from\s*['"][^'"]*['"]|['"][^'"]*['"]|\(\s*['"][^'"]*['"]\s*\))\s*;?/g),
476
+ ...source.matchAll(/\bexport\b[^;]*?\bfrom\s*['"][^'"]*['"]\s*;?/g),
477
+ ...source.matchAll(/\brequire\s*\(\s*['"][^'"]*['"]\s*\)/g),
478
+ ].map((match) => match[0].trim());
479
+ return statements
480
+ .filter((statement) => !ALLOWED_REDACTION_IMPORT.test(statement))
481
+ .map((statement) => ({
482
+ rule: 'redaction-imports',
483
+ file,
484
+ detail: `импорт ${specifierOf(statement)} — разрешены только типы из @verdeect/kit/observability`,
485
+ }));
486
+ });
487
+ }
488
+ function specifierOf(statement) {
489
+ const quoted = /['"]([^'"]*)['"]/.exec(statement);
490
+ return quoted?.[1] ?? statement.slice(0, 60);
491
+ }
492
+ /** Одно нарушение на (правило, файл, текст): базовый сервис и его переопределения дают одинаковые находки. */
493
+ function unique(violations) {
494
+ const seen = new Set();
495
+ return violations.filter((violation) => {
496
+ const key = `${violation.rule}\n${violation.file}\n${violation.detail}`;
497
+ if (seen.has(key)) {
498
+ return false;
499
+ }
500
+ seen.add(key);
501
+ return true;
502
+ });
503
+ }
504
+ /** Проверяет репозиторий сервиса по правилам §3.8; при нарушениях бросает ошибку со списком. */
505
+ export function assertRepoConventions(options) {
506
+ const files = readRepoFiles(options.root);
507
+ const services = checkedServices(files);
508
+ const sources = launchSources(files, services);
509
+ const violations = unique([
510
+ ...testImages(files),
511
+ ...replicaId(files),
512
+ ...stopGracePeriod(services),
513
+ ...envKeys(services),
514
+ ...peerVersions(files),
515
+ ...instrumentImport(sources),
516
+ ...commandUnrecognized(sources),
517
+ ...redactionImports(files),
518
+ ]).sort((a, b) => RULE_ORDER.indexOf(a.rule) - RULE_ORDER.indexOf(b.rule) || a.file.localeCompare(b.file));
519
+ if (violations.length === 0) {
520
+ return;
521
+ }
522
+ throw new Error(`assertRepoConventions: нарушены правила (${violations.length}):\n` +
523
+ violations.map((v) => `- [${v.rule}] ${v.file}: ${v.detail}`).join('\n'));
524
+ }
@@ -0,0 +1,113 @@
1
+ /** Значение `!reset` Compose: ключ сброшен к умолчанию, значение базового файла не наследуется. */
2
+ export declare const RESET: unique symbol;
3
+ /** Поле сервиса Compose: значение, `RESET` или undefined — ключа нет (наследуется из базового файла). */
4
+ export type ComposeField<T> = T | typeof RESET | undefined;
5
+ /** `command:` / `entrypoint:` Compose: массив (argv) или строка (Compose делит её на слова без shell). */
6
+ export type ComposeCommand = readonly string[] | string;
7
+ /** `build:` Compose: путь Dockerfile относительно root и стадия (`target`) или null — последняя стадия. */
8
+ export interface ComposeBuild {
9
+ /** Путь Dockerfile относительно root, через `/`. */
10
+ readonly dockerfile: string;
11
+ /** Стадия `target:` или null. */
12
+ readonly target: string | null;
13
+ }
14
+ /** Сервис compose-файла (как записан в файле, без слияния с базовым файлом). */
15
+ export interface ComposeService {
16
+ /** Путь compose-файла относительно root. */
17
+ readonly file: string;
18
+ /** Имя сервиса. */
19
+ readonly name: string;
20
+ /** Значение `image:` или null. */
21
+ readonly image: string | null;
22
+ /** environment ∪ env_file (environment перекрывает); null — ключ без значения. */
23
+ readonly env: ReadonlyMap<string, string | null>;
24
+ /** Ключ → файл, где он задан (compose или env-файл), относительно root. */
25
+ readonly envSource: ReadonlyMap<string, string>;
26
+ /** environment / env_file: `merge` — слияние с базовым, `reset` — `!reset`, `override` — `!override`. */
27
+ readonly envMode: 'merge' | 'reset' | 'override';
28
+ /** `stop_grace_period:`. */
29
+ readonly stopGracePeriod: ComposeField<string>;
30
+ /** `command:`. */
31
+ readonly command: ComposeField<ComposeCommand>;
32
+ /** `entrypoint:`. */
33
+ readonly entrypoint: ComposeField<ComposeCommand>;
34
+ /** `build:`. */
35
+ readonly build: ComposeField<ComposeBuild>;
36
+ }
37
+ /** CMD или ENTRYPOINT: exec-форма (массив) или shell-форма (строка). */
38
+ export interface LaunchInstruction {
39
+ /** Exec-форма или null — shell-форма. */
40
+ readonly exec: readonly string[] | null;
41
+ /** Текст инструкции после CMD/ENTRYPOINT. */
42
+ readonly shell: string;
43
+ }
44
+ /** Стадия Dockerfile с итоговыми ENTRYPOINT и CMD (с учётом наследования `FROM <стадия>`). */
45
+ export interface DockerStage {
46
+ /** Путь Dockerfile относительно root. */
47
+ readonly file: string;
48
+ /** Имя стадии (`AS <имя>`) или null. */
49
+ readonly name: string | null;
50
+ /** Последняя стадия файла (собирается без `target`). */
51
+ readonly last: boolean;
52
+ /** Итоговый ENTRYPOINT или null. */
53
+ readonly entrypoint: LaunchInstruction | null;
54
+ /** Итоговый CMD или null. */
55
+ readonly cmd: LaunchInstruction | null;
56
+ }
57
+ /** Манифест package.json. */
58
+ export interface Manifest {
59
+ /** Путь относительно root. */
60
+ readonly file: string;
61
+ /** dependencies ∪ devDependencies (имя → версия). */
62
+ readonly dependencies: ReadonlyMap<string, string>;
63
+ /** scripts.start или null. */
64
+ readonly start: string | null;
65
+ }
66
+ /** Текстовый файл репозитория. */
67
+ export interface TextFile {
68
+ /** Путь относительно root. */
69
+ readonly file: string;
70
+ /** Содержимое. */
71
+ readonly text: string;
72
+ }
73
+ /** Файлы репозитория сервиса, разобранные для правил §3.8. */
74
+ export interface RepoFiles {
75
+ /** Сервисы всех compose-файлов корня. */
76
+ readonly compose: readonly ComposeService[];
77
+ /** CI-файлы: .gitlab-ci.yml, .github/workflows/*.yml|yaml. */
78
+ readonly ci: readonly TextFile[];
79
+ /** Стадии Dockerfile корня и apps/*. */
80
+ readonly docker: readonly DockerStage[];
81
+ /** package.json корня, apps/*, packages/*. */
82
+ readonly manifests: readonly Manifest[];
83
+ /** src/redaction.ts и apps/*\/src/redaction.ts. */
84
+ readonly redaction: readonly TextFile[];
85
+ }
86
+ /** Интерполяции `${VAR:-default}` и `${VAR-default}` → `default`; `${VAR}` без умолчания не меняется. */
87
+ export declare function expandDefaults(text: string): string;
88
+ /**
89
+ * Разбор env-файла: `KEY=VALUE`, комментарии `#` и пустые строки пропускаются, `export ` и кавычки снимаются; у
90
+ * значения без кавычек снимается ` #…` (как в Compose).
91
+ */
92
+ export declare function parseEnvFile(text: string): Map<string, string>;
93
+ /** Разобранное значение `image:` compose. */
94
+ export interface ImageRef {
95
+ /** Репозиторий без префикса Docker Hub (`postgres`, `axllent/mailpit`); null — не определяется (переменная). */
96
+ readonly repository: string | null;
97
+ /** Тег до `@sha256:…`; без тега — `latest`. */
98
+ readonly tag: string;
99
+ /** В теге осталась неразрешённая переменная. */
100
+ readonly tagUnresolved: boolean;
101
+ }
102
+ /**
103
+ * Значение `image:` как ссылка на образ: подстановка `${VAR:-default}`, снятие префикса Docker Hub, отдельно
104
+ * дайджест `@sha256:…`; тег — после последнего `:` за последним `/` (порт реестра — не тег), без тега — `latest`.
105
+ */
106
+ export declare function parseImageRef(image: string): ImageRef;
107
+ /**
108
+ * Образы репозитория `repository` в свободном тексте (CI): `<repo>:<тег>`; префикс Docker Hub снимается; тег
109
+ * учитывается, только если в нём есть точка или буква.
110
+ */
111
+ export declare function imageTags(text: string, repository: string): string[];
112
+ /** Находит и разбирает файлы репозитория сервиса; отсутствующий каталог — пустой список. */
113
+ export declare function readRepoFiles(root: string): RepoFiles;