letopis 0.18.1 → 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/dist/write.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  //
18
18
  // FILE: lib/src/write.ts
19
- // VERSION: 1.0.0
19
+ // VERSION: 1.1.0
20
20
  // START_MODULE_CONTRACT
21
21
  // PURPOSE: Write-side движок: валидация, резолв концов/слотов, вычисление id (v5/v7), вставка новых версий, каскадное удаление, анонимизация, исполнение планов и батчей в транзакции.
22
22
  // SCOPE: ValidationError, runPlan, executeBatch, createOp/updateOp/delOp/anonymizeOp, toRow/readRows/deepMerge, BatchPlan.
@@ -36,11 +36,13 @@
36
36
  // END_MODULE_MAP
37
37
  //
38
38
  // START_CHANGE_SUMMARY
39
- // LAST_CHANGE: [v1.0.0 - Documented existing module: reverse-engineered contract + markup]
39
+ // LAST_CHANGE: [v1.1.0 - resolveAccount зовёт guardScoped: при enforceAccount без scope (db.as)
40
+ // запись запрещена с внятной подсказкой; ctx.account теперь приходит из scope, не из connect.
41
+ // Ранее: Documented existing module: reverse-engineered contract + markup]
40
42
  // END_CHANGE_SUMMARY
41
43
  import { randomUUID } from 'node:crypto';
42
44
  import { uuidv5, uuidv7 } from './uuid.js';
43
- import { buildRead, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, isTransient, retryDelay, RETRIES, } from './sql.js';
45
+ import { buildRead, guardScoped, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, purgeCallSql, isTransient, retryDelay, RETRIES, } from './sql.js';
44
46
  import { aclDenied } from './acl.js';
45
47
  // START_CONTRACT: ValidationError
46
48
  // PURPOSE: Ошибка валидации класса, несущая список проблемных полей (issues).
@@ -215,14 +217,15 @@ function aclWrite(ctx, step, op) {
215
217
  }
216
218
  }
217
219
  const noFilter = (f) => f === undefined;
218
- /** Entity.account NOT NULL: модификатор → connect() → System-аккаунт; иначе ошибка. */
220
+ /** Entity.account NOT NULL: модификатор → scope db.as() → System-аккаунт; иначе ошибка. */
219
221
  function resolveAccount(ctx, step) {
222
+ guardScoped(ctx);
220
223
  if (ctx.enforceAccount && ctx.account && step.accountFilter && step.accountFilter !== ctx.account) {
221
224
  throw new Error(`letopis: enforceAccount is on — writes are pinned to account ${ctx.account}`);
222
225
  }
223
226
  const acc = step.accountFilter ?? ctx.account ?? ctx.systemAccount;
224
227
  if (!acc) {
225
- throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / connect({account}) or seed a System account (lib/sql/seed.auth.sql)');
228
+ throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / db.as(account) or seed a System account (lib/sql/seed.auth.sql)');
226
229
  }
227
230
  return acc;
228
231
  }
@@ -581,6 +584,40 @@ export async function delOp(ctx, steps, mods, confirm) {
581
584
  });
582
585
  // END_BLOCK_DELETE_CASCADE
583
586
  }
