@rt-tools/agent-kit 0.8.3 → 0.9.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 (132) hide show
  1. package/README.md +21 -0
  2. package/assets/checks/board.github.mjs +48 -1
  3. package/assets/checks/check-board.github.mjs +84 -1
  4. package/assets/checks/check-lib-layers.mjs +13 -524
  5. package/assets/checks/check-specs.mjs +11 -782
  6. package/assets/checks/check-styles.mjs +185 -15
  7. package/assets/checks/lib-boundaries.mjs +143 -0
  8. package/assets/checks/lib-common.mjs +149 -0
  9. package/assets/checks/lib-domains.mjs +205 -0
  10. package/assets/checks/lib-manifests.mjs +60 -0
  11. package/assets/checks/lib-reexports.mjs +101 -0
  12. package/assets/checks/rt-kit-checks.config.mjs +17 -0
  13. package/assets/checks/spec-anchors.mjs +297 -0
  14. package/assets/checks/spec-common.mjs +222 -0
  15. package/assets/checks/spec-contract.mjs +152 -0
  16. package/assets/checks/spec-scenarios.mjs +201 -0
  17. package/assets/defaults/project.sh +8 -0
  18. package/assets/hooks/git-guard-push-tests.sh +8 -4
  19. package/assets/hooks/skill-gate.sh +1 -1
  20. package/assets/hooks/sql-guard-parse.sh +187 -0
  21. package/assets/hooks/sql-guard-request.sh +117 -0
  22. package/assets/hooks/sql-guard-target.sh +134 -0
  23. package/assets/hooks/sql-guard-write.sh +212 -0
  24. package/assets/hooks/sql-guard.sh +26 -596
  25. package/assets/hooks/waiting-turn-guard.sh +42 -13
  26. package/assets/laws/delivery.md +7 -0
  27. package/assets/laws/work-conduct.md +9 -0
  28. package/assets/patterns/admin-lists-screen.md +25 -14
  29. package/assets/patterns/admin-nav-item.md +1 -1
  30. package/assets/patterns/component-structure-new.md +1 -1
  31. package/assets/patterns/entity-aside.md +4 -2
  32. package/assets/patterns/observability-record.md +9 -0
  33. package/assets/patterns/shared-code-new.md +2 -2
  34. package/assets/patterns/task-flow-close.md +7 -1
  35. package/assets/rules/git-workflow.azure.md +7 -0
  36. package/assets/rules/git-workflow.github.md +41 -0
  37. package/assets/rules/git-workflow.gitlab.md +7 -0
  38. package/assets/rules/lib-layers.md +4 -0
  39. package/assets/rules/lists.md +10 -10
  40. package/assets/rules/shared-code.md +1 -1
  41. package/assets/rules/task-flow.md +47 -7
  42. package/assets/rules/testing.md +31 -0
  43. package/assets/rules/typescript-conventions.md +7 -0
  44. package/assets/skills/agent-kit.md +36 -0
  45. package/assets/templates/proposal.md +21 -0
  46. package/bin/agent-kit.d.ts.map +1 -1
  47. package/bin/agent-kit.js +117 -87
  48. package/bin/agent-kit.js.map +1 -1
  49. package/index.d.ts +1 -0
  50. package/index.d.ts.map +1 -1
  51. package/index.js +1 -0
  52. package/index.js.map +1 -1
  53. package/lib/argv.d.ts.map +1 -1
  54. package/lib/argv.js +6 -4
  55. package/lib/argv.js.map +1 -1
  56. package/lib/assets.d.ts.map +1 -1
  57. package/lib/assets.js +2 -1
  58. package/lib/assets.js.map +1 -1
  59. package/lib/cargo.d.ts +20 -0
  60. package/lib/cargo.d.ts.map +1 -1
  61. package/lib/cargo.js.map +1 -1
  62. package/lib/cascade.d.ts +55 -0
  63. package/lib/cascade.d.ts.map +1 -0
  64. package/lib/cascade.js +131 -0
  65. package/lib/cascade.js.map +1 -0
  66. package/lib/catalog.d.ts +0 -75
  67. package/lib/catalog.d.ts.map +1 -1
  68. package/lib/catalog.js +44 -127
  69. package/lib/catalog.js.map +1 -1
  70. package/lib/commands.d.ts.map +1 -1
  71. package/lib/commands.js +152 -85
  72. package/lib/commands.js.map +1 -1
  73. package/lib/companion.d.ts.map +1 -1
  74. package/lib/companion.js +5 -5
  75. package/lib/companion.js.map +1 -1
  76. package/lib/config.d.ts +2 -0
  77. package/lib/config.d.ts.map +1 -1
  78. package/lib/config.js +7 -5
  79. package/lib/config.js.map +1 -1
  80. package/lib/enroll.d.ts +64 -0
  81. package/lib/enroll.d.ts.map +1 -0
  82. package/lib/enroll.js +170 -0
  83. package/lib/enroll.js.map +1 -0
  84. package/lib/freshness.d.ts.map +1 -1
  85. package/lib/freshness.js +31 -17
  86. package/lib/freshness.js.map +1 -1
  87. package/lib/hooks-map.d.ts +30 -0
  88. package/lib/hooks-map.d.ts.map +1 -1
  89. package/lib/hooks-map.js +80 -18
  90. package/lib/hooks-map.js.map +1 -1
  91. package/lib/integrity.d.ts +1 -2
  92. package/lib/integrity.d.ts.map +1 -1
  93. package/lib/integrity.js +0 -1
  94. package/lib/integrity.js.map +1 -1
  95. package/lib/observations.d.ts.map +1 -1
  96. package/lib/observations.js +25 -12
  97. package/lib/observations.js.map +1 -1
  98. package/lib/order.d.ts +10 -0
  99. package/lib/order.d.ts.map +1 -0
  100. package/lib/order.js +14 -0
  101. package/lib/order.js.map +1 -0
  102. package/lib/picker.d.ts.map +1 -1
  103. package/lib/picker.js +8 -2
  104. package/lib/picker.js.map +1 -1
  105. package/lib/plan.js +1 -1
  106. package/lib/plan.js.map +1 -1
  107. package/lib/proposals.d.ts.map +1 -1
  108. package/lib/proposals.js +25 -8
  109. package/lib/proposals.js.map +1 -1
  110. package/lib/sections.js +1 -1
  111. package/lib/sections.js.map +1 -1
  112. package/lib/ship.d.ts.map +1 -1
  113. package/lib/ship.js +9 -1
  114. package/lib/ship.js.map +1 -1
  115. package/lib/shipment.d.ts.map +1 -1
  116. package/lib/shipment.js +14 -10
  117. package/lib/shipment.js.map +1 -1
  118. package/lib/snapshot.d.ts.map +1 -1
  119. package/lib/snapshot.js +2 -1
  120. package/lib/snapshot.js.map +1 -1
  121. package/lib/stamp.js +1 -1
  122. package/lib/stamp.js.map +1 -1
  123. package/lib/sync.d.ts +12 -2
  124. package/lib/sync.d.ts.map +1 -1
  125. package/lib/sync.js +11 -10
  126. package/lib/sync.js.map +1 -1
  127. package/lib/vars.d.ts.map +1 -1
  128. package/lib/vars.js +2 -3
  129. package/lib/vars.js.map +1 -1
  130. package/package.json +1 -1
  131. package/rt-tools-agent-kit-0.9.1.tgz +0 -0
  132. package/rt-tools-agent-kit-0.8.3.tgz +0 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Сценарии домена и уровень их привязки: что сценарий обещает, каким тестом это покрыто и не
