@rt-tools/agent-kit 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/assets/checks/board.github.mjs +48 -1
- package/assets/checks/check-board.github.mjs +84 -1
- package/assets/checks/check-lib-layers.mjs +13 -524
- package/assets/checks/check-specs.mjs +11 -782
- package/assets/checks/check-styles.mjs +185 -15
- package/assets/checks/lib-boundaries.mjs +143 -0
- package/assets/checks/lib-common.mjs +149 -0
- package/assets/checks/lib-domains.mjs +205 -0
- package/assets/checks/lib-manifests.mjs +60 -0
- package/assets/checks/lib-reexports.mjs +101 -0
- package/assets/checks/rt-kit-checks.config.mjs +17 -0
- package/assets/checks/spec-anchors.mjs +297 -0
- package/assets/checks/spec-common.mjs +222 -0
- package/assets/checks/spec-contract.mjs +152 -0
- package/assets/checks/spec-scenarios.mjs +201 -0
- package/assets/defaults/project.sh +8 -0
- package/assets/hooks/git-guard-push-tests.sh +8 -4
- package/assets/hooks/skill-gate.sh +1 -1
- package/assets/hooks/sql-guard-parse.sh +187 -0
- package/assets/hooks/sql-guard-request.sh +117 -0
- package/assets/hooks/sql-guard-target.sh +134 -0
- package/assets/hooks/sql-guard-write.sh +212 -0
- package/assets/hooks/sql-guard.sh +26 -596
- package/assets/hooks/waiting-turn-guard.sh +42 -13
- package/assets/laws/delivery.md +7 -0
- package/assets/laws/work-conduct.md +9 -0
- package/assets/patterns/admin-lists-screen.md +25 -14
- package/assets/patterns/admin-nav-item.md +1 -1
- package/assets/patterns/component-structure-new.md +1 -1
- package/assets/patterns/entity-aside.md +4 -2
- package/assets/patterns/observability-record.md +9 -0
- package/assets/patterns/shared-code-new.md +2 -2
- package/assets/patterns/task-flow-close.md +7 -1
- package/assets/rules/git-workflow.azure.md +7 -0
- package/assets/rules/git-workflow.github.md +41 -0
- package/assets/rules/git-workflow.gitlab.md +7 -0
- package/assets/rules/lib-layers.md +4 -0
- package/assets/rules/lists.md +10 -10
- package/assets/rules/shared-code.md +1 -1
- package/assets/rules/task-flow.md +47 -7
- package/assets/rules/testing.md +31 -0
- package/assets/rules/typescript-conventions.md +7 -0
- package/assets/skills/agent-kit.md +36 -0
- package/assets/templates/proposal.md +21 -0
- package/bin/agent-kit.d.ts.map +1 -1
- package/bin/agent-kit.js +115 -87
- package/bin/agent-kit.js.map +1 -1
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +1 -0
- package/index.js.map +1 -1
- package/lib/argv.d.ts.map +1 -1
- package/lib/argv.js +6 -4
- package/lib/argv.js.map +1 -1
- package/lib/assets.d.ts.map +1 -1
- package/lib/assets.js +2 -1
- package/lib/assets.js.map +1 -1
- package/lib/cargo.d.ts +20 -0
- package/lib/cargo.d.ts.map +1 -1
- package/lib/cargo.js.map +1 -1
- package/lib/cascade.d.ts +55 -0
- package/lib/cascade.d.ts.map +1 -0
- package/lib/cascade.js +131 -0
- package/lib/cascade.js.map +1 -0
- package/lib/catalog.d.ts +0 -75
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +44 -127
- package/lib/catalog.js.map +1 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +152 -85
- package/lib/commands.js.map +1 -1
- package/lib/companion.d.ts.map +1 -1
- package/lib/companion.js +5 -5
- package/lib/companion.js.map +1 -1
- package/lib/config.d.ts +2 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +7 -5
- package/lib/config.js.map +1 -1
- package/lib/enroll.d.ts +56 -0
- package/lib/enroll.d.ts.map +1 -0
- package/lib/enroll.js +123 -0
- package/lib/enroll.js.map +1 -0
- package/lib/freshness.d.ts.map +1 -1
- package/lib/freshness.js +31 -17
- package/lib/freshness.js.map +1 -1
- package/lib/hooks-map.d.ts +30 -0
- package/lib/hooks-map.d.ts.map +1 -1
- package/lib/hooks-map.js +80 -18
- package/lib/hooks-map.js.map +1 -1
- package/lib/integrity.d.ts +1 -2
- package/lib/integrity.d.ts.map +1 -1
- package/lib/integrity.js +0 -1
- package/lib/integrity.js.map +1 -1
- package/lib/observations.d.ts.map +1 -1
- package/lib/observations.js +25 -12
- package/lib/observations.js.map +1 -1
- package/lib/order.d.ts +10 -0
- package/lib/order.d.ts.map +1 -0
- package/lib/order.js +14 -0
- package/lib/order.js.map +1 -0
- package/lib/picker.d.ts.map +1 -1
- package/lib/picker.js +8 -2
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +1 -1
- package/lib/plan.js.map +1 -1
- package/lib/proposals.d.ts.map +1 -1
- package/lib/proposals.js +25 -8
- package/lib/proposals.js.map +1 -1
- package/lib/sections.js +1 -1
- package/lib/sections.js.map +1 -1
- package/lib/ship.d.ts.map +1 -1
- package/lib/ship.js +9 -1
- package/lib/ship.js.map +1 -1
- package/lib/shipment.d.ts.map +1 -1
- package/lib/shipment.js +14 -10
- package/lib/shipment.js.map +1 -1
- package/lib/snapshot.d.ts.map +1 -1
- package/lib/snapshot.js +2 -1
- package/lib/snapshot.js.map +1 -1
- package/lib/stamp.js +1 -1
- package/lib/stamp.js.map +1 -1
- package/lib/sync.d.ts +12 -2
- package/lib/sync.d.ts.map +1 -1
- package/lib/sync.js +11 -10
- package/lib/sync.js.map +1 -1
- package/lib/vars.d.ts.map +1 -1
- package/lib/vars.js +2 -3
- package/lib/vars.js.map +1 -1
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.9.0.tgz +0 -0
- 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|