587
+ /**
588
+ * .purge({confirm}): ФИЗИЧЕСКИЙ hard-erase — сносит логически удалённые цели + всё поддерево (все версии).
589
+ * Вся логика — в серверной purge() (двухфазность: живую цель не трогает; замыкание по links; SET LOCAL
590
+ * отключает entity_delete → плоский снос без TM_SelfModified). JS лишь резолвит цели и зовёт функцию.
591
+ * confirm: false → dry-превью (что сотрётся, БД цела); true → снос, возвращает снесённое ($deleted).
592
+ * gate: DELETE-право на класс цели (aclWrite) — поддерево уже было DELETE-авторизовано при мягком удалении.
593
+ * В отличие от .delete() (tombstone, обратимо) — необратимо, историю НЕ сохраняет.
594
+ */
595
+ // START_CONTRACT: purgeOp
596
+ // PURPOSE: .purge({confirm}) — резолв целей (вкл. tombstone) + вызов серверной purge() (снос при confirm, иначе dry-превью).
597
+ // INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; confirm: boolean }
598
+ // OUTPUTS: { Promise<Row[]> - снесённое с $deleted, либо dry-превью; [] если цели не tombstone/не найдены }
599
+ // SIDE_EFFECTS: физический DELETE в Entity через purge() (SET LOCAL letopis.purge отключает триггер)
600
+ // ERRORS: acl denies DELETE (класс цели)
601
+ // LINKS: M-WRITE, V-M-WRITE, M-ACL, M-DDL, M-SQL
602
+ // END_CONTRACT: purgeOp
603
+ export async function purgeOp(ctx, steps, mods, confirm) {
604
+ const target = steps[steps.length - 1];
605
+ aclWrite(ctx, target, 'DELETE');
606
+ // цели ищем ВКЛЮЧАЯ tombstone — .purge() работает по уже логически удалённым (двухфазность в purge())
607
+ const targets = await readRows(ctx, steps, { ...mods, withDeleted: true });
608
+ if (!targets.length)
609
+ return [];
610
+ return inTransaction(ctx, async (c) => {
611
+ const out = [];
612
+ // вся логика (двухфазность, замыкание, отключение триггера) — в серверной purge(); dry = !confirm
613
+ for (const t of targets) {
614
+ const rows = await runQuery(c, purgeCallSql(c.pgSchema), [c.partition, target.cls.id, t.id, !confirm], 'delete', [target.cls.id]);
615
+ for (const r of rows)
616
+ out.push(confirm ? { ...toRow(r), $deleted: true } : toRow(r));
617
+ }
618
+ return out;
619
+ });
620
+ }
584
621
  /** Разрезать план: сегменты (…шаги + op-шаг) и читающий хвост после последней операции. */
585
622
  function splitPlan(steps) {
586
623
  const segments = [];
@@ -604,6 +641,8 @@ function opCall(c, steps, op) {
604
641
  return anonymizeOp(c, steps, op.mods, op.fields ?? []);
605
642
  case 'delete':
606
643
  return delOp(c, steps, op.mods, op.confirm === true);
644
+ case 'purge':
645
+ return purgeOp(c, steps, op.mods, op.confirm === true);
607
646
  }
608
647
  }
609
648
  /** Виртуальный старт-шаг «от этих строк»: контекст/чтение следующего сегмента. */
@@ -651,7 +690,8 @@ export async function runPlan(ctx, steps, mods, mode) {
651
690
  last.push(...await opCall(c, rowSteps, seg.op));
652
691
  }
653
692
  }
654
- start = seg.op.kind === 'delete' ? last.filter((r) => r.class === targetCls.id) : last;
693
+ start = (seg.op.kind === 'delete' || seg.op.kind === 'purge')
694
+ ? last.filter((r) => r.class === targetCls.id) : last;
655
695
  }
656
696
  // END_BLOCK_RUNPLAN_SEGMENTS
657
697
  const opStep = segments[segments.length - 1].steps.at(-1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letopis",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "description": "Letopis (летопись): append-only versioned entity store on TimescaleDB with dot-notation chains — every change is a new row, history is first-class (asOf, versions, watch, cascade tombstones)",
5
5
  "keywords": [
6
6
  "timescaledb",
@@ -33,20 +33,26 @@
33
33
  "import": "./dist/index.js"
34
34
  }
35
35
  },
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
36
39
  "files": [
37
40
  "dist",
38
41
  "scripts",
39
42
  "sql",
40
43
  "docker",
41
44
  "README.md",
42
- "CHANGELOG.md"
45
+ "CHANGELOG.md",
46
+ "LICENSE"
43
47
  ],
44
48
  "scripts": {
45
49
  "build": "tsc",
46
50
  "typecheck": "tsc --noEmit",
47
51
  "test": "tsx --test --test-concurrency=1 --test-force-exit test/*.test.ts",
52
+ "check:docs": "node scripts/check-docs.mjs",
53
+ "api:contract": "node scripts/gen-api-contract.mjs",
48
54
  "bench": "tsx bench/history.bench.mjs",
49
- "prepublishOnly": "npm run typecheck && npm run build"
55
+ "prepublishOnly": "npm run typecheck && npm run check:docs && npm run build"
50
56
  },
51
57
  "dependencies": {
52
58
  "fastest-validator": "^1.19.0",
@@ -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