letopis 0.19.0 → 0.20.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/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +430 -100
- package/dist/acl.d.ts +7 -2
- package/dist/acl.js +3 -2
- package/dist/chain.d.ts +25 -0
- package/dist/chain.js +28 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.js +45 -22
- package/dist/schema.js +48 -6
- package/dist/sql.d.ts +18 -1
- package/dist/sql.js +111 -36
- package/dist/tables.js +9 -1
- package/dist/types.d.ts +30 -7
- package/dist/types.js +40 -3
- package/dist/up.js +15 -4
- package/dist/write.js +8 -5
- package/package.json +9 -3
- package/scripts/check-docs.mjs +338 -0
- package/scripts/gen-api-contract.mjs +221 -0
- package/scripts/gen-types.mjs +238 -42
- package/sql/seed.booking.sql +5 -4
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Сверка документации с кодом — гвард против дрейфа:
|
|
4
|
+
* cd lib && npm run check:docs (в CI — шагом после typecheck)
|
|
5
|
+
*
|
|
6
|
+
* Ничего не правит: печатает список расхождений и выходит с кодом 1.
|
|
7
|
+
* Проверок восемь — по классам дефектов, которые уже случались в этом репозитории:
|
|
8
|
+
* 1) версия пакета == верхняя запись CHANGELOG (semver не дублируется в docs/*.xml);
|
|
9
|
+
* две копии LICENSE (корень для GitHub, lib/ для npm) совпадают и соответствуют package.json
|
|
10
|
+
* 2) мёртвые ссылки на файлы репо из *.md и хедеров bench/*.mjs
|
|
11
|
+
* 3) список тест-файлов в README §16 == файлы на диске
|
|
12
|
+
* 4) число классов в заголовке SQL-сида == число INSERT-ов
|
|
13
|
+
* 5) codescribe-целостность: парность маркеров, M-* ↔ файл, M-* → V-M-*
|
|
14
|
+
* 6) docs/api-contract.json актуален (регенерация без расхождений)
|
|
15
|
+
* 7) RESERVED_CLASS_NAMES == case-метки Proxy в chain.ts; они же в таблице README §3
|
|
16
|
+
* 8) bench/*.mjs: синтаксис + импорты из либы существуют в barrel'е
|
|
17
|
+
*/
|
|
18
|
+
//
|
|
19
|
+
// FILE: lib/scripts/check-docs.mjs
|
|
20
|
+
// VERSION: 1.0.0
|
|
21
|
+
// START_MODULE_CONTRACT
|
|
22
|
+
// PURPOSE: Проверить, что документация не разошлась с кодом; при расхождении — внятный отчёт и exit 1.
|
|
23
|
+
// SCOPE: 8 проверок (версия+LICENSE, ссылки, список тестов, счётчики сидов, codescribe-целостность, актуальность api-contract, зарезервированные имена, импорты демо).
|
|
24
|
+
// DEPENDS: none
|
|
25
|
+
// LINKS: M-CHECK-DOCS, V-M-CHECK-DOCS
|
|
26
|
+
// ROLE: SCRIPT
|
|
27
|
+
// MAP_MODE: LOCALS
|
|
28
|
+
// END_MODULE_CONTRACT
|
|
29
|
+
//
|
|
30
|
+
// START_MODULE_MAP
|
|
31
|
+
// fail/ok - накопление проблем и отметки пройденных проверок
|
|
32
|
+
// rd - чтение файла относительно корня репозитория
|
|
33
|
+
// checkVersion - package.json vs CHANGELOG + совпадение двух копий LICENSE
|
|
34
|
+
// checkLinks - мёртвые ссылки на файлы репо
|
|
35
|
+
// checkTestList - README §16 vs test/*.test.ts
|
|
36
|
+
// checkSeedCounts - заголовок сида vs число INSERT-ов
|
|
37
|
+
// checkCodescribe - парность маркеров и связность M-* / V-M-*
|
|
38
|
+
// checkApiContract - актуальность docs/api-contract.json
|
|
39
|
+
// checkReserved - RESERVED_CLASS_NAMES vs case-метки chain.ts vs таблица README
|
|
40
|
+
// checkDemos - bench/*.mjs: синтаксис + импорты из либы существуют в barrel'е
|
|
41
|
+
// END_MODULE_MAP
|
|
42
|
+
//
|
|
43
|
+
// START_CHANGE_SUMMARY
|
|
44
|
+
// LAST_CHANGE: [v1.0.0 - Новый модуль: автоматическая сверка документации с кодом для CI]
|
|
45
|
+
// END_CHANGE_SUMMARY
|
|
46
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
47
|
+
import { existsSync } from 'node:fs';
|
|
48
|
+
import { execFileSync } from 'node:child_process';
|
|
49
|
+
import { fileURLToPath } from 'node:url';
|
|
50
|
+
import { join, dirname, resolve, relative } from 'node:path';
|
|
51
|
+
|
|
52
|
+
const LIB = fileURLToPath(new URL('..', import.meta.url));
|
|
53
|
+
const ROOT = resolve(LIB, '..');
|
|
54
|
+
const problems = [];
|
|
55
|
+
const passed = [];
|
|
56
|
+
const fail = (check, msg) => problems.push(`${check}: ${msg}`);
|
|
57
|
+
const ok = (check, msg) => passed.push(`${check} — ${msg}`);
|
|
58
|
+
const rd = (p) => readFile(join(ROOT, p), 'utf8');
|
|
59
|
+
|
|
60
|
+
// START_BLOCK_CHECK_VERSION
|
|
61
|
+
// Класс дефекта: docs/technology.xml держал v0.18.0, когда пакет был 0.19.0.
|
|
62
|
+
async function checkVersion() {
|
|
63
|
+
const pkg = JSON.parse(await rd('lib/package.json'));
|
|
64
|
+
const changelog = await rd('lib/CHANGELOG.md');
|
|
65
|
+
const top = changelog.match(/^##\s*\[(\d+\.\d+\.\d+)\]/m);
|
|
66
|
+
if (!top) return fail('version', 'в lib/CHANGELOG.md не найдена верхняя запись вида "## [x.y.z]"');
|
|
67
|
+
if (top[1] !== pkg.version) {
|
|
68
|
+
return fail('version', `lib/package.json = ${pkg.version}, верхняя запись CHANGELOG = ${top[1]}`);
|
|
69
|
+
}
|
|
70
|
+
// semver не должен дублироваться в машинной карте — иначе снова разойдётся
|
|
71
|
+
const tech = await rd('docs/technology.xml');
|
|
72
|
+
const dup = tech.match(/letopis"?\s+v?(\d+\.\d+\.\d+)/);
|
|
73
|
+
if (dup) fail('version', `docs/technology.xml снова дублирует semver (${dup[1]}) — источник истины только package.json + CHANGELOG`);
|
|
74
|
+
else ok('version', `${pkg.version} согласован (package.json ↔ CHANGELOG), в docs не дублируется`);
|
|
75
|
+
|
|
76
|
+
// LICENSE лежит в двух местах: корень (для GitHub) и lib/ (уходит в npm-тарбол).
|
|
77
|
+
// Копии обязаны совпадать, иначе разойдутся по годам/владельцу.
|
|
78
|
+
const [rootLic, libLic] = await Promise.all([rd('LICENSE'), rd('lib/LICENSE')]);
|
|
79
|
+
if (rootLic.trim() !== libLic.trim()) fail('version', 'LICENSE в корне и в lib/ различаются');
|
|
80
|
+
else if (!new RegExp(`\\b${pkg.license}\\b`).test(rootLic)) {
|
|
81
|
+
fail('version', `LICENSE не похож на ${pkg.license} из package.json`);
|
|
82
|
+
} else ok('version', `LICENSE: копии корень/lib совпадают и соответствуют ${pkg.license}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// END_BLOCK_CHECK_VERSION
|
|
86
|
+
// START_BLOCK_CHECK_LINKS
|
|
87
|
+
// Класс дефекта: SALON.md удалён, но на него осталось 5 живых ссылок.
|
|
88
|
+
async function checkLinks() {
|
|
89
|
+
const files = [
|
|
90
|
+
'README.md', 'AGENTS.md', 'AGENT-CHEATSHEET.md', 'BACKLOG.md', 'COMPARISON.md',
|
|
91
|
+
'RESEARCH.md', 'lib/README.md',
|
|
92
|
+
...(await readdir(join(ROOT, 'lib', 'bench'))).filter((f) => f.endsWith('.mjs')).map((f) => `lib/bench/${f}`),
|
|
93
|
+
].filter((f) => existsSync(join(ROOT, f)));
|
|
94
|
+
|
|
95
|
+
let checked = 0;
|
|
96
|
+
for (const f of files) {
|
|
97
|
+
const text = await rd(f);
|
|
98
|
+
const base = dirname(join(ROOT, f));
|
|
99
|
+
// markdown-ссылки на файлы репо
|
|
100
|
+
for (const m of text.matchAll(/\]\(([^)#\s]+\.(?:md|json|xml|sql|mjs|ts|yml))(?:#[^)]*)?\)/g)) {
|
|
101
|
+
const target = m[1];
|
|
102
|
+
if (/^(https?:|mailto:)/.test(target)) continue;
|
|
103
|
+
checked++;
|
|
104
|
+
if (!existsSync(resolve(base, target))) fail('links', `${f} → ${target} (файла нет)`);
|
|
105
|
+
}
|
|
106
|
+
// упоминания *.md в прозе/хедерах: ловит «статья SALON.md» без markdown-ссылки
|
|
107
|
+
for (const m of text.matchAll(/\b([A-Z][A-Z0-9_-]{2,})\.md\b/g)) {
|
|
108
|
+
const name = `${m[1]}.md`;
|
|
109
|
+
if (name === 'README.md' || name === 'CHANGELOG.md') continue;
|
|
110
|
+
checked++;
|
|
111
|
+
if (!existsSync(join(ROOT, name)) && !existsSync(join(ROOT, 'lib', name))) {
|
|
112
|
+
fail('links', `${f} упоминает ${name} — такого файла в репозитории нет`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!problems.some((p) => p.startsWith('links:'))) ok('links', `${checked} ссылок/упоминаний, все ведут в существующие файлы`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// END_BLOCK_CHECK_LINKS
|
|
120
|
+
// START_BLOCK_CHECK_TESTLIST
|
|
121
|
+
// Класс дефекта: README §16 не упоминал wave5.test.ts (новейшая фича релиза).
|
|
122
|
+
async function checkTestList() {
|
|
123
|
+
const onDisk = (await readdir(join(LIB, 'test'))).filter((f) => f.endsWith('.test.ts')).map((f) => f.replace('.test.ts', ''));
|
|
124
|
+
const readme = await rd('lib/README.md');
|
|
125
|
+
const section = readme.slice(readme.indexOf('## 16. Тесты'));
|
|
126
|
+
const listed = [...section.matchAll(/^\s*#\s+([a-z0-9-]+)\s+—/gm)].map((m) => m[1]);
|
|
127
|
+
const missing = onDisk.filter((t) => !listed.includes(t));
|
|
128
|
+
const extra = listed.filter((t) => !onDisk.includes(t));
|
|
129
|
+
if (missing.length) fail('test-list', `README §16 не описывает: ${missing.join(', ')}`);
|
|
130
|
+
if (extra.length) fail('test-list', `README §16 описывает несуществующие: ${extra.join(', ')}`);
|
|
131
|
+
if (!missing.length && !extra.length) ok('test-list', `${onDisk.length} тест-файлов, все описаны в README §16`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// END_BLOCK_CHECK_TESTLIST
|
|
135
|
+
// START_BLOCK_CHECK_SEEDS
|
|
136
|
+
// Класс дефекта: заголовок seed.booking.sql говорил «19 классов» при 20 INSERT-ах.
|
|
137
|
+
async function checkSeedCounts() {
|
|
138
|
+
for (const f of ['lib/sql/seed.booking.sql', 'lib/sql/seed.auth.sql']) {
|
|
139
|
+
const text = await rd(f);
|
|
140
|
+
const declared = text.match(/\((\d+)\s+класс\w*/);
|
|
141
|
+
if (!declared) continue;
|
|
142
|
+
const actual = (text.match(/^INSERT INTO/gm) ?? []).length;
|
|
143
|
+
if (Number(declared[1]) !== actual) {
|
|
144
|
+
fail('seed-counts', `${f}: заголовок обещает ${declared[1]} классов, INSERT-ов ${actual}`);
|
|
145
|
+
} else ok('seed-counts', `${f}: ${actual} классов — заголовок совпадает`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// END_BLOCK_CHECK_SEEDS
|
|
150
|
+
// START_BLOCK_CHECK_CODESCRIBE
|
|
151
|
+
// Автоматизация grep-инвариантов из AGENTS.md §Protocol п.6.
|
|
152
|
+
async function checkCodescribe() {
|
|
153
|
+
const governed = [
|
|
154
|
+
...(await readdir(join(LIB, 'src'))).filter((f) => f.endsWith('.ts')).map((f) => `lib/src/${f}`),
|
|
155
|
+
...(await readdir(join(LIB, 'sql'))).filter((f) => f.endsWith('.sql')).map((f) => `lib/sql/${f}`),
|
|
156
|
+
...(await readdir(join(LIB, 'scripts'))).filter((f) => f.endsWith('.mjs')).map((f) => `lib/scripts/${f}`),
|
|
157
|
+
];
|
|
158
|
+
// имена маркеров собираются из частей: иначе этот файл посчитал бы СВОИ строки-литералы
|
|
159
|
+
// за разметку и вечно падал бы на самом себе
|
|
160
|
+
const S = (n) => `START_${n}`;
|
|
161
|
+
const E = (n) => `END_${n}`;
|
|
162
|
+
const pairs = ['MODULE_CONTRACT', 'MODULE_MAP', 'CHANGE_SUMMARY'];
|
|
163
|
+
const contractFiles = new Set();
|
|
164
|
+
for (const f of governed) {
|
|
165
|
+
const text = await rd(f);
|
|
166
|
+
if (text.includes(S('MODULE_CONTRACT'))) contractFiles.add(f);
|
|
167
|
+
for (const name of pairs) {
|
|
168
|
+
const na = (text.match(new RegExp(S(name), 'g')) ?? []).length;
|
|
169
|
+
const nb = (text.match(new RegExp(E(name), 'g')) ?? []).length;
|
|
170
|
+
if (na !== nb) fail('codescribe', `${f}: ${S(name)}=${na}, ${E(name)}=${nb} — маркеры не парны`);
|
|
171
|
+
}
|
|
172
|
+
// блоки и контракты функций — парность по ИМЕНИ
|
|
173
|
+
for (const [, name] of text.matchAll(/START_BLOCK_([A-Z0-9_]+)/g)) {
|
|
174
|
+
if (!text.includes(`END_BLOCK_${name}`)) fail('codescribe', `${f}: START_BLOCK_${name} без END_BLOCK_${name}`);
|
|
175
|
+
}
|
|
176
|
+
for (const [, name] of text.matchAll(/START_CONTRACT:\s*([\w.]+)/g)) {
|
|
177
|
+
if (!text.includes(`END_CONTRACT: ${name}`)) fail('codescribe', `${f}: START_CONTRACT ${name} без END_CONTRACT`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// M-* ↔ файл, в обе стороны; и у каждого M-* есть V-M-*
|
|
182
|
+
const kg = await rd('docs/knowledge-graph.xml');
|
|
183
|
+
const vp = await rd('docs/verification-plan.xml');
|
|
184
|
+
const modules = [...kg.matchAll(/<(M-[A-Z0-9-]+)\s+NAME=/g)].map((m) => m[1]);
|
|
185
|
+
const mapped = new Set();
|
|
186
|
+
for (const mod of modules) {
|
|
187
|
+
const block = kg.slice(kg.indexOf(`<${mod} `), kg.indexOf(`</${mod}>`));
|
|
188
|
+
const path = block.match(/<path>([^<]+)<\/path>/);
|
|
189
|
+
if (!path) { fail('codescribe', `${mod}: нет <path>`); continue; }
|
|
190
|
+
const p = path[1].split(' ')[0];
|
|
191
|
+
if (!existsSync(join(ROOT, p))) { fail('codescribe', `${mod} → ${p}: файла нет`); continue; }
|
|
192
|
+
mapped.add(p);
|
|
193
|
+
if (governed.includes(p) && !contractFiles.has(p)) {
|
|
194
|
+
fail('codescribe', `${mod} → ${p}: в файле нет ${S('MODULE_CONTRACT')}`);
|
|
195
|
+
}
|
|
196
|
+
if (!vp.includes(`V-${mod}`)) fail('codescribe', `${mod}: нет V-${mod} в verification-plan.xml`);
|
|
197
|
+
}
|
|
198
|
+
for (const f of contractFiles) {
|
|
199
|
+
if (!mapped.has(f)) fail('codescribe', `${f} несёт MODULE_CONTRACT, но ни один M-* на него не указывает`);
|
|
200
|
+
}
|
|
201
|
+
if (!problems.some((p) => p.startsWith('codescribe:'))) {
|
|
202
|
+
ok('codescribe', `${governed.length} governed-файлов, ${modules.length} модулей M-* — маркеры парны, связи полны`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// END_BLOCK_CHECK_CODESCRIBE
|
|
207
|
+
// START_BLOCK_CHECK_API_CONTRACT
|
|
208
|
+
async function checkApiContract() {
|
|
209
|
+
try {
|
|
210
|
+
execFileSync(process.execPath, [join(LIB, 'scripts', 'gen-api-contract.mjs'), '--check'], { stdio: 'pipe' });
|
|
211
|
+
ok('api-contract', 'docs/api-contract.json совпадает с регенерацией из исходников');
|
|
212
|
+
} catch (e) {
|
|
213
|
+
const msg = String(e.stderr ?? e.stdout ?? e.message).trim().split('\n').pop();
|
|
214
|
+
fail('api-contract', msg || 'docs/api-contract.json устарел');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// END_BLOCK_CHECK_API_CONTRACT
|
|
219
|
+
// START_BLOCK_CHECK_RESERVED
|
|
220
|
+
// Класс дефекта: 43 имени шадоуют классы схемы, но нигде это не было записано.
|
|
221
|
+
async function checkReserved() {
|
|
222
|
+
const chain = await rd('lib/src/chain.ts');
|
|
223
|
+
const types = await rd('lib/src/types.ts');
|
|
224
|
+
const MARK = 'RESERVED_CLASS_NAMES: readonly string[] = [';
|
|
225
|
+
const start = types.indexOf(MARK);
|
|
226
|
+
if (start < 0) return fail('reserved', 'в lib/src/types.ts нет RESERVED_CLASS_NAMES');
|
|
227
|
+
// комментарии выбрасываем ДО извлечения строк (иначе апостроф в комментарии ломает разбор)
|
|
228
|
+
const listed = [
|
|
229
|
+
...types
|
|
230
|
+
.slice(start + MARK.length, types.indexOf('];', start))
|
|
231
|
+
.replace(/\/\/[^\n]*/g, '')
|
|
232
|
+
.matchAll(/'([^']+)'/g),
|
|
233
|
+
].map((m) => m[1]);
|
|
234
|
+
|
|
235
|
+
// реальность: ОБЕ формы перехвата во всех трёх Proxy-handler'ах — case-метки switch'ей
|
|
236
|
+
// и ранние `if (prop === '…')` (фасады db.accounts/auth/acl, гашение 'then').
|
|
237
|
+
// Раньше сканировались только case — из-за чего 8 имён фасадов не попали в список.
|
|
238
|
+
const live = [
|
|
239
|
+
...new Set([
|
|
240
|
+
...[...chain.matchAll(/case '([A-Za-z_$][\w$]*)':/g)].map((m) => m[1]),
|
|
241
|
+
...[...chain.matchAll(/(?<!typeof )prop === '([A-Za-z_$][\w$]*)'/g)].map((m) => m[1]),
|
|
242
|
+
]),
|
|
243
|
+
];
|
|
244
|
+
const notListed = live.filter((n) => !listed.includes(n));
|
|
245
|
+
const notLive = listed.filter((n) => !live.includes(n));
|
|
246
|
+
if (notListed.length) fail('reserved', `chain.ts перехватывает, но нет в RESERVED_CLASS_NAMES: ${notListed.join(', ')}`);
|
|
247
|
+
if (notLive.length) fail('reserved', `есть в RESERVED_CLASS_NAMES, но chain.ts больше не перехватывает: ${notLive.join(', ')}`);
|
|
248
|
+
|
|
249
|
+
// и то же самое обещано человеку в README §3
|
|
250
|
+
const readme = await rd('lib/README.md');
|
|
251
|
+
const table = readme.slice(readme.indexOf('#### Зарезервированные имена классов'), readme.indexOf('### 3.1'));
|
|
252
|
+
const missingInReadme = listed.filter((n) => !table.includes(`\`${n}\``));
|
|
253
|
+
if (missingInReadme.length) {
|
|
254
|
+
fail('reserved', `README §3 не перечисляет: ${missingInReadme.join(', ')}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ЧИСЛО имён названо прописью в трёх местах — и разъезжается тихо: проверка ловила
|
|
258
|
+
// только README §3, поэтому «51 имя» пережило добавление 'as' в шпаргалке и CHANGELOG.
|
|
259
|
+
const n = listed.length;
|
|
260
|
+
const счётчики = [
|
|
261
|
+
['lib/README.md', readme, new RegExp(`Всего ${n} им`)],
|
|
262
|
+
['AGENT-CHEATSHEET.md', await rd('AGENT-CHEATSHEET.md'), new RegExp(`\\*\\*Зарезервированные имена классов\\*\\*: ${n} им`)],
|
|
263
|
+
// только ТЕКУЩАЯ запись CHANGELOG: прошлые версии описывают своё время и неприкосновенны
|
|
264
|
+
['lib/CHANGELOG.md', (await rd('lib/CHANGELOG.md')).split(/^## \[/m)[1] ?? '', new RegExp(`\\*\\*${n} им`)],
|
|
265
|
+
];
|
|
266
|
+
for (const [file, text, re] of счётчики) {
|
|
267
|
+
if (!re.test(text)) fail('reserved', `${file} не называет актуальное число зарезервированных имён (${n})`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!problems.some((p) => p.startsWith('reserved:'))) {
|
|
271
|
+
ok('reserved', `${n} имён: код ↔ RESERVED_CLASS_NAMES ↔ README §3 ↔ шпаргалка ↔ CHANGELOG совпадают`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// END_BLOCK_CHECK_RESERVED
|
|
276
|
+
// START_BLOCK_CHECK_DEMOS
|
|
277
|
+
/**
|
|
278
|
+
* Класс дефекта: bench/*.mjs — фактический набор примеров (оттуда же живые ответы и тайминги
|
|
279
|
+
* README), но они не гоняются в CI и молча гниют. Полный прогон невозможен: демо читают
|
|
280
|
+
* готовый полигон v1.salondemo (~980k строк), засев которого слишком долог для CI. Поэтому
|
|
281
|
+
* проверяем дёшево и детерминированно: синтаксис + что каждый импорт из либы реально
|
|
282
|
+
* экспортируется barrel'ом.
|
|
283
|
+
*/
|
|
284
|
+
async function checkDemos() {
|
|
285
|
+
const barrel = await rd('lib/src/index.ts');
|
|
286
|
+
const exported = new Set([
|
|
287
|
+
...[...barrel.matchAll(/^export\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]),
|
|
288
|
+
...[...barrel.matchAll(/^export\s+(?:type\s+)?\{([^}]+)\}/gm)].flatMap((m) =>
|
|
289
|
+
m[1].split(',').map((s) => s.trim().split(/\s+as\s+/).pop().trim()).filter(Boolean)),
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
const dirs = [['lib/bench', await readdir(join(LIB, 'bench'))]];
|
|
293
|
+
let checked = 0;
|
|
294
|
+
for (const [dir, files] of dirs) {
|
|
295
|
+
for (const f of files.filter((x) => x.endsWith('.mjs'))) {
|
|
296
|
+
const rel = `${dir}/${f}`;
|
|
297
|
+
try {
|
|
298
|
+
execFileSync(process.execPath, ['--check', join(ROOT, rel)], { stdio: 'pipe' });
|
|
299
|
+
} catch (e) {
|
|
300
|
+
fail('demos', `${rel}: синтаксическая ошибка — ${String(e.stderr).split('\n')[1] ?? ''}`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const text = await rd(rel);
|
|
304
|
+
for (const m of text.matchAll(/import\s*\{([^}]+)\}\s*from\s*'(?:letopis|[^']*src\/index\.js)'/g)) {
|
|
305
|
+
for (const raw of m[1].split(',')) {
|
|
306
|
+
const name = raw.trim().split(/\s+as\s+/)[0].trim().replace(/^type\s+/, '');
|
|
307
|
+
if (!name) continue;
|
|
308
|
+
checked++;
|
|
309
|
+
if (!exported.has(name)) fail('demos', `${rel} импортирует "${name}" — barrel такого не экспортирует`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (!problems.some((p) => p.startsWith('demos:'))) {
|
|
315
|
+
ok('demos', `bench/*.mjs: синтаксис чист, ${checked} импортов из либы существуют`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// END_BLOCK_CHECK_DEMOS
|
|
320
|
+
// START_BLOCK_MAIN
|
|
321
|
+
await checkVersion();
|
|
322
|
+
await checkLinks();
|
|
323
|
+
await checkTestList();
|
|
324
|
+
await checkSeedCounts();
|
|
325
|
+
await checkCodescribe();
|
|
326
|
+
await checkApiContract();
|
|
327
|
+
await checkReserved();
|
|
328
|
+
await checkDemos();
|
|
329
|
+
|
|
330
|
+
for (const p of passed) console.log(` ok ${p}`);
|
|
331
|
+
if (problems.length) {
|
|
332
|
+
console.error(`\ncheck-docs: ${problems.length} расхождений документации с кодом\n`);
|
|
333
|
+
for (const p of problems) console.error(` ✗ ${p}`);
|
|
334
|
+
console.error('');
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
console.log(`\ncheck-docs: чисто (${passed.length} проверок), repo ${relative(process.cwd(), ROOT) || '.'}`);
|
|
338
|
+
// END_BLOCK_MAIN
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Машиночитаемый контракт публичного API → docs/api-contract.json:
|
|
4
|
+
* node scripts/gen-api-contract.mjs [--out=../docs/api-contract.json] [--check]
|
|
5
|
+
*
|
|
6
|
+
* Всё ВЫВОДИТСЯ из исходников (src/index.ts, src/chain.ts, src/types.ts) — файл не
|
|
7
|
+
* набивается руками, иначе он стал бы очередным источником дрейфа. --check не пишет,
|
|
8
|
+
* а сравнивает с закоммиченным и выходит с кодом 1 при расхождении (используется в CI).
|
|
9
|
+
*
|
|
10
|
+
* Назначение: дать ИИ-агенту разбираемый список того, ЧТО есть и чего УЖЕ НЕТ,
|
|
11
|
+
* не заставляя читать 3500 строк README.
|
|
12
|
+
*/
|
|
13
|
+
//
|
|
14
|
+
// FILE: lib/scripts/gen-api-contract.mjs
|
|
15
|
+
// VERSION: 1.0.0
|
|
16
|
+
// START_MODULE_CONTRACT
|
|
17
|
+
// PURPOSE: Сгенерировать docs/api-contract.json из исходников — экспорты, поверхность цепочки, снесённые имена, зарезервированные имена классов, каталог ошибок.
|
|
18
|
+
// SCOPE: парсинг src/index.ts (barrel), src/chain.ts (case-метки Proxy + ChainCore), src/types.ts (RESERVED_CLASS_NAMES), сбор throw-сообщений; запись/сверка JSON.
|
|
19
|
+
// DEPENDS: none
|
|
20
|
+
// LINKS: M-GEN-API-CONTRACT, V-M-GEN-API-CONTRACT
|
|
21
|
+
// ROLE: SCRIPT
|
|
22
|
+
// MAP_MODE: LOCALS
|
|
23
|
+
// END_MODULE_CONTRACT
|
|
24
|
+
//
|
|
25
|
+
// START_MODULE_MAP
|
|
26
|
+
// read - прочитать файл пакета относительно lib/
|
|
27
|
+
// exportsOf - публичные значения и типы из barrel src/index.ts
|
|
28
|
+
// chainSurface - case-метки Proxy цепочки/db + сигнатуры ChainCore
|
|
29
|
+
// removedNames - снесённые имена с версией и заменой (из migration-охранников)
|
|
30
|
+
// errorCatalog - все throw-сообщения letopis: с указанием файла
|
|
31
|
+
// main - сборка контракта, запись либо --check
|
|
32
|
+
// END_MODULE_MAP
|
|
33
|
+
//
|
|
34
|
+
// START_CHANGE_SUMMARY
|
|
35
|
+
// LAST_CHANGE: [v1.0.0 - Новый модуль: генерируемый машиночитаемый контракт API для агентов]
|
|
36
|
+
// END_CHANGE_SUMMARY
|
|
37
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
|
|
41
|
+
const LIB = fileURLToPath(new URL('..', import.meta.url));
|
|
42
|
+
const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
|
|
43
|
+
const out = args.out ?? join(LIB, '..', 'docs', 'api-contract.json');
|
|
44
|
+
const check = 'check' in args;
|
|
45
|
+
|
|
46
|
+
const read = (p) => readFile(join(LIB, p), 'utf8');
|
|
47
|
+
|
|
48
|
+
// START_CONTRACT: exportsOf
|
|
49
|
+
// PURPOSE: Собрать публичную поверхность из barrel src/index.ts (значения и типы).
|
|
50
|
+
// INPUTS: { src: string - текст src/index.ts }
|
|
51
|
+
// OUTPUTS: { { values: string[]; types: string[] } }
|
|
52
|
+
// SIDE_EFFECTS: none
|
|
53
|
+
// LINKS: M-GEN-API-CONTRACT, M-CONNECT
|
|
54
|
+
// END_CONTRACT: exportsOf
|
|
55
|
+
function exportsOf(src) {
|
|
56
|
+
const values = new Set();
|
|
57
|
+
const types = new Set();
|
|
58
|
+
// export function foo / export const foo / export class Foo
|
|
59
|
+
for (const m of src.matchAll(/^export\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z_$][\w$]*)/gm)) {
|
|
60
|
+
values.add(m[1]);
|
|
61
|
+
}
|
|
62
|
+
// export { a, b } from '…' / export type { A, B } from '…'
|
|
63
|
+
for (const m of src.matchAll(/^export\s+(type\s+)?\{([^}]+)\}/gm)) {
|
|
64
|
+
const bucket = m[1] ? types : values;
|
|
65
|
+
for (const raw of m[2].split(',')) {
|
|
66
|
+
const name = raw.trim().split(/\s+as\s+/).pop().trim();
|
|
67
|
+
if (name) bucket.add(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { values: [...values].sort(), types: [...types].sort() };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// START_CONTRACT: chainSurface
|
|
74
|
+
// PURPOSE: Извлечь имена, которые Proxy разбирает до резолва класса, и сигнатуры ChainCore.
|
|
75
|
+
// INPUTS: { chain: string - текст src/chain.ts }
|
|
76
|
+
// OUTPUTS: { { chainNames: string[]; dbNames: string[]; chainCore: {name,signature}[] } }
|
|
77
|
+
// SIDE_EFFECTS: none
|
|
78
|
+
// LINKS: M-GEN-API-CONTRACT, M-CHAIN
|
|
79
|
+
// END_CONTRACT: chainSurface
|
|
80
|
+
function chainSurface(chain) {
|
|
81
|
+
// ТРИ handler-switch'а, каждый разбирает имена до ctx.registry.has(prop):
|
|
82
|
+
// makeChain (цепочка) → makeBatch (батч) → makeDb (корень)
|
|
83
|
+
const batchAt = chain.indexOf('function makeBatch');
|
|
84
|
+
const dbAt = chain.indexOf('export function makeDb');
|
|
85
|
+
// перехват бывает ДВУХ форм: case-метка switch'а И ранний `if (prop === '…')`
|
|
86
|
+
// (так разбираются фасады db.accounts/auth/acl и гашение 'then') — ловим обе
|
|
87
|
+
const names = (text) =>
|
|
88
|
+
[
|
|
89
|
+
...new Set([
|
|
90
|
+
...[...text.matchAll(/case '([A-Za-z_$][\w$]*)':/g)].map((m) => m[1]),
|
|
91
|
+
...[...text.matchAll(/(?<!typeof )prop === '([A-Za-z_$][\w$]*)'/g)].map((m) => m[1]),
|
|
92
|
+
]),
|
|
93
|
+
].sort();
|
|
94
|
+
const chainNames = names(chain.slice(0, batchAt));
|
|
95
|
+
const batchNames = names(chain.slice(batchAt, dbAt)).filter((n) => !chainNames.includes(n));
|
|
96
|
+
const dbNames = names(chain.slice(dbAt)).filter((n) => !chainNames.includes(n) && !batchNames.includes(n));
|
|
97
|
+
|
|
98
|
+
// ChainCore — источник истины поверхности цепочки для типов и кодогена
|
|
99
|
+
const body = chain.slice(chain.indexOf('export interface ChainCore {'));
|
|
100
|
+
const end = body.indexOf('\n}');
|
|
101
|
+
const chainCore = [...body.slice(0, end).matchAll(/^\s{2}([A-Za-z_$][\w$]*)\((.*?)\):\s*(.+?);$/gm)]
|
|
102
|
+
.map((m) => ({ name: m[1], signature: `${m[1]}(${m[2]}): ${m[3]}` }));
|
|
103
|
+
return { chainNames, batchNames, dbNames, chainCore };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// START_CONTRACT: removedNames
|
|
107
|
+
// PURPOSE: Собрать снесённые имена API с версией удаления и заменой (из migration-охранников chain.ts).
|
|
108
|
+
// INPUTS: { chain: string - текст src/chain.ts }
|
|
109
|
+
// OUTPUTS: { { name, removedIn, message }[] }
|
|
110
|
+
// SIDE_EFFECTS: none
|
|
111
|
+
// LINKS: M-GEN-API-CONTRACT, M-CHAIN
|
|
112
|
+
// END_CONTRACT: removedNames
|
|
113
|
+
function removedNames(chain) {
|
|
114
|
+
const byMsg = new Map();
|
|
115
|
+
// все migration-охранники: renamed / removed / split into — с версией в скобках
|
|
116
|
+
for (const m of chain.matchAll(/letopis: ([^'`\n]*?(?:renamed to|removed|split into)[^'`\n]*)/g)) {
|
|
117
|
+
const text = m[1].replace(/\$\{[^}]*\}/g, '<…>').replace(/\s+/g, ' ').trim();
|
|
118
|
+
const ver = text.match(/\((\d+\.\d+\.\d+)\)/);
|
|
119
|
+
// что именно снесено: 'batch.execute()' / '.link()' / 'slot .delete()' / 'set()'
|
|
120
|
+
const what = text.match(/^((?:[A-Za-z_$][\w$]*\s+)?\.?[\w$.]*\(\))/);
|
|
121
|
+
const key = text.slice(0, 60);
|
|
122
|
+
if (byMsg.has(key)) continue;
|
|
123
|
+
byMsg.set(key, {
|
|
124
|
+
removed: what ? what[1] : null,
|
|
125
|
+
removedIn: ver ? ver[1] : null,
|
|
126
|
+
replacement: text.includes('—') ? text.split('—').slice(1).join('—').trim() : null,
|
|
127
|
+
message: `letopis: ${text}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return [...byMsg.values()].sort((a, b) => String(a.removed).localeCompare(String(b.removed)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// START_CONTRACT: errorCatalog
|
|
134
|
+
// PURPOSE: Собрать все throw-сообщения letopis по модулям — greppable каталог для агента.
|
|
135
|
+
// INPUTS: { files: Map<string,string> - имя модуля → текст }
|
|
136
|
+
// OUTPUTS: { { module, message }[] - шаблоны сообщений с ${…} как <…> }
|
|
137
|
+
// SIDE_EFFECTS: none
|
|
138
|
+
// LINKS: M-GEN-API-CONTRACT
|
|
139
|
+
// END_CONTRACT: errorCatalog
|
|
140
|
+
function errorCatalog(files) {
|
|
141
|
+
const rows = [];
|
|
142
|
+
for (const [mod, text] of files) {
|
|
143
|
+
for (const m of text.matchAll(/letopis(?:\.up|\.purge)?:\s?([^'`\n]{4,160})/g)) {
|
|
144
|
+
const msg = m[0].replace(/\$\{[^}]*\}/g, '<…>').replace(/\s+/g, ' ').trim();
|
|
145
|
+
if (!rows.some((r) => r.message === msg)) rows.push({ module: mod, message: msg });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return rows.sort((a, b) => a.message.localeCompare(b.message));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// START_BLOCK_MAIN
|
|
152
|
+
const index = await read('src/index.ts');
|
|
153
|
+
const chain = await read('src/chain.ts');
|
|
154
|
+
const types = await read('src/types.ts');
|
|
155
|
+
const pkg = JSON.parse(await read('package.json'));
|
|
156
|
+
|
|
157
|
+
const { chainNames, batchNames, dbNames, chainCore } = chainSurface(chain);
|
|
158
|
+
// начинаем ПОСЛЕ '= [' — иначе первый '[' попадёт на 'readonly string[]'
|
|
159
|
+
const RES_MARK = 'RESERVED_CLASS_NAMES: readonly string[] = [';
|
|
160
|
+
const resStart = types.indexOf(RES_MARK) + RES_MARK.length;
|
|
161
|
+
// комментарии выбрасываем ДО извлечения строк: апостроф или пример в кавычках внутри
|
|
162
|
+
// комментария иначе попадёт в список
|
|
163
|
+
const reserved = [
|
|
164
|
+
...types
|
|
165
|
+
.slice(resStart, types.indexOf('];', resStart))
|
|
166
|
+
.replace(/\/\/[^\n]*/g, '')
|
|
167
|
+
.matchAll(/'([^']+)'/g),
|
|
168
|
+
].map((m) => m[1]);
|
|
169
|
+
if (!reserved.length) throw new Error('gen-api-contract: не разобрал RESERVED_CLASS_NAMES из src/types.ts');
|
|
170
|
+
|
|
171
|
+
const files = new Map([
|
|
172
|
+
['chain', chain], ['sql', await read('src/sql.ts')], ['write', await read('src/write.ts')],
|
|
173
|
+
['schema', types && (await read('src/schema.ts'))], ['tables', await read('src/tables.ts')],
|
|
174
|
+
['auth', await read('src/auth.ts')], ['acl', await read('src/acl.ts')],
|
|
175
|
+
['tx', await read('src/tx.ts')], ['up', await read('src/up.ts')], ['index', index],
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
const contract = {
|
|
179
|
+
$comment:
|
|
180
|
+
'СГЕНЕРИРОВАНО lib/scripts/gen-api-contract.mjs из исходников — не редактировать руками. ' +
|
|
181
|
+
'Регенерация: cd lib && npm run api:contract. CI сверяет через npm run check:docs.',
|
|
182
|
+
package: { name: pkg.name, type: pkg.type, engines: pkg.engines, entry: pkg.main, types: pkg.types },
|
|
183
|
+
note: {
|
|
184
|
+
versionSource: 'lib/package.json + верхняя запись lib/CHANGELOG.md',
|
|
185
|
+
schemaOption:
|
|
186
|
+
'connect({schema}) требует ПОЛНОГО имени PG-схемы С версией движка ("v1.booking"); ' +
|
|
187
|
+
'up({schema,version}) берёт БАЗОВОЕ имя без точек и строит "v<version>.<schema>"',
|
|
188
|
+
classStep: 'шаг цепочки принимает id ИЛИ alias класса из таблицы Schema (db.Org ≡ db.Организация)',
|
|
189
|
+
writeVerbs: 'create/update/delete/purge/anonymize возвращают ЦЕПОЧКУ (звено плана); исполняет терминал',
|
|
190
|
+
},
|
|
191
|
+
exports: exportsOf(index),
|
|
192
|
+
chain: {
|
|
193
|
+
core: chainCore,
|
|
194
|
+
interceptedByChainProxy: chainNames,
|
|
195
|
+
interceptedByBatchProxy: batchNames,
|
|
196
|
+
interceptedByDbProxy: dbNames,
|
|
197
|
+
reservedClassNames: reserved,
|
|
198
|
+
reservedClassNamesNote:
|
|
199
|
+
'класс с таким id/alias недостижим как шаг ПОД ЭТИМ именем; schema.define() отказывает, loadRegistry предупреждает',
|
|
200
|
+
},
|
|
201
|
+
removed: removedNames(chain),
|
|
202
|
+
errors: errorCatalog(files),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const json = `${JSON.stringify(contract, null, 2)}\n`;
|
|
206
|
+
if (check) {
|
|
207
|
+
let current = null;
|
|
208
|
+
try { current = await readFile(out, 'utf8'); } catch { /* нет файла — расхождение */ }
|
|
209
|
+
if (current !== json) {
|
|
210
|
+
console.error(`api-contract: ${out} устарел — перегенерируйте: cd lib && npm run api:contract`);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
console.log('api-contract: актуален');
|
|
214
|
+
} else {
|
|
215
|
+
await writeFile(out, json, 'utf8');
|
|
216
|
+
console.log(
|
|
217
|
+
`written ${out}: ${contract.exports.values.length} values, ${contract.exports.types.length} types, ` +
|
|
218
|
+
`${chainCore.length} chain methods, ${contract.removed.length} removed, ${contract.errors.length} errors`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
// END_BLOCK_MAIN
|