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/CHANGELOG.md +254 -0
- package/LICENSE +21 -0
- package/README.md +492 -103
- package/dist/acl.d.ts +7 -2
- package/dist/acl.js +3 -2
- package/dist/chain.d.ts +46 -0
- package/dist/chain.js +38 -3
- package/dist/index.d.ts +8 -3
- package/dist/index.js +50 -19
- package/dist/schema.js +48 -6
- package/dist/sql.d.ts +26 -1
- package/dist/sql.js +130 -38
- package/dist/tables.d.ts +24 -0
- package/dist/tables.js +64 -1
- package/dist/types.d.ts +34 -9
- package/dist/types.js +40 -3
- package/dist/up.js +15 -4
- package/dist/write.d.ts +9 -0
- package/dist/write.js +46 -6
- 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/ddl.sql +114 -2
- package/sql/seed.booking.sql +5 -4
package/dist/write.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
//
|
|
18
18
|
// FILE: lib/src/write.ts
|
|
19
|
-
// VERSION: 1.
|
|
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.
|
|
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: модификатор →
|
|
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(…) /
|
|
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'
|
|
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.
|
|
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
|