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.
- package/README.md +58 -0
- package/bin/stack-gate.ts +63 -0
- package/package.json +38 -0
- package/scripts/ac.ts +494 -0
- package/scripts/check-debt.ts +260 -0
- package/scripts/check-pins.ts +108 -0
- package/scripts/check-priority.ts +120 -0
- package/scripts/check-proto-ids.ts +199 -0
- package/scripts/check-prototype-boundary.ts +153 -0
- package/scripts/check-screen-wiring.ts +312 -0
- package/scripts/check-write-path.ts +265 -0
- package/scripts/integration-env.ts +41 -0
- package/scripts/lib/priority.ts +161 -0
- package/scripts/next-slice.ts +602 -0
- package/scripts/preflight.ts +171 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* ГЕЙТ ДОЛГОВ — сознательно не сделанное обязано быть названным, счётным и найденным при правке.
|
|
4
|
+
*
|
|
5
|
+
* ─── ЗАЧЕМ ОН ПОЯВИЛСЯ ──────────────────────────────────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Случай, с которого он начался: эмиттер схемы не создал индексы, объявленные моделью данных, и
|
|
8
|
+
* записал причину КОММЕНТАРИЕМ В КОДЕ: «параметры зависят от объёма, объём не задан». Рассуждение
|
|
9
|
+
* выглядело взвешенным, поэтому дыру никто не искал. Поймать её не мог никто:
|
|
10
|
+
*
|
|
11
|
+
* • гейт `schema-emit` считал таблицы, перечисления и внешние ключи — индексы не считал;
|
|
12
|
+
* • ни один критерий приёмки не про физическую схему: все они про ПОВЕДЕНИЕ;
|
|
13
|
+
* • `BUILD_COMPLETE` напечатался честно — по своим условиям он и был выполнен.
|
|
14
|
+
*
|
|
15
|
+
* Итог: поиск ходил полным сканом, отчёт был зелёный, а владелец узнал об этом сам, глазами. Долг
|
|
16
|
+
* существовал, был обоснован и был невидим — и невидимость здесь опаснее самого долга.
|
|
17
|
+
*
|
|
18
|
+
* ─── ЧЕМ ЭТО НЕ TODO ───────────────────────────────────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* `TODO` в коде не считается, не истекает и ни на что не влияет: его пишут, чтобы успокоить себя.
|
|
21
|
+
* Долг здесь — ЗАПИСЬ В РЕЕСТРЕ, у которой есть цена и условие снятия, она печатается очередью и
|
|
22
|
+
* попадает в финальный отчёт сборки рядом с отметкой. `BUILD_COMPLETE` и «долгов: 7» видны вместе,
|
|
23
|
+
* и это единственное, что отличает законченную работу от объявленной законченной.
|
|
24
|
+
*
|
|
25
|
+
* ─── ПРИВЯЗКА ДВУСТОРОННЯЯ, И В ЭТОМ ВСЯ СИЛА ──────────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* Ссылка из кода на несуществующий долг — ошибка. Долг, на который ничто в коде не ссылается, —
|
|
28
|
+
* тоже ошибка: реестр, живущий отдельно от кода, устаревает первым, и через месяц никто не знает,
|
|
29
|
+
* снят долг или про него забыли. Тот же шов, что связывает критерий приёмки с тестом.
|
|
30
|
+
*
|
|
31
|
+
* Долг, которому в коде физически не на что повеситься (не сделан целый слайс, не куплено железо),
|
|
32
|
+
* объявляет это явно — `@нет-якоря` в поле разблокировки.
|
|
33
|
+
*
|
|
34
|
+
* bun run check:debt печатает таблицу, падает на браке формата
|
|
35
|
+
* bun run check:debt --quiet только код возврата
|
|
36
|
+
*/
|
|
37
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
38
|
+
import { join, relative } from "node:path";
|
|
39
|
+
|
|
40
|
+
const ROOT = process.cwd();
|
|
41
|
+
const РЕЕСТР = join(ROOT, "scripts/debt.open");
|
|
42
|
+
|
|
43
|
+
/** Форма записи. Разделитель полей — ` · `, потому что в тексте долга бывает всё остальное. */
|
|
44
|
+
const ФОРМАТ =
|
|
45
|
+
"DEBT-NNN <область> <ГГГГ-ММ-ДД> <что не сделано> · <чем снимается> · <цена, если не снять>";
|
|
46
|
+
|
|
47
|
+
export interface Долг {
|
|
48
|
+
id: string;
|
|
49
|
+
область: string;
|
|
50
|
+
заявлен: Date;
|
|
51
|
+
что: string;
|
|
52
|
+
снимается: string;
|
|
53
|
+
цена: string;
|
|
54
|
+
/** Долг объявил, что в коде якоря нет и быть не может. */
|
|
55
|
+
безЯкоря: boolean;
|
|
56
|
+
строка: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Находка {
|
|
60
|
+
строка: number;
|
|
61
|
+
текст: string;
|
|
62
|
+
причина: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ISO = /^\d{4}-\d{2}-\d{2}$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Разбор реестра. Каждая ошибка — НАХОДКА, а не исключение: строка с браком не должна прятать
|
|
69
|
+
* остальные, иначе первая же опечатка скроет девять исправных долгов.
|
|
70
|
+
*/
|
|
71
|
+
export function разобрать(текст: string, сегодня: Date): { долги: Долг[]; находки: Находка[] } {
|
|
72
|
+
const долги: Долг[] = [];
|
|
73
|
+
const находки: Находка[] = [];
|
|
74
|
+
const виденные = new Set<string>();
|
|
75
|
+
|
|
76
|
+
текст.split("\n").forEach((raw, i) => {
|
|
77
|
+
const строка = i + 1;
|
|
78
|
+
const line = raw.split("#")[0]!.trim();
|
|
79
|
+
if (!line) return;
|
|
80
|
+
|
|
81
|
+
const m = /^(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/.exec(line);
|
|
82
|
+
if (!m) {
|
|
83
|
+
находки.push({ строка, текст: line, причина: `не разбирается. Форма: ${ФОРМАТ}` });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const [, id, область, дата, хвост] = m as unknown as [string, string, string, string, string];
|
|
87
|
+
|
|
88
|
+
if (!/^DEBT-\d{3,}$/.test(id)) {
|
|
89
|
+
находки.push({ строка, текст: line, причина: `«${id}» — не идентификатор долга (DEBT-NNN)` });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Дубль идентификатора страшнее опечатки: ссылка из кода станет вести в два места сразу.
|
|
93
|
+
if (виденные.has(id)) {
|
|
94
|
+
находки.push({ строка, текст: line, причина: `${id} объявлен второй раз` });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
виденные.add(id);
|
|
98
|
+
|
|
99
|
+
if (!ISO.test(дата) || Number.isNaN(Date.parse(дата))) {
|
|
100
|
+
находки.push({ строка, текст: line, причина: `«${дата}» — не дата ГГГГ-ММ-ДД` });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const заявлен = new Date(`${дата}T00:00:00Z`);
|
|
104
|
+
// Долг из будущего — почти всегда опечатка в годе, и она тихо отключила бы счётчик возраста.
|
|
105
|
+
if (заявлен.getTime() > сегодня.getTime()) {
|
|
106
|
+
находки.push({ строка, текст: line, причина: `дата ${дата} в будущем` });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const части = хвост.split("·").map((с) => с.trim());
|
|
111
|
+
if (части.length !== 3 || части.some((с) => с.length === 0)) {
|
|
112
|
+
находки.push({
|
|
113
|
+
строка,
|
|
114
|
+
текст: line,
|
|
115
|
+
причина: `нужны ТРИ поля через « · »: что не сделано · чем снимается · цена. Дано: ${части.length}`,
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const [что, снимается, цена] = части as [string, string, string];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* ЦЕНА ОБЯЗАТЕЛЬНА, и это не формальность. Долг без цены неотличим от предпочтения: по нему
|
|
123
|
+
* нельзя решить, брать его сейчас или через полгода, — а решать это и есть единственное, ради
|
|
124
|
+
* чего реестр существует.
|
|
125
|
+
*/
|
|
126
|
+
долги.push({
|
|
127
|
+
id,
|
|
128
|
+
область,
|
|
129
|
+
заявлен,
|
|
130
|
+
что,
|
|
131
|
+
снимается: снимается.replace(/@нет-якоря/g, "").trim() || снимается,
|
|
132
|
+
цена,
|
|
133
|
+
безЯкоря: /@нет-якоря/.test(снимается),
|
|
134
|
+
строка,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return { долги, находки };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ─────────────────────────── Привязка к коду ─────────────────────────── */
|
|
142
|
+
|
|
143
|
+
const ПРОПУСК = new Set([
|
|
144
|
+
"node_modules",
|
|
145
|
+
".git",
|
|
146
|
+
"dist",
|
|
147
|
+
"build",
|
|
148
|
+
".turbo",
|
|
149
|
+
"coverage",
|
|
150
|
+
".next",
|
|
151
|
+
"drizzle",
|
|
152
|
+
]);
|
|
153
|
+
const РАСШИРЕНИЯ = /\.(ts|tsx|js|jsx|mjs|sql|md|json|yaml|yml)$/;
|
|
154
|
+
|
|
155
|
+
function файлы(dir: string, найдено: string[] = []): string[] {
|
|
156
|
+
for (const имя of readdirSync(dir)) {
|
|
157
|
+
if (ПРОПУСК.has(имя)) continue;
|
|
158
|
+
const путь = join(dir, имя);
|
|
159
|
+
if (statSync(путь).isDirectory()) файлы(путь, найдено);
|
|
160
|
+
else if (РАСШИРЕНИЯ.test(имя)) найдено.push(путь);
|
|
161
|
+
}
|
|
162
|
+
return найдено;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Где в коде упомянут каждый идентификатор долга. Сам реестр не считается ссылкой на себя. */
|
|
166
|
+
export function ссылкиИзКода(root: string): Map<string, string[]> {
|
|
167
|
+
const карта = new Map<string, string[]>();
|
|
168
|
+
for (const путь of файлы(root)) {
|
|
169
|
+
if (путь === РЕЕСТР) continue;
|
|
170
|
+
const текст = readFileSync(путь, "utf8");
|
|
171
|
+
for (const m of текст.matchAll(/DEBT-\d{3,}/g)) {
|
|
172
|
+
const id = m[0];
|
|
173
|
+
карта.set(id, [...(карта.get(id) ?? []), relative(root, путь)]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return карта;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ─────────────────────────────── Прогон ─────────────────────────────── */
|
|
180
|
+
|
|
181
|
+
export function проверить(
|
|
182
|
+
текст: string,
|
|
183
|
+
ссылки: Map<string, string[]>,
|
|
184
|
+
сегодня: Date
|
|
185
|
+
): { долги: Долг[]; находки: Находка[] } {
|
|
186
|
+
const { долги, находки } = разобрать(текст, сегодня);
|
|
187
|
+
const объявленные = new Set(долги.map((д) => д.id));
|
|
188
|
+
|
|
189
|
+
for (const д of долги) {
|
|
190
|
+
const где = ссылки.get(д.id) ?? [];
|
|
191
|
+
if (где.length === 0 && !д.безЯкоря) {
|
|
192
|
+
находки.push({
|
|
193
|
+
строка: д.строка,
|
|
194
|
+
текст: д.id,
|
|
195
|
+
причина:
|
|
196
|
+
"на долг ничто не ссылается из кода. Поставь `DEBT-NNN` там, где принято решение " +
|
|
197
|
+
"не делать, — иначе при правке этого места о долге не узнают. " +
|
|
198
|
+
"Якоря нет и быть не может — напиши `@нет-якоря` в поле разблокировки",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const [id, где] of ссылки) {
|
|
204
|
+
if (!объявленные.has(id)) {
|
|
205
|
+
находки.push({
|
|
206
|
+
строка: 0,
|
|
207
|
+
текст: id,
|
|
208
|
+
причина: `код ссылается на долг, которого нет в реестре: ${где.slice(0, 3).join(", ")}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { долги, находки };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (import.meta.main) {
|
|
217
|
+
const тихо = process.argv.includes("--quiet");
|
|
218
|
+
const сегодня = new Date();
|
|
219
|
+
|
|
220
|
+
if (!existsSync(РЕЕСТР)) {
|
|
221
|
+
// Отсутствие реестра — не «долгов нет», а «механизма нет». Разница существенная.
|
|
222
|
+
const ссылки = ссылкиИзКода(ROOT);
|
|
223
|
+
if (ссылки.size > 0) {
|
|
224
|
+
console.error(`✗ код ссылается на долги (${[...ссылки.keys()].join(", ")}), а ${РЕЕСТР} нет`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
if (!тихо)
|
|
228
|
+
console.log("долгов: 0 (реестра нет — заведи scripts/debt.open, когда появится первый)");
|
|
229
|
+
process.exit(0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { долги, находки } = проверить(readFileSync(РЕЕСТР, "utf8"), ссылкиИзКода(ROOT), сегодня);
|
|
233
|
+
|
|
234
|
+
if (!тихо) {
|
|
235
|
+
if (долги.length === 0) console.log("долгов: 0");
|
|
236
|
+
else {
|
|
237
|
+
console.log(`долгов: ${долги.length}\n`);
|
|
238
|
+
const дней = (д: Долг) => Math.floor((сегодня.getTime() - д.заявлен.getTime()) / 86_400_000);
|
|
239
|
+
for (const д of [...долги].sort((a, b) => a.заявлен.getTime() - b.заявлен.getTime())) {
|
|
240
|
+
const возраст = дней(д);
|
|
241
|
+
// Возраст печатается ВСЕГДА: долг, которому полгода, — это уже решение, а не отсрочка.
|
|
242
|
+
console.log(`${д.id} ${д.область} ${возраст} дн.`);
|
|
243
|
+
console.log(` не сделано: ${д.что}`);
|
|
244
|
+
console.log(` снимается: ${д.снимается}${д.безЯкоря ? " (якоря в коде нет)" : ""}`);
|
|
245
|
+
console.log(` цена: ${д.цена}\n`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (находки.length > 0) {
|
|
251
|
+
console.error(`✗ брак в реестре долгов (${находки.length}):`);
|
|
252
|
+
for (const н of находки) {
|
|
253
|
+
console.error(
|
|
254
|
+
` ${н.строка ? `${РЕЕСТР}:${н.строка}` : "код"} ${н.текст}\n ${н.причина}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* `bun run check:pins` — в объявлениях зависимостей нет плавающих версий.
|
|
4
|
+
*
|
|
5
|
+
* ЧТО ЛОВИТ. `"latest"` и `"*"` в `dependencies`/`devDependencies` любого пакета воркспейса.
|
|
6
|
+
* Такое объявление означает «что окажется новым в день установки». Пока цел `bun.lock`, оно молчит:
|
|
7
|
+
* свежий клон получает версии из локфайла, и всё выглядит воспроизводимым. Оно просыпается на
|
|
8
|
+
* `bun update`, на добавлении пакета и на любом резолве без локфайла — то есть тогда, когда никто
|
|
9
|
+
* не связывает поломку с объявлением, сделанным полгода назад.
|
|
10
|
+
*
|
|
11
|
+
* ЦЕНОЙ ЧЕГО ЭТО ПОНЯЛИ. 2026-08-26 `@tanstack/react-query: "latest"` разрешился в `5.102.5`, чей
|
|
12
|
+
* `query-core` в реестре не опубликован, и `bun install` перестал проходить в репозитории, где не
|
|
13
|
+
* меняли ничего. Починка была одна: дописать строку в `overrides`. Так и росла куча — симптом лечили,
|
|
14
|
+
* причину оставляли, и продукт наследовал кучу, в которой уже не отличить нужное от инерции.
|
|
15
|
+
*
|
|
16
|
+
* ПОЧЕМУ НЕ ПРОСТО «ЗАПРЕТИТЬ ЛЮБОЕ *». `peerDependencies` намеренно оставлены снаружи: там `"*"`
|
|
17
|
+
* значит «совместим с тем, что выберет приложение», и это не резолв, а объявление совместимости.
|
|
18
|
+
* Запрет там заставил бы пакет ядра диктовать продукту версию React — ровно то, чего швы избегают.
|
|
19
|
+
*
|
|
20
|
+
* ТОЧНО или КАРЕТКОЙ — решает роль пакета:
|
|
21
|
+
* ТОЧНО (`7.0.2`) компилятор и его типы. Их плавание меняет РЕЗУЛЬТАТ `bun run check`
|
|
22
|
+
* между машинами: тайпчек — это и есть проверка, и она не должна зависеть от
|
|
23
|
+
* дня установки.
|
|
24
|
+
* КАРЕТКОЙ (`^1.2.3`) остальное: патчи и миноры приезжают, мажор — решение человека.
|
|
25
|
+
*/
|
|
26
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
27
|
+
import { join, relative, resolve } from "node:path";
|
|
28
|
+
|
|
29
|
+
const ROOT = existsSync(join(process.cwd(), "package.json"))
|
|
30
|
+
? resolve(process.cwd())
|
|
31
|
+
: resolve(import.meta.dir, "..");
|
|
32
|
+
|
|
33
|
+
/** Определяют вердикт проверки — только точная версия. */
|
|
34
|
+
const ТОЧНО = new Set([
|
|
35
|
+
"typescript",
|
|
36
|
+
"@types/bun",
|
|
37
|
+
"@types/node",
|
|
38
|
+
"@types/react",
|
|
39
|
+
"@types/react-dom",
|
|
40
|
+
"oxlint",
|
|
41
|
+
"oxfmt",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const ПЛАВАЮЩИЕ = new Set(["*", "latest"]);
|
|
45
|
+
|
|
46
|
+
function манифесты(): string[] {
|
|
47
|
+
const out = [join(ROOT, "package.json")];
|
|
48
|
+
for (const каталог of ["packages", "apps"]) {
|
|
49
|
+
const корень = join(ROOT, каталог);
|
|
50
|
+
if (!existsSync(корень)) continue;
|
|
51
|
+
for (const e of readdirSync(корень, { withFileTypes: true })) {
|
|
52
|
+
if (!e.isDirectory()) continue;
|
|
53
|
+
const p = join(корень, e.name, "package.json");
|
|
54
|
+
if (existsSync(p)) out.push(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const плавающие: string[] = [];
|
|
61
|
+
const нестрогие: string[] = [];
|
|
62
|
+
|
|
63
|
+
const файлы = манифесты();
|
|
64
|
+
for (const путь of файлы) {
|
|
65
|
+
const pkg = JSON.parse(readFileSync(путь, "utf8"));
|
|
66
|
+
for (const секция of ["dependencies", "devDependencies"] as const) {
|
|
67
|
+
for (const [имя, версия] of Object.entries<string>(pkg[секция] ?? {})) {
|
|
68
|
+
// Воркспейс-ссылки — не версии реестра, их этот гейт не касается.
|
|
69
|
+
if (версия.startsWith("workspace:")) continue;
|
|
70
|
+
const где = `${relative(ROOT, путь)} → ${секция} → ${имя}`;
|
|
71
|
+
if (ПЛАВАЮЩИЕ.has(версия)) плавающие.push(`${где} = "${версия}"`);
|
|
72
|
+
else if (ТОЧНО.has(имя) && /^[\^~]/.test(версия))
|
|
73
|
+
нестрогие.push(`${где} = "${версия}" (нужна точная)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Пустой список файлов — это ПРОВАЛ, а не «нарушений нет».
|
|
80
|
+
* Гейт, которому нечего проверять, зелен ровно так же, как гейт, проверивший всё.
|
|
81
|
+
*/
|
|
82
|
+
if (файлы.length === 0) {
|
|
83
|
+
console.error("check:pins: не найдено ни одного package.json — проверять нечего.");
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (плавающие.length === 0 && нестрогие.length === 0) {
|
|
88
|
+
console.log(`pins: манифестов ${файлы.length} · плавающих версий нет`);
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (плавающие.length) {
|
|
93
|
+
console.error(`\n❌ плавающие версии (${плавающие.length}):`);
|
|
94
|
+
for (const s of плавающие) console.error(` ${s}`);
|
|
95
|
+
console.error(
|
|
96
|
+
"\n Замени на диапазон той версии, что стоит сейчас: она уже проверена этими уровнями.\n" +
|
|
97
|
+
" Посмотреть установленное: bun pm ls | grep <пакет>"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (нестрогие.length) {
|
|
101
|
+
console.error(`\n❌ каретка там, где нужна точная версия (${нестрогие.length}):`);
|
|
102
|
+
for (const s of нестрогие) console.error(` ${s}`);
|
|
103
|
+
console.error(
|
|
104
|
+
"\n Это компилятор и его типы: их версия меняет РЕЗУЛЬТАТ тайпчека, а не только поведение\n" +
|
|
105
|
+
" приложения. Проверка, зависящая от дня установки, — не проверка."
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
process.exit(1);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* `bun run check:priority` — объявления приоритета в спеке действительны.
|
|
4
|
+
*
|
|
5
|
+
* ─── ЗАЧЕМ ГЕЙТ, А НЕ ПРОСТО УМОЛЧАНИЕ ───────────────────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Приоритет — единственное поле спеки, которое МЕНЯЕТ ПОРЯДОК РАБОТ, и единственное, у которого
|
|
8
|
+
* ошибка выглядит как решение. `priority: heigh` при молчаливом умолчании даёт слайсу `normal`:
|
|
9
|
+
* владелец написал «первым», получил «как все», и ни одна строка вывода этого не скажет. Ровно тот
|
|
10
|
+
* класс лжи, против которого написаны остальные гейты этой репы, — только применённый к очереди,
|
|
11
|
+
* то есть к тому, ЧТО будет построено раньше.
|
|
12
|
+
*
|
|
13
|
+
* Поэтому недействительное объявление краснеет здесь, а `bun run next-slice` печатает его вслух.
|
|
14
|
+
* Два разных места: гейт останавливает, очередь показывает. Одно без другого либо ломает сборку
|
|
15
|
+
* без объяснения, либо объясняет, ничего не останавливая.
|
|
16
|
+
*
|
|
17
|
+
* ─── ЧЕГО ЗДЕСЬ НЕТ ──────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* Суждения о том, ВЕРЕН ли приоритет. «Поиску не место в high» — это разговор с владельцем, а не
|
|
20
|
+
* находка гейта; гейт, взявшийся судить о ценности, начинает ошибаться, и первое, что с ним
|
|
21
|
+
* делают, — отключают. Здесь проверяется только форма: значение из закрытого множества, причина
|
|
22
|
+
* названа, спутники не осиротели.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join, relative, resolve } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { parsePriority, PRIORITIES, type Priority } from "./lib/priority.ts";
|
|
28
|
+
|
|
29
|
+
/** Корень РЕПЫ, а не скрипта: скрипт живёт в плагине и запускается из чужого дерева. */
|
|
30
|
+
const ROOT = existsSync(join(process.cwd(), "package.json"))
|
|
31
|
+
? resolve(process.cwd())
|
|
32
|
+
: resolve(import.meta.dir, "..");
|
|
33
|
+
|
|
34
|
+
export interface Находка {
|
|
35
|
+
path: string;
|
|
36
|
+
error: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Свод {
|
|
40
|
+
находки: Находка[];
|
|
41
|
+
/** Сколько слайсов в каждой полосе (недействительные считаются по действующему значению). */
|
|
42
|
+
полосы: Record<Priority, number>;
|
|
43
|
+
/** Слайсов без объявления. Работают как `normal`, но решения за ними нет. */
|
|
44
|
+
безОбъявления: number;
|
|
45
|
+
всего: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Проверка НАД СПИСКОМ, а не над каталогом.
|
|
50
|
+
*
|
|
51
|
+
* Так она проверяема тестом без временных файлов: обход диска — это ввод, а не правило.
|
|
52
|
+
*/
|
|
53
|
+
export function проверить(файлы: { path: string; source: string }[]): Свод {
|
|
54
|
+
const свод: Свод = {
|
|
55
|
+
находки: [],
|
|
56
|
+
полосы: { high: 0, normal: 0, low: 0 },
|
|
57
|
+
безОбъявления: 0,
|
|
58
|
+
всего: файлы.length,
|
|
59
|
+
};
|
|
60
|
+
for (const f of файлы) {
|
|
61
|
+
const p = parsePriority(f.source);
|
|
62
|
+
свод.полосы[p.value]++;
|
|
63
|
+
if (p.error) свод.находки.push({ path: f.path, error: p.error });
|
|
64
|
+
else if (!p.declared) свод.безОбъявления++;
|
|
65
|
+
}
|
|
66
|
+
return свод;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Файлы слайсов — те, что несут `feature:` в шапке.
|
|
71
|
+
*
|
|
72
|
+
* Тем же признаком их находит `bun run ac` и `bun run next-slice`: сгенерированные своды
|
|
73
|
+
* (`index.md`, `catalogue.md`) его не несут, и это единственное, чем файл модуля отличается от
|
|
74
|
+
* указателя на модули.
|
|
75
|
+
*/
|
|
76
|
+
function собратьФайлы(dir: string): { path: string; source: string }[] {
|
|
77
|
+
if (!existsSync(dir)) return [];
|
|
78
|
+
const out: { path: string; source: string }[] = [];
|
|
79
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
80
|
+
const full = join(dir, e.name);
|
|
81
|
+
if (e.isDirectory()) out.push(...собратьФайлы(full));
|
|
82
|
+
else if (e.name.endsWith(".md")) {
|
|
83
|
+
const source = readFileSync(full, "utf8");
|
|
84
|
+
if (/^feature:\s*\S/m.test(source)) out.push({ path: full, source });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (import.meta.main) {
|
|
91
|
+
const usecases = join(ROOT, "docs/usecases");
|
|
92
|
+
const файлы = собратьФайлы(usecases);
|
|
93
|
+
if (файлы.length === 0) {
|
|
94
|
+
// Пустой каталог — не «нарушений нет». Тот же отказ, что у `ac`: зелёный на пустоте.
|
|
95
|
+
console.error(
|
|
96
|
+
`check:priority: в ${relative(ROOT, usecases)} нет ни одного файла слайса с \`feature:\` в шапке.\n` +
|
|
97
|
+
"Проверять нечего — а зелёный прогон на пустоте неотличим от пройденной проверки."
|
|
98
|
+
);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const свод = проверить(файлы);
|
|
102
|
+
|
|
103
|
+
for (const н of свод.находки) {
|
|
104
|
+
console.log(`НЕДЕЙСТВИТЕЛЬНО: ${relative(ROOT, н.path)}\n ${н.error}`);
|
|
105
|
+
}
|
|
106
|
+
const полосы = PRIORITIES.map((p) => `${p} ${свод.полосы[p]}`).join(" · ");
|
|
107
|
+
console.log(
|
|
108
|
+
`priority: слайсов ${свод.всего} · ${полосы} · без объявления ${свод.безОбъявления} · ` +
|
|
109
|
+
`недействительных ${свод.находки.length}`
|
|
110
|
+
);
|
|
111
|
+
if (свод.находки.length) {
|
|
112
|
+
console.error(
|
|
113
|
+
`\nПриоритет объявляется в шапке файла слайса и ТРЕБУЕТ причины:\n` +
|
|
114
|
+
` priority: high # ${PRIORITIES.join(" | ")}\n` +
|
|
115
|
+
` priority-by: owner # owner (не сбрасывается перегенерацией) | derived\n` +
|
|
116
|
+
` priority-why: "основной путь продукта: REQ-MCP-01"\n`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
process.exit(свод.находки.length ? 1 : 0);
|
|
120
|
+
}
|