3
+ * выключен ли тест переменной окружения.
4
+ */
5
+ import {
6
+ ACTOR,
7
+ E2E_ROOTS,
8
+ PARTIAL,
9
+ PERCEIVES,
10
+ PROMISE,
11
+ SCENARIO_HEADING,
12
+ SCENARIO_REFERENCE,
13
+ TEST_ROOTS,
14
+ UNCOVERED,
15
+ read,
16
+ walk,
17
+ } from './spec-common.mjs';
18
+
19
+ // ── 4. Сценарии и уровень привязки ────────────────────────────────────────────
20
+
21
+ function parseScenarios(file) {
22
+ const lines = read(file).split('\n');
23
+ const scenarios = [];
24
+ let current = null;
25
+ let inPromise = false;
26
+
27
+ lines.forEach((line, index) => {
28
+ const heading = SCENARIO_HEADING.exec(line);
29
+ if (heading) {
30
+ current = {
31
+ id: heading[1],
32
+ prefix: heading[2],
33
+ title: heading[4],
34
+ file,
35
+ line: index + 1,
36
+ uncovered: false,
37
+ partial: false,
38
+ promise: '',
39
+ };
40
+ inPromise = false;
41
+ scenarios.push(current);
42
+
43
+ return;
44
+ }
45
+ if (/^#{1,6}\s/.test(line)) {
46
+ current = null;
47
+
48
+ return;
49
+ }
50
+ if (!current) {
51
+ return;
52
+ }
53
+ if (UNCOVERED.test(line)) {
54
+ current.uncovered = true;
55
+ }
56
+ if (PARTIAL.test(line)) {
57
+ current.partial = true;
58
+ }
59
+ // «Тогда» и его продолжения с отступом — то, что сценарий обещает
60
+ if (PROMISE.test(line)) {
61
+ inPromise = true;
62
+ current.promise += ` ${line.trim()}`;
63
+
64
+ return;
65
+ }
66
+ if (inPromise && /^\s+\S/.test(line)) {
67
+ current.promise += ` ${line.trim()}`;
68
+
69
+ return;
70
+ }
71
+ inPromise = false;
72
+ });
73
+
74
+ return scenarios;
75
+ }
76
+
77
+ /**
78
+ * Обещан ли сценарием экран. Признак читается только из «Тогда»: «Дано» описывает
79
+ * обстановку, «Когда» — повод, а обещание пользователю стоит именно здесь.
80
+ *
81
+ * Человек и глагол восприятия требуются вместе, потому что порознь оба ошибаются.
82
+ * «Показывается» без человека стоит и там, где показывается запись в базе, а человек без
83
+ * восприятия — в каждом втором сценарии приёма заявки. Признак нарочно молчалив: сценарий,
84
+ * чьё «Тогда» человека не называет, под него не подпадает вовсе.
85
+ */
86
+ function promisesScreen(promise) {
87
+ return ACTOR.test(promise) && PERCEIVES.test(promise);
88
+ }
89
+
90
+ /**
91
+ * Константы, собранные из окружения, вместе с теми, что собраны из них. Ими выключают
92
+ * сквозной тест целиком: без `BASE_URL` или пары входа он не исполняется ни разу.
93
+ * Цепочка раскрывается, пока есть что раскрывать: `HAS_ADMIN_SESSION` собран из двух
94
+ * других констант, а не из `process.env` напрямую.
95
+ */
96
+ function environmentSwitches(root) {
97
+ // Объявление верхнего уровня: с отступом стоят локальные, и они гасят не тест, а случай
98
+ const declaration = /^const\s+([A-Za-z_]\w*)\s*(?::[^=]+)?=\s*([^;]+);/gm;
99
+ const assignments = [];
100
+ for (const file of walk(root, (name) => name.endsWith('.ts'))) {
101
+ for (const [, name, value] of read(file).matchAll(declaration)) {
102
+ assignments.push({ name, value });
103
+ }
104
+ }
105
+
106
+ const switches = new Set();
107
+ for (let pass = 0; pass <= assignments.length; pass += 1) {
108
+ const before = switches.size;
109
+ for (const { name, value } of assignments) {
110
+ if (value.includes('process.env') || [...switches].some((known) => new RegExp(`\\b${known}\\b`).test(value))) {
111
+ switches.add(name);
112
+ }
113
+ }
114
+ if (switches.size === before) {
115
+ break;
116
+ }
117
+ }
118
+
119
+ return switches;
120
+ }
121
+
122
+ /**
123
+ * Упоминания сценария в тестах: где стоит, идёт ли тест путём пользователя и не выключен ли
124
+ * он переменной окружения.
125
+ *
126
+ * Выключатель по состоянию стенда («у объекта меньше двух помещений») — это пропуск случая,
127
+ * и покрытие он не отменяет. Выключатель по переменной отменяет: тест с ним в обычном
128
+ * прогоне значится пропущенным, а сводка без этого читала бы его покрытием.
129
+ */
130
+ function collectReferences() {
131
+ const references = new Map();
132
+ const remember = (id, place) => {
133
+ if (!references.has(id)) {
134
+ references.set(id, []);
135
+ }
136
+ references.get(id).push(place);
137
+ };
138
+ const switchesByRoot = new Map();
139
+
140
+ for (const root of TEST_ROOTS) {
141
+ for (const file of walk(root, (name) => name.endsWith('.spec.ts'))) {
142
+ const e2eRoot = E2E_ROOTS.find((dir) => file.startsWith(`${dir}/`));
143
+ if (e2eRoot && !switchesByRoot.has(e2eRoot)) {
144
+ switchesByRoot.set(e2eRoot, environmentSwitches(e2eRoot));
145
+ }
146
+ const switches = switchesByRoot.get(e2eRoot) ?? new Set();
147
+ const switched = (line) =>
148
+ [...line.matchAll(/test\.skip\(([^,]*)/g)].some(
149
+ ([, condition]) =>
150
+ condition.includes('process.env') || [...switches].some((name) => new RegExp(`\\b${name}\\b`).test(condition))
151
+ );
152
+
153
+ const found = [];
154
+ let test = null;
155
+ let describeSwitched = false;
156
+
157
+ read(file)
158
+ .split('\n')
159
+ .forEach((line, index) => {
160
+ if (/^\s*test\.describe[.(]/.test(line)) {
161
+ describeSwitched = false;
162
+ test = null;
163
+ } else if (/^\s*test\s*\(/.test(line)) {
164
+ test = { off: describeSwitched };
165
+ } else if (/test\.skip\(/.test(line)) {
166
+ if (test) {
167
+ test.off = test.off || switched(line);
168
+ } else {
169
+ describeSwitched = describeSwitched || switched(line);
170
+ }
171
+ }
172
+
173
+ for (const [id] of line.matchAll(SCENARIO_REFERENCE)) {
174
+ found.push({ id, test, place: `${file}:${index + 1}` });
175
+ }
176
+ });
177
+
178
+ // Выключатель стоит первой строкой тела, то есть ниже заголовка теста с
179
+ // идентификатором: состояние теста читается, когда файл разобран целиком
180
+ found.forEach(({ id, test: own, place }) => remember(id, { place, screen: Boolean(e2eRoot), off: Boolean(own?.off) }));
181
+ }
182
+
183
+ // Наборы сценариев на shell. Так проверяются исполняемые файлы — гарды, проверки,
184
+ // умолчания: они не на TypeScript, и набор к ним пишут на том же языке, что и их
185
+ // самих. Выключателей здесь нет: пропустить сценарий в таком наборе нечем, поэтому
186
+ // достаточно найти идентификатор.
187
+ for (const file of walk(root, (name) => name.endsWith('.test.sh'))) {
188
+ read(file)
189
+ .split('\n')
190
+ .forEach((line, index) => {
191
+ for (const [id] of line.matchAll(SCENARIO_REFERENCE)) {
192
+ remember(id, { place: `${file}:${index + 1}`, screen: false, off: false });
193
+ }
194
+ });
195
+ }
196
+ }
197
+
198
+ return references;
199
+ }
200
+
201
+ export { collectReferences, parseScenarios, promisesScreen };
@@ -43,9 +43,17 @@ RT_HOOKS_TESTS="${RT_HOOKS_TESTS:-.claude/hooks/tests/run.sh}"
43
43
  # Заведённая проверка встаёт сюда, а не только в общий прогон, который никто не зовёт сам:
44
44
  # новая строка в её списке известного уезжает в главную ветку молча, а список при этом читается
45
45
  # как действующая охрана.
46
+ #
47
+ # Сверка раскладки идёт первой строкой и по двум причинам. Она стоит секунды, поэтому отбивает
48
+ # раньше, чем начнётся долгий прогон. И до неё расхождение разложенного с пакетом не отбивало
49
+ # ничего: правка, положенная в разложенную копию мимо источника, ничего не ломает в день, когда
50
+ # её делают, а всплывает на чужой работе — раскладка отказывает по правленому файлу целиком и не
51
+ # кладёт ни одного другого. Признак — сама настройка раскладки: дерево без неё пакета не ставит,
52
+ # и звать в нём нечего.
46
53
  rt_push_checks_default() {
47
54
  runner="$(rt_runner)"
48
55
  root="${CLAUDE_PROJECT_DIR:-.}"
56
+ [ -f "$root/.claude/rt-kit.json" ] && printf '%s\n' "$runner agent-kit sync --check"
49
57
  if [ -n "$1" ]; then
50
58
  printf '%s\n' "$runner nx affected -t lint test build --base=$1"
51
59
  else
@@ -36,10 +36,14 @@ case "$tool" in
36
36
  esac
37
37
 
38
38
  cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)"
39
- case "$cmd" in
40
- *git\ push*) ;;
41
- *) exit 0 ;;
42
- esac
39
+
40
+ # Вызов пуша узнаётся по двум признакам сразу — команда `git` в начале строки или за
41
+ # разделителем и слово `push` отдельным словом. Тем же приёмом, что у гарда поставки: одной
42
+ # подстрокой «git push» пуш не поймать — помощник учётных данных и заголовок запроса ставятся
43
+ # ключами `-c` между ними, и ровно этой формой здесь и пушат. Пока признаком была подстрока,
44
+ # весь набор гейта на таком пуше не гонялся вовсе, а молчание гарда читалось как «зелено».
45
+ printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]|$)' || exit 0
46
+ printf '%s' "$cmd" | grep -qE '(^|[[:space:]])push([[:space:]]|$)' || exit 0
43
47
 
