fullstack-gates 0.1.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.
@@ -0,0 +1,265 @@
1
+ /**
2
+ * `bun run check:write-path` — доходит ли действие ЭКРАНА до сервера.
3
+ *
4
+ * ПРАВИЛО: **у каждого пишущего эндпоинта сервера есть вызывающий в клиенте, либо он объявлен
5
+ * долгом.** Экран, который показывает «готово» и ничего не отправляет, — это молчаливый успех,
6
+ * самая дорогая разновидность вранья в этом проекте: приложение выглядит рабочим ровно до того
7
+ * момента, как кто-то перезагрузит страницу.
8
+ *
9
+ * ПОЧЕМУ ГЕЙТ, А НЕ ЗАМЕТКА. Нашёл это не тест и не ревью, а владелец: создал круг «тест» и не
10
+ * увидел его. Ни один уровень поймать не мог — юнит и интеграция проверяют СЕРВЕР (49 пишущих
11
+ * эндпоинтов, все покрыты), приёмка и e2e ходят по экранам и сверяют РАЗМЕТКУ. Между доказанным
12
+ * сервером и проверенной разметкой лежит провод, которого нет, и не проверял его никто.
13
+ *
14
+ * ЧТО СЧИТАЕТСЯ. Слева — пишущие методы в роутах слайсов, приведённые к
15
+ * полному пути через `prefix` их Elysia-группы. Справа — все `fetch(...)` клиента с методом, отличным
16
+ * от GET. Путь клиента нормализуется: `${id}` и прочая подстановка становится `:id`, query
17
+ * отбрасывается.
18
+ *
19
+ * РЕЖИМ ХРАПОВИКА, как у `check:proto` и `check:core-drift`. Неподключённый эндпоинт, не объявленный
20
+ * в `scripts/write-path.allow`, роняет прогон. Запись, которая больше не нужна (эндпоинт подключили),
21
+ * роняет тоже: список, который нельзя сократить, за месяц превращается в фольклор.
22
+ *
23
+ * ЧЕГО ГЕЙТ НЕ ДЕЛАЕТ. Он не проверяет, что вызов ПРАВИЛЬНЫЙ — тело, заголовки, обработку отказа.
24
+ * Это работа приёмочного сценария. Здесь проверяется единственное и самое грубое: провод есть или
25
+ * его нет.
26
+ */
27
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
28
+ import { join, relative, resolve } from "node:path";
29
+
30
+ /**
31
+ * Корень РЕПЫ, а не скрипта.
32
+ *
33
+ * Скрипт живёт в плагине и запускается из репы (`bun run "$CLAUDE_PLUGIN_ROOT/assets/scripts/…"`),
34
+ * поэтому свой собственный путь корнем быть не может: он указал бы внутрь плагина, гейт обошёл бы
35
+ * чужое дерево и объявил «нарушений нет» — зелёный на пустоте. Рабочий каталог задаёт `bun run`,
36
+ * и он всегда корень пакета; путь скрипта остаётся запасным для случая, когда файл всё-таки
37
+ * скопирован в репу.
38
+ */
39
+ const ROOT = existsSync(join(process.cwd(), "package.json"))
40
+ ? resolve(process.cwd())
41
+ : resolve(import.meta.dir, "..");
42
+ const ALLOW_FILE = join(ROOT, "scripts/write-path.allow");
43
+ const write = Bun.argv.includes("--write");
44
+
45
+ /**
46
+ * Где искать. Раскладка бывает разной (монорепо `packages/`+`apps/`, одиночный `src/`), поэтому
47
+ * берутся все существующие, а не одна угаданная, — то же правило, что у `check:proto`.
48
+ *
49
+ * Клиент ищется КАТАЛОГОМ, а не списком приложений: `apps/<любое>/src/client` и `src/client`. Не
50
+ * нашлось ни одного — это отказ ниже, а не пустой зелёный прогон.
51
+ */
52
+ const SERVER_ROOTS = ["packages", "apps", "src", "server"];
53
+ const CLIENT_DIR_NAME = "client";
54
+
55
+ const files: string[] = [];
56
+ function walk(dir: string, keep: (p: string) => boolean) {
57
+ if (!existsSync(dir)) return;
58
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
59
+ const full = join(dir, entry.name);
60
+ if (entry.isDirectory()) {
61
+ if (["node_modules", "dist", "build", ".turbo"].includes(entry.name)) continue;
62
+ walk(full, keep);
63
+ } else if (keep(full)) files.push(full);
64
+ }
65
+ }
66
+
67
+ /* ─────────────────────── что сервер умеет писать ─────────────────────── */
68
+
69
+ const routeFiles: string[] = [];
70
+ for (const dir of SERVER_ROOTS) {
71
+ const before = files.length;
72
+ walk(join(ROOT, dir), (p) =>
73
+ /features\/[^/]+\/server\/routes\.ts$/.test(p.split("\\").join("/"))
74
+ );
75
+ routeFiles.push(...files.slice(before));
76
+ }
77
+
78
+ type Endpoint = { method: string; path: string; file: string; line: number };
79
+ const endpoints: Endpoint[] = [];
80
+
81
+ for (const file of routeFiles) {
82
+ const rel = relative(ROOT, file).split("\\").join("/");
83
+ // Комментарии вырезаются ДО поиска — по той же причине, что и в `check:proto`: файл, который
84
+ // ОБЪЯСНЯЕТ отсутствующий `.post`, содержит эту строку и выглядел бы эндпоинтом.
85
+ const source = readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, (m) =>
86
+ m.replace(/[^\n]/g, " ")
87
+ );
88
+ const lines = source.split("\n");
89
+
90
+ /**
91
+ * Префикс берётся у ГРУППЫ, а не угадывается по имени слайса: `roles/server/routes.ts` держит и
92
+ * `/roles`, и `/domains`, а `governance-history` — `/governance-changes` и `/proposals`. Имя
93
+ * файла об этом не говорит ничего.
94
+ */
95
+ const prefixes: { line: number; prefix: string }[] = [];
96
+ lines.forEach((raw, i) => {
97
+ const m = /new Elysia\(\{\s*prefix:\s*"([^"]*)"/.exec(raw);
98
+ if (m) prefixes.push({ line: i + 1, prefix: m[1]! });
99
+ });
100
+ const prefixAt = (line: number) => {
101
+ let found = "";
102
+ for (const p of prefixes) if (p.line <= line) found = p.prefix;
103
+ return found;
104
+ };
105
+
106
+ /**
107
+ * Ищем ПО ВСЕМУ тексту, а не построчно: у Elysia вызов и путь почти всегда на разных строках
108
+ * (`.post(` \n `"/",`). Построчный поиск нашёл бы ноль эндпоинтов и объявил бы, что всё
109
+ * подключено, — то есть соврал бы ровно в ту сторону, против которой гейт и заведён.
110
+ */
111
+ const CALL = /\.(post|put|patch|delete)\(\s*"([^"]*)"/g;
112
+ let m: RegExpExecArray | null;
113
+ while ((m = CALL.exec(source)) !== null) {
114
+ const line = source.slice(0, m.index).split("\n").length;
115
+ const tail = m[2]!;
116
+ const full = `${prefixAt(line)}${tail === "/" ? "" : tail}` || "/";
117
+ endpoints.push({ method: m[1]!.toUpperCase(), path: full, file: rel, line });
118
+ }
119
+ }
120
+
121
+ /* ─────────────────────── что клиент действительно зовёт ─────────────────────── */
122
+
123
+ const clientFiles: string[] = [];
124
+ {
125
+ const before = files.length;
126
+ for (const dir of SERVER_ROOTS)
127
+ walk(join(ROOT, dir), (p) => {
128
+ const rel = p.split("\\").join("/");
129
+ return /\.tsx?$/.test(rel) && rel.includes(`/${CLIENT_DIR_NAME}/`);
130
+ });
131
+ clientFiles.push(...files.slice(before));
132
+ }
133
+
134
+ /**
135
+ * Путь → форма пути. `${…}` в шаблоне и любой сегмент-подстановка становятся `:id`, query
136
+ * отбрасывается. Иначе `/api/roles/${id}` и `/roles/:id` никогда не совпадут, и гейт объявил бы
137
+ * долгом весь сервер.
138
+ */
139
+ const shape = (raw: string): string =>
140
+ raw
141
+ .replace(/\?.*$/, "")
142
+ .replace(/^\/api/, "")
143
+ .replace(/\$\{[^}]*\}/g, ":id")
144
+ /**
145
+ * ИМЯ параметра роута тоже сводится к `:id`, и это не косметика.
146
+ *
147
+ * Клиент подставляет значение — `/api/roles/${id}/domains/${domainId}` — и о том, как параметр
148
+ * назван на сервере, не знает ничего. Сервер объявляет `/roles/:id/domains/:domainId`. Пока
149
+ * приводилась только клиентская сторона, совпасть могли ТОЛЬКО пути, у которых все параметры
150
+ * зовутся `:id`; всё остальное — `:kind`, `:contentId`, `:accountabilityId`, `:roleTemplateId` —
151
+ * не совпадало никогда, то есть числилось долгом даже будучи разведённым. Гейт при этом
152
+ * оставался зелёным (запись объявлена), и долг не мог убыть — самый тихий вид поломки: гейт,
153
+ * который нельзя удовлетворить, перестают пытаться удовлетворить.
154
+ */
155
+ .replace(/\/:[A-Za-z_$][\w$]*/g, "/:id")
156
+ .replace(/\/$/, "") || "/";
157
+
158
+ const called = new Set<string>();
159
+ for (const file of clientFiles) {
160
+ const source = readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, (m) =>
161
+ m.replace(/[^\n]/g, " ")
162
+ );
163
+ /**
164
+ * Вызов и его метод разнесены по строкам, поэтому берётся ОКНО текста после `fetch(`: путь в
165
+ * первом аргументе, `method:` — в объекте настроек следом. Окно ограничено, чтобы метод соседнего
166
+ * вызова не приписался этому.
167
+ */
168
+ const re = /fetch\(\s*[`"']([^`"']+)[`"']/g;
169
+ let m: RegExpExecArray | null;
170
+ while ((m = re.exec(source)) !== null) {
171
+ const window = source.slice(m.index, m.index + 400);
172
+ const method = /method:\s*"(POST|PUT|PATCH|DELETE)"/.exec(window)?.[1];
173
+ if (method) called.add(`${method} ${shape(m[1]!)}`);
174
+ }
175
+ }
176
+
177
+ /* ─────────────────────── сверка и храповик ─────────────────────── */
178
+
179
+ const declared = existsSync(ALLOW_FILE)
180
+ ? new Set(
181
+ readFileSync(ALLOW_FILE, "utf8")
182
+ .split("\n")
183
+ .map((l) => l.split("#")[0]!.trim())
184
+ .filter(Boolean)
185
+ )
186
+ : new Set<string>();
187
+
188
+ /**
189
+ * Два ключа намеренно. `key` — то, что ЧИТАЕТ человек в отчёте и в файле долга: путь ровно так, как
190
+ * его объявил роут. `match` — то, по чему идёт сверка: обе стороны, приведённые к одной форме.
191
+ * Свести их в один значило бы либо потерять читаемость долга (`/circles/:id/:id/:id`), либо
192
+ * потерять сверку.
193
+ */
194
+ const key = (e: Endpoint) => `${e.method} ${e.path}`;
195
+ const match = (e: Endpoint) => `${e.method} ${shape(e.path)}`;
196
+ const unconnected = endpoints.filter((e) => !called.has(match(e)));
197
+ const unconnectedKeys = new Set(unconnected.map(key));
198
+
199
+ if (write) {
200
+ const body = [
201
+ "# ЭКРАН НЕ ДОХОДИТ ДО СЕРВЕРА — объявленный ДОЛГ, а не разрешение.",
202
+ "#",
203
+ "# Каждая строка — пишущий эндпоинт, у которого в клиенте нет вызывающего. Это значит, что",
204
+ "# соответствующее действие экрана либо отсутствует, либо показывает успех, ничего не отправив.",
205
+ "#",
206
+ "# Список СОКРАЩАЕТСЯ по мере разводки экранов. Запись, которая больше не нужна, роняет гейт:",
207
+ "# непочищенный список за месяц становится фольклором. Новая строка без разводки — новый долг,",
208
+ "# и заводить его молча нельзя.",
209
+ "#",
210
+ `# Записано ${unconnectedKeys.size} из ${endpoints.length} пишущих эндпоинтов.`,
211
+ "",
212
+ ...[...unconnectedKeys].sort(),
213
+ "",
214
+ ].join("\n");
215
+ Bun.write(ALLOW_FILE, body);
216
+ console.log(`\nбазовая линия записана: ${unconnectedKeys.size} из ${endpoints.length}\n`);
217
+ process.exit(0);
218
+ }
219
+
220
+ console.log(`\n=== ПУТЬ ЗАПИСИ: ЭКРАН → СЕРВЕР ===`);
221
+
222
+ /**
223
+ * Ноль найденных эндпоинтов — это НЕ «всё подключено». Это гейт, который не отработал: переехавшая
224
+ * раскладка, не тот корень, изменившееся имя файла роутов. Зелёный при нулевом счёте — та же ложь,
225
+ * что и вручную поставленная галочка.
226
+ */
227
+ if (endpoints.length === 0 || clientFiles.length === 0) {
228
+ console.error(
229
+ `\nОШИБКА: найдено эндпоинтов ${endpoints.length}, файлов клиента ${clientFiles.length}.` +
230
+ `\nГейт не отработал — проверь раскладку (искали в ${SERVER_ROOTS.join(", ")} от ${ROOT}).`
231
+ );
232
+ process.exit(1);
233
+ }
234
+
235
+ console.log(
236
+ `Пишущих эндпоинтов: ${endpoints.length} · подключено: ${endpoints.length - unconnectedKeys.size} · долг: ${unconnectedKeys.size} (объявлено ${declared.size})`
237
+ );
238
+
239
+ const fresh = [...unconnectedKeys].filter((k) => !declared.has(k)).sort();
240
+ const stale = [...declared].filter((k) => !unconnectedKeys.has(k)).sort();
241
+
242
+ if (fresh.length) {
243
+ console.error(`\nНОВЫЙ пишущий эндпоинт без вызывающего в клиенте:\n`);
244
+ for (const k of fresh) {
245
+ const e = unconnected.find((x) => key(x) === k)!;
246
+ console.error(` ${k} ← ${e.file}:${e.line}`);
247
+ }
248
+ console.error(
249
+ `\nЭкран, показывающий «готово» и ничего не отправивший, выглядит рабочим до перезагрузки.` +
250
+ `\nЛибо разведи действие до API, либо объяви долг строкой в ${relative(ROOT, ALLOW_FILE)}.`
251
+ );
252
+ }
253
+
254
+ if (stale.length) {
255
+ console.error(`\nЗАПИСЬ ДОЛГА БОЛЬШЕ НЕ НУЖНА — удали её из ${relative(ROOT, ALLOW_FILE)}:\n`);
256
+ for (const k of stale) console.error(` ${k}`);
257
+ }
258
+
259
+ if (fresh.length || stale.length) {
260
+ console.error(`\nПроверка провалена: новых ${fresh.length}, протухших записей ${stale.length}.`);
261
+ process.exit(1);
262
+ }
263
+
264
+ console.log(`Новых разрывов нет. Объявленный долг: ${declared.size} эндпоинтов.\n`);
265
+ process.exit(0);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Окружение уровня «интеграция» — ОДНО место, где оно задаётся.
3
+ *
4
+ * Раньше это жило двумя копиями шелла ВНУТРИ `package.json`:
5
+ *
6
+ * set -a; [ -f .env ] && . ./.env; set +a; export TEST_DATABASE_URL=${TEST_DATABASE_URL:-…} …
7
+ *
8
+ * Половина той строки была лишней с самого начала: **bun грузит `.env` сам**, без `--env-file` и без
9
+ * `source`. Вторая половина — умолчания — задавалась в JSON, где её не прокомментируешь и не
10
+ * протестируешь, и потому расходилась между `ac` и `test:it`: достаточно поправить одну строку и
11
+ * забыть вторую, чтобы раннер приёмки и уровень 3 измеряли РАЗНЫЕ базы. Зелёный прогон против не той
12
+ * базы неотличим от зелёного прогона против своей — этим уже платили (см. CHANGELOG ядра 2026.08.2).
13
+ *
14
+ * Поэтому умолчания переехали сюда: их видно, у них есть причина, и она одна на всех потребителей.
15
+ */
16
+
17
+ /** Та же строка, что была в `package.json`. Совпадает с тем, что поднимает `docker compose` шаблона. */
18
+ export const DEFAULT_TEST_DATABASE_URL = "postgres://app:app@localhost:5432/app_test";
19
+
20
+ /**
21
+ * Проставляет окружение интеграции в текущий процесс и возвращает адрес тестовой БД.
22
+ *
23
+ * Именно в `process.env`, а не «возвращает объект»: дочерние процессы (`turbo run test`, `bun test`,
24
+ * `db:migrate`) наследуют окружение, и `turbo.json` объявляет эти же имена в `env` — то есть turbo
25
+ * учитывает их в ключе кэша. Вернуть значение и не проставить его значило бы, что уровень побежит с
26
+ * пустым `TEST_DATABASE_URL`: интеграционные тесты тогда не падают и не «скипаются» — их просто нет
27
+ * в отчёте.
28
+ *
29
+ * Уже заданное окружение НЕ перетирается: `TEST_DATABASE_URL=... bun run test:it` должен работать,
30
+ * иначе прогнать уровень против другой базы можно будет только правкой файла.
31
+ */
32
+ export function applyIntegrationEnv(): string {
33
+ const url = process.env.TEST_DATABASE_URL || DEFAULT_TEST_DATABASE_URL;
34
+ process.env.TEST_DATABASE_URL = url;
35
+ // Не «можно, если получится», а «обязано побежать»: без этого интеграционные тесты снимают себя
36
+ // сами, и отчёт приёмки молча теряет то, что они доказывают.
37
+ process.env.REQUIRE_INTEGRATION = "1";
38
+ // Общая тестовая база на несколько параллельных пакетов — держим соединения в узде.
39
+ process.env.DB_POOL_MAX = process.env.DB_POOL_MAX || "2";
40
+ return url;
41
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * ПРИОРИТЕТ СЛАЙСА — три значения и правило, по которому очередь их применяет.
3
+ *
4
+ * ─── ЗАЧЕМ ОН ВООБЩЕ ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * До этого очередь работ знала ровно один порядок — `feature: Ф<N>` из спеки. Это порядок АНАЛИЗА:
7
+ * им объявлено, что чем пользуется, и он же служил порядком стройки. У него есть свойство, которое
8
+ * долго не мешало и однажды помешало: он не знает, ЧТО В ПРОДУКТЕ ГЛАВНОЕ.
9
+ *
10
+ * На проекте базы знаний основной путь — вопрос к базе через MCP: индексация → поиск → поверхность.
11
+ * По номерам это Ф6, Ф7, Ф8, то есть последняя треть очереди. Сборка, идущая строго по номерам,
12
+ * шесть слайсов подряд строит то, без чего продукт неплох, и только потом — то, без чего его нет
13
+ * вовсе. Владелец при этом не может ни увидеть ключевой сценарий живым, ни проверить, что вся
14
+ * затея работает, пока не построено почти всё.
15
+ *
16
+ * Приоритет отвечает на второй вопрос, а не подменяет первый.
17
+ *
18
+ * ─── ЧТО ОН ЗНАЧИТ ТОЧНО ─────────────────────────────────────────────────────────────────────────
19
+ *
20
+ * **Приоритет задаёт ПОЛОСУ; `feature: Ф<N>` — порядок ВНУТРИ полосы.** Сначала берётся вся работа
21
+ * полосы `high` в объявленном порядке, потом вся `normal`, потом `low`. Это не «важное вперёд, а
22
+ * там как пойдёт»: внутри полосы порядок зависимостей сохраняется целиком, и слайс не может
23
+ * обогнать соседа, которым он пользуется, если оба в одной полосе.
24
+ *
25
+ * Чего приоритет НЕ делает: он не отменяет работу. `low` — это «позже», а не «не будем»: очередь
26
+ * пуста только когда пусты все три полосы, и отметка о завершении сборки читает именно это.
27
+ * Приоритет, умеющий закрывать сборку раньше времени, превратился бы в способ объявить победу.
28
+ *
29
+ * ─── ПОЧЕМУ ЗНАЧЕНИЕ ТРЕБУЕТ ПРИЧИНЫ ─────────────────────────────────────────────────────────────
30
+ *
31
+ * `priority-why` обязателен. Приоритет без названной причины — это предпочтение, а предпочтение
32
+ * переспрашивают каждую сессию: следующий агент видит `high`, не видит основания и либо чтит его
33
+ * как факт, либо тихо меняет. Названная причина превращает спор о вкусе в спор о том, верно ли
34
+ * прочитано требование, — а такой спор разрешим.
35
+ *
36
+ * ─── КТО ПОСТАВИЛ ────────────────────────────────────────────────────────────────────────────────
37
+ *
38
+ * `priority-by: owner` — поставил человек; перегенерация спеки его НЕ ТРОГАЕТ.
39
+ * `priority-by: derived` (умолчание) — вывела стадия анализа из требований; перегенерация вправе
40
+ * пересчитать.
41
+ *
42
+ * Без этого различения автоматическая простановка была бы разовой: первый же прогон генератора
43
+ * стёр бы решение владельца и не сказал бы об этом.
44
+ */
45
+
46
+ export const PRIORITIES = ["high", "normal", "low"] as const;
47
+ export type Priority = (typeof PRIORITIES)[number];
48
+
49
+ /**
50
+ * Значение по умолчанию — `normal`, но ОТСУТСТВИЕ объявления это не то же самое, что `normal`.
51
+ *
52
+ * `normal` — принятое решение («это обычная работа»), отсутствие — решения нет. Очередь обязана
53
+ * различать их в выводе: слайс без объявления считается обычным, и это НАЗЫВАЕТСЯ числом, иначе
54
+ * «никто не думал» неотличимо от «подумали и решили, что обычный».
55
+ */
56
+ export const DEFAULT_PRIORITY: Priority = "normal";
57
+
58
+ export interface PriorityDecl {
59
+ /** Действующее значение: объявленное, либо умолчание. */
60
+ value: Priority;
61
+ /** Было ли объявление вообще. */
62
+ declared: boolean;
63
+ /** Кто поставил. Без объявления — `derived`. */
64
+ by: "owner" | "derived";
65
+ /** Основание. Требуется, когда приоритет объявлен. */
66
+ why: string | null;
67
+ /** Текст нарушения, если объявление недействительно. `null` — объявление в порядке. */
68
+ error: string | null;
69
+ }
70
+
71
+ /** Полоса как число: чем меньше, тем раньше. */
72
+ export const rank = (p: Priority): number => PRIORITIES.indexOf(p);
73
+
74
+ /**
75
+ * Полоса вперёд, объявленный порядок внутри полосы.
76
+ *
77
+ * Вынесено сюда, а не оставлено выражением в сортировке очереди, ровно потому, что это ПРАВИЛО, а
78
+ * не деталь: оно проверяется тестом, и обратный ход у него читаемый — уберёшь первое слагаемое,
79
+ * и порядок вернётся к номерам спеки.
80
+ */
81
+ export const comparePriorityThenPhase = (
82
+ a: { priority: Priority; phase: number },
83
+ b: { priority: Priority; phase: number }
84
+ ): number => rank(a.priority) - rank(b.priority) || a.phase - b.phase;
85
+
86
+ /**
87
+ * Блок frontmatter, и только он.
88
+ *
89
+ * Читать приоритет по всему файлу нельзя: строка `priority: high` встречается в прозе use case —
90
+ * например, в примере тела запроса или в таблице ошибок, — и совпадение с ней дало бы слайсу
91
+ * приоритет, которого никто не объявлял. Такая ошибка не падает: она выглядит как решение.
92
+ */
93
+ export const frontmatterOf = (source: string): string => {
94
+ if (!source.startsWith("---")) return "";
95
+ const end = source.indexOf("\n---", 3);
96
+ return end === -1 ? "" : source.slice(4, end);
97
+ };
98
+
99
+ const scalar = (fm: string, key: string): string | null => {
100
+ const m = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(fm);
101
+ if (!m) return null;
102
+ const raw = (m[1] ?? "").trim();
103
+ // Кавычки снимаются, потому что контракт frontmatter требует их у значений с двоеточием, и
104
+ // владелец, закавычивший причину, не должен получить другой разбор, чем закавычивший её сосед.
105
+ const unquoted = /^"(.*)"$/.exec(raw)?.[1] ?? /^'(.*)'$/.exec(raw)?.[1] ?? raw;
106
+ return unquoted.trim();
107
+ };
108
+
109
+ /**
110
+ * Разбор объявления приоритета из полного текста файла слайса.
111
+ *
112
+ * НЕДЕЙСТВИТЕЛЬНОЕ объявление не превращается молча в умолчание. Опечатка `priority: heigh` при
113
+ * молчаливом умолчании дала бы слайсу `normal` — то есть ровно противоположное тому, что владелец
114
+ * написал, и без единого признака. Поэтому `error` возвращается наверх, а гейт по нему краснеет.
115
+ */
116
+ export function parsePriority(source: string): PriorityDecl {
117
+ const fm = frontmatterOf(source);
118
+ const value = scalar(fm, "priority");
119
+ const by = scalar(fm, "priority-by");
120
+ const why = scalar(fm, "priority-why");
121
+
122
+ const fail = (error: string): PriorityDecl => ({
123
+ value: DEFAULT_PRIORITY,
124
+ declared: value !== null,
125
+ by: by === "owner" ? "owner" : "derived",
126
+ why: why || null,
127
+ error,
128
+ });
129
+
130
+ if (value === null || value === "") {
131
+ // Спутники без хозяина — тоже ошибка: `priority-why` без `priority` описывает решение,
132
+ // которого в файле нет, и читается как объявленный приоритет при беглом взгляде.
133
+ if (by !== null) return fail("`priority-by` объявлен без `priority`");
134
+ if (why !== null) return fail("`priority-why` объявлен без `priority`");
135
+ return {
136
+ value: DEFAULT_PRIORITY,
137
+ declared: false,
138
+ by: "derived",
139
+ why: null,
140
+ error: null,
141
+ };
142
+ }
143
+
144
+ if (!(PRIORITIES as readonly string[]).includes(value)) {
145
+ return fail(`приоритет \`${value}\` — не одно из ${PRIORITIES.join(" | ")}`);
146
+ }
147
+ if (by !== null && by !== "owner" && by !== "derived") {
148
+ return fail(`\`priority-by: ${by}\` — не одно из owner | derived`);
149
+ }
150
+ if (!why) {
151
+ return fail("приоритет объявлен без `priority-why` — причина обязательна");
152
+ }
153
+
154
+ return {
155
+ value: value as Priority,
156
+ declared: true,
157
+ by: by === "owner" ? "owner" : "derived",
158
+ why,
159
+ error: null,
160
+ };
161
+ }