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,199 @@
1
+ /**
2
+ * `bun run check:proto-ids` — ЧЕМ ПРИЛОЖЕНИЕ АДРЕСУЕТ СУЩНОСТИ В ССЫЛКАХ.
3
+ *
4
+ * ПРАВИЛО: **ни одна ссылка, по которой человек может кликнуть, не содержит выдуманного
5
+ * идентификатора.** Идентичность приходит из модели данных — первичный ключ базы. Строка из
6
+ * вёрстки (`c-mkt`, `r-smm`, `m-kim`) ключом не является ни на одном слое, и в адресе — тоже.
7
+ *
8
+ * ПОЧЕМУ ГЕЙТ, А НЕ ЗАМЕТКА. Нашёл это не тест и не ревью, а владелец: «нажимаю создать круг и
9
+ * выходит `/circles/new?parent_circle_id=c-mkt`; и сама ссылка из меню ведёт на
10
+ * `/circles/c-company`». Ни один уровень поймать не мог:
11
+ *
12
+ * • юнит и интеграция проверяют СЕРВЕР — он про ссылки клиента не знает ничего;
13
+ * • переводчик адресов (`protoHref`) переписывает ИМЯ ФАЙЛА прототипа в маршрут приложения и
14
+ * пропускает параметры насквозь: `circle-detail.html?circle_id=c-mkt` становится
15
+ * `/circles/c-mkt` — то есть выглядит маршрутом приложения и им не является;
16
+ * • сверка с прототипом ходит по тем же выдуманным адресам и потому ЗЕЛЕНА на них: оракул и
17
+ * дефект согласованы между собой.
18
+ *
19
+ * Наружу это выходит как «приложение не работает»: страница открывается, показывает перенесённую
20
+ * вёрстку, а сервер на этот адрес отвечает «нет такой записи». Между настоящим ключом и настоящей
21
+ * разметкой лежит АДРЕС, и не проверял его никто.
22
+ *
23
+ * ЧТО СЧИТАЕТСЯ — ВЫХОД, А НЕ ВХОД.
24
+ *
25
+ * Разметка прототипа обязана оставаться прототипной: она правится перегенерацией, и выдуманные
26
+ * адреса в ней — свойство источника, а не дефект. Считать их значило бы мерить вход и получить
27
+ * число, которое нельзя свести к нулю, не сломав перенос.
28
+ *
29
+ * Поэтому берётся КАЖДЫЙ адрес перенесённых экранов и прогоняется через переводчик приложения.
30
+ * Долг — это адреса, у которых нет настоящего ключа: человек по ним кликает и попадает в
31
+ * объявленное «данных нет». Число падает ровно тогда, когда экран научился настоящей идентичности,
32
+ * и не падает от косметики.
33
+ *
34
+ * РЕЖИМ ХРАПОВИКА: базовая линия пишется один раз (`--write`), рост роняет прогон, снижение требует
35
+ * поправить число — список, который не обязан быть точным, за месяц становится украшением.
36
+ *
37
+ * ЧЕГО ГЕЙТ НЕ ДЕЛАЕТ. Он не проверяет, что настоящий ключ СУЩЕСТВУЕТ в базе — это работа e2e на
38
+ * живом стенде. Здесь проверяется: приложение вообще способно собрать этот адрес или нет.
39
+ */
40
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
41
+ import { join, relative, resolve } from "node:path";
42
+
43
+ /**
44
+ * Корень РЕПЫ, а не скрипта.
45
+ *
46
+ * Скрипт живёт в плагине и запускается из репы (`bun run "$CLAUDE_PLUGIN_ROOT/assets/scripts/…"`),
47
+ * поэтому свой собственный путь корнем быть не может: он указал бы внутрь плагина, гейт обошёл бы
48
+ * чужое дерево и объявил «нарушений нет» — зелёный на пустоте. Рабочий каталог задаёт `bun run`,
49
+ * и он всегда корень пакета; путь скрипта остаётся запасным для случая, когда файл всё-таки
50
+ * скопирован в репу.
51
+ */
52
+ const ROOT = existsSync(join(process.cwd(), "package.json"))
53
+ ? resolve(process.cwd())
54
+ : resolve(import.meta.dir, "..");
55
+ const ALLOW_FILE = join(ROOT, "scripts/proto-ids.allow");
56
+ const write = Bun.argv.includes("--write");
57
+
58
+ const SEARCH_ROOTS = ["packages", "apps", "src"];
59
+ const KEEP = (rel: string) => /\/(ui|client)\//.test(rel) && /\.tsx?$/.test(rel);
60
+
61
+ const files: string[] = [];
62
+ function walk(dir: string) {
63
+ if (!existsSync(dir)) return;
64
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
65
+ const full = join(dir, entry.name);
66
+ if (entry.isDirectory()) {
67
+ if (["node_modules", "dist", "build", ".turbo"].includes(entry.name)) continue;
68
+ walk(full);
69
+ } else if (KEEP(full.split("\\").join("/"))) files.push(full);
70
+ }
71
+ }
72
+ for (const dir of SEARCH_ROOTS) walk(join(ROOT, dir));
73
+
74
+ /**
75
+ * Переводчик ищется по имени, а не импортируется путём: раскладка клиента у каждой репы своя, а
76
+ * договорённость одна — единственное место, где рождается адрес.
77
+ */
78
+ const translator = files.find((f) => /\/proto-href\.tsx?$/.test(f.split("\\").join("/")));
79
+ if (!translator) {
80
+ console.error(
81
+ `\nОШИБКА: не найден переводчик адресов (*/proto-href.ts). Гейт не отработал — проверь раскладку.`
82
+ );
83
+ process.exit(1);
84
+ }
85
+ const { protoHref, UNAVAILABLE } = (await import(translator)) as {
86
+ protoHref: (raw: string, subs?: Record<string, string | undefined>) => string;
87
+ UNAVAILABLE: string;
88
+ };
89
+
90
+ type Hit = { file: string; href: string; to: string };
91
+ const hits: Hit[] = [];
92
+ const seen = new Set<string>();
93
+ for (const file of files) {
94
+ const rel = relative(ROOT, file).split("\\").join("/");
95
+ if (/proto-href\.(test\.)?tsx?$/.test(rel)) continue;
96
+ /**
97
+ * Комментарии вырезаются ДО поиска — то же правило, что у `check:proto` и `check:write-path`:
98
+ * файл, который ОБЪЯСНЯЕТ выдуманный адрес, содержит эту строку и выглядел бы дефектом.
99
+ */
100
+ const source = readFileSync(file, "utf8")
101
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
102
+ .replace(/(^|[^:"'`])\/\/[^\n]*/g, (m, head) => head + " ".repeat(m.length - head.length));
103
+ for (const m of source.matchAll(/["'`]([a-z0-9-]+\.html(?:\?[^"'`]*)?)["'`]/g)) {
104
+ const href = m[1]!;
105
+ if (href.includes("${")) continue;
106
+ const key = `${rel}|${href}`;
107
+ if (seen.has(key)) continue;
108
+ seen.add(key);
109
+ hits.push({ file: rel, href, to: protoHref(href) });
110
+ }
111
+ }
112
+
113
+ const dead = hits.filter((h) => h.to === UNAVAILABLE);
114
+ const byFile = new Map<string, number>();
115
+ for (const h of dead) byFile.set(h.file, (byFile.get(h.file) ?? 0) + 1);
116
+
117
+ if (write) {
118
+ const body = [
119
+ "# АДРЕС БЕЗ НАСТОЯЩЕГО КЛЮЧА — объявленный ДОЛГ, а не разрешение.",
120
+ "#",
121
+ "# Каждая строка: <файл> <число адресов>. По такому адресу человек кликает и попадает в",
122
+ "# объявленное «данных нет»: экран ещё не знает настоящей идентичности той записи.",
123
+ "#",
124
+ "# Список СОКРАЩАЕТСЯ по мере того, как экраны получают настоящие ключи. Уменьшилось число —",
125
+ "# правь строку; стало ноль — удаляй её.",
126
+ "#",
127
+ `# Записано ${byFile.size} файлов, ${dead.length} адресов из ${hits.length}.`,
128
+ "",
129
+ ...[...byFile.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([f, n]) => `${f} ${n}`),
130
+ "",
131
+ ].join("\n");
132
+ Bun.write(ALLOW_FILE, body);
133
+ console.log(`\nбазовая линия записана: ${dead.length} адресов из ${hits.length}\n`);
134
+ process.exit(0);
135
+ }
136
+
137
+ const declared = new Map<string, number>();
138
+ if (existsSync(ALLOW_FILE)) {
139
+ for (const line of readFileSync(ALLOW_FILE, "utf8").split("\n")) {
140
+ const text = line.split("#")[0]!.trim();
141
+ if (!text) continue;
142
+ const at = text.lastIndexOf(" ");
143
+ declared.set(text.slice(0, at), Number(text.slice(at + 1)));
144
+ }
145
+ }
146
+
147
+ console.log(`\n=== АДРЕСА БЕЗ НАСТОЯЩЕГО КЛЮЧА ===`);
148
+
149
+ /** Ноль собранных адресов — не «чисто», а не отработавший гейт. */
150
+ if (hits.length === 0) {
151
+ console.error(
152
+ `\nОШИБКА: не собрано ни одного адреса (файлов экранов ${files.length}). Гейт не отработал.`
153
+ );
154
+ process.exit(1);
155
+ }
156
+
157
+ console.log(
158
+ `Адресов в экранах: ${hits.length} · собирается настоящий: ${hits.length - dead.length} · долг: ${dead.length}`
159
+ );
160
+
161
+ const fresh: string[] = [];
162
+ const grown: string[] = [];
163
+ const stale: string[] = [];
164
+ for (const [file, n] of byFile) {
165
+ const was = declared.get(file);
166
+ if (was === undefined) fresh.push(`${file} ${n}`);
167
+ else if (n > was) grown.push(`${file}: было ${was}, стало ${n}`);
168
+ }
169
+ for (const [file, was] of declared) {
170
+ const now = byFile.get(file) ?? 0;
171
+ if (now === 0) stale.push(`${file} (было ${was}, стало 0)`);
172
+ else if (now < was) stale.push(`${file}: было ${was}, стало ${now} — поправь число`);
173
+ }
174
+
175
+ if (fresh.length) {
176
+ console.error(`\nНОВЫЙ адрес без ключа:\n`);
177
+ for (const f of fresh) console.error(` ${f}`);
178
+ for (const h of dead.filter((x) => fresh.some((f) => f.startsWith(`${x.file} `)))) {
179
+ console.error(` ${h.file} ${h.href}`);
180
+ }
181
+ }
182
+ if (grown.length) {
183
+ console.error(`\nДОЛГ ВЫРОС:\n`);
184
+ for (const g of grown) console.error(` ${g}`);
185
+ }
186
+ if (stale.length) {
187
+ console.error(`\nЗАПИСЬ ДОЛГА УСТАРЕЛА — поправь ${relative(ROOT, ALLOW_FILE)}:\n`);
188
+ for (const s of stale) console.error(` ${s}`);
189
+ }
190
+
191
+ if (fresh.length || grown.length || stale.length) {
192
+ console.error(
193
+ `\nПроверка провалена: новых ${fresh.length}, выросших ${grown.length}, устаревших ${stale.length}.`
194
+ );
195
+ process.exit(1);
196
+ }
197
+
198
+ console.log(`Новых адресов без ключа нет. Объявленный долг: ${dead.length} адресов.\n`);
199
+ process.exit(0);
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `bun run check:proto` — граница прототипа.
3
+ *
4
+ * ПРАВИЛО: **прототип — это внешний вид, а не данные.** Всё, что извлечено из вёрстки
5
+ * (`*.gen.ts`), — источник РАЗМЕТКИ, и дальше слоя представления не идёт. Домен, роуты,
6
+ * composition, сид и тесты идентичности берут из модели данных: первичный ключ базы, а не строку,
7
+ * которую кто-то придумал в HTML.
8
+ *
9
+ * ПОЧЕМУ ГЕЙТ, А НЕ АБЗАЦ В ИНСТРУКЦИИ. Это правило нарушается не по злому умыслу, а потому что в
10
+ * момент переноса экрана данных ещё нет, а строка `m-kim` в разметке уже есть — и она выглядит как
11
+ * готовый идентификатор. Инструкцию, которую надо ВСПОМНИТЬ, следующая сессия не вспомнит: она
12
+ * начнётся с чистого контекста и повторит ровно то же «очевидное» решение. Правило, которое должно
13
+ * держаться, обязано падать само.
14
+ *
15
+ * ЧТО БЫВАЕТ, ЕСЛИ НЕ ДЕРЖАТЬ. Выдуманный id уезжает в порт, из порта в агрегат, из агрегата в
16
+ * ответ сервера, из ответа — в привязку экрана. Дальше он становится ключом, по которому «работает»
17
+ * приложение, и подменить его настоящим уже нельзя без переписывания всех четырёх слоёв сразу.
18
+ * Приложение при этом ЗЕЛЁНОЕ: сценарии сверяют его же выдумку с ней же самой.
19
+ *
20
+ * РЕЖИМ ХРАПОВИКА. Объявленные нарушения перечислены в `scripts/prototype-boundary.allow` — это
21
+ * ДОЛГ, а не разрешение. Новое нарушение роняет прогон. Запись, которая больше не нарушает, тоже
22
+ * роняет прогон: список, который нельзя сократить, за месяц превращается в фольклор.
23
+ */
24
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
25
+ import { join, relative, resolve } from "node:path";
26
+
27
+ /**
28
+ * Корень РЕПЫ, а не скрипта.
29
+ *
30
+ * Скрипт живёт в плагине и запускается из репы (`bun run "$CLAUDE_PLUGIN_ROOT/assets/scripts/…"`),
31
+ * поэтому свой собственный путь корнем быть не может: он указал бы внутрь плагина, гейт обошёл бы
32
+ * чужое дерево и объявил «нарушений нет» — зелёный на пустоте. Рабочий каталог задаёт `bun run`,
33
+ * и он всегда корень пакета; путь скрипта остаётся запасным для случая, когда файл всё-таки
34
+ * скопирован в репу.
35
+ */
36
+ const ROOT = existsSync(join(process.cwd(), "package.json"))
37
+ ? resolve(process.cwd())
38
+ : resolve(import.meta.dir, "..");
39
+ const ALLOW_FILE = join(ROOT, "scripts/prototype-boundary.allow");
40
+
41
+ /** Где извлечённой вёрстке жить МОЖНО: слой представления и оболочка клиента. */
42
+ const PRESENTATION = [/\/ui\//, /\/client\//];
43
+
44
+ /**
45
+ * Где искать. Раскладка бывает разной (монорепо `packages/`+`apps/`, одиночный `src/`), поэтому
46
+ * берутся все существующие — а не одна угаданная. Ни одного каталога не нашлось → это отказ ниже,
47
+ * а не пустой зелёный прогон.
48
+ */
49
+ const SCAN = ["packages", "apps", "src", "lib", "server", "app"];
50
+ const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".turbo"]);
51
+
52
+ const files: string[] = [];
53
+ function walk(dir: string) {
54
+ if (!existsSync(dir)) return;
55
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
56
+ if (entry.isDirectory()) {
57
+ if (SKIP_DIRS.has(entry.name)) continue;
58
+ walk(join(dir, entry.name));
59
+ } else if (/\.tsx?$/.test(entry.name)) {
60
+ files.push(join(dir, entry.name));
61
+ }
62
+ }
63
+ }
64
+ for (const dir of SCAN) walk(join(ROOT, dir));
65
+
66
+ /** `import ... from "x.gen.ts"`, `export ... from "x.gen.ts"`, `import("x.gen.ts")`. */
67
+ const SPEC = /(?:\bfrom\s+|\bimport\s*\(\s*|\bimport\s+)["']([^"']+)["']/g;
68
+ const IS_GEN = /\.gen(\.tsx?)?$/;
69
+
70
+ type Violation = { file: string; line: number; spec: string };
71
+ const found: Violation[] = [];
72
+
73
+ for (const file of files) {
74
+ const rel = relative(ROOT, file).split("\\").join("/");
75
+ if (PRESENTATION.some((re) => re.test(`/${rel}`))) continue;
76
+ // Сам извлечённый файл собой не нарушает: он и есть разметка.
77
+ if (IS_GEN.test(rel.replace(/\.tsx?$/, ""))) continue;
78
+
79
+ // Комментарии вырезаются ДО поиска, иначе гейт краснеет на собственном объяснении: файл, который
80
+ // рассказывает, почему он больше не читает `./x.gen.ts`, содержит эту строку — и выглядит
81
+ // нарушителем. Ложное срабатывание здесь опаснее пропуска: гейт, который врёт, отключают.
82
+ const source = readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, (m) =>
83
+ m.replace(/[^\n]/g, " ")
84
+ );
85
+ const lines = source.split("\n");
86
+ lines.forEach((raw, i) => {
87
+ const code = raw.split("//")[0] ?? "";
88
+ let m: RegExpExecArray | null;
89
+ SPEC.lastIndex = 0;
90
+ while ((m = SPEC.exec(code)) !== null) {
91
+ const spec = m[1]!;
92
+ if (IS_GEN.test(spec.replace(/\.tsx?$/, ""))) {
93
+ found.push({ file: rel, line: i + 1, spec });
94
+ }
95
+ }
96
+ });
97
+ }
98
+
99
+ /** Объявленный долг: одна строка — один файл-нарушитель. Комментарии и пустые строки игнорируются. */
100
+ const declared = existsSync(ALLOW_FILE)
101
+ ? new Set(
102
+ readFileSync(ALLOW_FILE, "utf8")
103
+ .split("\n")
104
+ .map((l) => l.split("#")[0]!.trim())
105
+ .filter(Boolean)
106
+ )
107
+ : new Set<string>();
108
+
109
+ const offenders = new Set(found.map((v) => v.file));
110
+ const fresh = found.filter((v) => !declared.has(v.file));
111
+ const stale = [...declared].filter((f) => !offenders.has(f));
112
+
113
+ console.log(`\n=== ГРАНИЦА ПРОТОТИПА ===`);
114
+ console.log(`Проверено файлов: ${files.length}`);
115
+
116
+ /**
117
+ * Ноль просмотренных файлов — это НЕ «нарушений нет». Это гейт, который не отработал: не тот
118
+ * корень, переехавшая раскладка, запуск не из той директории. Зелёный при нулевом счёте — та же
119
+ * ложь, что и вручную поставленная галочка, только дешевле в исполнении.
120
+ */
121
+ if (files.length === 0) {
122
+ console.error(
123
+ `\nОШИБКА: не просмотрено ни одного файла — искали в ${SCAN.join(", ")} от ${ROOT}.` +
124
+ `\nГейт не отработал. Проверь корень репозитория и раскладку исходников.`
125
+ );
126
+ process.exit(1);
127
+ }
128
+ console.log(
129
+ `Файлов с импортом *.gen вне представления: ${offenders.size} (объявлено ${declared.size})`
130
+ );
131
+
132
+ if (fresh.length) {
133
+ console.error(`\nНОВОЕ нарушение границы — извлечённая вёрстка ушла за пределы ui/:\n`);
134
+ for (const v of fresh) console.error(` ${v.file}:${v.line} ← ${v.spec}`);
135
+ console.error(
136
+ `\nПрототип — это внешний вид. Идентичность и данные берутся из модели данных (PK базы),` +
137
+ `\nа не из строк вёрстки. Либо перенеси чтение в персистентность, либо оставь ` +
138
+ `\nизвлечённое в ui/**.`
139
+ );
140
+ }
141
+
142
+ if (stale.length) {
143
+ console.error(`\nЗАПИСЬ ДОЛГА БОЛЬШЕ НЕ НУЖНА — удали её из ${relative(ROOT, ALLOW_FILE)}:\n`);
144
+ for (const f of stale) console.error(` ${f}`);
145
+ }
146
+
147
+ if (fresh.length || stale.length) {
148
+ console.error(`\nПроверка провалена: новых ${fresh.length}, протухших записей ${stale.length}.`);
149
+ process.exit(1);
150
+ }
151
+
152
+ console.log(`Новых нарушений нет. Объявленный долг: ${declared.size} файлов.\n`);
153
+ process.exit(0);
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * ПРОВОДКА ЭКРАНА — то, что лежит между доказанным сервером и проверенной разметкой.
4
+ *
5
+ * Три дефекта подряд нашёл ЧЕЛОВЕК, а не прогон, и у всех трёх одна форма: сервер прав, разметка
6
+ * на месте, а между ними обрыв, которого не видит ни один уровень.
7
+ * Юнит и интеграция зовут сервер напрямую; `check:write-path` смотрит ТОЛЬКО пишущие эндпоинты;
8
+ * оракул разметки согласен с прототипом по построению. Провод не проверяет никто.
9
+ *
10
+ * Здесь проверяется ровно он, четырьмя признаками. Каждый — из УЖЕ СЛУЧИВШЕГОСЯ дефекта, а не
11
+ * придуман: признак, под который нет своего провала, ловит выдуманную болезнь и первым отключается.
12
+ *
13
+ * 1. МЁРТВЫЙ ПРОП-КОЛЛБЭК. `onQuery` объявлен в типе, композиция его передаёт, экран не зовёт
14
+ * НИ РАЗУ. Набранное в поле никуда не уходило.
15
+ * 2. МЁРТВЫЙ ПРОП-ЗНАЧЕНИЕ. `query` объявлен и не читается: экран брал запрос из `defaultValue`
16
+ * перенесённой вёрстки и показывал прототипное «ко».
17
+ * 3. ДУБЛЬ ХВАТАЛКИ. Один `id`/`data-testid` на двух узлах: `id="q"` был и в оболочке, и на
18
+ * экране поиска, и `label` подписывал чужое поле; `nb-<id>` делили круг и роль.
19
+ * Сценарий, адресующий такой узел, берёт произвольный.
20
+ * 4. ЧИТАЮЩИЙ ЭНДПОИНТ БЕЗ ВЫЗЫВАЮЩЕГО. `check:write-path` покрывает только мутации; экран,
21
+ * который ничего не читает, показывает выдумку и выглядит работающим.
22
+ *
23
+ * РЕЖИМ ХРАПОВИКА: базовая линия в `scripts/screen-wiring.allow`, пишется `--write`. Рост роняет
24
+ * прогон, снижение требует поправить число — список обязан СОКРАЩАТЬСЯ.
25
+ */
26
+ import { existsSync, readFileSync, readdirSync } 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
+ const ALLOW = join(ROOT, "scripts/screen-wiring.allow");
33
+ const write = Bun.argv.includes("--write");
34
+ const verbose = Bun.argv.includes("--details");
35
+
36
+ const SKIP = new Set(["node_modules", "dist", "build", ".turbo", ".next", "coverage"]);
37
+ const files: string[] = [];
38
+ const walk = (dir: string) => {
39
+ if (!existsSync(dir)) return;
40
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
41
+ const full = join(dir, e.name);
42
+ if (e.isDirectory()) {
43
+ if (SKIP.has(e.name)) continue;
44
+ walk(full);
45
+ } else if (/\.tsx?$/.test(e.name) && !/\.(test|spec)\./.test(e.name)) files.push(full);
46
+ }
47
+ };
48
+ for (const d of ["packages", "apps"]) walk(join(ROOT, d));
49
+
50
+ /** Комментарии вырезаются: файл, ОБЪЯСНЯЮЩИЙ дефект, содержит его имена и дефектом не является. */
51
+ const strip = (src: string): string =>
52
+ src
53
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
54
+ .replace(
55
+ /(^|[^:"'`])\/\/[^\n]*/g,
56
+ (m, head: string) => head + " ".repeat(m.length - head.length)
57
+ );
58
+
59
+ const isScreen = (rel: string) =>
60
+ /(^|\/)src\//.test(rel) &&
61
+ !/(^|\/)(server|db|domain|application|bin)\//.test(rel) &&
62
+ /(^|\/)(client|ui)\//.test(rel);
63
+
64
+ type Finding = { file: string; kind: string; what: string };
65
+ const findings: Finding[] = [];
66
+
67
+ /* ─── 1 и 2 · мёртвая проводка пропов ──────────────────────────────────────────────────── */
68
+
69
+ for (const path of files) {
70
+ const rel = relative(ROOT, path).split("\\").join("/");
71
+ if (!isScreen(rel)) continue;
72
+ const src = strip(readFileSync(path, "utf8"));
73
+
74
+ // Тип пропов экрана: `export type <Имя>Props = { … }`.
75
+ const typeAt = src.match(/export type \w*Props\s*=\s*\{/);
76
+ if (!typeAt || typeAt.index === undefined) continue;
77
+ let depth = 0;
78
+ let end = typeAt.index;
79
+ for (let i = typeAt.index + typeAt[0].length - 1; i < src.length; i += 1) {
80
+ if (src[i] === "{") depth += 1;
81
+ else if (src[i] === "}") {
82
+ depth -= 1;
83
+ if (depth === 0) {
84
+ end = i;
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ const block = src.slice(typeAt.index, end);
90
+ const body = src.slice(end);
91
+
92
+ /*
93
+ Берутся ТОЛЬКО ключи верхнего уровня.
94
+
95
+ Первый прогон считал пропами всё подряд: имена полей вложенных объектов (`draft`, `patch`) и
96
+ параметры коллбэков (`onSave?: (patch: {…}) => …`). Одиннадцать «мёртвых пропов» из двенадцати
97
+ оказались ими. Признак, дающий столько ложных, отключают первым — и вместе с ним настоящую
98
+ находку.
99
+ */
100
+ const topLevel: { name: string; at: number }[] = [];
101
+ {
102
+ let d = 0;
103
+ let paren = 0;
104
+ for (let i = 0; i < block.length; i += 1) {
105
+ const c = block[i]!;
106
+ if (c === "{") d += 1;
107
+ else if (c === "}") d -= 1;
108
+ else if (c === "(") paren += 1;
109
+ else if (c === ")") paren -= 1;
110
+ else if (d === 1 && paren === 0 && /[A-Za-z_]/.test(c)) {
111
+ const rest = block.slice(i);
112
+ const key = rest.match(/^(\w+)\??\s*:/);
113
+ if (key) {
114
+ topLevel.push({ name: key[1]!, at: i });
115
+ i += key[0].length - 1;
116
+ } else {
117
+ // Не ключ — проматываем слово целиком, чтобы не разбирать его по буквам.
118
+ i += (rest.match(/^\w+/)?.[0].length ?? 1) - 1;
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ for (const m of topLevel) {
125
+ const name = m.name;
126
+ if (name.length < 3) continue;
127
+ // Считаем упоминания В ТЕЛЕ компонента, а не в типе.
128
+ const used = new RegExp(`\\b${name}\\b`).test(body);
129
+ if (used) continue;
130
+ const callback = /^on[A-Z]/.test(name);
131
+ findings.push({
132
+ file: rel,
133
+ kind: callback ? "мёртвый проп-коллбэк" : "мёртвый проп-значение",
134
+ what: name,
135
+ });
136
+ }
137
+ }
138
+
139
+ /* ─── 3 · дубль хваталки ───────────────────────────────────────────────────────────────── */
140
+
141
+ /**
142
+ * Дубль считается только там, где узлы бывают на странице ОДНОВРЕМЕННО.
143
+ *
144
+ * Первый прогон дал 130 находок, и почти все были ложью: `f-name` есть и на создании круга, и на
145
+ * создании роли — экраны разные, вместе их не бывает. Настоящий дефект был другим:
146
+ * `id="q"` стоял в ОБОЛОЧКЕ и на экране поиска, а оболочка смонтирована всегда. Значит совпадать
147
+ * могут двое: оболочка с любым экраном и экран сам с собой (со своими же компонентами).
148
+ */
149
+ const handlesOf = (path: string): Set<string> => {
150
+ const src = strip(readFileSync(path, "utf8"));
151
+ const out = new Set<string>();
152
+ // Порог был `{2,}`, и он пропускал ровно тот дефект, ради которого признак заведён: `id="q"`
153
+ // короче двух знаков. Односимвольная хваталка ничем не лучше длинной — она такая же чужая.
154
+ for (const m of src.matchAll(/(?:\bid|data-testid)\s*[=:]\s*"([\w-]+)"/g)) out.add(m[1] ?? "");
155
+ return out;
156
+ };
157
+
158
+ const shellFiles = files.filter((f) => /shell-tree|app-shell/.test(relative(ROOT, f)));
159
+ const shellHandles = new Set<string>();
160
+ for (const f of shellFiles) for (const h of handlesOf(f)) shellHandles.add(h);
161
+
162
+ /** Экран — это КАТАЛОГ: сам файл плюс вынесенные из него компоненты рядом. */
163
+ const screenDirs = new Map<string, string[]>();
164
+ for (const path of files) {
165
+ const rel = relative(ROOT, path).split("\\").join("/");
166
+ if (!isScreen(rel) || shellFiles.includes(path)) continue;
167
+ const dir = rel.slice(0, rel.lastIndexOf("/"));
168
+ screenDirs.set(dir, [...(screenDirs.get(dir) ?? []), path]);
169
+ }
170
+
171
+ for (const [, paths] of screenDirs) {
172
+ const seenHere = new Map<string, number>();
173
+ for (const path of paths) {
174
+ for (const h of handlesOf(path)) seenHere.set(h, (seenHere.get(h) ?? 0) + 1);
175
+ }
176
+ for (const [h, n] of seenHere) {
177
+ if (shellHandles.has(h)) {
178
+ // Путь — КОНКРЕТНЫЙ файл, а не «каталог + оболочка»: очередь относит долг к слайсу по пути,
179
+ // и строка, которой нет на диске, до слайса не доедет.
180
+ findings.push({
181
+ file: relative(ROOT, paths[0]!),
182
+ kind: "дубль хваталки",
183
+ what: `${h} (с оболочкой)`,
184
+ });
185
+ } else if (n > 1) {
186
+ findings.push({ file: relative(ROOT, paths[0]!), kind: "дубль хваталки", what: h });
187
+ }
188
+ }
189
+ }
190
+
191
+ /* ─── 4 · читающий эндпоинт без вызывающего ────────────────────────────────────────────── */
192
+
193
+ const routeFiles = files.filter((f) => f.endsWith("/server/routes.ts"));
194
+ const clientText = files
195
+ .filter((f) => /(^|\/)(client|ui)\//.test(relative(ROOT, f)))
196
+ .map((f) => readFileSync(f, "utf8"))
197
+ .join("\n");
198
+
199
+ for (const path of routeFiles) {
200
+ const rel = relative(ROOT, path).split("\\").join("/");
201
+ const src = strip(readFileSync(path, "utf8"));
202
+ /*
203
+ Адрес берётся из ПУТИ МОНТИРОВАНИЯ, а не из имени слайса.
204
+
205
+ Первый прогон объявил «без вызывающего» три слайса, у которых вызывающий есть: слайс
206
+ `transversal` монтируется на `/role-links`, `organization` — на `/organizations`,
207
+ `governance-history` — на `/governance-changes` и `/proposals`. Гейт, который ошибается на
208
+ четверти проверенного, перестают читать.
209
+ */
210
+ for (const mount of src.matchAll(/prefix:\s*"\/([\w-]+)"/g)) {
211
+ const prefix = mount[1] ?? "";
212
+ if (new RegExp(`/api/${prefix}\\b`).test(clientText)) continue;
213
+ // Есть ли у этого монтирования вообще чтение — иначе это чисто пишущий блок.
214
+ const after = src.slice(mount.index, mount.index + 4000);
215
+ if (!/\.get\(/.test(after)) continue;
216
+ findings.push({
217
+ file: rel,
218
+ kind: "читающий эндпоинт без вызывающего",
219
+ what: `GET /${prefix}`,
220
+ });
221
+ }
222
+ }
223
+
224
+ /* ─── отчёт и храповик ─────────────────────────────────────────────────────────────────── */
225
+
226
+ const byKind = new Map<string, number>();
227
+ for (const f of findings) byKind.set(f.kind, (byKind.get(f.kind) ?? 0) + 1);
228
+
229
+ console.log(`\n=== ПРОВОДКА ЭКРАНА ===`);
230
+ console.log(`Файлов просмотрено: ${files.length} · находок: ${findings.length}`);
231
+ for (const [kind, n] of [...byKind.entries()].sort((a, b) => b[1] - a[1])) {
232
+ console.log(` ${String(n).padStart(3)} · ${kind}`);
233
+ }
234
+
235
+ /** Ноль просмотренных файлов — не «чисто», а не отработавший гейт. */
236
+ if (files.length === 0) {
237
+ console.error("\nОШИБКА: экранов не найдено — гейт не отработал.");
238
+ process.exit(1);
239
+ }
240
+
241
+ if (verbose) {
242
+ console.log("");
243
+ for (const f of findings) console.log(` ${f.kind}: ${f.what}\n ${f.file}`);
244
+ }
245
+
246
+ const byFile = new Map<string, number>();
247
+ for (const f of findings) byFile.set(f.file, (byFile.get(f.file) ?? 0) + 1);
248
+
249
+ if (write) {
250
+ const body = [
251
+ "# ПРОВОДКА ЭКРАНА — объявленный ДОЛГ, а не разрешение.",
252
+ "#",
253
+ "# Каждая строка: <файл> <число>. Форма та же, что у соседних гейтов, и не по симметрии:",
254
+ "# очередь слайсов относит долг к слайсу ПО ПУТИ, а значит путь обязан быть настоящим.",
255
+ "#",
256
+ "# Список СОКРАЩАЕТСЯ. Стало ноль — удаляй строку; долг дошёл до нуля целиком — снимай гейт.",
257
+ `# Записано ${byFile.size} файлов, ${findings.length} находок; просмотрено ${files.length}.`,
258
+ "",
259
+ ...[...byFile.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([f, n]) => `${f} ${n}`),
260
+ "",
261
+ ].join("\n");
262
+ Bun.write(ALLOW, body);
263
+ console.log(`\nбазовая линия записана: ${findings.length} в ${byFile.size} файлах\n`);
264
+ process.exit(0);
265
+ }
266
+
267
+ const declared = new Map<string, number>();
268
+ if (existsSync(ALLOW)) {
269
+ for (const line of readFileSync(ALLOW, "utf8").split("\n")) {
270
+ const text = line.split("#")[0]!.trim();
271
+ if (!text) continue;
272
+ const at = text.lastIndexOf(" ");
273
+ declared.set(text.slice(0, at), Number(text.slice(at + 1)));
274
+ }
275
+ }
276
+
277
+ const fresh: string[] = [];
278
+ const grown: string[] = [];
279
+ const stale: string[] = [];
280
+ for (const [file, n] of byFile) {
281
+ const was = declared.get(file);
282
+ if (was === undefined) fresh.push(file);
283
+ else if (n > was) grown.push(`${file}: было ${was}, стало ${n}`);
284
+ }
285
+ for (const [file, was] of declared) {
286
+ const now = byFile.get(file) ?? 0;
287
+ if (now === 0) stale.push(`${file} (было ${was}, стало 0)`);
288
+ else if (now < was) stale.push(`${file}: было ${was}, стало ${now} — поправь число`);
289
+ }
290
+
291
+ if (fresh.length) {
292
+ console.error(`\nНОВЫЙ ОБРЫВ ПРОВОДКИ:\n`);
293
+ for (const f of findings.filter((x) => fresh.includes(x.file)).slice(0, 40)) {
294
+ console.error(` ${f.file}\n ${f.kind}: ${f.what}`);
295
+ }
296
+ }
297
+ if (grown.length) {
298
+ console.error(`\nДОЛГ ВЫРОС:\n`);
299
+ for (const g of grown) console.error(` ${g}`);
300
+ }
301
+ if (stale.length) {
302
+ console.error(`\nЗАПИСЬ ДОЛГА УСТАРЕЛА — поправь ${relative(ROOT, ALLOW)}:\n`);
303
+ for (const s of stale) console.error(` ${s}`);
304
+ }
305
+ if (fresh.length || grown.length || stale.length) {
306
+ console.error(
307
+ `\nПроверка провалена: новых ${fresh.length}, выросших ${grown.length}, устаревших ${stale.length}.`
308
+ );
309
+ process.exit(1);
310
+ }
311
+
312
+ console.log(`Новых обрывов нет. Объявленный долг: ${findings.length}.\n`);