@rt-tools/agent-kit 0.3.0 → 0.5.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 (202) hide show
  1. package/README.md +194 -30
  2. package/assets/agents/business-analyst.md +74 -0
  3. package/assets/agents/project-manager.md +70 -0
  4. package/assets/agents/qa-engineer.md +72 -0
  5. package/assets/agents/skill-curator.md +110 -0
  6. package/assets/agents/spec-critic.md +44 -0
  7. package/assets/agents/spec-writer.md +50 -0
  8. package/assets/checks/board.github.mjs +329 -0
  9. package/assets/checks/check-board.github.mjs +181 -0
  10. package/assets/checks/check-doc-paths.mjs +163 -0
  11. package/assets/checks/check-dupes.mjs +277 -0
  12. package/assets/checks/check-lib-layers.mjs +573 -0
  13. package/assets/checks/check-reuse.mjs +208 -0
  14. package/assets/checks/check-schema-drift.mjs +186 -0
  15. package/assets/checks/check-specs.mjs +1086 -0
  16. package/assets/checks/check-styles.mjs +109 -0
  17. package/assets/checks/rt-kit-checks.config.mjs +134 -0
  18. package/assets/checks/task-new.github.mjs +198 -0
  19. package/assets/commands/skill-curator.md +70 -0
  20. package/assets/defaults/gate-map.sh +106 -0
  21. package/assets/defaults/project.sh +204 -0
  22. package/assets/hooks/browser-device-id.sh +0 -0
  23. package/assets/hooks/browser-guard-device-id.sh +2 -1
  24. package/assets/hooks/browser-guard-no-asking.sh +27 -0
  25. package/assets/hooks/browser-guard-no-listing.sh +2 -1
  26. package/assets/hooks/browser-guard-no-other-drivers.sh +2 -1
  27. package/assets/hooks/browser-guard-require-select.sh +2 -1
  28. package/assets/hooks/commit-msg.sh +1 -1
  29. package/assets/hooks/constitution-index.sh +5 -4
  30. package/assets/hooks/dev-server-guard.sh +8 -6
  31. package/assets/hooks/docs-guard.sh +223 -37
  32. package/assets/hooks/git-guard-delivery.sh +171 -31
  33. package/assets/hooks/git-guard-main.sh +1 -0
  34. package/assets/hooks/git-guard-push-tests.sh +34 -13
  35. package/assets/hooks/glossary-load.sh +23 -0
  36. package/assets/hooks/grill-gate.sh +96 -0
  37. package/assets/hooks/lint-after-edit.sh +155 -30
  38. package/assets/hooks/qa-dataid-guard.sh +72 -32
  39. package/assets/hooks/reuse-first-guard.sh +105 -34
  40. package/assets/hooks/skill-gate-rearm.sh +1 -0
  41. package/assets/hooks/skill-gate.sh +75 -15
  42. package/assets/hooks/skill-loaded.sh +1 -0
  43. package/assets/hooks/sql-guard.sh +606 -56
  44. package/assets/hooks/task-context-load.sh +100 -0
  45. package/assets/hooks/task-flow-guard.sh +118 -0
  46. package/assets/laws/{access.md → application/access.md} +1 -4
  47. package/assets/laws/{locales.md → application/locales.md} +1 -3
  48. package/assets/laws/application/money.md +41 -0
  49. package/assets/laws/application/ownership.md +32 -0
  50. package/assets/laws/{search-visibility.md → application/search-visibility.md} +1 -1
  51. package/assets/laws/code-structure.md +7 -6
  52. package/assets/laws/delivery.md +53 -3
  53. package/assets/laws/entity-editing.md +49 -55
  54. package/assets/laws/entity-models.md +4 -14
  55. package/assets/laws/frontend-application.md +5 -5
  56. package/assets/laws/lib-imports.md +14 -1
  57. package/assets/laws/lists.md +33 -0
  58. package/assets/laws/navigation.md +40 -0
  59. package/assets/laws/project-documentation.md +27 -8
  60. package/assets/laws/reuse-first.md +26 -21
  61. package/assets/laws/shared-code.md +13 -1
  62. package/assets/laws/verifiability.md +30 -1
  63. package/assets/laws/work-conduct.md +59 -0
  64. package/assets/patterns/admin-lists-screen.md +131 -0
  65. package/assets/patterns/admin-nav-item.md +71 -0
  66. package/assets/patterns/angular-patterns-state.md +29 -22
  67. package/assets/patterns/api-layer-pair.md +40 -30
  68. package/assets/patterns/browser-verification-measure.md +41 -38
  69. package/assets/patterns/browser-verification-stand.md +106 -42
  70. package/assets/patterns/component-structure-new.md +33 -32
  71. package/assets/patterns/dependencies-upgrade.md +65 -0
  72. package/assets/patterns/doc-style-sweep.md +65 -28
  73. package/assets/patterns/doc-style-write.md +36 -33
  74. package/assets/patterns/entity-aside.md +136 -0
  75. package/assets/patterns/entity-models-new.md +124 -0
  76. package/assets/patterns/entity-store.md +91 -0
  77. package/assets/patterns/git-workflow-commit.azure.md +259 -0
  78. package/assets/patterns/git-workflow-commit.github.md +337 -0
  79. package/assets/patterns/git-workflow-commit.gitlab.md +283 -0
  80. package/assets/patterns/git-workflow-merge.md +42 -25
  81. package/assets/patterns/git-workflow-migration.md +61 -31
  82. package/assets/patterns/git-workflow-restart.md +20 -20
  83. package/assets/patterns/lib-layers-move.md +50 -32
  84. package/assets/patterns/lib-layers-new.md +41 -29
  85. package/assets/patterns/ownership-scope-resolve.md +69 -0
  86. package/assets/patterns/permissions-procedure.md +35 -33
  87. package/assets/patterns/platform-access-di.md +39 -25
  88. package/assets/patterns/pricing-quote.md +71 -0
  89. package/assets/patterns/reuse-first-extend.md +22 -22
  90. package/assets/patterns/seo-page.md +52 -40
  91. package/assets/patterns/seo-verify.md +48 -29
  92. package/assets/patterns/shared-code-new.md +37 -31
  93. package/assets/patterns/spec-driven-domain.md +60 -37
  94. package/assets/patterns/spec-driven-rule.md +55 -40
  95. package/assets/patterns/styling-bem-component.md +43 -32
  96. package/assets/patterns/styling-bem-layout.md +30 -24
  97. package/assets/patterns/task-flow-close.md +154 -0
  98. package/assets/patterns/task-flow-resume.md +94 -0
  99. package/assets/patterns/task-flow-start.md +129 -0
  100. package/assets/patterns/testing-e2e.md +53 -51
  101. package/assets/patterns/testing-unit.md +70 -46
  102. package/assets/patterns/translations-key.md +32 -19
  103. package/assets/patterns/ts-procedure.md +24 -25
  104. package/assets/rules/angular-patterns.md +50 -27
  105. package/assets/rules/api-layer.md +46 -28
  106. package/assets/rules/browser-verification.md +67 -48
  107. package/assets/rules/component-structure.md +43 -27
  108. package/assets/rules/dependencies.md +66 -0
  109. package/assets/rules/doc-style.md +95 -39
  110. package/assets/rules/entity-conventions.md +78 -0
  111. package/assets/rules/entity-models.md +70 -0
  112. package/assets/rules/git-workflow.azure.md +116 -0
  113. package/assets/rules/git-workflow.github.md +123 -0
  114. package/assets/rules/git-workflow.gitlab.md +113 -0
  115. package/assets/rules/lib-layers.md +56 -30
  116. package/assets/rules/lists.md +73 -0
  117. package/assets/rules/navigation.md +78 -0
  118. package/assets/rules/ownership-scope.md +63 -0
  119. package/assets/rules/permissions.md +43 -25
  120. package/assets/rules/platform-access.md +57 -29
  121. package/assets/rules/pricing.md +64 -0
  122. package/assets/rules/reuse-first.md +57 -43
  123. package/assets/rules/seo.md +51 -30
  124. package/assets/rules/shared-code.md +51 -26
  125. package/assets/rules/spec-driven.md +107 -51
  126. package/assets/rules/styling-bem.md +54 -39
  127. package/assets/rules/task-flow.md +150 -0
  128. package/assets/rules/testing.md +78 -47
  129. package/assets/rules/translations.md +48 -31
  130. package/assets/rules/typescript-conventions.md +57 -27
  131. package/assets/skills/agent-kit.md +85 -0
  132. package/assets/skills/write-a-skill.md +108 -0
  133. package/assets/templates/gate-map.sh +23 -15
  134. package/assets/templates/implementation.md +14 -8
  135. package/assets/templates/pattern.md +1 -1
  136. package/assets/templates/project.sh +32 -19
  137. package/assets/templates/rule.md +2 -2
  138. package/assets/variants.json +20 -0
  139. package/assets/workflows/feature.js +134 -0
  140. package/assets/workflows/plan.js +150 -0
  141. package/bin/agent-kit.d.ts.map +1 -1
  142. package/bin/agent-kit.js +78 -5
  143. package/bin/agent-kit.js.map +1 -1
  144. package/bin/prompt.d.ts +5 -0
  145. package/bin/prompt.d.ts.map +1 -1
  146. package/bin/prompt.js +19 -7
  147. package/bin/prompt.js.map +1 -1
  148. package/index.d.ts +1 -0
  149. package/index.d.ts.map +1 -1
  150. package/index.js +1 -0
  151. package/index.js.map +1 -1
  152. package/lib/assets.d.ts +8 -3
  153. package/lib/assets.d.ts.map +1 -1
  154. package/lib/assets.js +13 -3
  155. package/lib/assets.js.map +1 -1
  156. package/lib/catalog.d.ts +52 -5
  157. package/lib/catalog.d.ts.map +1 -1
  158. package/lib/catalog.js +104 -16
  159. package/lib/catalog.js.map +1 -1
  160. package/lib/commands.d.ts +22 -1
  161. package/lib/commands.d.ts.map +1 -1
  162. package/lib/commands.js +202 -14
  163. package/lib/commands.js.map +1 -1
  164. package/lib/companion.d.ts +5 -1
  165. package/lib/companion.d.ts.map +1 -1
  166. package/lib/companion.js +29 -2
  167. package/lib/companion.js.map +1 -1
  168. package/lib/config.d.ts +26 -9
  169. package/lib/config.d.ts.map +1 -1
  170. package/lib/config.js +41 -15
  171. package/lib/config.js.map +1 -1
  172. package/lib/freshness.d.ts +14 -0
  173. package/lib/freshness.d.ts.map +1 -0
  174. package/lib/freshness.js +116 -0
  175. package/lib/freshness.js.map +1 -0
  176. package/lib/hooks-map.d.ts +27 -0
  177. package/lib/hooks-map.d.ts.map +1 -0
  178. package/lib/hooks-map.js +77 -0
  179. package/lib/hooks-map.js.map +1 -0
  180. package/lib/integrity.d.ts +36 -0
  181. package/lib/integrity.d.ts.map +1 -0
  182. package/lib/integrity.js +44 -0
  183. package/lib/integrity.js.map +1 -0
  184. package/lib/picker.d.ts +11 -1
  185. package/lib/picker.d.ts.map +1 -1
  186. package/lib/picker.js +44 -6
  187. package/lib/picker.js.map +1 -1
  188. package/lib/sync.d.ts +26 -0
  189. package/lib/sync.d.ts.map +1 -1
  190. package/lib/sync.js +59 -4
  191. package/lib/sync.js.map +1 -1
  192. package/lib/variants.d.ts +44 -0
  193. package/lib/variants.d.ts.map +1 -0
  194. package/lib/variants.js +82 -0
  195. package/lib/variants.js.map +1 -0
  196. package/package.json +1 -1
  197. package/rt-tools-agent-kit-0.5.0.tgz +0 -0
  198. package/assets/laws/admin-lists.md +0 -35
  199. package/assets/laws/admin-navigation.md +0 -38
  200. package/assets/patterns/git-workflow-commit.md +0 -175
  201. package/assets/rules/git-workflow.md +0 -106
  202. package/rt-tools-agent-kit-0.3.0.tgz +0 -0
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Сплошная проверка того, что готовое не обошли.
4
+ *
5
+ * Гард `reuse-first-guard.sh` судит правку в момент, когда её пишут, и знает только
6
+ * добавленный текст. Написанное до него не считает никто: проверка стилей меряет классы,
7
+ * линтеры — типы и приёмы, а то, что экран собран нативной кнопкой вместо кнопки кита, не
8
+ * видно ни одному из них. Эта проверка отвечает на другой вопрос — «а сколько такого в
9
+ * дереве сейчас», — и потому смотрит на файл целиком, а не на правку.
10
+ *
11
+ * Признаки те же, что у гарда, и берутся оттуда же: расходиться им нельзя, иначе правка
12
+ * проходит гард и падает на гейте. Отличий два. Первое: инвентарь кита не читается — гард
13
+ * спрашивает диск, потому что отвечает одной правке, а сплошной проверке важно накопленное,
14
+ * и пропавший пакет молча обнулял бы сводку. Второе: маркер `native-ok` снимает свою
15
+ * строку, а не весь файл.
16
+ *
17
+ * Накопленное к моменту заведения проверки лежит в tools/reuse-allowlist.json и отказом не
18
+ * считается: гейт падает на новом расхождении, а старое остаётся видимым числом в сводке.
19
+ * Снимок списка — `node tools/check-reuse.mjs --baseline`.
20
+ *
21
+ * Ненулевой код возврата и перечень расхождений.
22
+ */
23
+ import { readFileSync, readdirSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+
26
+ import { allowlistOf, CONFIG, ROOT } from './rt-kit-checks.config.mjs';
27
+
28
+ const ALLOWLIST = allowlistOf('reuse');
29
+ const SOURCE_ROOTS = CONFIG.sourceRoots;
30
+ const SKIPPED_DIRS = CONFIG.skippedDirs;
31
+ const BACKEND_ROOTS = CONFIG.backendRoots;
32
+
33
+ /**
34
+ * Признак расхождения: чем он виден в тексте и на что готовое его меняет. Порядок строк —
35
+ * порядок в сводке, поэтому родственные признаки стоят рядом.
36
+ */
37
+ const SIGNALS = [
38
+ {
39
+ key: 'input',
40
+ ext: '.html',
41
+ find: (text) => count(text, /<input\b/g),
42
+ instead: 'rt-input / rt-input-number / rt-checkbox / rt-file-input',
43
+ },
44
+ { key: 'textarea', ext: '.html', find: (text) => count(text, /<textarea\b/g), instead: 'rt-textarea' },
45
+ { key: 'select', ext: '.html', find: (text) => count(text, /<select\b/g), instead: 'rt-select / rt-multiselect' },
46
+ {
47
+ key: 'button',
48
+ ext: '.html',
49
+ find: (text) => count(withoutKitButtons(text), /<button\b/g),
50
+ instead: 'button[rtButton] / rt-icon-button',
51
+ },
52
+ { key: 'table', ext: '.html', find: (text) => count(text, /<table\b/g), instead: 'rt-table' },
53
+ { key: 'dialog', ext: '.html', find: (text) => count(text, /<dialog\b/g), instead: 'rt-dialog' },
54
+ { key: 'alert', ext: '.html', find: (text) => count(text, /role="alert"/g), instead: 'rt-message' },
55
+
56
+ { key: 'overlay', ext: '.scss', find: fullScreenOverlays, instead: 'rt-dialog / rt-aside / rt-bottom-sheet' },
57
+ {
58
+ key: 'backdrop',
59
+ ext: '.scss',
60
+ find: (text) => count(text, /backdrop-filter|background(-color)?: *(rgba\( *0 *, *0 *, *0|rgb\( *0 +0 +0)/g),
61
+ instead: 'backdrop рисует rt-dialog',
62
+ },
63
+ { key: 'z-index', ext: '.scss', find: (text) => count(text, /z-index: *\d{4,}/g), instead: 'слой оверлеев кита уже выше' },
64
+ { key: 'spin', ext: '.scss', find: (text) => count(text, /@keyframes[^{]*(spin|rotate|loading)/g), instead: 'rt-spinner' },
65
+ {
66
+ key: 'shimmer',
67
+ ext: '.scss',
68
+ find: (text) => count(text, /@keyframes[^{]*(shimmer|skeleton|pulse)/g),
69
+ instead: 'rt-skeleton / rt-skeleton-wrapper',
70
+ },
71
+
72
+ { key: 'inline-template', ext: '.component.ts', find: (text) => count(text, /^ *template: *['"`]/gm), instead: 'шаблон в своём .html' },
73
+ // Список стилей открывается своей строкой, а строка с кавычкой идёт следующей
74
+ {
75
+ key: 'inline-styles',
76
+ ext: '.component.ts',
77
+ find: (text) => count(text, /^ *styles: *(\[\s*)?['"`\n]/gm),
78
+ instead: 'стили в своём .scss',
79
+ },
80
+ {
81
+ key: 'value-accessor',
82
+ ext: '.component.ts',
83
+ find: (text) => (/ControlValueAccessor|NG_VALUE_ACCESSOR/.test(text) && !/extends VmFormControlBase/.test(text) ? 1 : 0),
84
+ instead: 'VmFormControlBase',
85
+ },
86
+ {
87
+ key: 'mapper-base',
88
+ ext: '.mapper.ts',
89
+ // На бэкенде перевод сущности написан свободными функциями — это долг `Q-S-1`,
90
+ // а не место для этой проверки. Где лежит бэкенд, знает настройка дерева: зашитый здесь
91
+ // корень молча проверял бы фронтовым мерилом чужой код у всякого, кто держит его иначе.
92
+ skip: (path) => BACKEND_ROOTS.some((root) => path.startsWith(root)),
93
+ find: (text) => (/class +[A-Za-z0-9_]+Mapper/.test(text) && !/extends BaseMapper/.test(text) ? 1 : 0),
94
+ instead: 'BaseMapper и this.typeCast',
95
+ },
96
+ {
97
+ key: 'procedure-mark',
98
+ ext: '.procedure.ts',
99
+ find: (text) => (/export class/.test(text) && !/@ConnectProcedure/.test(text) ? 1 : 0),
100
+ instead: '@ConnectProcedure()',
101
+ },
102
+ {
103
+ key: 'aside-base',
104
+ ext: '.component.ts',
105
+ onlyNamed: /aside[^/]*\.component\.ts$/,
106
+ find: (text) => (/export class/.test(text) && !/extends VmRouteAsideComponent/.test(text) ? 1 : 0),
107
+ instead: 'VmRouteAsideComponent',
108
+ },
109
+ ];
110
+
111
+ function count(text, expression) {
112
+ return [...text.matchAll(expression)].length;
113
+ }
114
+
115
+ /** `button[rtButton]` — предписанный вариант, а подстрока `<button` у него та же */
116
+ function withoutKitButtons(text) {
117
+ return text.replace(/<button\b[^>]*rtButton[^>]*>/gs, '');
118
+ }
119
+
120
+ /** Хост, растянутый на весь экран: `inset: 0` либо все четыре стороны в нуле */
121
+ function fullScreenOverlays(text) {
122
+ if (!/position: *fixed/.test(text)) {
123
+ return 0;
124
+ }
125
+ const stretched =
126
+ /inset: *0/.test(text) ||
127
+ (/(^|[^-])top: *0/.test(text) && /left: *0/.test(text) && /right: *0/.test(text) && /bottom: *0/.test(text));
128
+
129
+ return stretched ? 1 : 0;
130
+ }
131
+
132
+ function collectFiles(dir) {
133
+ const files = [];
134
+ for (const entry of readdirSync(join(ROOT, dir), { withFileTypes: true })) {
135
+ if (SKIPPED_DIRS.includes(entry.name)) {
136
+ continue;
137
+ }
138
+ const path = `${dir}/${entry.name}`;
139
+ if (entry.isDirectory()) {
140
+ files.push(...collectFiles(path));
141
+ } else if (/\.(html|scss|ts)$/.test(entry.name)) {
142
+ files.push(path);
143
+ }
144
+ }
145
+
146
+ return files;
147
+ }
148
+
149
+ /** Тесты, storybook и сквозные тесты не судятся: там нативное уместно */
150
+ function judged(path) {
151
+ return !/\.stories\.(ts|html)$|\.spec\.ts$|\/site-e2e\/|\/admin-e2e\//.test(path);
152
+ }
153
+
154
+ /**
155
+ * Строка с маркером — осознанное отступление, названное автором. Снимается только она сама:
156
+ * весь файл маркер не гасит, иначе один разрешённый случай прикрывал бы соседние.
157
+ */
158
+ function withoutMarked(text) {
159
+ return text
160
+ .split('\n')
161
+ .filter((line) => !line.includes('native-ok'))
162
+ .join('\n');
163
+ }
164
+
165
+ const allowlist = JSON.parse(readFileSync(join(ROOT, ALLOWLIST), 'utf8'));
166
+ const known = new Set([...(allowlist.accepted ?? []), ...(allowlist.debt ?? [])]);
167
+ const debt = new Set(allowlist.debt ?? []);
168
+
169
+ const findings = [];
170
+ for (const root of SOURCE_ROOTS) {
171
+ for (const path of collectFiles(root).filter(judged).sort()) {
172
+ const text = withoutMarked(readFileSync(join(ROOT, path), 'utf8'));
173
+ for (const signal of SIGNALS) {
174
+ if (!path.endsWith(signal.ext) || signal.skip?.(path) || (signal.onlyNamed && !signal.onlyNamed.test(path))) {
175
+ continue;
176
+ }
177
+ const found = signal.find(text);
178
+ if (found > 0) {
179
+ findings.push({ key: `${signal.key} ×${found} @ ${path}`, instead: signal.instead });
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ const fresh = findings.filter((finding) => !known.has(finding.key));
186
+ const stale = [...known].filter((key) => !findings.some((finding) => finding.key === key));
187
+
188
+ if (process.argv.includes('--baseline')) {
189
+ console.log(JSON.stringify({ ...allowlist, debt: findings.map((finding) => finding.key).sort() }, null, 4));
190
+ process.exit(0);
191
+ }
192
+
193
+ const problems = [
194
+ ...fresh.map((finding) => `${finding.key} — готовое: ${finding.instead}`),
195
+ ...stale.map((key) => `${key}: значится в ${ALLOWLIST}, а в дереве такого расхождения больше нет — строку поправить или убрать`),
196
+ ];
197
+
198
+ if (problems.length > 0) {
199
+ console.error(`check-reuse: расхождений ${problems.length}\n`);
200
+ problems.forEach((problem) => console.error(` ${problem}`));
201
+ console.error('\nПравило единообразия — скил `reuse-first`.');
202
+ process.exit(1);
203
+ }
204
+
205
+ const places = findings.reduce((sum, finding) => sum + Number(finding.key.match(/×(\d+)/)[1]), 0);
206
+ console.log(
207
+ `check-reuse: мест, где готовое обошли, ${places} в ${findings.length} признаках — принято ${findings.length - debt.size}, долг ${debt.size}, новых нет`
208
+ );
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Проверка того, что миграции и `prisma/schema.prisma` описывают одну и ту же базу.
4
+ *
5
+ * Расхождение между ними не видит ни линт, ни сборка: оно живёт не в коде, а между
6
+ * схемой и SQL. Так в главную ветку уехала миграция, создававшая два индекса,
7
+ * которых схема не объявляла, — выкатка упала уже в конвейере.
8
+ *
9
+ * Меряются именно миграции, а не база того, кто запускает проверку. База
10
+ * разработчика законно несёт след любой недоделанной ветки: одна такая держала
11
+ * пуш чужой правки четырьмя таблицами и восемью колонками, которых в схеме
12
+ * главной ветки нет, — при том что миграции со схемой сходились.
13
+ *
14
+ * Поэтому миграции накатываются на одноразовую теневую базу и сравнивается она.
15
+ * Теневая база заводится на каждый прогон и сносится после: оставленная между
16
+ * прогонами, она сама накопит след ветки с миграцией, и проверка снова начнёт
17
+ * судить о состоянии машины вместо репозитория.
18
+ *
19
+ * Ненулевой код возврата и объяснение расхождения.
20
+ */
21
+ import { spawnSync } from 'node:child_process';
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import process from 'node:process';
25
+ import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
26
+
27
+ /** Суффикс теневой базы: по нему видно, что сносится именно она, а не чья-то рабочая */
28
+ const SHADOW_SUFFIX = '_gate_shadow';
29
+
30
+ /**
31
+ * Признаки боевой базы — те же, что у `sql-guard`. Схема на проде меняется только
32
+ * выкаткой, и проверка туда не ходит ни читать, ни писать.
33
+ */
34
+ const PRODUCTION_MARKS = CONFIG.productionMarks ?? [];
35
+
36
+ /** Сервер жив, но базы нет; сервера нет вовсе — оба означают «проверять негде» */
37
+ const SERVER_DOWN_CODES = ['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ETIMEDOUT'];
38
+
39
+ function databaseUrl() {
40
+ if (process.env['DATABASE_URL']) {
41
+ return process.env['DATABASE_URL'];
42
+ }
43
+
44
+ const envFile = join(ROOT, '.env');
45
+ if (!existsSync(envFile)) {
46
+ return '';
47
+ }
48
+
49
+ const line = readFileSync(envFile, 'utf8')
50
+ .split('\n')
51
+ .find((row) => row.startsWith('DATABASE_URL='));
52
+
53
+ return line
54
+ ? line
55
+ .slice('DATABASE_URL='.length)
56
+ .trim()
57
+ .replace(/^["']|["']$/g, '')
58
+ : '';
59
+ }
60
+
61
+ /** Адрес теневой базы и адрес служебной, из которой она заводится и сносится */
62
+ function shadowAddresses(url) {
63
+ const parsed = new URL(url);
64
+ const name = decodeURIComponent(parsed.pathname.replace(/^\//, ''));
65
+ const shadowName = `${name}${SHADOW_SUFFIX}`;
66
+
67
+ const shadow = new URL(url);
68
+ shadow.pathname = `/${encodeURIComponent(shadowName)}`;
69
+
70
+ // Завести и снести базу можно только из другой базы того же сервера; `postgres`
71
+ // есть всегда, а рабочая для этого не годится — снос идёт при живых к ней
72
+ // подключениях.
73
+ const service = new URL(url);
74
+ service.pathname = '/postgres';
75
+
76
+ return { shadowName, shadowUrl: shadow.toString(), serviceUrl: service.toString() };
77
+ }
78
+
79
+ function prisma(args, url) {
80
+ return spawnSync('npx', ['prisma', ...args], {
81
+ cwd: ROOT,
82
+ encoding: 'utf8',
83
+ env: { ...process.env, DATABASE_URL: url },
84
+ });
85
+ }
86
+
87
+ async function withServiceClient(serviceUrl, run) {
88
+ // Клиент базы подтягивается на месте, а не импортом сверху: дерево без хранилища этого
89
+ // пакета в зависимостях не держит, и статический импорт ронял бы проверку до того, как она
90
+ // успеет сказать, что сверять здесь нечего.
91
+ const pg = (await import('pg')).default;
92
+ const client = new pg.Client({ connectionString: serviceUrl });
93
+ try {
94
+ await client.connect();
95
+ } catch (error) {
96
+ // Погашенный докер — обычное состояние машины, а не повод не дать запушить
97
+ // документацию.
98
+ if (SERVER_DOWN_CODES.includes(error?.code)) {
99
+ console.log('check-schema-drift: база недоступна — сверять негде');
100
+
101
+ return 0;
102
+ }
103
+ throw error;
104
+ }
105
+
106
+ try {
107
+ return await run(client);
108
+ } finally {
109
+ await client.end();
110
+ }
111
+ }
112
+
113
+ async function main() {
114
+ if (!existsSync(join(ROOT, CONFIG.schemaFile))) {
115
+ console.log('check-schema-drift: схемы нет — сверять нечего');
116
+
117
+ return 0;
118
+ }
119
+
120
+ const url = databaseUrl();
121
+ if (!url) {
122
+ console.log('check-schema-drift: адрес базы не задан — сверять негде');
123
+
124
+ return 0;
125
+ }
126
+
127
+ if (PRODUCTION_MARKS.some((mark) => url.includes(mark))) {
128
+ console.log('check-schema-drift: адрес боевой — проверка туда не ходит');
129
+
130
+ return 0;
131
+ }
132
+
133
+ const { shadowName, shadowUrl, serviceUrl } = shadowAddresses(url);
134
+
135
+ return withServiceClient(serviceUrl, async (client) => {
136
+ // Идентификатор в кавычках: имя базы выведено из адреса, а не из текста запроса
137
+ const quoted = `"${shadowName.replace(/"/g, '""')}"`;
138
+ await client.query(`DROP DATABASE IF EXISTS ${quoted}`);
139
+
140
+ try {
141
+ // Базу заводит сам `migrate deploy`: своей команды создания не нужно
142
+ const deploy = prisma(['migrate', 'deploy'], shadowUrl);
143
+ if (deploy.status !== 0) {
144
+ console.error('check-schema-drift: миграции не накатываются на чистую базу\n');
145
+ console.error(`${deploy.stdout ?? ''}${deploy.stderr ?? ''}`);
146
+
147
+ return 1;
148
+ }
149
+
150
+ // `--exit-code`: 0 — расхождений нет, 2 — есть, прочее — сбой самой команды
151
+ const diff = prisma(
152
+ ['migrate', 'diff', '--from-config-datasource', '--to-schema', CONFIG.schemaFile, '--exit-code'],
153
+ shadowUrl
154
+ );
155
+ if (diff.status === 2) {
156
+ console.error('check-schema-drift: миграции и схема описывают разные базы\n');
157
+ console.error(`${diff.stdout ?? ''}${diff.stderr ?? ''}`);
158
+ console.error(
159
+ '\nЛибо схема правлена без миграции, либо миграция создаёт то, чего схема не объявляет.\nКак писать миграцию — паттерн `git-workflow-migration`.'
160
+ );
161
+
162
+ return 1;
163
+ }
164
+ if (diff.status !== 0) {
165
+ console.log('check-schema-drift: сверка не отработала — пропущено');
166
+
167
+ return 0;
168
+ }
169
+
170
+ console.log('check-schema-drift: миграции и схема сошлись');
171
+
172
+ return 0;
173
+ } finally {
174
+ await client.query(`DROP DATABASE IF EXISTS ${quoted}`);
175
+ }
176
+ });
177
+ }
178
+
179
+ main().then(
180
+ (code) => process.exit(code),
181
+ (error) => {
182
+ // Сбой самой проверки пуш не держит: сломанная обвязка не должна мешать работать
183
+ console.log(`check-schema-drift: проверка не отработала (${error?.message ?? error}) — пропущено`);
184
+ process.exit(0);
185
+ }
186
+ );