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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight for a verification tier.
|
|
3
|
+
*
|
|
4
|
+
* The rule: **a tier whose prerequisites are missing FAILS — it does not skip.** Today the
|
|
5
|
+
* integration suite silently skips without `TEST_DATABASE_URL`, so a CI run with no Postgres is
|
|
6
|
+
* green and proves nothing. That is the same lie as a hand-ticked checkbox, and it is worse in CI,
|
|
7
|
+
* because nobody is looking.
|
|
8
|
+
*
|
|
9
|
+
* So: each tier declares what it needs, this checks it, and when something is missing it says what
|
|
10
|
+
* to run — instead of a green tick over an empty suite.
|
|
11
|
+
*
|
|
12
|
+
* bun run scripts/preflight.ts it # tier 3: real Postgres
|
|
13
|
+
* bun run scripts/preflight.ts e2e # tier 4: Postgres + a live app + a browser
|
|
14
|
+
*/
|
|
15
|
+
const tier = Bun.argv[2] ?? "unit";
|
|
16
|
+
|
|
17
|
+
type Check = { what: string; ok: boolean | Promise<boolean>; fix: string };
|
|
18
|
+
|
|
19
|
+
const DB = process.env.TEST_DATABASE_URL ?? "";
|
|
20
|
+
const BASE = (process.env.E2E_BASE_URL || "http://localhost:3000").replace(/\/+$/, "");
|
|
21
|
+
/** Креды уровня e2e — те же, что читает `apps/web/e2e/support.ts`. Один источник, иначе разойдутся. */
|
|
22
|
+
const E2E_EMAIL = process.env.E2E_EMAIL || process.env.SEED_ADMIN_EMAIL || "admin@example.com";
|
|
23
|
+
const E2E_PASSWORD = process.env.E2E_PASSWORD || process.env.SEED_ADMIN_PASSWORD || "";
|
|
24
|
+
/** Пакетов в прогоне ~6, у каждого свой пул; плюс запас на psql/dev. */
|
|
25
|
+
const MIN_FREE_SLOTS = 30;
|
|
26
|
+
|
|
27
|
+
async function pgReachable(url: string): Promise<boolean> {
|
|
28
|
+
if (!url) return false;
|
|
29
|
+
try {
|
|
30
|
+
// Bun's own Postgres client — no dependency to resolve from the repo root (the `pg` import did
|
|
31
|
+
// not resolve here and made a perfectly healthy database look down: a preflight that lies is
|
|
32
|
+
// worse than none).
|
|
33
|
+
const sql = new Bun.SQL(url);
|
|
34
|
+
await sql`select 1`;
|
|
35
|
+
await sql.end();
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Enough free connection slots to run the suite.
|
|
44
|
+
*
|
|
45
|
+
* This looks pedantic and is not: a `bun run dev` left running holds a pool against the SAME
|
|
46
|
+
* Postgres, and the suite then dies with "remaining connection slots are reserved for superuser" —
|
|
47
|
+
* which surfaces as DrizzleQueryError deep inside unrelated tests. It reads as a code regression, and
|
|
48
|
+
* you will go bisecting commits for an hour before you look at `pg_stat_activity`. Ask the question
|
|
49
|
+
* here, once, and say what to close.
|
|
50
|
+
*/
|
|
51
|
+
async function enoughConnectionSlots(url: string): Promise<boolean> {
|
|
52
|
+
if (!url) return false;
|
|
53
|
+
try {
|
|
54
|
+
const sql = new Bun.SQL(url);
|
|
55
|
+
const [row] = await sql`
|
|
56
|
+
select
|
|
57
|
+
(select setting::int from pg_settings where name = 'max_connections') as max,
|
|
58
|
+
(select count(*)::int from pg_stat_activity) as used
|
|
59
|
+
`;
|
|
60
|
+
await sql.end();
|
|
61
|
+
const free = (row?.max ?? 0) - (row?.used ?? 0);
|
|
62
|
+
if (free < MIN_FREE_SLOTS) {
|
|
63
|
+
console.error(
|
|
64
|
+
`\n ℹ свободных слотов Postgres: ${free} (из ${row?.max}). Тесты открывают пул на пакет.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return free >= MIN_FREE_SLOTS;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Есть ли пользователь, которым сценарий собирается войти.
|
|
75
|
+
*
|
|
76
|
+
* Без него e2e падает на форме входа с сообщением про неверные креды — то есть выглядит как поломка
|
|
77
|
+
* авторизации, хотя это просто незасеянная база. Спросить один раз здесь дешевле, чем разбираться потом.
|
|
78
|
+
*/
|
|
79
|
+
async function userSeeded(databaseUrl: string, email: string): Promise<boolean> {
|
|
80
|
+
if (!databaseUrl || !email) return false;
|
|
81
|
+
try {
|
|
82
|
+
const sql = new Bun.SQL(databaseUrl);
|
|
83
|
+
const rows = await sql`select count(*)::int as n from users where email = ${email}`;
|
|
84
|
+
await sql.end();
|
|
85
|
+
return (rows[0]?.n ?? 0) > 0;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function httpOk(url: string): Promise<boolean> {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
|
94
|
+
return res.ok;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const CHECKS: Record<string, Check[]> = {
|
|
101
|
+
unit: [],
|
|
102
|
+
|
|
103
|
+
it: [
|
|
104
|
+
{
|
|
105
|
+
what: "TEST_DATABASE_URL задан",
|
|
106
|
+
ok: DB.length > 0,
|
|
107
|
+
fix: "export TEST_DATABASE_URL=postgres://app:app@localhost:5432/app_test",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
what: "Postgres отвечает",
|
|
111
|
+
// ТОЛЬКО доступность. Мигрирована ли база — не спрашиваем: `test:it` прогоняет `db:migrate`
|
|
112
|
+
// перед сюитой, и авторитет по состоянию схемы там, где он и должен быть, — у drizzle.
|
|
113
|
+
// Прежняя подпись здесь обещала «И база мигрирована», а проверяла «в public есть хоть одна
|
|
114
|
+
// таблица». Отставшая база проходила гейт, и падение слайса выглядело как регрессия кода.
|
|
115
|
+
ok: pgReachable(DB),
|
|
116
|
+
fix: "createdb app_test (или подними Postgres)",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
what: `свободно ≥${MIN_FREE_SLOTS} слотов подключений Postgres`,
|
|
120
|
+
ok: enoughConnectionSlots(DB),
|
|
121
|
+
fix:
|
|
122
|
+
"закрой то, что держит пул (чаще всего — забытый `bun run dev`):\n" +
|
|
123
|
+
" pkill -f 'bun --hot src/server.ts'\n" +
|
|
124
|
+
" иначе прогон умрёт с «remaining connection slots are reserved for superuser» ВНУТРИ\n" +
|
|
125
|
+
" посторонних тестов, и это будет выглядеть как регрессия в коде — а это лимит ресурса",
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
|
|
129
|
+
e2e: [
|
|
130
|
+
{
|
|
131
|
+
what: `приложение живо на ${BASE}`,
|
|
132
|
+
// Именно `/api/health`, а НЕ произвольный путь: SPA-фолбэк отдаёт 200 на что угодно, поэтому
|
|
133
|
+
// проверка любого другого адреса проходила бы всегда — и была бы преформой, а не проверкой.
|
|
134
|
+
ok: httpOk(`${BASE}/api/health`),
|
|
135
|
+
fix: `bun run dev (или E2E_BASE_URL=<адрес> bun run verify:all)`,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
what: "пароль для входа задан (E2E_PASSWORD или SEED_ADMIN_PASSWORD)",
|
|
139
|
+
ok: E2E_PASSWORD.length > 0,
|
|
140
|
+
fix:
|
|
141
|
+
"задай SEED_ADMIN_PASSWORD в .env и засей пользователя: bun run db:seed\n" +
|
|
142
|
+
" пароля по умолчанию здесь нет намеренно — известный пароль администратора хуже, чем его отсутствие",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
what: "засеянный пользователь есть в базе",
|
|
146
|
+
ok: userSeeded(process.env.DATABASE_URL ?? "", E2E_EMAIL),
|
|
147
|
+
fix: "bun run db:seed (без пользователя войти нечем, и сценарий упадёт на форме входа)",
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const checks = CHECKS[tier];
|
|
153
|
+
if (!checks) {
|
|
154
|
+
console.error(`неизвестный уровень: ${tier} (unit | it | e2e)`);
|
|
155
|
+
process.exit(2);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const results = await Promise.all(checks.map(async (c) => ({ ...c, ok: await c.ok })));
|
|
159
|
+
const failed = results.filter((r) => !r.ok);
|
|
160
|
+
|
|
161
|
+
for (const r of results) console.log(` ${r.ok ? "✅" : "❌"} ${r.what}`);
|
|
162
|
+
|
|
163
|
+
if (failed.length > 0) {
|
|
164
|
+
console.error(`\n❌ уровень «${tier}» не может быть запущен — не выполнены условия:\n`);
|
|
165
|
+
for (const f of failed) console.error(` ${f.what}\n → ${f.fix}\n`);
|
|
166
|
+
console.error(
|
|
167
|
+
"Это НЕ повод пропустить уровень: пропущенный уровень выглядит как пройденный, и именно так\n" +
|
|
168
|
+
"в CI появляется зелёный прогон, который ничего не доказал.\n"
|
|
169
|
+
);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|