44
48
  # Пробный пуш ничего не отправляет: гонять ради него весь набор незачем.
45
49
  case "$cmd" in
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # rt-hook: PreToolUse Edit|Write|MultiEdit|Bash|mcp__webstorm__create_new_file|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
2
+ # rt-hook: PreToolUse Edit|Write|MultiEdit|Bash|mcp__webstorm__create_new_file|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool|mcp__claude-in-chrome__.*
3
3
  # Гейт правил: не даёт править файл, пока не загружено правило, под которое он подпадает.
4
4
  #
5
5
  # Закон и правило, которых никто не открывает, не действуют. Напоминание в подсказке помогает
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env bash
2
+ # Разбор команды для гарда хранилища: приведение к одному виду, нарезка на сегменты и ответ
3
+ # на вопрос, доставляет ли сегмент SQL до сервера.
4
+ #
5
+ # Строки `# rt-hook:` здесь нет намеренно: событие и образец вызова объявляет сам гард, а
6
+ # помощник рядом хуком не регистрируется и в одиночку ничего не решает.
7
+
8
+ # Дальше разбираем без учёта регистра и переводов строк: `delete\n from bookings`
9
+ # — тот же запрос, что и в одну строку.
10
+ #
11
+ # Перевод строки при этом превращается в `;`, а не в пробел: в командной строке он РАЗДЕЛЯЕТ
12
+ # вызовы ровно как `;`, и склейка его в пробел стирала границу между ними. Многострочная
13
+ # команда становилась одним сегментом, доказательство чтения из первой строки покрывало
14
+ # неразобранный вызов из второй, и запись на боевую базу проходила молча — тот же обход,
15
+ # что чинили для `&&`, только набранный с новой строки.
16
+ normalize() {
17
+ perl -0ne '
18
+ # Продолжение длинной команды обратным слешем — это одна строка, а не две: без
19
+ # склейки адрес из первой уезжал в соседний сегмент, где клиента уже нет.
20
+ s/\\\n/ /g;
21
+ # Перевод строки заменяется на `;` ТОЛЬКО вне кавычек. Внутри `-c "…"` он часть
22
+ # запроса: многострочный SELECT резался на сегменты, и штатное чтение боевой базы
23
+ # переставало быть доказанным.
24
+ my ($out, $quote, $esc) = ("", "", 0);
25
+ for my $ch (split //, $_) {
26
+ if ($esc) { $out .= $ch; $esc = 0; next; }
27
+ if ($ch eq "\\") { $out .= $ch; $esc = 1; next; }
28
+ if ($quote ne "") {
29
+ $out .= ($ch eq "\n" ? " " : $ch);
30
+ $quote = "" if $ch eq $quote;
31
+ } elsif ($ch eq q{"} || $ch eq q{'"'"'}) {
32
+ $quote = $ch; $out .= $ch;
33
+ } elsif ($ch eq "\n" || $ch eq ";") {
34
+ $out .= "\x01"; # граница независимых команд
35
+ } elsif ($ch eq "|") {
36
+ $out .= "\x02"; # граница звена конвейера
37
+ } elsif ($ch eq "&") {
38
+ $out .= "\x01";
39
+ } else {
40
+ $out .= $ch;
41
+ }
42
+ }
43
+ # Соседние маркеры схлопываются: `&&` и `||` дают по два подряд.
44
+ $out =~ s/\x01+/\x01/g;
45
+ $out =~ s/\x02\x01/\x01/g;
46
+ $out =~ s/\x01\x02/\x01/g;
47
+ # Незакрытая кавычка означает, что автомат разъехался: апостроф в комментарии
48
+ # (`# don'"'"'t forget`) оставлял состояние «внутри строки» до конца ввода, и все
49
+ # последующие переводы строк переставали разделять вызовы — сегментация выключалась
50
+ # одним символом. В таком случае честнее ничего не печатать: вызывающий разделит
51
+ # строки грубым способом, и лишнее дробление сыграет в пользу строгости.
52
+ print $out if $quote eq "";
53
+ ' 2>/dev/null
54
+ }
55
+
56
+ # Грубое разделение — запасной путь: все переводы строк становятся разделителями. Оно строже
57
+ # точного (может разрезать многострочный запрос), поэтому годится и как фолбэк без perl,
58
+ # и как ответ на неразобранную команду.
59
+ sql_flatten() {
60
+ flat_rough="$(printf '%s' "$sql" | perl -0pe 's/\\\n/ /g' 2>/dev/null | tr '\n;&' '\001\001\001' | tr '|' '\002' | tr '\t' ' ' | tr '[:upper:]' '[:lower:]')"
61
+ [ -z "$flat_rough" ] && flat_rough="$(printf '%s' "$sql" | tr '\n;&' '\001\001\001' | tr '|' '\002' | tr '\t' ' ' | tr '[:upper:]' '[:lower:]')"
62
+
63
+ if command -v perl >/dev/null 2>&1; then
64
+ flat="$(printf '%s' "$sql" | normalize | tr '\t' ' ' | tr '[:upper:]' '[:lower:]')"
65
+ [ -z "$flat" ] && flat="$flat_rough"
66
+ else
67
+ flat="$flat_rough"
68
+ fi
69
+ }
70
+
71
+ # Разбор идёт по сегментам, а не по всей строке: `ls -l /opt && … psql -c "\copy …"` целиком
72
+ # выглядел как чтение, потому что `-l` от `ls` засчитывался за `psql -l`.
73
+ #
74
+ # Нарезка живёт в ЕДИНСТВЕННОМ месте. Пока наборов разделителей было два — один в раннем слое,
75
+ # другой здесь, — расхождение само по себе оказалось дырой: одиночный `|` считался границей
76
+ # тут и не считался там, и доставка файла конвейером в клиент проходила как чтение.
77
+ # Команда — то, что стоит между `;`, `&&`, `||` и переводами строк. Конвейер границей НЕ
78
+ # является: `cat fix.sql | psql …` — не две независимые команды, а одна доставка SQL, где
79
+ # левое звено питает правое. Пока `|` резал команды, глагол из `echo "drop table …"` терялся
80
+ # вместе с отброшенным звеном, и запись проходила молча.
81
+ split_segments() {
82
+ # Перевод строки в конце обязателен: без него `read` не отдаёт последнюю строку в тело
83
+ # цикла, и команда из одного сегмента молча выпадала из разбора целиком.
84
+ printf '%s\n' "$1" | tr '\001' '\n'
85
+ }
86
+
87
+ # Звенья конвейера внутри одной команды.
88
+ split_pipeline() {
89
+ printf '%s' "$1" | tr '\002' '\n'
90
+ }
91
+
92
+ # Команда считается вызовом клиента, только если клиент стоит ГОЛОВОЙ хотя бы одного звена.
93
+ # Имя в аргументе (`grep -rn "prisma db push" docs/`) вызовом не является.
94
+ calls_client() {
95
+ _found=""
96
+ _links="$(printf '%s' "$1" | tr '\002' '\n')"
97
+ while IFS= read -r link; do
98
+ [ -z "$link" ] && continue
99
+ # Голова звена: пропускаем присваивания окружения и обёртки вроде sudo/time/xargs.
100
+ head="$(printf '%s' "$link" | sed -E 's/^[[:space:]]*//; s/^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*//; s/^(sudo|time|env|nice|xargs)[[:space:]]+//')"
101
+ case "$head" in
102
+ psql*|pg_restore*|pg_dump*|pg_dumpall*|prisma*|*/psql*|*/pg_restore*|*/pg_dump*)
103
+ _found="yes"; break ;;
104
+ esac
105
+ # Транспорт до машины: сам вызов стоит внутри строки, которую он исполняет.
106
+ case "$head" in
107
+ ssh\ *|scp\ *|docker\ *|*/ssh\ *|*/docker\ *)
108
+ printf '%s' "$link" | grep -qE '(^|[^[:alnum:]_.-])(psql|pg_restore|pg_dump|prisma)([^[:alnum:]_.-]|$)' \
109
+ && { _found="yes"; break; } ;;
110
+ esac
111
+ # Раннер пакетов перед клиентом: `npx prisma migrate deploy`.
112
+ case "$head" in
113
+ npx\ *|pnpm\ *|yarn\ *|bun\ *|npm\ *)
114
+ printf '%s' "$head" | grep -qE '^(npx|pnpm|yarn|bun|npm)([[:space:]]+(exec|run|dlx))?[[:space:]]+(psql|pg_restore|pg_dump|prisma)([[:space:]]|$)' \
115
+ && { _found="yes"; break; } ;;
116
+ esac
117
+ done <<CALLS_EOF
118
+ $_links
119
+ CALLS_EOF
120
+ [ -n "$_found" ]
121
+ }
122
+
123
+ # Голова сегмента — читающий инструмент: имя клиента стоит в его аргументе, то есть это поиск
124
+ # или просмотр файла, а не доставка SQL. `grep -rn "prisma db push" docs/` читает документацию.
125
+ # Решает именно ГОЛОВА: тот же приём применён в dev-server-guard якорем BOUND.
126
+ is_reader() {
127
+ printf '%s' "$1" | grep -qE '^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*((sudo|time|env|nice|xargs)[[:space:]]+)*([^[:space:]]*/)?(grep|rg|ag|ack|find|awk|sed|cat|less|more|head|tail|wc|echo|printf|jq|diff|comm|sort|uniq|column)([[:space:]]|$)'
128
+ }
129
+
130
+ # Сегменты с настоящим вызовом клиента: читающие головы отсеиваются здесь же, а не отдельным
131
+ # слоем с собственным выходом.
132
+ client_segments() {
133
+ split_segments "$flat" | while IFS= read -r seg; do
134
+ [ -z "$seg" ] && continue
135
+ calls_client "$seg" || continue
136
+ # Маркер звена больше не нужен: дальше команда разбирается как одна строка.
137
+ printf '%s\n' "$(printf '%s' "$seg" | tr '\002' ' ')"
138
+ done
139
+ }
140
+
141
+ # Адресат берётся ТОЛЬКО из сегментов вызова клиента и только из аргументов, а не из данных.
142
+ # Домен сайта живёт в самих строках — в почте владельца, в canonical и og:url объекта, — а
143
+ # рядом в цепочке его дёргают по HTTP. Пока признак искался по всей команде, `curl
144
+ # https://<домен приложения>/... && psql -p 5432 -c "update …"` отклонялся как запись в
145
+ # боевую базу, и опт-аута у этой ветки нет по замыслу: команда становилась неисполнимой.
146
+ # Текст запроса вырезается ДО нарезки на сегменты. Внутри `-c "…"` свободно живут `;` и `||`
147
+ # — по ним сегменты и режутся, поэтому кусок запроса с доменом в данных становился отдельным
148
+ # сегментом, и почта на домене приложения снова читалась как адрес боевой базы.
149
+ sql_addr_flatten() {
150
+ addr_flat="$flat"
151
+ if command -v perl >/dev/null 2>&1; then
152
+ addr_flat="$(printf '%s' "$flat" | perl -0pe '
153
+ s/(^|[^[:alnum:]])--?(c|command|querytext)(=|\s+)("([^"\\]|\\.)*"|\x27[^\x27]*\x27)/$1/gs
154
+ ' 2>/dev/null || printf '%s' "$flat")"
155
+ fi
156
+ }
157
+
158
+ addr_segments() {
159
+ split_segments "$addr_flat" | while IFS= read -r seg; do
160
+ [ -z "$seg" ] && continue
161
+ flat_seg="$(printf '%s' "$seg" | tr '\002' ' ')"
162
+ # Адрес берётся из команд с вызовом клиента и из транспорта до машины: `ssh root@…`
163
+ # называет боевой хост, а сам вызов стоит дальше по цепочке.
164
+ if calls_client "$seg"; then
165
+ printf '%s\n' "$flat_seg"
166
+ elif printf '%s' "$flat_seg" | grep -qE '(^|[^[:alnum:]_.-])(ssh|scp|docker)([^[:alnum:]_.-]|$)|(pghost|pgport|pgdatabase|pguser|database_url|postgres_url)='; then
167
+ is_reader "$flat_seg" && continue
168
+ printf '%s\n' "$flat_seg"
169
+ fi
170
+ done
171
+ }
172
+
173
+ # Сегменты вызова клиента для дальнейшего разбора. У запроса из редактора сегментов нет вовсе
174
+ # — там весь текст и есть запрос.
175
+ sql_collect_segments() {
176
+ segments="$(client_segments)"
177
+ if [ -z "$segments" ]; then
178
+ if [ -n "$conn" ] || [ "$context" != "команда psql/prisma" ]; then
179
+ segments="$flat"
180
+ else
181
+ # Команда, где имя клиента встретилось только в аргументах читающих инструментов,
182
+ # ничего никуда не доставляет. Выход здесь безопасен: он стоит ПОСЛЕ разбора, а не
183
+ # вместо него, и срабатывает лишь когда настоящего вызова в команде нет.
184
+ exit 0
185
+ fi
186
+ fi
187
+ }
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bash
2
+ # Запрос из ввода гарда хранилища: что именно поедет к серверу и через что.
3
+ #
4
+ # Строки `# rt-hook:` здесь нет намеренно: событие и образец вызова объявляет сам гард, а
5
+ # помощник рядом хуком не регистрируется и в одиночку ничего не решает.
6
+ #
7
+ # Отсюда выходят четыре значения: `sql` — текст запроса или команда целиком, `conn` —
8
+ # идентификатор подключения редактора, `context` — чем это доставляется, и `cmd` — исходная
9
+ # командная строка, нужная разбору адреса миграции в исходном регистре.
10
+
11
+ # Три вида ввода: запрос через подключение редактора, команда оболочки и та же команда,
12
+ # завёрнутая в универсальный исполнитель. Инструмент, не названный здесь, гарда не касается.
13
+ sql_read_request() {
14
+ sql=""
15
+ context=""
16
+ conn=""
17
+ case "$tool" in
18
+ mcp__webstorm__execute_sql_query)
19
+ sql="$(printf '%s' "$input" | jq -r '.tool_input.queryText // empty' 2>/dev/null)"
20
+ conn="$(printf '%s' "$input" | jq -r '.tool_input.connectionId // empty' 2>/dev/null)"
21
+ context="запрос через подключение редактора"
22
+ ;;
23
+ # Терминал IDE исполняет ту же командную строку и кладёт её в то же поле, что и Bash:
24
+ # без этой ветки весь гард обходился сменой инструмента.
25
+ Bash | mcp__webstorm__execute_terminal_command | mcp__webstorm__execute_tool)
26
+ cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)"
27
+ [ -z "$cmd" ] && exit 0
28
+
29
+ # Универсальный исполнитель зовёт ЛЮБОЙ инструмент редактора по имени, в том числе
30
+ # `execute_sql_query`, — и тогда в строке нет ни одного имени клиента, по которому
31
+ # гард себя включает. Сам инструмент закрыт в permissions.deny, но полагаться только
32
+ # на настройку нельзя: её снимут, а гард останется.
33
+ if [ "$tool" = "mcp__webstorm__execute_tool" ] && command -v perl >/dev/null 2>&1; then
34
+ case "$cmd" in
35
+ *execute_sql_query*)
36
+ inner_sql="$(printf '%s' "$cmd" | perl -0ne '
37
+ if (/--queryText(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|\x27([^\x27]*)\x27|(.+))/s) {
38
+ print defined $1 ? $1 : (defined $2 ? $2 : $3);
39
+ }
40
+ ' 2>/dev/null)"
41
+ inner_conn="$(printf '%s' "$cmd" | perl -0ne '
42
+ if (/--connectionId(?:=|\s+)(?:"([^"]*)"|\x27([^\x27]*)\x27|(\S+))/) {
43
+ print defined $1 ? $1 : (defined $2 ? $2 : $3);
44
+ }
45
+ ' 2>/dev/null)"
46
+ ;;
47
+ esac
48
+ # Терминал, завёрнутый в тот же исполнитель: разбираем настоящую команду.
49
+ if [ -z "$inner_sql" ]; then
50
+ inner_cmd="$(printf '%s' "$cmd" | perl -0ne '
51
+ if (/--command(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|\x27([^\x27]*)\x27|(.+))/s) {
52
+ print defined $1 ? $1 : (defined $2 ? $2 : $3);
53
+ }
54
+ ' 2>/dev/null)"
55
+ [ -n "$inner_cmd" ] && cmd="$inner_cmd"
56
+ fi
57
+ fi
58
+
59
+ # Завёрнутый запрос разбирается как запрос из редактора, а не как команда оболочки:
60
+ # имени клиента в нём нет, зато есть подключение и текст SQL.
61
+ if [ -n "$inner_sql" ]; then
62
+ sql="$inner_sql"
63
+ conn="$inner_conn"
64
+ context="запрос через подключение редактора (обёртка исполнителя)"
65
+ else
66
+
67
+ # Команда, которая ничего никуда не доставляет, разбору не подлежит — даже если имя
68
+ # клиента стоит в её аргументах. `grep -rn "prisma db push" docs/` читает файлы, а не
69
+ # базу; раньше он получал deny, и обойти это можно было только испортив сам шаблон
70
+ # поиска. Тот же приём уже применён в dev-server-guard: решает ГОЛОВА команды, а не
71
+ # упоминание имени где-то внутри.
72
+ # Интересуют команды, которые доносят SQL до сервера. Клиент ищется КАК СЛОВО в любом
73
+ # месте команды, а не только в начале: удаление на бою выглядит как
74
+ # `ssh root@host "docker compose exec postgres psql -c '…'"` — при проверке одного
75
+ # лишь начала строки оно проходило мимо гарда целиком.
76
+ #
77
+ # Порядок здесь принципиален. Раньше выше стояла отсечка «команда начинается с git/gh»,
78
+ # снимавшая ложные срабатывания на тексте коммита, — и она же открывала обход: в
79
+ # `git log && psql … -c "DELETE …"` первое звено уносило с собой весь остальной SQL.
80
+ # Теперь сперва ищется клиент, и только его отсутствие завершает проверку.
81
+ printf '%s\n' "$cmd" | grep -qE \
82
+ '(^|[^[:alnum:]_.-])(psql|pg_restore|pg_dump|prisma)([^[:alnum:]_.-]|$)' \
83
+ || exit 0
84
+
85
+ # Ложные срабатывания на описаниях снимаются иначе — вырезанием текста сообщений
86
+ # (`-m '…'`, `-am "…"`, `--message="…"`, `-F file`), а не отказом от проверки всей
87
+ # команды. В самом сообщении SQL не исполняется, но слова «delete» и «drop» в нём
88
+ # обычны: `git commit -am "chore(api): update prisma schema"` разбирался как
89
+ # UPDATE без WHERE, потому что шаблон требовал `-m` вплотную к дефису.
90
+ if command -v perl >/dev/null 2>&1; then
91
+ # `file` в альтернации быть не должно: под него попадал `--file=cleanup.sql`
92
+ # у psql, и признак «SQL приехал файлом» умирал раньше, чем его проверяли.
93
+ # Здесь только флаги, которые несут ТЕКСТ сообщения.
94
+ cmd="$(printf '%s' "$cmd" | perl -0pe '
95
+ s/(^|[^[:alnum:]])--?[a-zA-Z]*(m|message|body|title|body-file|F)(=|\s+)("([^"\\]|\\.)*"|\x27[^\x27]*\x27|[^\s;&|]+)/$1/gs
96
+ ' 2>/dev/null || printf '%s' "$cmd")"
97
+ # После вырезания сообщения клиента может уже не остаться — тогда это была
98
+ # git-команда, лишь упоминавшая psql в тексте.
99
+ printf '%s\n' "$cmd" | grep -qE \
100
+ '(^|[^[:alnum:]_.-])(psql|pg_restore|pg_dump|prisma)([^[:alnum:]_.-]|$)' \
101
+ || exit 0
102
+ else
103
+ # Без perl вырезать текст сообщения нечем, и разбирать его как SQL нельзя:
104
+ # штатный коммит упирался бы в отказ. Доставки SQL в git-команде не бывает,
105
+ # поэтому здесь дешевле пропустить, чем ломать работу.
106
+ case "$cmd" in
107
+ git\ *|*/git\ *|gh\ *|*/gh\ *) exit 0 ;;
108
+ esac
109
+ fi
110
+
111
+ sql="$cmd"
112
+ context="команда psql/prisma"
113
+ fi
114
+ ;;
115
+ *) exit 0 ;;
116
+ esac
117
+ }