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,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun run next-slice` — что брать следующим и почему.
|
|
3
|
+
*
|
|
4
|
+
* Очередь работы существовала и раньше, но в двух местах и в разных единицах: долг вёрстки — в
|
|
5
|
+
* `scripts/prototype-boundary.allow`, покрытие критериев — в `docs/usecases/**`. Чтобы ответить
|
|
6
|
+
* «какой слайс следующий», приходилось складывать их в голове, а сложенное в голове держится ровно
|
|
7
|
+
* до следующей сессии. Здесь они сведены и напечатаны одним ответом.
|
|
8
|
+
*
|
|
9
|
+
* Скрипт НИЧЕГО не решает за человека: он печатает три структурных факта на слайс и правило, по
|
|
10
|
+
* которому отсортировал. Правило видно в выводе, а не спрятано в коде, — иначе порядок работ
|
|
11
|
+
* становится магией, которую никто не оспаривает.
|
|
12
|
+
*
|
|
13
|
+
* Читаемые факты (все — из репозитория, ни одного из отчёта прошлого прогона):
|
|
14
|
+
* • критерии: сколько `- [x]` из скольких `- [ ]`/`- [x]` в `docs/usecases/<пакет>/<слайс>.md`;
|
|
15
|
+
* галочки ставит `bun run ac` по результатам тестов, руками их не ставят;
|
|
16
|
+
* • долг вёрстки: сколько файлов слайса числятся в `prototype-boundary.allow`;
|
|
17
|
+
* • порт: существует ли `features/<слайс>/db/store.ts` — то есть заведена ли персистентность;
|
|
18
|
+
* • провод: сколько пишущих эндпоинтов слайса числятся долгом в `scripts/write-path.allow` —
|
|
19
|
+
* то есть сколько действий экрана до сервера не доходят.
|
|
20
|
+
*/
|
|
21
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { comparePriorityThenPhase, parsePriority, type Priority } from "./lib/priority.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Корень РЕПЫ, а не скрипта.
|
|
28
|
+
*
|
|
29
|
+
* Скрипт живёт в плагине и запускается из репы (`bun run "$CLAUDE_PLUGIN_ROOT/assets/scripts/…"`),
|
|
30
|
+
* поэтому свой собственный путь корнем быть не может: он указал бы внутрь плагина, гейт обошёл бы
|
|
31
|
+
* чужое дерево и объявил «нарушений нет» — зелёный на пустоте. Рабочий каталог задаёт `bun run`,
|
|
32
|
+
* и он всегда корень пакета; путь скрипта остаётся запасным для случая, когда файл всё-таки
|
|
33
|
+
* скопирован в репу.
|
|
34
|
+
*/
|
|
35
|
+
const ROOT = existsSync(join(process.cwd(), "package.json"))
|
|
36
|
+
? resolve(process.cwd())
|
|
37
|
+
: resolve(import.meta.dir, "..");
|
|
38
|
+
const USECASES = join(ROOT, "docs/usecases");
|
|
39
|
+
const ALLOW = join(ROOT, "scripts/prototype-boundary.allow");
|
|
40
|
+
const CONFLICTS = join(ROOT, "scripts/docs-conflicts.open");
|
|
41
|
+
const WRITE_PATH = join(ROOT, "scripts/write-path.allow");
|
|
42
|
+
const PROTO_IDS = join(ROOT, "scripts/proto-ids.allow");
|
|
43
|
+
const PROTO_VALUES = join(ROOT, "scripts/proto-values.allow");
|
|
44
|
+
const WIRING = join(ROOT, "scripts/screen-wiring.allow");
|
|
45
|
+
/**
|
|
46
|
+
* Критерии, ждущие ЧУЖОГО слайса. Формат строки: `AC-XXX-01.2 <пакет>/<слайс> причина`.
|
|
47
|
+
*
|
|
48
|
+
* Заведено потому, что очередь трижды подряд называла один и тот же слайс: его остаток нельзя
|
|
49
|
+
* закрыть кодом ЭТОГО слайса — критерий требует поиска, которого ещё нет, или отсутствия
|
|
50
|
+
* транспортной ветвистости там, где кода вовсе нет. `/build` брал названный слайс, упирался и брал
|
|
51
|
+
* его снова: работа есть, сделать её нельзя, отметка `BUILD_BLOCKED` при этом была бы ложью — работа
|
|
52
|
+
* закрывается, просто не сейчас.
|
|
53
|
+
*
|
|
54
|
+
* Это НЕ разрешение и не «известная особенность»: строка называет, ЧЕГО ждут, и исчезает, когда
|
|
55
|
+
* ожидаемый слайс построен. Список только сокращается.
|
|
56
|
+
*/
|
|
57
|
+
const AC_BLOCKED = join(ROOT, "scripts/ac-blocked.open");
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Реестр ДОЛГОВ — сознательно не сделанное (`scripts/debt.open`, гейт `check:debt`).
|
|
61
|
+
*
|
|
62
|
+
* Очередь его не исполняет и не сортирует по нему: долг не работа слайса, а решение не делать.
|
|
63
|
+
* Она его СЧИТАЕТ и печатает, потому что очередь — то единственное место, куда смотрят каждый цикл.
|
|
64
|
+
* Долг, видимый только в отдельной команде, которую никто не запускает, равен отсутствующему.
|
|
65
|
+
*/
|
|
66
|
+
const DEBT = join(ROOT, "scripts/debt.open");
|
|
67
|
+
|
|
68
|
+
/** Ожидания по критериям: `AC-ID` → слайс, которого он ждёт. */
|
|
69
|
+
const blockedBy = new Map<string, string>();
|
|
70
|
+
if (existsSync(AC_BLOCKED)) {
|
|
71
|
+
for (const raw of readFileSync(AC_BLOCKED, "utf8").split("\n")) {
|
|
72
|
+
const line = raw.split("#")[0]!.trim();
|
|
73
|
+
if (!line) continue;
|
|
74
|
+
const [id, slice] = line.split(/\s+/);
|
|
75
|
+
if (id && slice) blockedBy.set(id, slice);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const долгов = existsSync(DEBT)
|
|
80
|
+
? readFileSync(DEBT, "utf8")
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter((l) => /^\s*DEBT-\d/.test(l.split("#")[0] ?? "")).length
|
|
83
|
+
: 0;
|
|
84
|
+
|
|
85
|
+
type Slice = {
|
|
86
|
+
pkg: string;
|
|
87
|
+
slice: string;
|
|
88
|
+
order: string;
|
|
89
|
+
title: string;
|
|
90
|
+
/** Полоса работ: `high` идёт раньше `normal`, `normal` раньше `low`. См. `lib/priority.ts`. */
|
|
91
|
+
priority: Priority;
|
|
92
|
+
/** Объявлен ли приоритет вслух. Не объявлено ≠ normal: normal — решение, отсутствие — его нет. */
|
|
93
|
+
prioDeclared: boolean;
|
|
94
|
+
/** Кто поставил: `owner` перегенерацией спеки не сбрасывается. */
|
|
95
|
+
prioBy: "owner" | "derived";
|
|
96
|
+
/** Основание приоритета: ID требования либо решение владельца. Обязательно при объявлении. */
|
|
97
|
+
prioWhy: string | null;
|
|
98
|
+
/** Недействительное объявление. Молча умолчанием НЕ становится — печатается вслух. */
|
|
99
|
+
prioError: string | null;
|
|
100
|
+
total: number;
|
|
101
|
+
covered: number;
|
|
102
|
+
/** Критериев, ждущих чужого слайса. Работой ЭТОГО слайса они не являются. */
|
|
103
|
+
waiting: number;
|
|
104
|
+
/** Чего именно ждут — списком слайсов, чтобы это было видно, а не выводилось. */
|
|
105
|
+
waitingFor: string[];
|
|
106
|
+
debt: number;
|
|
107
|
+
/** Пишущие эндпоинты слайса, до которых экран не дотягивается. */
|
|
108
|
+
wire: number;
|
|
109
|
+
/** Адресов экрана, для которых приложение не умеет собрать настоящий ключ (`check:proto-ids`). */
|
|
110
|
+
addr: number;
|
|
111
|
+
/** Значений прототипа, которые экран ПЕЧАТАЕТ: счётчик из вёрстки, ключ в подписи (`check:proto-values`). */
|
|
112
|
+
vals: number;
|
|
113
|
+
/** Обрывы проводки: мёртвый проп, дубль хваталки, чтение без вызывающего. */
|
|
114
|
+
wiring: number;
|
|
115
|
+
hasPort: boolean;
|
|
116
|
+
/** Открытые противоречия между артефактами: пока они есть, слайс в работу не берут. */
|
|
117
|
+
conflicts: string[];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Слайс, которому принадлежит файл долга: `packages/<пакет>/src/features/<слайс>/…`. */
|
|
121
|
+
const sliceOfPath = (p: string): string | null => {
|
|
122
|
+
const m = /^packages\/([^/]+)\/src\/features\/([^/]+)\//.exec(p);
|
|
123
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Пишущие эндпоинты без вызывающего — по слайсам.
|
|
128
|
+
*
|
|
129
|
+
* Слайс определяется ПРЕФИКСОМ пути, а он объявлен в `routes.ts` каждой группы. Читаем префиксы
|
|
130
|
+
* оттуда, а не выводим из имени слайса: `roles` держит и `/roles`, и `/domains`, а
|
|
131
|
+
* `governance-history` — `/governance-changes` и `/proposals`. Совпадение ищем по самому длинному
|
|
132
|
+
* префиксу, иначе `/role-links` уедет в `/roles`.
|
|
133
|
+
*/
|
|
134
|
+
const wireBySlice = new Map<string, number>();
|
|
135
|
+
if (existsSync(WRITE_PATH)) {
|
|
136
|
+
const prefixes: Array<{ prefix: string; slice: string }> = [];
|
|
137
|
+
const scan = (dir: string) => {
|
|
138
|
+
if (!existsSync(dir)) return;
|
|
139
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
140
|
+
const full = join(dir, e.name);
|
|
141
|
+
if (e.isDirectory()) {
|
|
142
|
+
if (["node_modules", "dist", "build", ".turbo"].includes(e.name)) continue;
|
|
143
|
+
scan(full);
|
|
144
|
+
} else if (e.name === "routes.ts") {
|
|
145
|
+
const m = /packages\/([^/]+)\/src\/features\/([^/]+)\/server\/routes\.ts$/.exec(
|
|
146
|
+
full.split("\\").join("/")
|
|
147
|
+
);
|
|
148
|
+
if (!m) continue;
|
|
149
|
+
for (const p of readFileSync(full, "utf8").matchAll(
|
|
150
|
+
/new Elysia\(\{\s*prefix:\s*"([^"]*)"/g
|
|
151
|
+
))
|
|
152
|
+
prefixes.push({ prefix: p[1]!, slice: `${m[1]}/${m[2]}` });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
scan(join(ROOT, "packages"));
|
|
157
|
+
prefixes.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
158
|
+
for (const raw of readFileSync(WRITE_PATH, "utf8").split("\n")) {
|
|
159
|
+
const line = raw.split("#")[0]!.trim();
|
|
160
|
+
if (!line) continue;
|
|
161
|
+
const path = line.split(/\s+/)[1] ?? "";
|
|
162
|
+
const hit = prefixes.find(
|
|
163
|
+
(p) => p.prefix && (path === p.prefix || path.startsWith(`${p.prefix}/`))
|
|
164
|
+
);
|
|
165
|
+
if (hit) wireBySlice.set(hit.slice, (wireBySlice.get(hit.slice) ?? 0) + 1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Адреса без настоящего ключа — по слайсам.
|
|
171
|
+
*
|
|
172
|
+
* Файл долга (`scripts/proto-ids.allow`) перечисляет ФАЙЛЫ экранов, а путь экрана уже несёт и пакет,
|
|
173
|
+
* и слайс: `packages/<пакет>/src/features/<слайс>/ui/…`. Клиент приложения (каталог `client`
|
|
174
|
+
* внутри `apps`) слайсу не принадлежит — его строки считаются общими и в очередь не уезжают: слайс
|
|
175
|
+
* за оболочку не отвечает, и повесить её долг на первый попавшийся слайс значило бы отправить
|
|
176
|
+
* работу не туда.
|
|
177
|
+
*/
|
|
178
|
+
const addrBySlice = new Map<string, number>();
|
|
179
|
+
if (existsSync(PROTO_IDS)) {
|
|
180
|
+
for (const raw of readFileSync(PROTO_IDS, "utf8").split("\n")) {
|
|
181
|
+
const line = raw.split("#")[0]!.trim();
|
|
182
|
+
if (!line) continue;
|
|
183
|
+
const at = line.lastIndexOf(" ");
|
|
184
|
+
const file = line.slice(0, at);
|
|
185
|
+
const n = Number(line.slice(at + 1));
|
|
186
|
+
// Разбор СЕГМЕНТАМИ, а не регуляркой: путь и так разложен слэшами, а выражение, кончающееся
|
|
187
|
+
// на экранированный слэш перед закрывающим, читается парсером неоднозначно.
|
|
188
|
+
const seg = file.split("/");
|
|
189
|
+
const ok = seg[0] === "packages" && seg[2] === "src" && seg[3] === "features" && seg.length > 5;
|
|
190
|
+
if (!ok || !Number.isFinite(n)) continue;
|
|
191
|
+
const key = seg[1] + "/" + seg[4];
|
|
192
|
+
addrBySlice.set(key, (addrBySlice.get(key) ?? 0) + n);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Значения прототипа — по слайсам, тем же разбором пути, что у адресов.
|
|
198
|
+
*
|
|
199
|
+
* Считается это РАБОТОЙ по той же причине, по которой ею считается провод: за напечатанным числом
|
|
200
|
+
* прототипа нет критерия. Критерии доказываются серверными тестами, поэтому «Найдено 22 из 9» на
|
|
201
|
+
* экране совместимо с покрытием 100% — и очередь, не видя этого числа, объявила бы пустоту
|
|
202
|
+
* приложению, которое врёт глазами.
|
|
203
|
+
*/
|
|
204
|
+
const valsBySlice = new Map<string, number>();
|
|
205
|
+
if (existsSync(PROTO_VALUES)) {
|
|
206
|
+
for (const raw of readFileSync(PROTO_VALUES, "utf8").split("\n")) {
|
|
207
|
+
const line = raw.split("#")[0]!.trim();
|
|
208
|
+
if (!line) continue;
|
|
209
|
+
const at = line.lastIndexOf(" ");
|
|
210
|
+
const file = line.slice(0, at);
|
|
211
|
+
const n = Number(line.slice(at + 1));
|
|
212
|
+
const seg = file.split("/");
|
|
213
|
+
const ok = seg[0] === "packages" && seg[2] === "src" && seg[3] === "features" && seg.length > 5;
|
|
214
|
+
if (!ok || !Number.isFinite(n)) continue;
|
|
215
|
+
const key = seg[1] + "/" + seg[4];
|
|
216
|
+
valsBySlice.set(key, (valsBySlice.get(key) ?? 0) + n);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Долг ПРОВОДКИ — по слайсам, тем же разбором пути, что у значений.
|
|
222
|
+
*
|
|
223
|
+
* Считается работой по той же причине, что и провод записи: за обрывом НЕТ критерия. Экран
|
|
224
|
+
* выгрузки показывает «Выгрузка сформирована» и файла не создаёт, а `AC-EXP-03.*` при этом
|
|
225
|
+
* зелёные — они доказаны серверными тестами. Измерить и не гнать — это не «почти сделано»,
|
|
226
|
+
* это счёт, который никто не закрывает.
|
|
227
|
+
*/
|
|
228
|
+
const wiringBySlice = new Map<string, number>();
|
|
229
|
+
if (existsSync(WIRING)) {
|
|
230
|
+
for (const raw of readFileSync(WIRING, "utf8").split("\n")) {
|
|
231
|
+
const line = raw.split("#")[0]!.trim();
|
|
232
|
+
if (!line) continue;
|
|
233
|
+
const at = line.lastIndexOf(" ");
|
|
234
|
+
const file = line.slice(0, at);
|
|
235
|
+
const n = Number(line.slice(at + 1));
|
|
236
|
+
const seg = file.split("/");
|
|
237
|
+
const ok = seg[0] === "packages" && seg[2] === "src" && seg[3] === "features" && seg.length > 5;
|
|
238
|
+
if (!ok || !Number.isFinite(n)) continue;
|
|
239
|
+
const key = seg[1] + "/" + seg[4];
|
|
240
|
+
wiringBySlice.set(key, (wiringBySlice.get(key) ?? 0) + n);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const debtBySlice = new Map<string, number>();
|
|
245
|
+
/** Файлы долга уровня ПАКЕТА (composition): работа есть, но она не принадлежит одному срезу. */
|
|
246
|
+
let sharedDebt = 0;
|
|
247
|
+
if (existsSync(ALLOW)) {
|
|
248
|
+
for (const raw of readFileSync(ALLOW, "utf8").split("\n")) {
|
|
249
|
+
const line = raw.split("#")[0]!.trim();
|
|
250
|
+
if (!line) continue;
|
|
251
|
+
const key = sliceOfPath(line);
|
|
252
|
+
// Файл уровня пакета (composition) слайсу не принадлежит — он общий, и приписать его одному
|
|
253
|
+
// срезу значило бы обещать, что этот срез его и погасит.
|
|
254
|
+
if (key) debtBySlice.set(key, (debtBySlice.get(key) ?? 0) + 1);
|
|
255
|
+
else sharedDebt++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Конфликт → слайс, которого он касается.
|
|
261
|
+
*
|
|
262
|
+
* Адрес слайса берётся из ПУТИ артефакта: `docs/{usecases/<пакет>,api,ui}/<слайс>.md`. Имя слайса
|
|
263
|
+
* уникально по всему каталогу, поэтому пакета в пути `api`/`ui` не требуется. Файлы, не
|
|
264
|
+
* принадлежащие слайсу (`docs/engineering`, `docs/architecture`, `docs/seeds.md`), не приписываются
|
|
265
|
+
* никому: приписать их одному срезу значило бы обещать, что этот срез их и закроет.
|
|
266
|
+
*/
|
|
267
|
+
const conflictsBySlice = new Map<string, string[]>();
|
|
268
|
+
let unassignedConflicts = 0;
|
|
269
|
+
if (existsSync(CONFLICTS)) {
|
|
270
|
+
for (const raw of readFileSync(CONFLICTS, "utf8").split("\n")) {
|
|
271
|
+
const line = raw.split("#")[0]!.trim();
|
|
272
|
+
if (!line) continue;
|
|
273
|
+
const file = line.split("|")[0]!.trim();
|
|
274
|
+
const m = /^docs\/(?:usecases\/[^/]+|api|ui)\/([^/]+)\.md$/.exec(file);
|
|
275
|
+
if (!m) {
|
|
276
|
+
unassignedConflicts++;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const slice = m[1]!;
|
|
280
|
+
conflictsBySlice.set(slice, [...(conflictsBySlice.get(slice) ?? []), line]);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Каталога use cases может не быть вовсе — так выглядит репа сразу после `setup-tooling`, до
|
|
286
|
+
* первого слайса. Без этой проверки `readdirSync` бросал сырой ENOENT со стеком: человек читал
|
|
287
|
+
* «scandir failed» и шёл чинить bun или права на каталог, хотя чинить нечего — работы просто ещё
|
|
288
|
+
* нет. Отказ должен называть причину, иначе он хуже молчания. (`check-priority` так и делает.)
|
|
289
|
+
*/
|
|
290
|
+
if (!existsSync(USECASES)) {
|
|
291
|
+
console.error(
|
|
292
|
+
`next-slice: нет каталога ${USECASES} — use cases ещё не заведены.\n` +
|
|
293
|
+
"Очередь работ строится ИЗ НИХ, поэтому назвать следующий слайс не из чего.\n" +
|
|
294
|
+
"Заведите первый: docs/usecases/<пакет>/<слайс>.md с `feature:` в шапке."
|
|
295
|
+
);
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const slices: Slice[] = [];
|
|
300
|
+
for (const pkg of readdirSync(USECASES, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
|
301
|
+
for (const file of readdirSync(join(USECASES, pkg.name)).filter((f) => f.endsWith(".md"))) {
|
|
302
|
+
const path = join(USECASES, pkg.name, file);
|
|
303
|
+
const text = readFileSync(path, "utf8");
|
|
304
|
+
// Индекс каталога не несёт `feature:` — так он и отличается от файла модуля.
|
|
305
|
+
const order = /^feature:\s*(.+)$/m.exec(text)?.[1]?.trim();
|
|
306
|
+
if (!order) continue;
|
|
307
|
+
|
|
308
|
+
const slice = file.replace(/\.md$/, "");
|
|
309
|
+
const criteria = [...text.matchAll(/^- \[( |x)\] `(AC-[A-Z]+-\d+\.\d+)`/gm)];
|
|
310
|
+
const открытые = criteria.filter((m) => m[1] !== "x").map((m) => m[2]!);
|
|
311
|
+
const ждут = открытые.filter((id) => blockedBy.has(id));
|
|
312
|
+
const prio = parsePriority(text);
|
|
313
|
+
slices.push({
|
|
314
|
+
pkg: pkg.name,
|
|
315
|
+
slice,
|
|
316
|
+
order,
|
|
317
|
+
priority: prio.value,
|
|
318
|
+
prioDeclared: prio.declared,
|
|
319
|
+
prioBy: prio.by,
|
|
320
|
+
prioWhy: prio.why,
|
|
321
|
+
prioError: prio.error,
|
|
322
|
+
title: /^title:\s*"?(.+?)"?$/m.exec(text)?.[1]?.trim() ?? slice,
|
|
323
|
+
total: criteria.length,
|
|
324
|
+
covered: criteria.filter((m) => m[1] === "x").length,
|
|
325
|
+
waiting: ждут.length,
|
|
326
|
+
waitingFor: [...new Set(ждут.map((id) => blockedBy.get(id)!))].sort(),
|
|
327
|
+
debt: debtBySlice.get(`${pkg.name}/${slice}`) ?? 0,
|
|
328
|
+
wire: wireBySlice.get(`${pkg.name}/${slice}`) ?? 0,
|
|
329
|
+
addr: addrBySlice.get(`${pkg.name}/${slice}`) ?? 0,
|
|
330
|
+
vals: valsBySlice.get(`${pkg.name}/${slice}`) ?? 0,
|
|
331
|
+
wiring: wiringBySlice.get(`${pkg.name}/${slice}`) ?? 0,
|
|
332
|
+
hasPort: existsSync(join(ROOT, `packages/${pkg.name}/src/features/${slice}/db/store.ts`)),
|
|
333
|
+
conflicts: conflictsBySlice.get(slice) ?? [],
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Порядок работ — ОБЪЯВЛЕННЫЙ, а не выведенный здесь.
|
|
340
|
+
*
|
|
341
|
+
* Работа есть у слайса, чьи критерии доказаны не все: `covered < total`. Это единственный честный
|
|
342
|
+
* признак — «нет порта» им не является (у `access` данные производные, порт ему не нужен вовсе), и
|
|
343
|
+
* «есть долг вёрстки» тоже: долг говорит, КАКАЯ работа, а не есть ли она.
|
|
344
|
+
*
|
|
345
|
+
* Очерёдность берётся из `feature: Ф<N>` спеки. Это порядок аналитики, и он же порядок
|
|
346
|
+
* зависимостей: роли живут внутри кругов (`roles.circle_id → circles.id`), назначения — внутри
|
|
347
|
+
* ролей. Сортировать по числу критериев, как я сделал сперва, значит поставить `roles` (40) перед
|
|
348
|
+
* `circles` (29) — то есть строить этаж раньше несущей стены.
|
|
349
|
+
*/
|
|
350
|
+
const phaseNo = (s: Slice) => Number(/\d+/.exec(s.order)?.[0] ?? 999);
|
|
351
|
+
/**
|
|
352
|
+
* Работа есть у слайса, чьи критерии доказаны не все — ЛИБО чей экран не дотягивается до сервера.
|
|
353
|
+
*
|
|
354
|
+
* Второе добавлено 2026-08-20 и отличается от долга вёрстки принципиально. Долг вёрстки говорит,
|
|
355
|
+
* КАКАЯ работа, а не есть ли она: слайс с прототипной фикстурой может иметь все критерии
|
|
356
|
+
* доказанными. Неподключённый пишущий эндпоинт — другое: за ним НЕТ критерия вовсе, потому что
|
|
357
|
+
* критерии доказываются серверными тестами, и покрытие 100% при неработающей форме — законное
|
|
358
|
+
* состояние отчёта. Не считая провод работой, очередь объявляла бы пустоту приложению, в котором
|
|
359
|
+
* ничего нельзя сохранить.
|
|
360
|
+
*/
|
|
361
|
+
/**
|
|
362
|
+
* Работа есть у слайса, у которого не доказаны критерии, ЛИБО экран не доходит до сервера, ЛИБО
|
|
363
|
+
* экран адресует записи выдумкой.
|
|
364
|
+
*
|
|
365
|
+
* Третье слагаемое появилось не из симметрии. Репа была: покрытие 320/322, провод разведён, очередь
|
|
366
|
+
* называла три слайса — и приложение не работало, потому что каждая вторая ссылка вела на
|
|
367
|
+
* `/circles/c-mkt`, которого в базе нет. Долг был ИЗМЕРЕН гейтом и не был РАБОТОЙ: очередь его не
|
|
368
|
+
* видела, `/build` по нему не гнал, а `BUILD_COMPLETE` при таком состоянии — ложь о готовности.
|
|
369
|
+
* Измерить и не гнать — это не «почти сделано», это счёт, который никто не закрывает.
|
|
370
|
+
*/
|
|
371
|
+
/*
|
|
372
|
+
Четвёртое слагаемое — ПРОВОДКА, и оно добавлено по той же причине, что третье.
|
|
373
|
+
|
|
374
|
+
Репа была: покрытие 322/322, провод записи разведён, значения сняты — очередь называла работу
|
|
375
|
+
только там, где не хватало критериев. И при этом кнопка «Выгрузить» показывала «Выгрузка
|
|
376
|
+
сформирована», не создавая файла: `GET /export` не звал никто. Гейт это ИЗМЕРИЛ и не сделал
|
|
377
|
+
РАБОТОЙ — очередь долга не видела, `/build` по нему не гнал, а `BUILD_COMPLETE` при таком
|
|
378
|
+
состоянии — ложь о готовности.
|
|
379
|
+
*/
|
|
380
|
+
/*
|
|
381
|
+
Пятое уточнение — ОЖИДАНИЕ, и оно не добавляет работы, а вычитает её.
|
|
382
|
+
|
|
383
|
+
Критерий, ждущий чужого слайса, работой ЭТОГО слайса не является: закрыть его здешним кодом
|
|
384
|
+
нельзя. Без вычитания очередь называла один и тот же слайс трижды подряд — `/build` брал его,
|
|
385
|
+
упирался и брал снова, а `BUILD_BLOCKED` при этом был бы ложью: работа закрывается, просто не
|
|
386
|
+
сейчас и не здесь.
|
|
387
|
+
|
|
388
|
+
Ожидание ОБЪЯВЛЕНО (`scripts/ac-blocked.open`) и печатается числом: невидимое ожидание
|
|
389
|
+
превратило бы «сделано» и «отложено» в одно состояние — ровно то, против чего заведён весь
|
|
390
|
+
остальной счёт.
|
|
391
|
+
*/
|
|
392
|
+
const hasWork = (s: Slice) =>
|
|
393
|
+
s.covered + s.waiting < s.total || s.wire > 0 || s.addr > 0 || s.vals > 0 || s.wiring > 0 ? 1 : 0;
|
|
394
|
+
/**
|
|
395
|
+
* Слайс с открытым конфликтом НЕ называется следующим.
|
|
396
|
+
*
|
|
397
|
+
* Он не исчезает из таблицы — исчезнувшая работа выглядит сделанной, — но уходит вниз и помечен.
|
|
398
|
+
* Разработчик, взявший такой слайс, встретит противоречие посреди работы и придёт с вопросом; ровно
|
|
399
|
+
* это и должно решаться раньше, на аналитическом этапе, а не догадкой в коде.
|
|
400
|
+
*/
|
|
401
|
+
const ready = (s: Slice) => (s.conflicts.length === 0 ? 1 : 0);
|
|
402
|
+
/**
|
|
403
|
+
* ПРИОРИТЕТ стоит МЕЖДУ готовностью и номером спеки, и это его точное место.
|
|
404
|
+
*
|
|
405
|
+
* Выше него — только то, есть ли работа вообще и не заблокирована ли она: слайс без работы
|
|
406
|
+
* приоритетом не оживает, а слайс с открытым противоречием приоритетом не разблокируется. Ниже —
|
|
407
|
+
* объявленный порядок, и он действует ЦЕЛИКОМ внутри полосы: приоритет двигает полосы, а не
|
|
408
|
+
* отдельные слайсы, иначе он ломал бы зависимости внутри полосы, ради которых номер и заведён.
|
|
409
|
+
*
|
|
410
|
+
* Правило разбора и обратный ход к нему — `lib/priority.ts`.
|
|
411
|
+
*/
|
|
412
|
+
const ranked = [...slices].sort(
|
|
413
|
+
(a, b) =>
|
|
414
|
+
hasWork(b) - hasWork(a) ||
|
|
415
|
+
ready(b) - ready(a) ||
|
|
416
|
+
comparePriorityThenPhase(
|
|
417
|
+
{ priority: a.priority, phase: phaseNo(a) },
|
|
418
|
+
{ priority: b.priority, phase: phaseNo(b) }
|
|
419
|
+
)
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const bar = (s: Slice) => (s.total === 0 ? "—" : `${s.covered}/${s.total}`);
|
|
423
|
+
const pct = (s: Slice) => (s.total === 0 ? 0 : Math.round((s.covered / s.total) * 100));
|
|
424
|
+
|
|
425
|
+
console.log(`\n=== ОЧЕРЕДЬ СЛАЙСОВ ===`);
|
|
426
|
+
console.log(
|
|
427
|
+
`Правило: работа есть там, где доказаны не все критерии, ЛИБО экран не доходит до сервера,
|
|
428
|
+
ЛИБО экран адресует записи выдумкой, ЛИБО экран ПЕЧАТАЕТ значения прототипа,
|
|
429
|
+
ЛИБО у экрана ОБОРВАНА ПРОВОДКА: мёртвый проп, дубль хваталки, чтение без вызывающего.`
|
|
430
|
+
);
|
|
431
|
+
console.log(
|
|
432
|
+
`Порядок: ПРИОРИТЕТ задаёт полосу (high → normal → low), а внутри полосы очерёдность —
|
|
433
|
+
объявленная спекой (feature: Ф<N>), она же порядок зависимостей. Галочки ставит bun run ac.
|
|
434
|
+
Приоритет объявляется в шапке файла слайса: priority + priority-why (+ priority-by: owner).\n`
|
|
435
|
+
);
|
|
436
|
+
console.log(
|
|
437
|
+
` ${"прио".padEnd(7)} ${"Ф".padEnd(4)} ${"слайс".padEnd(30)} ${"крит.".padEnd(8)} ${"ждут".padEnd(5)} ${"%".padEnd(5)} ${"провод".padEnd(7)} ${"адрес".padEnd(7)} ${"значения".padEnd(9)} ${"проводка".padEnd(9)} ${"порт".padEnd(6)} конфликты`
|
|
438
|
+
);
|
|
439
|
+
for (const s of ranked) {
|
|
440
|
+
const name = `${s.pkg}/${s.slice}`;
|
|
441
|
+
/** Не объявленный приоритет помечен точкой: «обычный по умолчанию» ≠ «решено, что обычный». */
|
|
442
|
+
const prio = s.prioError
|
|
443
|
+
? "⛔СБОЙ"
|
|
444
|
+
: `${s.priority}${s.prioDeclared ? (s.prioBy === "owner" ? "*" : "") : "·"}`;
|
|
445
|
+
console.log(
|
|
446
|
+
` ${prio.padEnd(7)} ${s.order.padEnd(4)} ${name.padEnd(30)} ${bar(s).padEnd(8)} ${(s.waiting || "—").toString().padEnd(5)} ${String(pct(s)).padEnd(5)} ` +
|
|
447
|
+
`${(s.wire || "—").toString().padEnd(7)} ${(s.addr || "—").toString().padEnd(7)} ${(s.vals || "—").toString().padEnd(9)} ${(s.wiring || "—").toString().padEnd(9)} ${(s.hasPort ? "есть" : "нет").padEnd(6)} ` +
|
|
448
|
+
`${s.conflicts.length ? `⛔ ${s.conflicts.length}` : "—"}`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const next = ranked[0];
|
|
453
|
+
if (next) {
|
|
454
|
+
console.log(`\n=== СЛЕДУЮЩИЙ: ${next.pkg}/${next.slice} ===`);
|
|
455
|
+
console.log(` ${next.title}`);
|
|
456
|
+
console.log(
|
|
457
|
+
` критериев ${next.total}, доказано ${next.covered}` +
|
|
458
|
+
(next.waiting ? `, ждут чужого слайса ${next.waiting} (${next.waitingFor.join(", ")})` : "")
|
|
459
|
+
);
|
|
460
|
+
/**
|
|
461
|
+
* ПОЧЕМУ именно он — строкой, а не выводом из таблицы выше.
|
|
462
|
+
*
|
|
463
|
+
* Порядок, который надо восстанавливать в голове по колонкам, восстанавливают неверно: первый же
|
|
464
|
+
* агент, увидев `high` у трёх слайсов, возьмёт тот, что удобнее. Названная причина делает выбор
|
|
465
|
+
* оспоримым — а неоспоримый порядок работ и есть магия, против которой написан весь этот скрипт.
|
|
466
|
+
*/
|
|
467
|
+
const behind = ranked.filter((s) => hasWork(s) === 1 && s !== next && phaseNo(s) < phaseNo(next));
|
|
468
|
+
console.log(
|
|
469
|
+
` приоритет: ${next.priority}` +
|
|
470
|
+
(next.prioError
|
|
471
|
+
? ` — ОБЪЯВЛЕНИЕ НЕДЕЙСТВИТЕЛЬНО: ${next.prioError}`
|
|
472
|
+
: next.prioDeclared
|
|
473
|
+
? ` (${next.prioBy === "owner" ? "владелец" : "выведен"}) — ${next.prioWhy}`
|
|
474
|
+
: " (не объявлен — умолчание)")
|
|
475
|
+
);
|
|
476
|
+
if (behind.length) {
|
|
477
|
+
// Приоритет ОБГОНЯЕТ объявленный порядок — ровно то, ради чего он заведён, и ровно то, что
|
|
478
|
+
// нельзя делать молча: обгон соседа, которым слайс пользуется, встретится посреди работы.
|
|
479
|
+
console.log(
|
|
480
|
+
` обгоняет по спеке: ${behind.map((s) => `${s.order} ${s.pkg}/${s.slice} (${s.priority})`).join(", ")}\n` +
|
|
481
|
+
` Проверь, что обгон законен: слайс не должен обгонять того, чьими данными он живёт.`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
console.log(
|
|
485
|
+
` неподключённых эндпоинтов: ${next.wire || "нет"} · адресов без ключа: ${next.addr || "нет"} · значений прототипа: ${next.vals || "нет"} · обрывов проводки: ${next.wiring || "нет"} · порт персистентности: ${next.hasPort ? "есть" : "НЕТ"}`
|
|
486
|
+
);
|
|
487
|
+
console.log(`\n Спека: docs/usecases/${next.pkg}/${next.slice}.md`);
|
|
488
|
+
console.log(` Данные стенда: docs/seeds.md (что уже засеяно — bun run db:seed печатает числа)`);
|
|
489
|
+
console.log(
|
|
490
|
+
`\n ПЕРВЫЙ ШАГ — не работа, а проверка предпосылки: чем адресуются сущности слайса.\n` +
|
|
491
|
+
` Если ключ — строка из вёрстки (r-smm, c-mkt), слайс не начинают, пока это не решено:\n` +
|
|
492
|
+
` выдумка, ставшая ключом, потом снимается только переписыванием четырёх слоёв разом.\n` +
|
|
493
|
+
` Разбор таких случаев ведут в документе расхождений порта.`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const left = ranked.filter((s) => hasWork(s) === 1).length;
|
|
498
|
+
const blocked = ranked.filter((s) => hasWork(s) === 1 && s.conflicts.length > 0);
|
|
499
|
+
const withWork = ranked.filter((s) => hasWork(s) === 1);
|
|
500
|
+
const highLeft = withWork.filter((s) => s.priority === "high").length;
|
|
501
|
+
console.log(
|
|
502
|
+
`\nСлайсов с работой: ${left} из ${slices.length}; из них заблокировано конфликтом: ${blocked.length}.`
|
|
503
|
+
);
|
|
504
|
+
/**
|
|
505
|
+
* Высокий приоритет — ОТДЕЛЬНЫМ числом, потому что по нему ставится цель внешнего цикла.
|
|
506
|
+
*
|
|
507
|
+
* Одно общее «слайсов с работой» не отличает «осталось три обычных» от «основной путь не собран».
|
|
508
|
+
* Цель, поставленная по одному числу, второго не увидит — так уже было с долгом адресации.
|
|
509
|
+
*/
|
|
510
|
+
console.log(
|
|
511
|
+
`Из них высокого приоритета: ${highLeft}` +
|
|
512
|
+
// Оговорка только там, где полоса `high` вообще ОБЪЯВЛЕНА. «Основной путь собран» при нуле
|
|
513
|
+
// объявленных high — не отчёт о состоянии, а поздравление с тем, чего никто не заявлял.
|
|
514
|
+
(highLeft === 0 && left > 0 && slices.some((s) => s.priority === "high")
|
|
515
|
+
? " — полоса high пуста, дальше идёт обычная (сборка НЕ закончена)"
|
|
516
|
+
: "")
|
|
517
|
+
);
|
|
518
|
+
/**
|
|
519
|
+
* Не объявленный приоритет — счётчиком, а не молчанием.
|
|
520
|
+
*
|
|
521
|
+
* Слайс без объявления работает как `normal`, и это правильное умолчание. Но «никто не решал» и
|
|
522
|
+
* «решили, что обычный» — разные состояния, и неразличимые в отчёте они означают, что вопрос
|
|
523
|
+
* приоритетов считается закрытым, пока его никто не открывал.
|
|
524
|
+
*/
|
|
525
|
+
const waitingTotal = slices.reduce((a, s) => a + s.waiting, 0);
|
|
526
|
+
if (waitingTotal > 0) {
|
|
527
|
+
const кого = [...new Set(slices.flatMap((s) => s.waitingFor))].sort();
|
|
528
|
+
console.log(
|
|
529
|
+
`Критериев, ждущих чужого слайса: ${waitingTotal} (ждут: ${кого.join(", ")}) — ` +
|
|
530
|
+
`объявлено в scripts/ac-blocked.open, работой своего слайса они не считаются`
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* ДОЛГИ — рядом с работой, а не в отдельной команде.
|
|
535
|
+
*
|
|
536
|
+
* Долг это не «ещё не дошли руки», а решение НЕ делать объявленное: очередь его не назовёт никогда,
|
|
537
|
+
* потому что работой слайса он не является. Значит единственное место, где его увидят каждый цикл,
|
|
538
|
+
* — вот эта сводка. Напечатать его только по отдельной команде — то же самое, что не печатать.
|
|
539
|
+
*/
|
|
540
|
+
if (долгов > 0) {
|
|
541
|
+
console.log(
|
|
542
|
+
`Долгов (сознательно не сделано): ${долгов} — scripts/debt.open, разбор: bun run check:debt. ` +
|
|
543
|
+
`Сборку они НЕ блокируют, но и не исчезают`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const undeclared = slices.filter((s) => !s.prioDeclared && !s.prioError).length;
|
|
548
|
+
if (undeclared) {
|
|
549
|
+
console.log(
|
|
550
|
+
`Без объявленного приоритета: ${undeclared} из ${slices.length} (работают как normal; в таблице помечены «·»)`
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const broken = slices.filter((s) => s.prioError);
|
|
554
|
+
if (broken.length) {
|
|
555
|
+
console.log(`\n⛔ НЕДЕЙСТВИТЕЛЬНОЕ ОБЪЯВЛЕНИЕ ПРИОРИТЕТА — ${broken.length}:`);
|
|
556
|
+
for (const s of broken) console.log(` ${s.pkg}/${s.slice}: ${s.prioError}`);
|
|
557
|
+
console.log(
|
|
558
|
+
` Такой слайс СЧИТАЕТСЯ обычным, но объявлено было другое. Гейт bun run ac по этому краснеет.`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (unassignedConflicts) {
|
|
562
|
+
console.log(
|
|
563
|
+
`Конфликтов вне слайса: ${unassignedConflicts} (engineering, architecture, seeds) — ` +
|
|
564
|
+
`они не блокируют очередь, но и не исчезают: scripts/docs-conflicts.open`
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Долг ВЁРСТКИ печатается, только пока он есть.
|
|
569
|
+
*
|
|
570
|
+
* Гейт границы прототипа (`check:proto`) СНИМАЕТСЯ, когда фикстура перестаёт пересекать границу
|
|
571
|
+
* представления: порт кончился, и дальше он держал бы проект у прототипа. Строка «долг 0 (столько
|
|
572
|
+
* же считает bun run check:proto)» после снятия ссылалась бы на команду, которой в репе нет, —
|
|
573
|
+
* очередь обязана печатать состояние репы, а не память о нём.
|
|
574
|
+
*/
|
|
575
|
+
const sliceDebt = [...debtBySlice.values()].reduce((a, b) => a + b, 0);
|
|
576
|
+
if (sliceDebt + sharedDebt > 0) {
|
|
577
|
+
console.log(
|
|
578
|
+
`Файлов долга вёрстки: ${sliceDebt} по срезам + ${sharedDebt} уровня пакета = ${sliceDebt + sharedDebt}` +
|
|
579
|
+
` (столько же считает bun run check:proto)`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
const valsTotal = [...valsBySlice.values()].reduce((a, b) => a + b, 0);
|
|
583
|
+
const wiringTotal = [...wiringBySlice.values()].reduce((a, b) => a + b, 0);
|
|
584
|
+
if (valsTotal > 0) {
|
|
585
|
+
console.log(
|
|
586
|
+
`Значений прототипа в экранах: ${valsTotal} (столько же считает bun run check:proto-values)`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
/*
|
|
590
|
+
Своё условие, а не общее со значениями.
|
|
591
|
+
|
|
592
|
+
Строка проводки стояла ВНУТРИ `if (valsTotal > 0)`, и её не было видно ровно тогда, когда она
|
|
593
|
+
единственная и оставалась: репа с нулём значений прототипа и ненулевым долгом проводки печатала
|
|
594
|
+
очередь, из которой остаток не следовал. Два независимых числа под одним условием — это не
|
|
595
|
+
форматирование, это отчёт, который молчит по чужой причине.
|
|
596
|
+
*/
|
|
597
|
+
if (wiringTotal > 0) {
|
|
598
|
+
console.log(
|
|
599
|
+
`Обрывов проводки: ${wiringTotal} (столько же считает bun run check:wiring) — мёртвый проп, дубль хваталки, чтение без вызывающего`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
console.log(`Отчёт покрытия целиком: docs/acceptance.md (обновляется bun run ac)\n`);
|