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.
@@ -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