@rt-tools/agent-kit 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -30
- package/assets/agents/business-analyst.md +74 -0
- package/assets/agents/project-manager.md +70 -0
- package/assets/agents/qa-engineer.md +72 -0
- package/assets/agents/skill-curator.md +110 -0
- package/assets/agents/spec-critic.md +44 -0
- package/assets/agents/spec-writer.md +50 -0
- package/assets/checks/board.github.mjs +286 -0
- package/assets/checks/check-board.github.mjs +188 -0
- package/assets/checks/check-doc-paths.mjs +163 -0
- package/assets/checks/check-dupes.mjs +277 -0
- package/assets/checks/check-lib-layers.mjs +573 -0
- package/assets/checks/check-reuse.mjs +208 -0
- package/assets/checks/check-schema-drift.mjs +186 -0
- package/assets/checks/check-specs.mjs +1007 -0
- package/assets/checks/check-styles.mjs +109 -0
- package/assets/checks/rt-kit-checks.config.mjs +134 -0
- package/assets/checks/task-new.github.mjs +198 -0
- package/assets/commands/skill-curator.md +70 -0
- package/assets/defaults/gate-map.sh +100 -0
- package/assets/defaults/project.sh +179 -0
- package/assets/hooks/browser-device-id.sh +0 -0
- package/assets/hooks/browser-guard-device-id.sh +2 -1
- package/assets/hooks/browser-guard-no-asking.sh +27 -0
- package/assets/hooks/browser-guard-no-listing.sh +2 -1
- package/assets/hooks/browser-guard-no-other-drivers.sh +2 -1
- package/assets/hooks/browser-guard-require-select.sh +2 -1
- package/assets/hooks/commit-msg.sh +1 -1
- package/assets/hooks/constitution-index.sh +5 -4
- package/assets/hooks/dev-server-guard.sh +8 -6
- package/assets/hooks/docs-guard.sh +223 -37
- package/assets/hooks/git-guard-delivery.sh +86 -29
- package/assets/hooks/git-guard-main.sh +1 -0
- package/assets/hooks/git-guard-push-tests.sh +34 -13
- package/assets/hooks/glossary-load.sh +23 -0
- package/assets/hooks/lint-after-edit.sh +155 -30
- package/assets/hooks/qa-dataid-guard.sh +72 -32
- package/assets/hooks/reuse-first-guard.sh +105 -34
- package/assets/hooks/skill-gate-rearm.sh +1 -0
- package/assets/hooks/skill-gate.sh +75 -15
- package/assets/hooks/skill-loaded.sh +1 -0
- package/assets/hooks/sql-guard.sh +606 -56
- package/assets/hooks/task-context-load.sh +100 -0
- package/assets/hooks/task-flow-guard.sh +107 -0
- package/assets/laws/{access.md → application/access.md} +1 -4
- package/assets/laws/{locales.md → application/locales.md} +1 -3
- package/assets/laws/application/money.md +41 -0
- package/assets/laws/application/ownership.md +32 -0
- package/assets/laws/{search-visibility.md → application/search-visibility.md} +1 -1
- package/assets/laws/code-structure.md +7 -6
- package/assets/laws/delivery.md +53 -3
- package/assets/laws/entity-editing.md +49 -55
- package/assets/laws/entity-models.md +4 -14
- package/assets/laws/frontend-application.md +5 -5
- package/assets/laws/lib-imports.md +14 -1
- package/assets/laws/lists.md +33 -0
- package/assets/laws/navigation.md +40 -0
- package/assets/laws/project-documentation.md +17 -8
- package/assets/laws/reuse-first.md +26 -21
- package/assets/laws/shared-code.md +13 -1
- package/assets/laws/verifiability.md +17 -1
- package/assets/laws/work-conduct.md +48 -0
- package/assets/patterns/admin-lists-screen.md +131 -0
- package/assets/patterns/admin-nav-item.md +71 -0
- package/assets/patterns/angular-patterns-state.md +29 -22
- package/assets/patterns/api-layer-pair.md +40 -30
- package/assets/patterns/browser-verification-measure.md +41 -38
- package/assets/patterns/browser-verification-stand.md +106 -42
- package/assets/patterns/component-structure-new.md +33 -32
- package/assets/patterns/dependencies-upgrade.md +65 -0
- package/assets/patterns/doc-style-sweep.md +65 -28
- package/assets/patterns/doc-style-write.md +36 -33
- package/assets/patterns/entity-aside.md +136 -0
- package/assets/patterns/entity-models-new.md +124 -0
- package/assets/patterns/entity-store.md +91 -0
- package/assets/patterns/git-workflow-commit.azure.md +259 -0
- package/assets/patterns/git-workflow-commit.github.md +333 -0
- package/assets/patterns/git-workflow-commit.gitlab.md +283 -0
- package/assets/patterns/git-workflow-merge.md +42 -25
- package/assets/patterns/git-workflow-migration.md +61 -31
- package/assets/patterns/git-workflow-restart.md +20 -20
- package/assets/patterns/lib-layers-move.md +50 -32
- package/assets/patterns/lib-layers-new.md +41 -29
- package/assets/patterns/ownership-scope-resolve.md +69 -0
- package/assets/patterns/permissions-procedure.md +35 -33
- package/assets/patterns/platform-access-di.md +39 -25
- package/assets/patterns/pricing-quote.md +71 -0
- package/assets/patterns/reuse-first-extend.md +22 -22
- package/assets/patterns/seo-page.md +52 -40
- package/assets/patterns/seo-verify.md +48 -29
- package/assets/patterns/shared-code-new.md +37 -31
- package/assets/patterns/spec-driven-domain.md +44 -37
- package/assets/patterns/spec-driven-rule.md +55 -40
- package/assets/patterns/styling-bem-component.md +43 -32
- package/assets/patterns/styling-bem-layout.md +30 -24
- package/assets/patterns/task-flow-close.md +90 -0
- package/assets/patterns/task-flow-resume.md +94 -0
- package/assets/patterns/task-flow-start.md +117 -0
- package/assets/patterns/testing-e2e.md +53 -51
- package/assets/patterns/testing-unit.md +70 -46
- package/assets/patterns/translations-key.md +32 -19
- package/assets/patterns/ts-procedure.md +24 -25
- package/assets/rules/angular-patterns.md +46 -27
- package/assets/rules/api-layer.md +46 -28
- package/assets/rules/browser-verification.md +66 -48
- package/assets/rules/component-structure.md +43 -27
- package/assets/rules/dependencies.md +66 -0
- package/assets/rules/doc-style.md +81 -39
- package/assets/rules/entity-conventions.md +78 -0
- package/assets/rules/entity-models.md +70 -0
- package/assets/rules/git-workflow.azure.md +116 -0
- package/assets/rules/git-workflow.github.md +123 -0
- package/assets/rules/git-workflow.gitlab.md +113 -0
- package/assets/rules/lib-layers.md +56 -30
- package/assets/rules/lists.md +73 -0
- package/assets/rules/navigation.md +78 -0
- package/assets/rules/ownership-scope.md +63 -0
- package/assets/rules/permissions.md +43 -25
- package/assets/rules/platform-access.md +57 -29
- package/assets/rules/pricing.md +64 -0
- package/assets/rules/reuse-first.md +57 -43
- package/assets/rules/seo.md +51 -30
- package/assets/rules/shared-code.md +51 -26
- package/assets/rules/spec-driven.md +96 -50
- package/assets/rules/styling-bem.md +54 -39
- package/assets/rules/task-flow.md +110 -0
- package/assets/rules/testing.md +78 -47
- package/assets/rules/translations.md +48 -31
- package/assets/rules/typescript-conventions.md +57 -27
- package/assets/skills/agent-kit.md +81 -0
- package/assets/skills/write-a-skill.md +108 -0
- package/assets/templates/gate-map.sh +23 -15
- package/assets/templates/implementation.md +14 -8
- package/assets/templates/pattern.md +1 -1
- package/assets/templates/project.sh +32 -19
- package/assets/templates/rule.md +1 -1
- package/assets/variants.json +20 -0
- package/assets/workflows/feature.js +134 -0
- package/assets/workflows/plan.js +150 -0
- package/bin/agent-kit.d.ts.map +1 -1
- package/bin/agent-kit.js +78 -5
- package/bin/agent-kit.js.map +1 -1
- package/bin/prompt.d.ts +5 -0
- package/bin/prompt.d.ts.map +1 -1
- package/bin/prompt.js +19 -7
- package/bin/prompt.js.map +1 -1
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +1 -0
- package/index.js.map +1 -1
- package/lib/assets.d.ts +8 -3
- package/lib/assets.d.ts.map +1 -1
- package/lib/assets.js +13 -3
- package/lib/assets.js.map +1 -1
- package/lib/catalog.d.ts +52 -5
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +104 -16
- package/lib/catalog.js.map +1 -1
- package/lib/commands.d.ts +22 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +202 -14
- package/lib/commands.js.map +1 -1
- package/lib/companion.d.ts +5 -1
- package/lib/companion.d.ts.map +1 -1
- package/lib/companion.js +29 -2
- package/lib/companion.js.map +1 -1
- package/lib/config.d.ts +26 -9
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +41 -15
- package/lib/config.js.map +1 -1
- package/lib/freshness.d.ts +14 -0
- package/lib/freshness.d.ts.map +1 -0
- package/lib/freshness.js +116 -0
- package/lib/freshness.js.map +1 -0
- package/lib/hooks-map.d.ts +24 -0
- package/lib/hooks-map.d.ts.map +1 -0
- package/lib/hooks-map.js +72 -0
- package/lib/hooks-map.js.map +1 -0
- package/lib/integrity.d.ts +36 -0
- package/lib/integrity.d.ts.map +1 -0
- package/lib/integrity.js +44 -0
- package/lib/integrity.js.map +1 -0
- package/lib/picker.d.ts +11 -1
- package/lib/picker.d.ts.map +1 -1
- package/lib/picker.js +44 -6
- package/lib/picker.js.map +1 -1
- package/lib/sync.d.ts +26 -0
- package/lib/sync.d.ts.map +1 -1
- package/lib/sync.js +59 -4
- package/lib/sync.js.map +1 -1
- package/lib/variants.d.ts +44 -0
- package/lib/variants.d.ts.map +1 -0
- package/lib/variants.js +82 -0
- package/lib/variants.js.map +1 -0
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.4.0.tgz +0 -0
- package/assets/laws/admin-lists.md +0 -35
- package/assets/laws/admin-navigation.md +0 -38
- package/assets/patterns/git-workflow-commit.md +0 -175
- package/assets/rules/git-workflow.md +0 -106
- package/rt-tools-agent-kit-0.3.0.tgz +0 -0
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Проверка того, что спек домена не разошёлся с кодом.
|
|
4
|
+
*
|
|
5
|
+
* Первая редакция сверяла только идентификаторы сценариев против заголовков
|
|
6
|
+
* тестов. Разбор роем нашёл в спеке возрастом в один день одиннадцать
|
|
7
|
+
* расхождений с кодом, и проверка была зелёной на всех: совпадение
|
|
8
|
+
* идентификатора не говорит ни о том, что правило где-то исполняется, ни о том,
|
|
9
|
+
* что тест проверяет обещанное. Отсюда пять механизмов ниже — каждый сверяет
|
|
10
|
+
* текст с фактом, а не с другим текстом.
|
|
11
|
+
*
|
|
12
|
+
* 1. ПРИВЯЗКА ПРАВИЛА К КОДУ. Живёт в `implementation.md` рядом со спеком:
|
|
13
|
+
* таблица «правило → `файл:символ`». Сверяется в обе стороны — правило без
|
|
14
|
+
* строки и строка без правила, — и требует, чтобы файл был, а символ в нём
|
|
15
|
+
* встречался. Правило шире своей привязки так не напишешь: «потолок суммы на
|
|
16
|
+
* приёме заявки» не прошло бы, потому что символ живёт в процедуре решения
|
|
17
|
+
* владельца, а четырём незаведённым механикам скидок привязки не нашлось бы
|
|
18
|
+
* вовсе. Ключ связи — сам текст правила, поэтому переформулировать его, забыв
|
|
19
|
+
* поправить привязку, нельзя. Правило без привязки — намерение, и писать его
|
|
20
|
+
* надо как `Q-N`.
|
|
21
|
+
*
|
|
22
|
+
* 2. КОНТРАКТ ПРОТИВ ДЕКОРАТОРОВ. Таблица процедур сверяется с тем, что
|
|
23
|
+
* объявлено в `*.procedure.ts` домена: `@RequiresPermission` / `@PublicProcedure`
|
|
24
|
+
* и дескриптор метода. В обе стороны — иначе процедура, которую домен
|
|
25
|
+
* обслуживает, но забыл описать, остаётся видна только в декораторе.
|
|
26
|
+
*
|
|
27
|
+
* 3. КОД ОТКАЗА С ТОЧКОЙ БРОСКА. Код принимается, только если `Code.X`
|
|
28
|
+
* действительно бросается где-то в либах домена. Коды выписывались по
|
|
29
|
+
* замыслу, и на одном пути обещанного `NotFound` не бросал никто.
|
|
30
|
+
*
|
|
31
|
+
* 4. УРОВЕНЬ ПРИВЯЗКИ. Сценарий, чей тест идёт не тем путём, что пользователь,
|
|
32
|
+
* или проверяет часть обещанного, помечается `Покрытие: частичное` и уходит в
|
|
33
|
+
* долги, а не в покрытие. Иначе зелёная сводка означает меньше, чем кажется.
|
|
34
|
+
*
|
|
35
|
+
* 5. МЁРТВАЯ ПРИВЯЗКА. Наличия символа мало: объявленный и никем не позванный
|
|
36
|
+
* символ проходил проверку насквозь. Так пять правил про правку сущности
|
|
37
|
+
* оказались привязаны к механике общей основы асайда, которую не зовёт ни один
|
|
38
|
+
* экран. Символ, объявленный в файле и больше нигде не встречающийся, местом
|
|
39
|
+
* исполнения правила не считается.
|
|
40
|
+
*
|
|
41
|
+
* Ненулевой код возврата и перечень расхождений.
|
|
42
|
+
*/
|
|
43
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
44
|
+
import { dirname, join } from 'node:path';
|
|
45
|
+
|
|
46
|
+
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
47
|
+
|
|
48
|
+
const SPECS_DIR = CONFIG.specsDir;
|
|
49
|
+
const CONSTITUTION_DIR = 'docs/constitution';
|
|
50
|
+
/**
|
|
51
|
+
* Каталоги под `docs/specs`, доменами не являющиеся: шаблон содержит образцы с
|
|
52
|
+
* плейсхолдерами, и обязательных разделов у них нет.
|
|
53
|
+
*/
|
|
54
|
+
const NOT_DOMAINS = ['_template'];
|
|
55
|
+
/**
|
|
56
|
+
* Где ищутся тесты. Берётся из настройки дерева, а не из кода: зашитые здесь корни молча не
|
|
57
|
+
* находили ни одного теста у дерева, которое держит код иначе, — и каждый сценарий выглядел
|
|
58
|
+
* непокрытым, притом что тест на него был.
|
|
59
|
+
*/
|
|
60
|
+
const TEST_ROOTS = CONFIG.sourceRoots;
|
|
61
|
+
/** Где ищется вызов символа из привязки. */
|
|
62
|
+
const SOURCE_ROOTS = [...CONFIG.sourceRoots, ...(CONFIG.schemaFile ? [CONFIG.schemaFile.split('/')[0]] : [])];
|
|
63
|
+
const SKIPPED_DIRS = CONFIG.skippedDirs;
|
|
64
|
+
|
|
65
|
+
/** `### SC-BK-03 — заявка на занятые даты` */
|
|
66
|
+
const SCENARIO_HEADING = /^###\s+(SC-([A-Z]{2,4})-(\d{2,3}))\s+—\s+(.+?)\s*$/;
|
|
67
|
+
/** Отметка осознанно непокрытого сценария; причина обязательна */
|
|
68
|
+
const UNCOVERED = /^Не покрыто:\s*\S/;
|
|
69
|
+
/** Тест есть, но проверяет не всё обещанное или идёт другим путём */
|
|
70
|
+
const PARTIAL = /^Покрытие:\s*частичное\s*—\s*\S/;
|
|
71
|
+
/** Упоминание сценария в заголовке теста */
|
|
72
|
+
const SCENARIO_REFERENCE = /\bSC-[A-Z]{2,4}-\d{2,3}\b/g;
|
|
73
|
+
/** Строка обещания сценария; её продолжения идут с отступом */
|
|
74
|
+
const PROMISE = /^Тогда\s+\S/;
|
|
75
|
+
/**
|
|
76
|
+
* Человек перед экраном и его восприятие. Границы слова не ставятся: `\b` в JavaScript
|
|
77
|
+
* считает буквой только латиницу, и `\bгость\b` не совпал бы ни разу.
|
|
78
|
+
*/
|
|
79
|
+
const ACTOR = /(гост[ьяию]|владел(?:ец|ьца|ьцу|ьцем)|сотрудник\w*|оператор\w*|пользовател\w+)/i;
|
|
80
|
+
const PERCEIVES = /(вид(?:ит|ят|но)|чита(?:ет|ют)|смотр(?:ит|ят))/i;
|
|
81
|
+
/** Сквозные тесты: только они идут тем же путём, что пользователь */
|
|
82
|
+
const E2E_ROOTS = CONFIG.e2eRoots;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Якорь правила: `путь/к/файлу.ts:символ` в обратных кавычках. Расширение до восьми
|
|
86
|
+
* букв — иначе `schema.prisma` не считается путём, и правило про умолчание колонки
|
|
87
|
+
* выглядит как правило без якоря. Заглавные и десять букв нужны ради `api.Dockerfile`:
|
|
88
|
+
* без них правило про режим исполнения образа считалось правилом с пустой привязкой,
|
|
89
|
+
* а привязать его больше не к чему — режим объявлен ровно там.
|
|
90
|
+
*/
|
|
91
|
+
const ANCHOR = /`([\w./-]+\.[A-Za-z]{2,10}):([A-Za-z_][\w-]*)`/g;
|
|
92
|
+
/** Строка шапки, объявляющая либы, чьи процедуры домен обслуживает */
|
|
93
|
+
const PROCEDURE_ROOTS = /^\*\*Процедуры:\*\*\s*(.+)$/;
|
|
94
|
+
const BACKTICKED = /`([^`]+)`/g;
|
|
95
|
+
|
|
96
|
+
const REQUIRED_HEADINGS = [
|
|
97
|
+
'## Зачем',
|
|
98
|
+
'## Терминология',
|
|
99
|
+
'### Как это называется в интерфейсе',
|
|
100
|
+
'## Правила',
|
|
101
|
+
'## Что не входит',
|
|
102
|
+
'## Контракт',
|
|
103
|
+
'### Коды отказов',
|
|
104
|
+
'## Данные',
|
|
105
|
+
'## Экраны и состояния',
|
|
106
|
+
'## Сквозные требования',
|
|
107
|
+
'### Локали',
|
|
108
|
+
'### SEO',
|
|
109
|
+
'### Мобильная раскладка',
|
|
110
|
+
'### Мультиобъектность',
|
|
111
|
+
'## Решения',
|
|
112
|
+
'## Открытые вопросы',
|
|
113
|
+
'## История изменений',
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const problems = [];
|
|
117
|
+
const report = (where, message) => problems.push(`${where}: ${message}`);
|
|
118
|
+
|
|
119
|
+
function walk(dir, accept) {
|
|
120
|
+
const found = [];
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(join(ROOT, dir), { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const path = `${dir}/${entry.name}`;
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
if (!SKIPPED_DIRS.includes(entry.name)) {
|
|
132
|
+
found.push(...walk(path, accept));
|
|
133
|
+
}
|
|
134
|
+
} else if (accept(entry.name)) {
|
|
135
|
+
found.push(path);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return found;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const read = (path) => readFileSync(join(ROOT, path), 'utf8');
|
|
143
|
+
const exists = (path) => existsSync(join(ROOT, path));
|
|
144
|
+
|
|
145
|
+
/** Директории доменов: `docs/specs/<домен>`, кроме шаблона. */
|
|
146
|
+
function collectDomains() {
|
|
147
|
+
try {
|
|
148
|
+
return readdirSync(join(ROOT, SPECS_DIR), { withFileTypes: true })
|
|
149
|
+
.filter((entry) => entry.isDirectory() && !NOT_DOMAINS.includes(entry.name))
|
|
150
|
+
.map((entry) => entry.name);
|
|
151
|
+
} catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Строки раздела: от его заголовка до следующего заголовка того же или более
|
|
158
|
+
* высокого уровня. Подразделы в раздел входят — «Коды отказов» разбираются
|
|
159
|
+
* отдельно, но остаются частью «Контракта».
|
|
160
|
+
*/
|
|
161
|
+
function sectionOf(text, heading) {
|
|
162
|
+
const level = heading.match(/^#+/)[0].length;
|
|
163
|
+
const lines = text.split('\n');
|
|
164
|
+
const start = lines.findIndex((line) => line.trimEnd() === heading);
|
|
165
|
+
if (start < 0) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
const rest = lines.slice(start + 1);
|
|
169
|
+
const end = rest.findIndex((line) => {
|
|
170
|
+
const marks = line.match(/^(#+)\s/);
|
|
171
|
+
|
|
172
|
+
return marks && marks[1].length <= level;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return end < 0 ? rest : rest.slice(0, end);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Пункты списка верхнего уровня вместе с их продолжениями. */
|
|
179
|
+
function bulletsOf(lines) {
|
|
180
|
+
const bullets = [];
|
|
181
|
+
for (const [index, line] of lines.entries()) {
|
|
182
|
+
if (/^-\s+\S/.test(line)) {
|
|
183
|
+
bullets.push({ line: index, text: line });
|
|
184
|
+
} else if (bullets.length && /^\s+\S/.test(line)) {
|
|
185
|
+
bullets[bullets.length - 1].text += ` ${line.trim()}`;
|
|
186
|
+
} else if (!line.trim()) {
|
|
187
|
+
continue;
|
|
188
|
+
} else if (/^[#|]/.test(line)) {
|
|
189
|
+
// таблица или заголовок — список кончился
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return bullets;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── 1. Якоря правил ────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
|
|
200
|
+
|
|
201
|
+
/** Символ ищется как слово: подстрока дала бы ложное совпадение на префиксе. */
|
|
202
|
+
function fileHasSymbol(path, symbol) {
|
|
203
|
+
return new RegExp(`\\b${escapeForRegExp(symbol)}\\b`).test(read(path));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Привязки, у которых остаётся выяснить, зовёт ли символ хоть кто-нибудь. Копятся
|
|
208
|
+
* в один список и разбираются одним проходом по исходникам: обходить `apps` и
|
|
209
|
+
* `libs` на каждую из пятисот привязок было бы полтысячи обходов.
|
|
210
|
+
*/
|
|
211
|
+
const traced = [];
|
|
212
|
+
|
|
213
|
+
/** Жирное начало пункта — ключ, по которому правило находит свою строку привязки. */
|
|
214
|
+
function ruleHeadOf(bulletText) {
|
|
215
|
+
const bold = bulletText.match(/\*\*(.+?)\*\*/s);
|
|
216
|
+
|
|
217
|
+
return bold ? bold[1].replace(/\s+/g, ' ').trim() : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Правила живут в `spec.md`, привязка к коду — в `implementation.md` рядом. Разделены
|
|
222
|
+
* потому, что спек описывает продукт и читается без знания устройства, а привязка
|
|
223
|
+
* устаревает при каждом переименовании.
|
|
224
|
+
*
|
|
225
|
+
* Ключ связи — сам текст правила, а не отдельный идентификатор: тогда правку формулировки
|
|
226
|
+
* невозможно сделать, забыв про привязку, — строка перестанет находиться.
|
|
227
|
+
*
|
|
228
|
+
* Заголовок раздела приходит доводом: у спека это `## Правила`, у закона — `## Статьи`.
|
|
229
|
+
* Одно слово в двух смыслах развели именно здесь: «правило» — слой между законом и скилом,
|
|
230
|
+
* а внутри закона живут статьи.
|
|
231
|
+
*/
|
|
232
|
+
function checkRuleImplementation(specFile, text, mapFile, heading = '## Правила') {
|
|
233
|
+
const bullets = bulletsOf(sectionOf(text, heading));
|
|
234
|
+
if (!bullets.length) {
|
|
235
|
+
report(specFile, `в разделе \`${heading}\` нет ни одного пункта`);
|
|
236
|
+
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!exists(mapFile)) {
|
|
241
|
+
report(specFile, `нет файла \`${mapFile.split('/').pop()}\` рядом — правилам не к чему привязаться`);
|
|
242
|
+
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const rows = new Map();
|
|
247
|
+
for (const line of read(mapFile).split('\n')) {
|
|
248
|
+
const cells = line.match(/^\|([^|]+)\|([^|]*)\|\s*$/);
|
|
249
|
+
if (!cells) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const head = cells[1].replace(/\s+/g, ' ').trim();
|
|
253
|
+
// Шапка таблицы: у спека колонка называется «Правило», у закона — «Статья».
|
|
254
|
+
if (!head || head === 'Правило' || head === 'Статья' || /^-+$/.test(head)) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
rows.set(head, { anchors: [...cells[2].matchAll(ANCHOR)], used: false });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const bullet of bullets) {
|
|
261
|
+
const head = ruleHeadOf(bullet.text);
|
|
262
|
+
if (!head) {
|
|
263
|
+
report(specFile, `правило без жирного начала: «${bullet.text.replace(/^-\s+/, '').slice(0, 60)}…»`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const row = rows.get(head);
|
|
267
|
+
if (!row) {
|
|
268
|
+
report(
|
|
269
|
+
mapFile,
|
|
270
|
+
`правило без привязки: «${head.slice(0, 60)}…» — допиши строку с \`файл:символ\`, ` +
|
|
271
|
+
'либо перенеси правило в «Открытые вопросы» как Q-N'
|
|
272
|
+
);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
row.used = true;
|
|
276
|
+
if (!row.anchors.length) {
|
|
277
|
+
report(mapFile, `у правила «${head.slice(0, 60)}…» пустая привязка`);
|
|
278
|
+
}
|
|
279
|
+
for (const [, path, symbol] of row.anchors) {
|
|
280
|
+
if (!exists(path)) {
|
|
281
|
+
report(mapFile, `привязка ведёт в никуда: нет файла \`${path}\``);
|
|
282
|
+
} else if (!fileHasSymbol(path, symbol)) {
|
|
283
|
+
report(mapFile, `привязка не сходится: в \`${path}\` нет \`${symbol}\``);
|
|
284
|
+
} else {
|
|
285
|
+
traced.push({ mapFile, path, symbol });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Обратная сторона: строка, под которой правила больше нет, — след переименования.
|
|
291
|
+
// Без неё привязка копится и начинает описывать несуществующие обещания
|
|
292
|
+
for (const [head, row] of rows) {
|
|
293
|
+
if (!row.used) {
|
|
294
|
+
report(mapFile, `привязка без пункта: «${head.slice(0, 60)}…» — в \`${specFile}\` такого пункта нет`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── 5. Мёртвые привязки ───────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Код без комментариев. Упоминание символа в пояснении вызовом не является, а
|
|
303
|
+
* пояснений у мёртвого кода как раз обычно больше, чем у живого.
|
|
304
|
+
*/
|
|
305
|
+
const codeOf = (text) => text.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:`'"])\/\/.*$/gm, '$1');
|
|
306
|
+
|
|
307
|
+
const DECLARATION_MODIFIERS = '(?:export|declare|abstract|public|private|protected|static|readonly|override|async|accessor)';
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Объявлен ли символ здесь. Проверка идёт только по объявлениям: привязка к
|
|
311
|
+
* чужому полю (`HttpStatus.SERVICE_UNAVAILABLE`), ключу словаря или содержимому
|
|
312
|
+
* строки законна и встречается ровно один раз по своей природе.
|
|
313
|
+
*/
|
|
314
|
+
function fileDeclaresSymbol(code, symbol) {
|
|
315
|
+
const escaped = escapeForRegExp(symbol);
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
new RegExp(`\\b(?:const|let|var|function|class|interface|type|enum)\\s+${escaped}\\b`).test(code) ||
|
|
319
|
+
new RegExp(`^\\s*(?:${DECLARATION_MODIFIERS}\\s+)*#?${escaped}\\s*[(<:=]`, 'm').test(code)
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Файлы, в которых встречается каждый символ. Дефис берётся в токен целиком ради
|
|
325
|
+
* атрибутов разметки, а части такого токена добавляются отдельно: иначе
|
|
326
|
+
* `resolving` внутри `data-resolving` перестал бы находиться.
|
|
327
|
+
*/
|
|
328
|
+
function symbolOwners() {
|
|
329
|
+
const owners = new Map();
|
|
330
|
+
const remember = (token, file) => {
|
|
331
|
+
let files = owners.get(token);
|
|
332
|
+
if (!files) {
|
|
333
|
+
files = new Set();
|
|
334
|
+
owners.set(token, files);
|
|
335
|
+
}
|
|
336
|
+
files.add(file);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
for (const file of SOURCE_ROOTS.flatMap((root) => walk(root, (name) => name.endsWith('.ts') || name.endsWith('.html')))) {
|
|
340
|
+
const text = file.endsWith('.ts') ? codeOf(read(file)) : read(file);
|
|
341
|
+
for (const [token] of text.matchAll(/[A-Za-z_][\w-]*/g)) {
|
|
342
|
+
remember(token, file);
|
|
343
|
+
if (token.includes('-')) {
|
|
344
|
+
token.split('-').forEach((part) => part && remember(part, file));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return owners;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Символ, объявленный в своём файле и больше нигде не встречающийся, ничего не
|
|
354
|
+
* исполняет: правило, привязанное к нему, описывает намерение.
|
|
355
|
+
*/
|
|
356
|
+
function checkTracedAnchors() {
|
|
357
|
+
const code = new Map();
|
|
358
|
+
const codeAt = (path) => {
|
|
359
|
+
if (!code.has(path)) {
|
|
360
|
+
code.set(path, codeOf(read(path)));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return code.get(path);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const declared = traced.filter(({ path, symbol }) => path.endsWith('.ts') && fileDeclaresSymbol(codeAt(path), symbol));
|
|
367
|
+
if (!declared.length) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const owners = symbolOwners();
|
|
372
|
+
for (const { mapFile, path, symbol } of declared) {
|
|
373
|
+
const here = (codeAt(path).match(new RegExp(`\\b${escapeForRegExp(symbol)}\\b`, 'g')) || []).length;
|
|
374
|
+
const elsewhere = [...(owners.get(symbol) || [])].filter((file) => file !== path).length;
|
|
375
|
+
if (here + elsewhere < 2) {
|
|
376
|
+
report(
|
|
377
|
+
mapFile,
|
|
378
|
+
`привязка ведёт в мёртвый код: \`${symbol}\` объявлен в \`${path}\` и больше нигде не встречается — ` +
|
|
379
|
+
'либо правило исполняется в другом месте, либо ему место в «Открытых вопросах» как Q-N'
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── 1a. Законы, которые применяет спек ────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Связь «закон — правило» и «правило — паттерн» сверяется в обе стороны, а спек до сих пор
|
|
389
|
+
* говорил только о домене. Закон при этом он применял: ссылки на `docs/constitution/…`
|
|
390
|
+
* лежали внутри строки зависимостей и посреди текста, и по закону нельзя было узнать, какие
|
|
391
|
+
* домены на нём стоят, — только грепом.
|
|
392
|
+
*
|
|
393
|
+
* Отсюда строка `**Законы:**` в шапке и сверка обеих сторон: закон, названный в тексте, но не
|
|
394
|
+
* объявленный, и объявленный закон, которого нет.
|
|
395
|
+
*/
|
|
396
|
+
const SPEC_LAWS = /^\*\*Законы:\*\*\s*(.+)$/;
|
|
397
|
+
/**
|
|
398
|
+
* Ссылка на закон где угодно в тексте спека — по ней считается вторая сторона связи. Слой в
|
|
399
|
+
* пути необязателен: законы приложения лежат в `application/`, а называются так же.
|
|
400
|
+
*/
|
|
401
|
+
const LAW_REFERENCE = new RegExp(`\`${CONSTITUTION_DIR}/(?:application/)?([a-z-]+)\\.md\``, 'g');
|
|
402
|
+
|
|
403
|
+
function checkSpecLaws(file, text, laws) {
|
|
404
|
+
const line = text.split('\n').find((candidate) => SPEC_LAWS.test(candidate));
|
|
405
|
+
if (!line) {
|
|
406
|
+
report(file, 'в шапке нет строки `**Законы:**` — не видно, какие законы домен применяет');
|
|
407
|
+
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const declared = new Set([...line.match(SPEC_LAWS)[1].matchAll(BACKTICKED)].map(([, name]) => name));
|
|
412
|
+
for (const name of declared) {
|
|
413
|
+
if (!laws.has(name)) {
|
|
414
|
+
report(file, `в строке \`**Законы:**\` назван \`${name}\`, а закона с таким именем нет ни в одном слое`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
for (const [, name] of text.matchAll(LAW_REFERENCE)) {
|
|
419
|
+
if (!declared.has(name)) {
|
|
420
|
+
report(file, `закон \`${name}\` назван в тексте, но не объявлен в строке \`**Законы:**\``);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── 2. Контракт против декораторов ────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
/** Корни либ, чьи процедуры домен обслуживает; объявлены в шапке спека. */
|
|
428
|
+
function procedureRootsOf(text) {
|
|
429
|
+
const line = text.split('\n').find((candidate) => PROCEDURE_ROOTS.test(candidate));
|
|
430
|
+
if (!line) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const value = line.match(PROCEDURE_ROOTS)[1];
|
|
434
|
+
if (/^\s*нет\s*$/i.test(value.replace(/[`.]/g, ''))) {
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return [...value.matchAll(BACKTICKED)].map(([, path]) => path);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Что объявлено в самих процедурах: метод контракта и право на него. */
|
|
442
|
+
function declaredProcedures(roots) {
|
|
443
|
+
const found = new Map();
|
|
444
|
+
for (const root of roots) {
|
|
445
|
+
for (const file of walk(root, (name) => name.endsWith('.procedure.ts'))) {
|
|
446
|
+
const text = read(file);
|
|
447
|
+
const method = text.match(/\.method\.([A-Za-z_]\w*)/);
|
|
448
|
+
if (!method) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const service = text.match(/typeof\s+(\w+)\.method\./);
|
|
452
|
+
const required = text.match(/@RequiresPermission\(\s*'([^']+)'/);
|
|
453
|
+
const isPublic = /@PublicProcedure\(/.test(text);
|
|
454
|
+
found.set(method[1].toLowerCase(), {
|
|
455
|
+
file,
|
|
456
|
+
method: method[1],
|
|
457
|
+
service: service ? service[1] : '',
|
|
458
|
+
permission: required ? required[1] : isPublic ? 'публично' : '',
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return found;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Строки таблицы «Контракта»: первая ячейка — процедура, вторая — право. */
|
|
467
|
+
function contractRows(text) {
|
|
468
|
+
const rows = [];
|
|
469
|
+
for (const [index, line] of sectionOf(text, '## Контракт').entries()) {
|
|
470
|
+
if (!line.startsWith('|') || /^\|[\s:|-]+\|$/.test(line)) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const cells = line
|
|
474
|
+
.split('|')
|
|
475
|
+
.slice(1, -1)
|
|
476
|
+
.map((cell) => cell.trim());
|
|
477
|
+
if (cells.length < 2) {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const name = (cells[0].match(/`([^`]+)`/) || [])[1];
|
|
481
|
+
if (!name) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const permission = (cells[1].match(/`([^`]+)`/) || [])[1] || cells[1];
|
|
485
|
+
rows.push({ line: index, name, permission: permission.trim(), short: name.split('.').pop() });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return rows;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function checkContract(file, text, roots) {
|
|
492
|
+
if (roots === null) {
|
|
493
|
+
report(file, 'в шапке нет строки `**Процедуры:**` — нечем сверить таблицу «Контракта» с декораторами');
|
|
494
|
+
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const declared = declaredProcedures(roots);
|
|
498
|
+
const rows = contractRows(text);
|
|
499
|
+
const described = new Set();
|
|
500
|
+
|
|
501
|
+
for (const row of rows) {
|
|
502
|
+
const found = declared.get(row.short.toLowerCase());
|
|
503
|
+
if (!found) {
|
|
504
|
+
report(file, `в «Контракте» есть \`${row.name}\`, но процедуры с таким методом в ${roots.join(', ')} нет`);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
described.add(row.short.toLowerCase());
|
|
508
|
+
if (found.permission && row.permission !== found.permission) {
|
|
509
|
+
report(
|
|
510
|
+
file,
|
|
511
|
+
`право у \`${row.name}\` разошлось: в спеке «${row.permission}», ` + `в \`${found.file}\` объявлено «${found.permission}»`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
for (const [key, found] of declared) {
|
|
517
|
+
if (!described.has(key)) {
|
|
518
|
+
report(
|
|
519
|
+
file,
|
|
520
|
+
`процедура \`${found.service}.${found.method}\` (${found.file}) домену принадлежит, ` + 'но в таблице «Контракта» её нет'
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── 3. Коды отказов ───────────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
function checkRefusalCodes(file, text, roots) {
|
|
529
|
+
const section = sectionOf(text, '### Коды отказов');
|
|
530
|
+
const bullets = bulletsOf(section);
|
|
531
|
+
/**
|
|
532
|
+
* «Не применимо» — законный ответ и здесь. Домен, у которого есть процедуры,
|
|
533
|
+
* но нет ни одного `Code.X`, иначе не описывался вовсе: пустой раздел
|
|
534
|
+
* проверка отвергает, а любой выписанный код отвергает тем более — бросать
|
|
535
|
+
* его в домене некому. Так живёт проверка живости: отказ она подаёт кодом
|
|
536
|
+
* ответа, а не отказом процедуры.
|
|
537
|
+
*/
|
|
538
|
+
// Без `\b`: кириллица не входит в `\w`, и границы слова после «применимо» не возникает
|
|
539
|
+
const notApplicable = section.some((line) => /^Не применимо/.test(line.trim()));
|
|
540
|
+
if (!bullets.length) {
|
|
541
|
+
if (!notApplicable) {
|
|
542
|
+
report(file, 'в разделе `### Коды отказов` нет ни одного кода и нет ответа «Не применимо»');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (!roots || !roots.length) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const sources = roots.flatMap((root) => walk(root, (name) => name.endsWith('.ts') && !name.endsWith('.spec.ts')));
|
|
551
|
+
const thrown = new Set();
|
|
552
|
+
for (const source of sources) {
|
|
553
|
+
for (const [, code] of read(source).matchAll(/\bCode\.([A-Za-z]\w*)/g)) {
|
|
554
|
+
thrown.add(code);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
for (const bullet of bullets) {
|
|
559
|
+
const code = (bullet.text.match(/`([A-Za-z]\w*)`/) || [])[1];
|
|
560
|
+
if (!code) {
|
|
561
|
+
report(file, `в «Кодах отказов» строка без кода в кавычках: «${bullet.text.slice(0, 60)}…»`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (!thrown.has(code)) {
|
|
565
|
+
report(file, `код отказа \`${code}\` в домене нигде не бросается — либо он не отсюда, либо путь его не даёт`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── 4. Сценарии и уровень привязки ────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
function parseScenarios(file) {
|
|
573
|
+
const lines = read(file).split('\n');
|
|
574
|
+
const scenarios = [];
|
|
575
|
+
let current = null;
|
|
576
|
+
let inPromise = false;
|
|
577
|
+
|
|
578
|
+
lines.forEach((line, index) => {
|
|
579
|
+
const heading = SCENARIO_HEADING.exec(line);
|
|
580
|
+
if (heading) {
|
|
581
|
+
current = {
|
|
582
|
+
id: heading[1],
|
|
583
|
+
prefix: heading[2],
|
|
584
|
+
title: heading[4],
|
|
585
|
+
file,
|
|
586
|
+
line: index + 1,
|
|
587
|
+
uncovered: false,
|
|
588
|
+
partial: false,
|
|
589
|
+
promise: '',
|
|
590
|
+
};
|
|
591
|
+
inPromise = false;
|
|
592
|
+
scenarios.push(current);
|
|
593
|
+
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
597
|
+
current = null;
|
|
598
|
+
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (!current) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (UNCOVERED.test(line)) {
|
|
605
|
+
current.uncovered = true;
|
|
606
|
+
}
|
|
607
|
+
if (PARTIAL.test(line)) {
|
|
608
|
+
current.partial = true;
|
|
609
|
+
}
|
|
610
|
+
// «Тогда» и его продолжения с отступом — то, что сценарий обещает
|
|
611
|
+
if (PROMISE.test(line)) {
|
|
612
|
+
inPromise = true;
|
|
613
|
+
current.promise += ` ${line.trim()}`;
|
|
614
|
+
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (inPromise && /^\s+\S/.test(line)) {
|
|
618
|
+
current.promise += ` ${line.trim()}`;
|
|
619
|
+
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
inPromise = false;
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
return scenarios;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Обещан ли сценарием экран. Признак читается только из «Тогда»: «Дано» описывает
|
|
630
|
+
* обстановку, «Когда» — повод, а обещание пользователю стоит именно здесь.
|
|
631
|
+
*
|
|
632
|
+
* Человек и глагол восприятия требуются вместе, потому что порознь оба ошибаются.
|
|
633
|
+
* «Показывается» без человека стоит и там, где показывается запись в базе, а человек без
|
|
634
|
+
* восприятия — в каждом втором сценарии приёма заявки. Признак нарочно молчалив: сценарий,
|
|
635
|
+
* чьё «Тогда» человека не называет, под него не подпадает вовсе.
|
|
636
|
+
*/
|
|
637
|
+
function promisesScreen(promise) {
|
|
638
|
+
return ACTOR.test(promise) && PERCEIVES.test(promise);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Константы, собранные из окружения, вместе с теми, что собраны из них. Ими выключают
|
|
643
|
+
* сквозной тест целиком: без `BASE_URL` или пары входа он не исполняется ни разу.
|
|
644
|
+
* Цепочка раскрывается, пока есть что раскрывать: `HAS_ADMIN_SESSION` собран из двух
|
|
645
|
+
* других констант, а не из `process.env` напрямую.
|
|
646
|
+
*/
|
|
647
|
+
function environmentSwitches(root) {
|
|
648
|
+
// Объявление верхнего уровня: с отступом стоят локальные, и они гасят не тест, а случай
|
|
649
|
+
const declaration = /^const\s+([A-Za-z_]\w*)\s*(?::[^=]+)?=\s*([^;]+);/gm;
|
|
650
|
+
const assignments = [];
|
|
651
|
+
for (const file of walk(root, (name) => name.endsWith('.ts'))) {
|
|
652
|
+
for (const [, name, value] of read(file).matchAll(declaration)) {
|
|
653
|
+
assignments.push({ name, value });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const switches = new Set();
|
|
658
|
+
for (let pass = 0; pass <= assignments.length; pass += 1) {
|
|
659
|
+
const before = switches.size;
|
|
660
|
+
for (const { name, value } of assignments) {
|
|
661
|
+
if (value.includes('process.env') || [...switches].some((known) => new RegExp(`\\b${known}\\b`).test(value))) {
|
|
662
|
+
switches.add(name);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (switches.size === before) {
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return switches;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Упоминания сценария в тестах: где стоит, идёт ли тест путём пользователя и не выключен ли
|
|
675
|
+
* он переменной окружения.
|
|
676
|
+
*
|
|
677
|
+
* Выключатель по состоянию стенда («у объекта меньше двух помещений») — это пропуск случая,
|
|
678
|
+
* и покрытие он не отменяет. Выключатель по переменной отменяет: тест с ним в обычном
|
|
679
|
+
* прогоне значится пропущенным, а сводка без этого читала бы его покрытием.
|
|
680
|
+
*/
|
|
681
|
+
function collectReferences() {
|
|
682
|
+
const references = new Map();
|
|
683
|
+
const remember = (id, place) => {
|
|
684
|
+
if (!references.has(id)) {
|
|
685
|
+
references.set(id, []);
|
|
686
|
+
}
|
|
687
|
+
references.get(id).push(place);
|
|
688
|
+
};
|
|
689
|
+
const switchesByRoot = new Map();
|
|
690
|
+
|
|
691
|
+
for (const root of TEST_ROOTS) {
|
|
692
|
+
for (const file of walk(root, (name) => name.endsWith('.spec.ts'))) {
|
|
693
|
+
const e2eRoot = E2E_ROOTS.find((dir) => file.startsWith(`${dir}/`));
|
|
694
|
+
if (e2eRoot && !switchesByRoot.has(e2eRoot)) {
|
|
695
|
+
switchesByRoot.set(e2eRoot, environmentSwitches(e2eRoot));
|
|
696
|
+
}
|
|
697
|
+
const switches = switchesByRoot.get(e2eRoot) ?? new Set();
|
|
698
|
+
const switched = (line) =>
|
|
699
|
+
[...line.matchAll(/test\.skip\(([^,]*)/g)].some(
|
|
700
|
+
([, condition]) =>
|
|
701
|
+
condition.includes('process.env') || [...switches].some((name) => new RegExp(`\\b${name}\\b`).test(condition))
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
const found = [];
|
|
705
|
+
let test = null;
|
|
706
|
+
let describeSwitched = false;
|
|
707
|
+
|
|
708
|
+
read(file)
|
|
709
|
+
.split('\n')
|
|
710
|
+
.forEach((line, index) => {
|
|
711
|
+
if (/^\s*test\.describe[.(]/.test(line)) {
|
|
712
|
+
describeSwitched = false;
|
|
713
|
+
test = null;
|
|
714
|
+
} else if (/^\s*test\s*\(/.test(line)) {
|
|
715
|
+
test = { off: describeSwitched };
|
|
716
|
+
} else if (/test\.skip\(/.test(line)) {
|
|
717
|
+
if (test) {
|
|
718
|
+
test.off = test.off || switched(line);
|
|
719
|
+
} else {
|
|
720
|
+
describeSwitched = describeSwitched || switched(line);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
for (const [id] of line.matchAll(SCENARIO_REFERENCE)) {
|
|
725
|
+
found.push({ id, test, place: `${file}:${index + 1}` });
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// Выключатель стоит первой строкой тела, то есть ниже заголовка теста с
|
|
730
|
+
// идентификатором: состояние теста читается, когда файл разобран целиком
|
|
731
|
+
found.forEach(({ id, test: own, place }) => remember(id, { place, screen: Boolean(e2eRoot), off: Boolean(own?.off) }));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return references;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ── прогон ────────────────────────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
function checkSpecHeadings(file, text) {
|
|
741
|
+
const headings = new Set(
|
|
742
|
+
text
|
|
743
|
+
.split('\n')
|
|
744
|
+
.filter((line) => /^#{1,6}\s/.test(line))
|
|
745
|
+
.map((line) => line.trimEnd())
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
REQUIRED_HEADINGS.filter((required) => !headings.has(required)).forEach((required) =>
|
|
749
|
+
report(file, `нет обязательного раздела \`${required}\``)
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const domains = collectDomains();
|
|
754
|
+
const scenarios = [];
|
|
755
|
+
const byId = new Map();
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Имена законов и путь к каждому. Слоёв два: общий лежит в корне `docs/constitution/`, законы
|
|
759
|
+
* приложения — в `application/` под ним. Имя берётся без каталога, потому что называют закон
|
|
760
|
+
* везде одинаково: ни `law:` в шапке правила, ни `**Законы:**` в спеке не знают, в каком он
|
|
761
|
+
* слое, и переезд между слоями не переписывает ни одну из этих строк.
|
|
762
|
+
*
|
|
763
|
+
* Отсюда требование: имена законов уникальны по всему дереву конституции. Два файла с одним
|
|
764
|
+
* именем в разных слоях назывались бы одной строкой `law:`, и правило досталось бы тому, кого
|
|
765
|
+
* обошли первым.
|
|
766
|
+
*/
|
|
767
|
+
const laws = new Map();
|
|
768
|
+
for (const file of walk(CONSTITUTION_DIR, (name) => name.endsWith('.md'))) {
|
|
769
|
+
const name = file.slice(file.lastIndexOf('/') + 1, -'.md'.length);
|
|
770
|
+
if (laws.has(name)) {
|
|
771
|
+
report(file, `закон с таким именем уже есть — \`${laws.get(name)}\`; имена законов уникальны на оба слоя`);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
laws.set(name, file);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
for (const domain of domains) {
|
|
778
|
+
const base = `${SPECS_DIR}/${domain}`;
|
|
779
|
+
// Домен, у которого есть только `proposed/`, ещё не существует: спека о нём
|
|
780
|
+
// нет, пока фича не выкачена
|
|
781
|
+
const isProposedOnly = exists(`${base}/proposed`) && !exists(`${base}/spec.md`);
|
|
782
|
+
if (!isProposedOnly) {
|
|
783
|
+
['spec.md', 'scenarios.md']
|
|
784
|
+
.filter((name) => !exists(`${base}/${name}`))
|
|
785
|
+
.forEach((name) => report(base, `нет файла \`${name}\` — домен описан наполовину`));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Спеки фич из `proposed/` проверяются наравне со спеком домена: они и есть
|
|
789
|
+
// договорённость, по которой пишется код, а не черновик. Три сверки с кодом к
|
|
790
|
+
// ним не применяются — кода, с которым сверять, ещё нет
|
|
791
|
+
for (const specFile of walk(base, (name) => name === 'spec.md')) {
|
|
792
|
+
const text = read(specFile);
|
|
793
|
+
checkSpecHeadings(specFile, text);
|
|
794
|
+
// Законы спек объявляет и в `proposed/`: договорённость о продукте есть до кода
|
|
795
|
+
checkSpecLaws(specFile, text, laws);
|
|
796
|
+
if (specFile.includes('/proposed/')) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
checkRuleImplementation(specFile, text, `${dirname(specFile)}/implementation.md`);
|
|
800
|
+
const roots = procedureRootsOf(text);
|
|
801
|
+
checkContract(specFile, text, roots);
|
|
802
|
+
checkRefusalCodes(specFile, text, roots);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const found = walk(base, (name) => name === 'scenarios.md').flatMap(parseScenarios);
|
|
806
|
+
const prefixes = new Set(found.map((scenario) => scenario.prefix));
|
|
807
|
+
if (prefixes.size > 1) {
|
|
808
|
+
report(base, `в домене больше одного префикса сценариев: ${[...prefixes].sort().join(', ')}`);
|
|
809
|
+
}
|
|
810
|
+
scenarios.push(...found);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Закон о проекте не знает ничего: ни путей, ни имён файлов, ни привязок. Он только
|
|
814
|
+
// объявляет статьи, а всё остальное — дело правил, которые на него ссылаются. Поэтому
|
|
815
|
+
// спутника с привязкой у закона нет и быть не может: файл с путями `libs/...`, лежащий
|
|
816
|
+
// рядом с законом, привязал бы закон к этому проекту.
|
|
817
|
+
//
|
|
818
|
+
// «Открытые вопросы» отсюда сняты: проверка видела заголовок, а не вопросы под ним, и
|
|
819
|
+
// пустой раздел проходил её так же, как заполненный. Закон, у которого всё решено, писал
|
|
820
|
+
// эту строку ради самой строки.
|
|
821
|
+
const LAW_HEADINGS = ['## Статьи'];
|
|
822
|
+
|
|
823
|
+
for (const file of walk(CONSTITUTION_DIR, (name) => name.endsWith('.md'))) {
|
|
824
|
+
const text = read(file);
|
|
825
|
+
LAW_HEADINGS.filter((heading) => !text.split('\n').some((line) => line.trimEnd() === heading)).forEach((heading) =>
|
|
826
|
+
report(file, `нет раздела \`${heading}\``)
|
|
827
|
+
);
|
|
828
|
+
if (/`[\w./-]+\.(ts|mjs|js|sh|scss|html|json|proto|conf|yml|md)[:`]/.test(text)) {
|
|
829
|
+
report(file, 'закон называет файл проекта — путям и привязкам место в правиле, а не здесь');
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Правило — скил с `kind: rule` в шапке. Оно и знает о проекте: имена, пути, связи. Привязка
|
|
834
|
+
// его утверждений к коду живёт в `implementation.md` рядом со скилом.
|
|
835
|
+
const RULE_HEADING = '## Как закон применяется здесь';
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Шапка скила — первый блок между `---`. Читается только она: паттерн, который учит заводить
|
|
839
|
+
* правило, показывает шапку правила примером в фенсе, и поиск по всему тексту принял бы этот
|
|
840
|
+
* пример за настоящее объявление.
|
|
841
|
+
*/
|
|
842
|
+
function frontMatterOf(text) {
|
|
843
|
+
const found = text.match(/^---\n([\s\S]*?)\n---/);
|
|
844
|
+
|
|
845
|
+
return found ? found[1] : '';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Правила и паттерны, найденные в дереве скилов: по ним считаются обе стороны связи. */
|
|
849
|
+
const ruled = new Set();
|
|
850
|
+
const patterned = new Set();
|
|
851
|
+
const nameOf = (head) => (head.match(/^name:\s*(\S+)/m) || [])[1] || '';
|
|
852
|
+
|
|
853
|
+
for (const file of walk('.claude/skills', (name) => name === 'SKILL.md')) {
|
|
854
|
+
const text = read(file);
|
|
855
|
+
const head = frontMatterOf(text);
|
|
856
|
+
const kind = (head.match(/^kind:\s*(\S+)/m) || [])[1];
|
|
857
|
+
|
|
858
|
+
if (kind === 'pattern') {
|
|
859
|
+
const rule = (head.match(/^rule:\s*(\S+)/m) || [])[1];
|
|
860
|
+
if (!rule) {
|
|
861
|
+
report(file, 'паттерн не объявил правило — допиши `rule:` в шапку');
|
|
862
|
+
} else if (!exists(`.claude/skills/${rule}/SKILL.md`)) {
|
|
863
|
+
report(file, `паттерн объявил правило \`${rule}\`, а скила с таким именем нет`);
|
|
864
|
+
} else {
|
|
865
|
+
patterned.add(rule);
|
|
866
|
+
}
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (kind !== 'rule') {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const law = (head.match(/^law:\s*(\S+)/m) || [])[1];
|
|
875
|
+
if (!law) {
|
|
876
|
+
report(file, 'правило не объявило закон — допиши `law:` в шапку');
|
|
877
|
+
} else if (!laws.has(law)) {
|
|
878
|
+
report(file, `правило объявило закон \`${law}\`, а закона с таким именем нет ни в одном слое`);
|
|
879
|
+
} else {
|
|
880
|
+
ruled.add(law);
|
|
881
|
+
}
|
|
882
|
+
checkRuleImplementation(file, text, `${dirname(file)}/implementation.md`, RULE_HEADING);
|
|
883
|
+
|
|
884
|
+
const name = nameOf(head);
|
|
885
|
+
if (name && name !== file.slice('.claude/skills/'.length, -'/SKILL.md'.length)) {
|
|
886
|
+
report(file, `имя в шапке (\`${name}\`) не совпадает с каталогом скила`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Обратные стороны связи. Закон без правила читается как договорённость, которую этот проект
|
|
891
|
+
// не применяет; правило без паттерна оставляет готовый код там, где ему не место, — в самом
|
|
892
|
+
// правиле, которое читается при каждой правке.
|
|
893
|
+
[...laws]
|
|
894
|
+
.filter(([law]) => !ruled.has(law))
|
|
895
|
+
.forEach(([, file]) => report(file, 'у закона нет ни одного правила — заведи скил с `law:` на него'));
|
|
896
|
+
|
|
897
|
+
for (const file of walk('.claude/skills', (name) => name === 'SKILL.md')) {
|
|
898
|
+
const head = frontMatterOf(read(file));
|
|
899
|
+
const name = nameOf(head);
|
|
900
|
+
if (/^kind:\s*rule\s*$/m.test(head) && name && !patterned.has(name)) {
|
|
901
|
+
report(file, 'у правила нет ни одного паттерна — заведи скил с `rule:` на него');
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
checkTracedAnchors();
|
|
906
|
+
|
|
907
|
+
for (const scenario of scenarios) {
|
|
908
|
+
const seen = byId.get(scenario.id);
|
|
909
|
+
if (seen) {
|
|
910
|
+
report(`${scenario.file}:${scenario.line}`, `идентификатор ${scenario.id} уже занят (${seen.file}:${seen.line})`);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
byId.set(scenario.id, scenario);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const references = collectReferences();
|
|
917
|
+
const uncovered = [];
|
|
918
|
+
const partial = [];
|
|
919
|
+
let covered = 0;
|
|
920
|
+
|
|
921
|
+
for (const scenario of byId.values()) {
|
|
922
|
+
const places = references.get(scenario.id) ?? [];
|
|
923
|
+
const hasTest = places.length > 0;
|
|
924
|
+
if (scenario.uncovered && hasTest) {
|
|
925
|
+
report(`${scenario.file}:${scenario.line}`, `${scenario.id} помечен «Не покрыто», но тест на него есть (${places[0].place})`);
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
if (scenario.uncovered) {
|
|
929
|
+
uncovered.push(scenario);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (!hasTest) {
|
|
933
|
+
report(`${scenario.file}:${scenario.line}`, `${scenario.id} не упомянут ни в одном тесте и не помечен «Не покрыто»`);
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
if (scenario.partial) {
|
|
937
|
+
partial.push(scenario);
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
// Обещание, данное пользователю, закрывается тестом, идущим его путём. Юнит проверяет
|
|
941
|
+
// тот же расчёт мимо экрана: он верен и покрытием сценария не является
|
|
942
|
+
if (promisesScreen(scenario.promise) && !places.some(({ screen, off }) => screen && !off)) {
|
|
943
|
+
const off = places.some(({ screen }) => screen);
|
|
944
|
+
report(
|
|
945
|
+
`${scenario.file}:${scenario.line}`,
|
|
946
|
+
`${scenario.id} обещает то, что человек видит, а ${off ? 'сквозной тест на него выключен переменной окружения' : 'проверяет его только юнит'} ` +
|
|
947
|
+
`(${places[0].place}) — либо тест идёт путём пользователя, либо сценарию нужна отметка «Покрытие: частичное»`
|
|
948
|
+
);
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
covered += 1;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
for (const [id, places] of references) {
|
|
955
|
+
if (!byId.has(id)) {
|
|
956
|
+
report(places[0].place, `тест ссылается на ${id}, а такого сценария в \`${SPECS_DIR}\` нет`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (problems.length > 0) {
|
|
961
|
+
console.error(`check-specs: расхождений ${problems.length}\n`);
|
|
962
|
+
problems.forEach((problem) => console.error(` ${problem}`));
|
|
963
|
+
console.error('\nПравила работы со спеками — скил `spec-driven`.');
|
|
964
|
+
process.exit(1);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
console.log(
|
|
968
|
+
`check-specs: доменов ${domains.length}, сценариев ${byId.size} — ` +
|
|
969
|
+
`покрыто ${covered}, частично ${partial.length}, без тестов ${uncovered.length}`
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
const debts = [...partial, ...uncovered];
|
|
973
|
+
if (debts.length > 0) {
|
|
974
|
+
console.log('\nДолги — покрытие неполное:');
|
|
975
|
+
partial.forEach((scenario) => console.log(` частично ${scenario.id} — ${scenario.title} (${scenario.file}:${scenario.line})`));
|
|
976
|
+
uncovered.forEach((scenario) => console.log(` нет теста ${scenario.id} — ${scenario.title} (${scenario.file}:${scenario.line})`));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Договорённость, по которой код уже написан, вливается в спек домена, а её директория
|
|
981
|
+
* удаляется. Оставленная в главной ветке, она читается как предложенное и не выкаченное —
|
|
982
|
+
* то есть как ложь о работающем месяц приложении.
|
|
983
|
+
*
|
|
984
|
+
* Признак — все сценарии директории покрыты тестами: пока хоть один помечен «Не покрыто»,
|
|
985
|
+
* фича не дописана. Падением это не делается: на середине работы часть тестов уже есть, и
|
|
986
|
+
* такая проверка краснела бы всю дорогу.
|
|
987
|
+
*/
|
|
988
|
+
const proposed = new Map();
|
|
989
|
+
for (const scenario of byId.values()) {
|
|
990
|
+
const at = scenario.file.indexOf('/proposed/');
|
|
991
|
+
if (at === -1) {
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
const dir = scenario.file.slice(0, scenario.file.indexOf('/', at + '/proposed/'.length));
|
|
995
|
+
const group = proposed.get(dir) ?? { total: 0, ready: 0 };
|
|
996
|
+
group.total += 1;
|
|
997
|
+
if (references.has(scenario.id) && !scenario.uncovered && !scenario.partial) {
|
|
998
|
+
group.ready += 1;
|
|
999
|
+
}
|
|
1000
|
+
proposed.set(dir, group);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const ripe = [...proposed].filter(([, group]) => group.total > 0 && group.total === group.ready);
|
|
1004
|
+
if (ripe.length > 0) {
|
|
1005
|
+
console.log('\nПора вливать — сценарии закрыты тестами, договорённость ждёт переезда в спек домена:');
|
|
1006
|
+
ripe.forEach(([dir, group]) => console.log(` ${dir} — сценариев ${group.total}`));
|
|
1007
|
+
}
|