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 ADDED
@@ -0,0 +1,58 @@
1
+ # fullstack-gates
2
+
3
+ Проверки, которые **отказываются**, а не пропускают. Обычные файлы на вход, код возврата на выход,
4
+ **ноль зависимостей** — только `node:fs`, `node:path` и рантайм Bun.
5
+
6
+ ```bash
7
+ bun add -d fullstack-gates
8
+ ```
9
+
10
+ ```jsonc
11
+ // package.json
12
+ "scripts": {
13
+ "check:pins": "stack-gate pins",
14
+ "check:priority": "stack-gate priority",
15
+ "check:debt": "stack-gate debt",
16
+ "check": "bun run lint && bun run type-check && bun run check:pins && bun run check:priority",
17
+ "ac": "stack-gate ac"
18
+ }
19
+ ```
20
+
21
+ ## Правило, из которого следует всё остальное
22
+
23
+ **Пропущенная проверка выглядит ровно как пройденная.** Поэтому здесь нет ни одного гейта, который
24
+ молча зеленеет, когда ему нечего проверять: пустой каталог критериев — отказ, пустой список
25
+ манифестов — отказ, опечатка в имени гейта — отказ. Зелёный прогон обязан означать, что что-то было
26
+ проверено, а не что проверять было нечего.
27
+
28
+ ## Гейты
29
+
30
+ | `stack-gate …` | Что проверяет | Почему это не ловит ничто другое |
31
+ |---|---|---|
32
+ | `pins` | в объявлениях зависимостей нет `"latest"` / `"*"` | пока цел локфайл, плавающее объявление МОЛЧИТ; просыпается на `bun update` — когда никто уже не связывает поломку с объявлением полугодовой давности |
33
+ | `ac` | критерии приёмки доказаны прогоном, галочки переписаны по факту | галочка, поставленная рукой, — обещание, а не отчёт |
34
+ | `priority` | объявления приоритета в спеке действительны | опечатка `heigh` при молчаливом умолчании даёт `normal` — противоположное написанному, без признаков |
35
+ | `next-slice` | какой слайс следующий: покрытие, провод, адреса, конфликты | очередь, названная человеком, называет удобное, а не следующее |
36
+ | `wiring` | проводка экрана: мёртвый проп, дубль `data-testid`, эндпоинт без вызывающего | юнит и интеграция зовут сервер напрямую, оракул разметки согласен с прототипом по построению — провод не смотрит никто |
37
+ | `write-path` | у каждого пишущего эндпоинта есть вызывающий в клиенте | кнопка, которая рапортует об успехе и ничего не делает, проходит все остальные уровни |
38
+ | `proto` | извлечённая вёрстка не пересекает границу слоя представления | фикстура прототипа, ставшая ключом сущности, снимается потом переписыванием четырёх слоёв разом |
39
+ | `proto-ids` | идентификаторы из прототипа не стали ключами | то же, но про адресацию |
40
+ | `debt` | сознательно не сделанное названо и счётно | «пока не делаем», записанное комментарием в коде, выглядит взвешенным — и потому дыру никто не ищет |
41
+ | `preflight` | условия уровня выполнены до его запуска | уровень, чьи условия не выполнены, обязан ПАДАТЬ и говорить, что запустить |
42
+
43
+ `stack-gate` без аргументов печатает список; неизвестное имя — отказ с кодом 2, а не тихий пропуск.
44
+
45
+ ## Что гейт считает корнем репы
46
+
47
+ Текущий каталог, если в нём есть `package.json`. Поэтому зовите их из корня — так же, как `bun run`.
48
+
49
+ ## Раскладка, которую предполагают гейты
50
+
51
+ Часть проверок читает конкретные места: `docs/usecases/<пакет>/<слайс>.md` (критерии приёмки),
52
+ `packages/*` и `apps/*` (манифесты), `apps/<любое>/src/client` (клиент). Раскладка описана в
53
+ [плагине `fullstack`](https://github.com/itquick-aihub/fullstack-plugin) — он же ставит эти скрипты
54
+ в репу и объясняет, что где живёт. Гейт, которому нечего проверять, скажет об этом и вернёт 1.
55
+
56
+ ## Лицензия
57
+
58
+ MIT.
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `stack-gate <гейт>` — единая точка входа во все гейты пакета.
4
+ *
5
+ * Зачем диспетчер, а не по `bin` на файл. Двенадцать имён вроде `check-pins` и `ac` в общем
6
+ * `node_modules/.bin` — это двенадцать шансов столкнуться с чужим пакетом, и столкновение молчаливое:
7
+ * побеждает тот, кто установился последним, а `bun run check` продолжает печатать «зелено». Одно
8
+ * специфичное имя занимает одну ячейку и конфликтует заметно.
9
+ *
10
+ * Второе: путь до скрипта перестаёт быть частью продукта. Раскладка внутри пакета — наше дело, и
11
+ * менять её можно, не трогая `package.json` в каждой репе.
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ const ГЕЙТЫ: Record<string, string> = {
17
+ pins: "check-pins.ts",
18
+ priority: "check-priority.ts",
19
+ debt: "check-debt.ts",
20
+ wiring: "check-screen-wiring.ts",
21
+ proto: "check-prototype-boundary.ts",
22
+ "proto-ids": "check-proto-ids.ts",
23
+ "write-path": "check-write-path.ts",
24
+ "next-slice": "next-slice.ts",
25
+ ac: "ac.ts",
26
+ preflight: "preflight.ts",
27
+ };
28
+
29
+ const [имя, ...остальные] = process.argv.slice(2);
30
+
31
+ if (!имя || имя === "--help" || имя === "-h") {
32
+ console.log("stack-gate <гейт> [аргументы]\n\nГейты:");
33
+ for (const k of Object.keys(ГЕЙТЫ)) console.log(` ${k}`);
34
+ console.log("\nПример: stack-gate pins · stack-gate ac --no-tests");
35
+ process.exit(имя ? 0 : 2);
36
+ }
37
+
38
+ const файл = ГЕЙТЫ[имя];
39
+ if (!файл) {
40
+ console.error(
41
+ `stack-gate: гейта «${имя}» нет. Доступны: ${Object.keys(ГЕЙТЫ).join(", ")}.\n` +
42
+ "Опечатка в имени не должна выглядеть как пройденная проверка, поэтому это отказ, а не пропуск."
43
+ );
44
+ process.exit(2);
45
+ }
46
+
47
+ const путь = join(import.meta.dir, "..", "scripts", файл);
48
+ if (!existsSync(путь)) {
49
+ console.error(`stack-gate: файл гейта не найден: ${путь}. Пакет установлен не полностью.`);
50
+ process.exit(2);
51
+ }
52
+
53
+ /**
54
+ * Дочерний процесс, а не `import`: гейты — самостоятельные программы, которые общаются кодом
55
+ * возврата и пишут в stdout. `import` склеил бы их состояние и проглотил `process.exit`.
56
+ * `cwd` НЕ подменяется: гейт определяет корень репы по нему, и это должен быть каталог вызова.
57
+ */
58
+ const proc = Bun.spawnSync(["bun", "run", путь, ...остальные], {
59
+ stdio: ["inherit", "inherit", "inherit"],
60
+ cwd: process.cwd(),
61
+ env: process.env,
62
+ });
63
+ process.exit(proc.exitCode ?? 1);
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "fullstack-gates",
3
+ "version": "0.1.0",
4
+ "description": "Verification gates for a Bun + Elysia + Drizzle + Turborepo modular monolith: pinned dependency declarations, acceptance-criteria coverage, work queue, screen wiring, prototype boundary, declared debt. Plain files in, exit code out — no dependencies.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "stack-gate": "bin/stack-gate.ts"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "scripts",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "bun": ">=1.4.0"
17
+ },
18
+ "keywords": [
19
+ "bun",
20
+ "monorepo",
21
+ "ci",
22
+ "gates",
23
+ "acceptance-criteria",
24
+ "traceability",
25
+ "dependency-pinning",
26
+ "elysia",
27
+ "drizzle",
28
+ "turborepo"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/itquick-aihub/fullstack-plugin.git",
33
+ "directory": "packages/fullstack-gates"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
package/scripts/ac.ts ADDED
@@ -0,0 +1,494 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join, relative, resolve } from "node:path";
4
+
5
+ /**
6
+ * `bun run ac` — acceptance coverage.
7
+ *
8
+ * The rule this enforces: **a checkbox is a REPORT, not a promise.** Nobody ticks an acceptance
9
+ * criterion by hand; a criterion is ticked because something proved it, and this script is what
10
+ * looks. That is the whole point — a hand-ticked list is a lie within a week, and we have the scars.
11
+ *
12
+ * How it works:
13
+ * 1. Use cases live in `docs/usecases/<package>/<slice>.md` — one file per module. They are the "what"
14
+ * (the analyst's layer, and the first thing written, often before the package exists); the code
15
+ * is the "how". Keeping them in `docs/` means a use case has a home from the day it is thought
16
+ * of, not from the day someone creates a package for it.
17
+ * 2. Every acceptance criterion has a stable id: `- [ ] \`AC-GW-01.3\` …`.
18
+ * 3. A test claims a criterion by naming it: `it("AC-GW-01.3 — 403 when …")`. No registry, no
19
+ * decorator — the id in the title IS the link.
20
+ * 4. Some criteria CANNOT be proven by a test — behaviour of an external system, a limit enforced by
21
+ * a real proxy. Those carry `live:YYYY-MM-DD@<commit>` — evidence from a stand
22
+ * run, with a date, so it visibly goes stale instead of quietly becoming folklore.
23
+ *
24
+ * Gates:
25
+ * - a test naming an unknown id → error (typo protection)
26
+ * - a criterion nobody proves → listed as a hole; `--strict` makes it an exit code
27
+ *
28
+ * Usage: bun run ac [--strict] [--no-tests] (`--no-tests` reuses the last junit run)
29
+ */
30
+ import { $ } from "bun";
31
+
32
+ import { applyIntegrationEnv } from "./integration-env";
33
+
34
+ /**
35
+ * Корень РЕПЫ, а не скрипта. Файл — КОПИЯ, живущая в `scripts/` репы, но тот же исходник иногда
36
+ * зовут прямо из ассетов плагина; тогда `import.meta.url` указывает мимо репы, и отчёт пишется не
37
+ * туда. Поэтому первично `cwd` (там, где есть `package.json`), а путь от файла — запасной.
38
+ */
39
+ const ROOT = existsSync(join(process.cwd(), "package.json"))
40
+ ? resolve(process.cwd())
41
+ : new URL("..", import.meta.url).pathname.replace(/\/$/, "");
42
+ const USECASES_DIR = join(ROOT, "docs/usecases");
43
+ const JUNIT = "/tmp/ac-junit.xml";
44
+ const STALE_DAYS = 60;
45
+
46
+ const strict = Bun.argv.includes("--strict");
47
+ const skipTests = Bun.argv.includes("--no-tests");
48
+
49
+ /**
50
+ * Source files are `docs/usecases/<пакет>/<слайс>.md` — mirroring the code's own two levels
51
+ * (`packages/<пакет>/…/features/<слайс>`), so "where does this use case live" has the same answer as
52
+ * "where does this code live". They are the ones carrying `feature:` frontmatter; the generated index
53
+ * (`index.md`) is recognized by NOT having it, so prose sitting alongside can never be mistaken for
54
+ * a module.
55
+ */
56
+ async function findUsecaseFiles(dir: string = USECASES_DIR, out: string[] = []): Promise<string[]> {
57
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
58
+ const path = join(dir, entry.name);
59
+ if (entry.isDirectory()) {
60
+ await findUsecaseFiles(path, out);
61
+ } else if (entry.name.endsWith(".md") && /^feature:\s*\S/m.test(await Bun.file(path).text())) {
62
+ out.push(path);
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** The package a use-case file belongs to = its directory, exactly as in `packages/`. */
69
+ function packageOf(path: string): string {
70
+ const rel = relative(USECASES_DIR, path);
71
+ return rel.includes("/") ? rel.split("/")[0]! : "—";
72
+ }
73
+
74
+ interface Criterion {
75
+ id: string;
76
+ text: string;
77
+ live: string | null; // "2026-07-14@402b5b9"
78
+ e2e: string | null; // "2026-08-14@a1b2c3d" — см. parseEvidence()
79
+ line: number;
80
+ }
81
+ interface UseCaseFile {
82
+ path: string;
83
+ feature: string;
84
+ title: string;
85
+ criteria: Criterion[];
86
+ /** UC-заголовки, за которыми сразу идёт `### AC` — то есть без описания. */
87
+ bodiless: string[];
88
+ lines: string[];
89
+ }
90
+
91
+ const AC_LINE = /^\s*[-*]\s*\[[ xX]\]\s*`(AC-[A-Z]+-\d+\.\d+)`\s*(.*)$/;
92
+
93
+ /** Полный идентификатор критерия. Ровно он и достаётся из имени теста. */
94
+ const AC_ID = /AC-[A-Z]+-\d+\.\d+/g;
95
+
96
+ /**
97
+ * ОБРЕЗАННАЯ ССЫЛКА В ИМЕНИ ТЕСТА — брак, который иначе не виден никак.
98
+ *
99
+ * Тест, названный `AC-IMP-06.1/.2`, связывается ТОЛЬКО с первым идентификатором: `/.2` не полный
100
+ * ID, и раннер его не видит. Тест зелёный, автор уверен, что доказал оба, а второй критерий тихо
101
+ * остаётся непокрытым — и обнаруживается это счётчиком покрытия через недели, если вообще.
102
+ *
103
+ * Случилось дважды на одном слайсе за один день (`AC-IMP-04.5`, затем `AC-IMP-06.2`), причём
104
+ * второй раз — уже зная про первый. Значит это не невнимательность, а форма, к которой тянет:
105
+ * два соседних критерия проверяются одним сценарием, и объединить их в имени кажется естественным.
106
+ *
107
+ * Ловится тем, ЧТО ИДЁТ СРАЗУ ЗА идентификатором: голое число после `/`, `,`, `и` или тире — это
108
+ * попытка сослаться на соседний критерий, не назвав его. Полный второй ID (`AC-…-06.2`) под правило
109
+ * не подпадает и подпадать не должен: два ID в имени — законный способ доказать оба одним тестом.
110
+ */
111
+ export function danglingRefs(name: string): string[] {
112
+ const найдено: string[] = [];
113
+ for (const m of name.matchAll(AC_ID)) {
114
+ const хвост = name.slice((m.index ?? 0) + m[0].length);
115
+ const обрезок = /^\s*(?:[/,]|и\s|[-–—])\s*\.?\d+/.exec(хвост);
116
+ if (обрезок) найдено.push(`${m[0]}${обрезок[0]}`);
117
+ }
118
+ return найдено;
119
+ }
120
+ const LIVE = /·\s*live:(\d{4}-\d{2}-\d{2})@([0-9a-f]{7,40})/;
121
+ const E2E = /`e2e:(\d{4}-\d{2}-\d{2}@[0-9a-f]{7,40})`/;
122
+ const UC_HEADING = /^## (UC-[A-Z]+-\d+)\./;
123
+
124
+ /**
125
+ * Доказательство критерия одной строкой. Два вида, оба с датой и коммитом, чтобы видимо протухать:
126
+ * `live:ГГГГ-ММ-ДД@<коммит>` — прогон на стенде, поведение внешней системы. Формат — БЕЗ обратных
127
+ * кавычек, `· live:...` (так задокументировано в `traceability.md` и так его пишут руками);
128
+ * меняем его тут неохотно, чтобы не разойтись с уже написанным.
129
+ * `e2e:ГГГГ-ММ-ДД@<коммит>` — браузерный прогон уровня 4, ставится ТОЛЬКО прогоном (`claimAc` из
130
+ * `e2e/support.ts`), поэтому вложен в обратные кавычки: так его пишет `claimAc`, и так же он
131
+ * отличим от произвольного текста критерия, где обратные кавычки — обычное дело (например,
132
+ * `` `draft` ``).
133
+ *
134
+ * Второй заведён потому, что уровень 4 не идёт через `bun test` и в junit не попадает: браузерный
135
+ * тест физически не мог отметить ни одного критерия, и UI-часть требований оставалась недоказуемой
136
+ * тем гейтом, который для этого и существует.
137
+ *
138
+ * Дата без коммита доказательством НЕ считается: дату проставить легко, а коммит привязывает
139
+ * доказательство к состоянию кода.
140
+ *
141
+ * Номера строки в результате нет: функция не знает своей позиции в файле — это знает вызывающий.
142
+ */
143
+ export function parseEvidence(line: string): Omit<Criterion, "line"> | null {
144
+ const m = line.match(AC_LINE);
145
+ if (!m) return null;
146
+ const [, id, tail] = m;
147
+ const rest = tail ?? "";
148
+ return {
149
+ id: id!,
150
+ // Сначала снимаем маркер e2e (его `claimAc` мог дописать без «·» перед ним — иначе он попал бы
151
+ // в текст критерия), потом отрезаем всё после первого «·» (старые тесты/live-евиденс).
152
+ text: rest.replace(E2E, "").split("·")[0]!.trim(),
153
+ live: rest.match(LIVE) ? `${rest.match(LIVE)![1]}@${rest.match(LIVE)![2]}` : null,
154
+ e2e: rest.match(E2E)?.[1] ?? null,
155
+ };
156
+ }
157
+
158
+ function parse(path: string, source: string): UseCaseFile {
159
+ const lines = source.split("\n");
160
+ const feature = source.match(/^feature:\s*(.+)$/m)?.[1]?.trim() ?? "?";
161
+ const title = source.match(/^title:\s*(.+)$/m)?.[1]?.trim() ?? path;
162
+ const criteria: Criterion[] = [];
163
+ const bodiless: string[] = [];
164
+
165
+ lines.forEach((line, i) => {
166
+ // A use case whose heading is followed straight by its AC list is not a use case — it is a
167
+ // checklist wearing one. That is exactly how the prose (Цель/Актёр/Поток/Ошибки/Полиморфизм)
168
+ // was once lost in a refactor without a single test going red: nothing was watching.
169
+ const uc = line.match(UC_HEADING);
170
+ if (uc) {
171
+ const next = lines.slice(i + 1).find((l) => l.trim().length > 0) ?? "";
172
+ if (next.startsWith("###")) bodiless.push(uc[1]!);
173
+ return;
174
+ }
175
+
176
+ const c = parseEvidence(line);
177
+ if (!c) return;
178
+ criteria.push({ ...c, line: i });
179
+ });
180
+
181
+ return { path, feature, title, criteria, bodiless, lines };
182
+ }
183
+
184
+ // ── what the tests proved ───────────────────────────────────────────────────────────────────────
185
+ interface Proof {
186
+ passing: Map<string, string[]>; // AC id → test names that pass
187
+ failing: Map<string, string[]>;
188
+ unknownIds: Set<string>;
189
+ /** Имена тестов с обрезанной ссылкой на соседний критерий. Брак, а не предупреждение. */
190
+ dangling: Map<string, string[]>;
191
+ }
192
+
193
+ async function runTests(knownIds: Set<string>): Promise<Proof> {
194
+ if (!skipTests) {
195
+ // Окружение интеграции — из одного места, а не из строки в `package.json`. Вызов здесь, а не на
196
+ // верхнем уровне файла: при `--no-tests` уровень it не нужен и трогать окружение незачем.
197
+ applyIntegrationEnv();
198
+ // Уровень it — часть доказательства, а не опция. Без TEST_DATABASE_URL интеграционные тесты не
199
+ // регистрируются вовсе: они не падают и не «скипаются» — их просто нет в отчёте. Генератор тогда
200
+ // МОЛЧА снимает галочки с критериев, которые доказываются только интеграцией (так и случилось с
201
+ // AC-IDP-03.2), и отчёт приёмки начинает зависеть от того, из какой оболочки его позвали.
202
+ // Пропущенный уровень — не пройденный: отказываемся переписывать документы, а не врём тихо.
203
+ if (!process.env.TEST_DATABASE_URL) {
204
+ console.error(
205
+ "❌ TEST_DATABASE_URL пуст — уровень it не побежит, и галочки, которые он доказывает,\n" +
206
+ " снялись бы молча. Умолчание живёт в scripts/integration-env.ts; пустым оно бывает\n" +
207
+ " только если его СТЁРЛИ явно (`TEST_DATABASE_URL= bun run ac`).\n" +
208
+ " Нужен только отчёт по прошлому прогону — `bun run scripts/ac.ts --no-tests`."
209
+ );
210
+ process.exit(1);
211
+ }
212
+ // Bun's junit reporter gives us names + status; a non-zero exit just means some test failed,
213
+ // which is itself a result we want to report rather than crash on.
214
+ await $`bun test --reporter=junit --reporter-outfile=${JUNIT}`.cwd(ROOT).nothrow().quiet();
215
+ }
216
+
217
+ const xml = await Bun.file(JUNIT).text();
218
+ const passing = new Map<string, string[]>();
219
+ const failing = new Map<string, string[]>();
220
+ const unknownIds = new Set<string>();
221
+ const dangling = new Map<string, string[]>();
222
+
223
+ // A testcase is either self-closing (`<testcase … />`) or wraps a <failure>. The lazy attr match
224
+ // BEFORE the branch is what keeps a self-closing tag from swallowing the next dozen testcases —
225
+ // the greedy version silently merged them and reported zero coverage.
226
+ for (const match of xml.matchAll(/<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g)) {
227
+ const attrs = match[1] ?? "";
228
+ const body = match[3] ?? "";
229
+ const name = attrs.match(/name="([^"]*)"/)?.[1] ?? "";
230
+ const failed = /<failure|<error/.test(body);
231
+
232
+ const обрезки = danglingRefs(name);
233
+ if (обрезки.length > 0) dangling.set(name, обрезки);
234
+
235
+ for (const id of name.match(AC_ID) ?? []) {
236
+ if (!knownIds.has(id)) {
237
+ unknownIds.add(id);
238
+ continue;
239
+ }
240
+ const bucket = failed ? failing : passing;
241
+ bucket.set(id, [...(bucket.get(id) ?? []), name]);
242
+ }
243
+ }
244
+ return { passing, failing, unknownIds, dangling };
245
+ }
246
+
247
+ // ── rewrite the checkboxes: the file becomes a report of what is true right now ──────────────────
248
+ function daysSince(date: string): number {
249
+ return Math.floor((Date.now() - new Date(date).getTime()) / 86_400_000);
250
+ }
251
+
252
+ /**
253
+ * Критерий доказан: ни один тест не падает, и есть хоть одно доказательство — прошедший тест либо
254
+ * стендовая/браузерная отметка. Единственное место для этой проверки: раньше она была продублирована
255
+ * в трёх независимых местах (`render()`, подсчёт дырок, подсчёт покрытия для index.md), и правка
256
+ * добавления `e2e` требовала синхронно менять все три — следующий вид доказательства забыли бы
257
+ * повторить хотя бы в одном, и критерий оказался бы доказан в одном отчёте и не доказан в другом.
258
+ */
259
+ function isProven(passes: number, fails: number, live: string | null, e2e: string | null): boolean {
260
+ return fails === 0 && (passes > 0 || live !== null || e2e !== null);
261
+ }
262
+
263
+ function render(
264
+ file: UseCaseFile,
265
+ proof: Proof
266
+ ): { text: string; covered: number; stale: string[] } {
267
+ const lines = [...file.lines];
268
+ const stale: string[] = [];
269
+ let covered = 0;
270
+
271
+ for (const c of file.criteria) {
272
+ const passes = proof.passing.get(c.id)?.length ?? 0;
273
+ const fails = proof.failing.get(c.id)?.length ?? 0;
274
+
275
+ const evidence: string[] = [];
276
+ if (passes) evidence.push(`тесты: ${passes}`);
277
+ if (fails) evidence.push(`🔴 падает: ${fails}`);
278
+ let stales = false;
279
+ if (c.live) {
280
+ const age = daysSince(c.live.split("@")[0]!);
281
+ evidence.push(`live:${c.live}${age > STALE_DAYS ? ` ⚠️ ${age}д` : ""}`);
282
+ if (age > STALE_DAYS) stales = true;
283
+ }
284
+ if (c.e2e) {
285
+ // Без инлайн-предупреждения после кавычки: `claimAc` ищет `` `e2e:...` `` регуляркой, которая
286
+ // требует, чтобы маркер был ПОСЛЕДНИМ на строке — текст после закрывающей кавычки сломал бы
287
+ // поиск на следующем прогоне claimAc. Протухание видно в сводном списке ниже.
288
+ const age = daysSince(c.e2e.split("@")[0]!);
289
+ evidence.push(`\`e2e:${c.e2e}\``);
290
+ if (age > STALE_DAYS) stales = true;
291
+ }
292
+ if (stales) stale.push(c.id);
293
+
294
+ const proven = isProven(passes, fails, c.live, c.e2e);
295
+ if (proven) covered++;
296
+
297
+ const tail = evidence.length ? ` · ${evidence.join(" · ")}` : "";
298
+ lines[c.line] = `- [${proven ? "x" : " "}] \`${c.id}\` ${c.text}${tail}`;
299
+ }
300
+
301
+ return { text: lines.join("\n"), covered, stale };
302
+ }
303
+
304
+ // ── main ────────────────────────────────────────────────────────────────────────────────────────
305
+ // Под гвардом: `ac.test.ts` импортирует `parseEvidence` из этого же модуля, а импорт ES-модуля
306
+ // выполняет его тело целиком. Без гварда юнит-тест на чистую функцию попутно гонял бы весь отчёт —
307
+ // писал файлы в docs/usecases и падал по process.exit(1) из-за отсутствующего TEST_DATABASE_URL.
308
+ if (import.meta.main) {
309
+ const files = (await findUsecaseFiles()).sort();
310
+ const parsed = await Promise.all(files.map(async (p) => parse(p, await Bun.file(p).text())));
311
+ const knownIds = new Set(parsed.flatMap((f) => f.criteria.map((c) => c.id)));
312
+
313
+ console.log(`📄 use-case файлов: ${parsed.length}, критериев: ${knownIds.size}`);
314
+
315
+ // Пустой каталог — это ПРОВАЛ, а не «покрыто 0/0».
316
+ //
317
+ // `new-project.sh` штатно велит удалить демо-пакет вместе с его use cases. После этого прогон
318
+ // печатал «✅ покрыто 0/0» и выходил нулём: `verify:full` зелёный, доказано ничего. Это ровно тот
319
+ // молчаливый успех, против которого написан весь этот скрипт, — только на уровень выше.
320
+ if (knownIds.size === 0) {
321
+ console.error(
322
+ "\n❌ ни одного критерия приёмки: в " +
323
+ relative(ROOT, USECASES_DIR) +
324
+ " нет ни одного use case с `feature:` в шапке.\n" +
325
+ " Зелёный прогон без критериев ничего не доказывает. Опиши первый use case — или, если\n" +
326
+ " проект действительно ещё без них, убери `ac` из `verify:full`, а не делай его пустым."
327
+ );
328
+ process.exit(1);
329
+ }
330
+ const proof = await runTests(knownIds);
331
+
332
+ const rows: string[] = [];
333
+ let totalCovered = 0;
334
+ let totalCriteria = 0;
335
+ const holes: string[] = [];
336
+ const staleAll: string[] = [];
337
+
338
+ for (const file of parsed) {
339
+ const { text, covered, stale } = render(file, proof);
340
+ await Bun.write(file.path, text);
341
+
342
+ totalCovered += covered;
343
+ totalCriteria += file.criteria.length;
344
+ staleAll.push(...stale);
345
+
346
+ for (const c of file.criteria) {
347
+ const proven = isProven(
348
+ proof.passing.get(c.id)?.length ?? 0,
349
+ proof.failing.get(c.id)?.length ?? 0,
350
+ c.live,
351
+ c.e2e
352
+ );
353
+ if (!proven) holes.push(`${c.id} — ${c.text}`);
354
+ }
355
+
356
+ const pct = file.criteria.length ? Math.round((covered / file.criteria.length) * 100) : 0;
357
+ rows.push(
358
+ `| ${packageOf(file.path)} | ${file.feature} | ${file.title} | ${covered}/${file.criteria.length} | ${pct}% | \`${relative(ROOT, file.path)}\` |`
359
+ );
360
+ }
361
+
362
+ // The aggregate: an index, not a second copy of the prose. The module file stays the source.
363
+ const report = `# Покрытие критериев приёмки
364
+
365
+ > Сгенерировано \`bun run ac\` — не правь руками. Критерии живут в \`docs/usecases/<пакет>/<слайс>.md\`;
366
+ > галочку ставит прогон, а не человек.
367
+
368
+ **Итог: ${totalCovered}/${totalCriteria} (${Math.round((totalCovered / Math.max(1, totalCriteria)) * 100)}%)** · непокрыто: ${holes.length}
369
+
370
+ | Пакет | Ф | Слайс | Покрыто | % | Файл |
371
+ |---|---|---|---|---|---|
372
+ ${rows.join("\n")}
373
+
374
+ Какие именно критерии не покрыты — видно там же, где они живут: незакрытый \`- [ ]\` в файле модуля.
375
+ Второй копии списка здесь нет намеренно: свод — это оглавление, а не вторая копия прозы.
376
+ ${staleAll.length ? `\n## ⚠️ Протухшие доказательства (\`live\`/\`e2e\`, > ${STALE_DAYS} дней)\n\n${staleAll.map((s) => `- ${s}`).join("\n")}\n\nЭто НЕ дубль галочек: отметка \`live:\`/\`e2e:\` протухает молча, и единственное место, где это видно сразу, — здесь.\n` : ""}`;
377
+
378
+ await Bun.write(join(ROOT, "docs/acceptance.md"), report);
379
+
380
+ // docs/usecases/index.md — СВОД: оглавление по модулям + покрытие. Проза живёт в файле модуля,
381
+ // здесь только указатель, иначе появится вторая копия правды, и она разойдётся с первой.
382
+ /**
383
+ * Ссылка ставится, ТОЛЬКО если цель существует.
384
+ *
385
+ * Генератор печатал указатели на `engineering/traceability.md`, `glossary.md` и
386
+ * `engineering/decisions.md` — документы, которых он не создаёт и которых в ПРОДУКТЕ может не быть
387
+ * вовсе. Сверка артефактов ловила их битой ссылкой, и чинили это правкой СГЕНЕРИРОВАННОГО файла,
388
+ * то есть заводили копию генератора, расходящуюся с ним на первом же прогоне. Генератор,
389
+ * ссылающийся в пустоту, чинится в генераторе.
390
+ */
391
+ const есть = (rel: string) => existsSync(join(ROOT, "docs", rel));
392
+ const ссылка = (rel: string, подпись: string) =>
393
+ есть(rel) ? `[\`../${rel}\`](../${rel})` : `\`${подпись}\` (в этой репе нет)`;
394
+
395
+ const механика = есть("engineering/traceability.md")
396
+ ? ` (механика — ${ссылка("engineering/traceability.md", "")})`
397
+ : "";
398
+ const инварианты = есть("engineering/decisions.md")
399
+ ? `это \`NFR-*\` в ${ссылка("engineering/decisions.md", "")}, а не отдельный список: второй копии не будет`
400
+ : "каждый UC называет их сам: единого реестра `NFR-*` в этой репе нет";
401
+
402
+ const index = `# Use Cases
403
+
404
+ > Сгенерировано \`bun run ac\`. **Не правь этот файл** — правь \`docs/usecases/<пакет>/<слайс>.md\`.
405
+ > Раскладка повторяет код: пакет → слайс.
406
+ > Галочку ставит прогон, а не человек: она отчёт, а не обещание${механика}.
407
+ >
408
+ > Термины и действующие лица → ${ссылка("glossary.md", "docs/glossary.md")}. Инварианты, которые держат
409
+ > ВСЕ UC (deny-default, идемпотентность, актор из принципала, серверные списки, аудит) — ${инварианты}.
410
+
411
+ **Покрытие: ${totalCovered}/${totalCriteria} (${Math.round((totalCovered / Math.max(1, totalCriteria)) * 100)}%)** · подробности: [\`../acceptance.md\`](../acceptance.md)
412
+
413
+ | Пакет | Ф | Слайс | Критерии | Покрыто | Файл |
414
+ |---|---|---|---|---|---|
415
+ ${parsed
416
+ .slice()
417
+ .sort(
418
+ (a, b) =>
419
+ packageOf(a.path).localeCompare(packageOf(b.path)) ||
420
+ Number(a.feature.replace(/\D/g, "")) - Number(b.feature.replace(/\D/g, ""))
421
+ )
422
+ .map((f) => {
423
+ const covered = f.criteria.filter((c) =>
424
+ isProven(
425
+ proof.passing.get(c.id)?.length ?? 0,
426
+ proof.failing.get(c.id)?.length ?? 0,
427
+ c.live,
428
+ c.e2e
429
+ )
430
+ ).length;
431
+ const rel = relative(USECASES_DIR, f.path);
432
+ return `| \`${packageOf(f.path)}\` | ${f.feature} | ${f.title} | ${f.criteria.length} | ${covered} | [\`${rel}\`](${rel}) |`;
433
+ })
434
+ .join("\n")}
435
+
436
+ ## Как читать
437
+
438
+ - **Критерий без галочки** — это либо реальная дырка, либо тест, который его доказывает, но не
439
+ сослался на ID. Скрипт не умеет их различать и не притворяется, что умеет.
440
+ - **\`live:ДАТА@коммит\`** — доказано на стенде: то, что тестом не докажешь (поведение внешней системы,
441
+ лимит через настоящий прокси). Протухает через 60 дней, иначе стало бы лазейкой.
442
+ - **\`e2e:ДАТА@коммит\`** — доказано браузерным прогоном уровня 4 (\`claimAc\` в \`apps/web/e2e/support.ts\`):
443
+ уровень 4 не идёт через \`bun test\` и в junit не попадает, поэтому ставит отметку сам прогон. Тоже
444
+ протухает через 60 дней.
445
+ `;
446
+ await Bun.write(join(USECASES_DIR, "index.md"), index);
447
+
448
+ console.log(`✅ покрыто ${totalCovered}/${totalCriteria}; отчёт → docs/acceptance.md`);
449
+
450
+ // A UC that is only a checkbox list has lost the thing a use case IS: цель, актёр, поток, ошибки,
451
+ // полиморфизм. It once happened silently during a refactor, and no test could see it — so the
452
+ // engine looks.
453
+ const bodiless = parsed.flatMap((f) =>
454
+ f.bodiless.map((uc) => `${uc} (${relative(ROOT, f.path)})`)
455
+ );
456
+ if (bodiless.length > 0) {
457
+ console.warn(
458
+ `\n⚠️ use case без описания (сразу «### AC»): ${bodiless.join(", ")}\n` +
459
+ " Критерии — это ещё не use case. Допиши Цель · Актёр · Поток · Ошибки · Полиморфизм."
460
+ );
461
+ if (strict) process.exit(1);
462
+ }
463
+ /**
464
+ * Отказ идёт ПЕРВЫМ, раньше остальных: обрезанная ссылка — причина, а «непокрытый критерий» ниже
465
+ * — её следствие, и увидев только следствие, чинить будут не то.
466
+ */
467
+ if (proof.dangling.size > 0) {
468
+ console.error(
469
+ `\n❌ имя теста ссылается на соседний критерий, не называя его (${proof.dangling.size}):`
470
+ );
471
+ for (const [name, обрезки] of proof.dangling) {
472
+ console.error(` ${name}`);
473
+ console.error(` обрезано: ${обрезки.join(", ")}`);
474
+ }
475
+ console.error(
476
+ "\n Раннер связывает тест ТОЛЬКО с полными идентификаторами: `AC-XXX-01.1/.2` доказывает\n" +
477
+ " первый критерий и молча оставляет второй непокрытым — при зелёном тесте.\n" +
478
+ " Почини одним из двух: раздели на два теста (обычно верно — у критериев разные утверждения)\n" +
479
+ " либо назови оба полностью: `AC-XXX-01.1 и AC-XXX-01.2 — …`."
480
+ );
481
+ process.exit(1);
482
+ }
483
+
484
+ if (proof.unknownIds.size > 0) {
485
+ console.error(
486
+ `\n❌ тесты ссылаются на несуществующие критерии: ${[...proof.unknownIds].join(", ")}`
487
+ );
488
+ process.exit(1);
489
+ }
490
+ if (strict && holes.length > 0) {
491
+ console.error(`\n❌ непокрытых критериев: ${holes.length}`);
492
+ process.exit(1);
493
+ }
494
+ }