@rt-tools/agent-kit 0.8.3 → 0.9.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.
Files changed (132) hide show
  1. package/README.md +12 -0
  2. package/assets/checks/board.github.mjs +48 -1
  3. package/assets/checks/check-board.github.mjs +84 -1
  4. package/assets/checks/check-lib-layers.mjs +13 -524
  5. package/assets/checks/check-specs.mjs +11 -782
  6. package/assets/checks/check-styles.mjs +185 -15
  7. package/assets/checks/lib-boundaries.mjs +143 -0
  8. package/assets/checks/lib-common.mjs +149 -0
  9. package/assets/checks/lib-domains.mjs +205 -0
  10. package/assets/checks/lib-manifests.mjs +60 -0
  11. package/assets/checks/lib-reexports.mjs +101 -0
  12. package/assets/checks/rt-kit-checks.config.mjs +17 -0
  13. package/assets/checks/spec-anchors.mjs +297 -0
  14. package/assets/checks/spec-common.mjs +222 -0
  15. package/assets/checks/spec-contract.mjs +152 -0
  16. package/assets/checks/spec-scenarios.mjs +201 -0
  17. package/assets/defaults/project.sh +8 -0
  18. package/assets/hooks/git-guard-push-tests.sh +8 -4
  19. package/assets/hooks/skill-gate.sh +1 -1
  20. package/assets/hooks/sql-guard-parse.sh +187 -0
  21. package/assets/hooks/sql-guard-request.sh +117 -0
  22. package/assets/hooks/sql-guard-target.sh +134 -0
  23. package/assets/hooks/sql-guard-write.sh +212 -0
  24. package/assets/hooks/sql-guard.sh +26 -596
  25. package/assets/hooks/waiting-turn-guard.sh +42 -13
  26. package/assets/laws/delivery.md +7 -0
  27. package/assets/laws/work-conduct.md +9 -0
  28. package/assets/patterns/admin-lists-screen.md +25 -14
  29. package/assets/patterns/admin-nav-item.md +1 -1
  30. package/assets/patterns/component-structure-new.md +1 -1
  31. package/assets/patterns/entity-aside.md +4 -2
  32. package/assets/patterns/observability-record.md +9 -0
  33. package/assets/patterns/shared-code-new.md +2 -2
  34. package/assets/patterns/task-flow-close.md +7 -1
  35. package/assets/rules/git-workflow.azure.md +7 -0
  36. package/assets/rules/git-workflow.github.md +41 -0
  37. package/assets/rules/git-workflow.gitlab.md +7 -0
  38. package/assets/rules/lib-layers.md +4 -0
  39. package/assets/rules/lists.md +10 -10
  40. package/assets/rules/shared-code.md +1 -1
  41. package/assets/rules/task-flow.md +47 -7
  42. package/assets/rules/testing.md +31 -0
  43. package/assets/rules/typescript-conventions.md +7 -0
  44. package/assets/skills/agent-kit.md +36 -0
  45. package/assets/templates/proposal.md +21 -0
  46. package/bin/agent-kit.d.ts.map +1 -1
  47. package/bin/agent-kit.js +115 -87
  48. package/bin/agent-kit.js.map +1 -1
  49. package/index.d.ts +1 -0
  50. package/index.d.ts.map +1 -1
  51. package/index.js +1 -0
  52. package/index.js.map +1 -1
  53. package/lib/argv.d.ts.map +1 -1
  54. package/lib/argv.js +6 -4
  55. package/lib/argv.js.map +1 -1
  56. package/lib/assets.d.ts.map +1 -1
  57. package/lib/assets.js +2 -1
  58. package/lib/assets.js.map +1 -1
  59. package/lib/cargo.d.ts +20 -0
  60. package/lib/cargo.d.ts.map +1 -1
  61. package/lib/cargo.js.map +1 -1
  62. package/lib/cascade.d.ts +55 -0
  63. package/lib/cascade.d.ts.map +1 -0
  64. package/lib/cascade.js +131 -0
  65. package/lib/cascade.js.map +1 -0
  66. package/lib/catalog.d.ts +0 -75
  67. package/lib/catalog.d.ts.map +1 -1
  68. package/lib/catalog.js +44 -127
  69. package/lib/catalog.js.map +1 -1
  70. package/lib/commands.d.ts.map +1 -1
  71. package/lib/commands.js +152 -85
  72. package/lib/commands.js.map +1 -1
  73. package/lib/companion.d.ts.map +1 -1
  74. package/lib/companion.js +5 -5
  75. package/lib/companion.js.map +1 -1
  76. package/lib/config.d.ts +2 -0
  77. package/lib/config.d.ts.map +1 -1
  78. package/lib/config.js +7 -5
  79. package/lib/config.js.map +1 -1
  80. package/lib/enroll.d.ts +56 -0
  81. package/lib/enroll.d.ts.map +1 -0
  82. package/lib/enroll.js +123 -0
  83. package/lib/enroll.js.map +1 -0
  84. package/lib/freshness.d.ts.map +1 -1
  85. package/lib/freshness.js +31 -17
  86. package/lib/freshness.js.map +1 -1
  87. package/lib/hooks-map.d.ts +30 -0
  88. package/lib/hooks-map.d.ts.map +1 -1
  89. package/lib/hooks-map.js +80 -18
  90. package/lib/hooks-map.js.map +1 -1
  91. package/lib/integrity.d.ts +1 -2
  92. package/lib/integrity.d.ts.map +1 -1
  93. package/lib/integrity.js +0 -1
  94. package/lib/integrity.js.map +1 -1
  95. package/lib/observations.d.ts.map +1 -1
  96. package/lib/observations.js +25 -12
  97. package/lib/observations.js.map +1 -1
  98. package/lib/order.d.ts +10 -0
  99. package/lib/order.d.ts.map +1 -0
  100. package/lib/order.js +14 -0
  101. package/lib/order.js.map +1 -0
  102. package/lib/picker.d.ts.map +1 -1
  103. package/lib/picker.js +8 -2
  104. package/lib/picker.js.map +1 -1
  105. package/lib/plan.js +1 -1
  106. package/lib/plan.js.map +1 -1
  107. package/lib/proposals.d.ts.map +1 -1
  108. package/lib/proposals.js +25 -8
  109. package/lib/proposals.js.map +1 -1
  110. package/lib/sections.js +1 -1
  111. package/lib/sections.js.map +1 -1
  112. package/lib/ship.d.ts.map +1 -1
  113. package/lib/ship.js +9 -1
  114. package/lib/ship.js.map +1 -1
  115. package/lib/shipment.d.ts.map +1 -1
  116. package/lib/shipment.js +14 -10
  117. package/lib/shipment.js.map +1 -1
  118. package/lib/snapshot.d.ts.map +1 -1
  119. package/lib/snapshot.js +2 -1
  120. package/lib/snapshot.js.map +1 -1
  121. package/lib/stamp.js +1 -1
  122. package/lib/stamp.js.map +1 -1
  123. package/lib/sync.d.ts +12 -2
  124. package/lib/sync.d.ts.map +1 -1
  125. package/lib/sync.js +11 -10
  126. package/lib/sync.js.map +1 -1
  127. package/lib/vars.d.ts.map +1 -1
  128. package/lib/vars.js +2 -3
  129. package/lib/vars.js.map +1 -1
  130. package/package.json +1 -1
  131. package/rt-tools-agent-kit-0.9.0.tgz +0 -0
  132. package/rt-tools-agent-kit-0.8.3.tgz +0 -0
@@ -38,792 +38,21 @@
38
38
  * экран. Символ, объявленный в файле и больше нигде не встречающийся, местом
39
39
  * исполнения правила не считается.
40
40
  *
41
+ * Каждый механизм живёт своим модулем рядом: привязки и законы — `spec-anchors.mjs`,
42
+ * контракт и коды отказов — `spec-contract.mjs`, сценарии и покрытие — `spec-scenarios.mjs`,
43
+ * общее чтение дерева — `spec-common.mjs`. Здесь остаётся прогон: он обходит домены и
44
+ * складывает найденное в один перечень.
45
+ *
41
46
  * Ненулевой код возврата и перечень расхождений.
42
47
  */
43
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
48
+ import { readdirSync } from 'node:fs';
44
49
  import { dirname, join } from 'node:path';
45
50
 
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
- /**
66
- * `### SC-BK-03 — заявка на занятые даты`
67
- *
68
- * Номер принимается от одной цифры до трёх. Заголовок, не подошедший под шаблон, сценария не
69
- * заводит и отказа не даёт: дерево, пронумеровавшее сценарии с единицы, теряло бы первые
70
- * девять из них молча — ни в покрытии, ни в долгах, при зелёной сверке.
71
- */
72
- const SCENARIO_HEADING = /^###\s+(SC-([A-Z]{2,4})-(\d{1,3}))\s+—\s+(.+?)\s*$/;
73
- /** Отметка осознанно непокрытого сценария; причина обязательна */
74
- const UNCOVERED = /^Не покрыто:\s*\S/;
75
- /** Тест есть, но проверяет не всё обещанное или идёт другим путём */
76
- const PARTIAL = /^Покрытие:\s*частичное\s*—\s*\S/;
77
- /** Упоминание сценария в заголовке теста; номер той же длины, что и в заголовке сценария */
78
- const SCENARIO_REFERENCE = /\bSC-[A-Z]{2,4}-\d{1,3}\b/g;
79
- /** Строка обещания сценария; её продолжения идут с отступом */
80
- const PROMISE = /^Тогда\s+\S/;
81
- /**
82
- * Человек перед экраном и его восприятие. Границы слова не ставятся: `\b` в JavaScript
83
- * считает буквой только латиницу, и `\bгость\b` не совпал бы ни разу.
84
- */
85
- const ACTOR = /(гост[ьяию]|владел(?:ец|ьца|ьцу|ьцем)|сотрудник\w*|оператор\w*|пользовател\w+)/i;
86
- const PERCEIVES = /(вид(?:ит|ят|но)|чита(?:ет|ют)|смотр(?:ит|ят))/i;
87
- /** Сквозные тесты: только они идут тем же путём, что пользователь */
88
- const E2E_ROOTS = CONFIG.e2eRoots;
89
-
90
- /**
91
- * Якорь правила: `путь/к/файлу.ts:символ` в обратных кавычках. Расширение до восьми
92
- * букв — иначе `schema.prisma` не считается путём, и правило про умолчание колонки
93
- * выглядит как правило без якоря. Заглавные и десять букв нужны ради `api.Dockerfile`:
94
- * без них правило про режим исполнения образа считалось правилом с пустой привязкой,
95
- * а привязать его больше не к чему — режим объявлен ровно там.
96
- *
97
- * Символ — любая буква, а не только латинская: тексты, которые исполняет модель, написаны
98
- * своим языком, и латиницей в них называется ровно то, что утверждения не держит — имя поля
99
- * шапки, имя инструмента. Привязанное к имени поля утверждение остаётся зелёным, когда текст
100
- * переписан целиком. Алфавит не перечисляется диапазонами: перечисленные молча не покрывают
101
- * соседнего, и промах выглядит отсутствием привязки. Путь при этом остаётся латинским — он
102
- * адрес в дереве, а не слово текста.
103
- */
104
- const ANCHOR = /`([\w./-]+\.[A-Za-z]{2,10}):(\p{L}[\p{L}\p{N}_-]*|_[\w-]*)`/gu;
105
- /**
106
- * Явный вердикт вместо якоря: статья, которой в дереве исполняться негде. Так бывает
107
- * законно — правило говорит о службе, которой дерево не держит, или о движении человека,
108
- * до которого проверке не дотянуться: кнопку слияния нажимают в браузере, где хуков нет
109
- * вовсе. Якорь такой статье можно поставить только в файл, который её не исполняет, —
110
- * проверка примет, а читателю совратёт.
111
- *
112
- * Принимается вердикт с причиной, а не одно слово: пустой он становится способом закрыть
113
- * любую строку, и таблица за месяц превращается в список отговорок. Порог длины — та же
114
- * мера, что у обхода гарда документов: причина короче его причиной не считается.
115
- *
116
- * Конец слова ищется отрицательным просмотром, а не `\b`: границей слова JavaScript знает
117
- * только латиницу, и после кириллической буквы её нет вовсе — вердикт не опознавался ни
118
- * разу.
119
- */
120
- const VERDICT = /^\s*(?:\*\*)?Не (?:исполняется|применимо|проверяется)(?![\p{L}\p{N}_])/u;
121
- const VERDICT_MIN = 40;
122
- /** Строка шапки, объявляющая либы, чьи процедуры домен обслуживает */
123
- const PROCEDURE_ROOTS = /^\*\*Процедуры:\*\*\s*(.+)$/;
124
- const BACKTICKED = /`([^`]+)`/g;
125
-
126
- const REQUIRED_HEADINGS = [
127
- '## Зачем',
128
- '## Терминология',
129
- '### Как это называется в интерфейсе',
130
- '## Правила',
131
- '## Что не входит',
132
- '## Контракт',
133
- '### Коды отказов',
134
- '## Данные',
135
- '## Экраны и состояния',
136
- '## Сквозные требования',
137
- '### Локали',
138
- '### SEO',
139
- '### Мобильная раскладка',
140
- '### Мультиобъектность',
141
- '## Решения',
142
- '## Открытые вопросы',
143
- '## История изменений',
144
- ];
145
-
146
- const problems = [];
147
- const report = (where, message) => problems.push(`${where}: ${message}`);
148
-
149
- function walk(dir, accept) {
150
- const found = [];
151
- let entries;
152
- try {
153
- entries = readdirSync(join(ROOT, dir), { withFileTypes: true });
154
- } catch {
155
- return found;
156
- }
157
-
158
- for (const entry of entries) {
159
- const path = `${dir}/${entry.name}`;
160
- if (entry.isDirectory()) {
161
- if (!SKIPPED_DIRS.includes(entry.name)) {
162
- found.push(...walk(path, accept));
163
- }
164
- } else if (accept(entry.name)) {
165
- found.push(path);
166
- }
167
- }
168
-
169
- return found;
170
- }
171
-
172
- const read = (path) => readFileSync(join(ROOT, path), 'utf8');
173
- const exists = (path) => existsSync(join(ROOT, path));
174
-
175
- /** Директории доменов: `docs/specs/<домен>`, кроме шаблона. */
176
- function collectDomains() {
177
- try {
178
- return readdirSync(join(ROOT, SPECS_DIR), { withFileTypes: true })
179
- .filter((entry) => entry.isDirectory() && !NOT_DOMAINS.includes(entry.name))
180
- .map((entry) => entry.name);
181
- } catch {
182
- return [];
183
- }
184
- }
185
-
186
- /**
187
- * Строки раздела: от его заголовка до следующего заголовка того же или более
188
- * высокого уровня. Подразделы в раздел входят — «Коды отказов» разбираются
189
- * отдельно, но остаются частью «Контракта».
190
- */
191
- function sectionOf(text, heading) {
192
- const level = heading.match(/^#+/)[0].length;
193
- const lines = text.split('\n');
194
- const start = lines.findIndex((line) => line.trimEnd() === heading);
195
- if (start < 0) {
196
- return [];
197
- }
198
- const rest = lines.slice(start + 1);
199
- const end = rest.findIndex((line) => {
200
- const marks = line.match(/^(#+)\s/);
201
-
202
- return marks && marks[1].length <= level;
203
- });
204
-
205
- return end < 0 ? rest : rest.slice(0, end);
206
- }
207
-
208
- /** Пункты списка верхнего уровня вместе с их продолжениями. */
209
- function bulletsOf(lines) {
210
- const bullets = [];
211
- for (const [index, line] of lines.entries()) {
212
- if (/^-\s+\S/.test(line)) {
213
- bullets.push({ line: index, text: line });
214
- } else if (bullets.length && /^\s+\S/.test(line)) {
215
- bullets[bullets.length - 1].text += ` ${line.trim()}`;
216
- } else if (!line.trim()) {
217
- continue;
218
- } else if (/^[#|]/.test(line)) {
219
- // таблица или заголовок — список кончился
220
- break;
221
- }
222
- }
223
-
224
- return bullets;
225
- }
226
-
227
- // ── 1. Якоря правил ────────────────────────────────────────────────────────────
228
-
229
- const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
230
-
231
- /**
232
- * Символ ищется как слово: подстрока дала бы ложное совпадение на префиксе.
233
- *
234
- * Границы слова считаются буквой любого алфавита, а не `\b`: он в JavaScript знает буквой
235
- * только латиницу, и `\bСемья\b` не совпадает ни разу — привязка на русском слове читалась
236
- * как ведущая в файл, где этого слова нет, при том что слово стоит там первой же строкой.
237
- */
238
- function fileHasSymbol(path, symbol) {
239
- // Дефис здесь не экранируется: вне класса символов он ничего не значит, а под флагом `u`
240
- // лишнее экранирование — уже отказ разбора. Общий экранировщик его защищает, потому что
241
- // рассчитан и на класс тоже, и `task-flow` роняло всю сверку целиком.
242
- const word = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
243
- return new RegExp(`(?<![\\p{L}\\p{N}_])${word}(?![\\p{L}\\p{N}_])`, 'u').test(read(path));
244
- }
245
-
246
- /**
247
- * Привязки, у которых остаётся выяснить, зовёт ли символ хоть кто-нибудь. Копятся
248
- * в один список и разбираются одним проходом по исходникам: обходить `apps` и
249
- * `libs` на каждую из пятисот привязок было бы полтысячи обходов.
250
- */
251
- const traced = [];
252
-
253
- /** Жирное начало пункта — ключ, по которому правило находит свою строку привязки. */
254
- function ruleHeadOf(bulletText) {
255
- const bold = bulletText.match(/\*\*(.+?)\*\*/s);
256
-
257
- return bold ? bold[1].replace(/\s+/g, ' ').trim() : null;
258
- }
259
-
260
- /**
261
- * Правила живут в `spec.md`, привязка к коду — в `implementation.md` рядом. Разделены
262
- * потому, что спек описывает продукт и читается без знания устройства, а привязка
263
- * устаревает при каждом переименовании.
264
- *
265
- * Ключ связи — сам текст правила, а не отдельный идентификатор: тогда правку формулировки
266
- * невозможно сделать, забыв про привязку, — строка перестанет находиться.
267
- *
268
- * Заголовок раздела приходит доводом: у спека это `## Правила`, у закона — `## Статьи`.
269
- * Одно слово в двух смыслах развели именно здесь: «правило» — слой между законом и скилом,
270
- * а внутри закона живут статьи.
271
- */
272
- /**
273
- * Строки таблицы привязок компаньона.
274
- *
275
- * Компаньон правила держит три таблицы: чем вещи правила названы в этом дереве, где лежат
276
- * механизмы и где исполняется каждая статья. Привязки — только третья, и берётся она по имени
277
- * раздела, а не по месту в файле. Пока читался весь файл, строки первых двух попадали в список
278
- * наравне с настоящими и тут же объявлялись расхождением: статьи с таким текстом в правиле нет
279
- * и быть не может. Две трети перечня в дереве были ими, и правильно дописанная строка «Где это
280
- * лежит» отвечала отказом.
281
- *
282
- * У компаньона спека домена раздела нет: там таблица одна, и сужать нечего — такой зовёт без
283
- * имени раздела. У правила раздел стоит в образце компаньона, поэтому его отсутствие — отказ:
284
- * молча прочесть вместо него весь файл значило бы вернуть тот же дефект.
285
- */
286
- function rowsOfMap(specFile, mapFile, mapHeading) {
287
- const text = read(mapFile);
288
- if (!mapHeading) {
289
- return text.split('\n');
290
- }
291
- const section = sectionOf(text, mapHeading);
292
- if (!section.length) {
293
- report(mapFile, `нет раздела \`${mapHeading}\` — привязкам правила негде лежать`);
294
- }
295
-
296
- return section;
297
- }
298
-
299
- function checkRuleImplementation(specFile, text, mapFile, heading = '## Правила', mapHeading = '') {
300
- const bullets = bulletsOf(sectionOf(text, heading));
301
- if (!bullets.length) {
302
- report(specFile, `в разделе \`${heading}\` нет ни одного пункта`);
303
-
304
- return;
305
- }
306
-
307
- if (!exists(mapFile)) {
308
- report(specFile, `нет файла \`${mapFile.split('/').pop()}\` рядом — правилам не к чему привязаться`);
309
-
310
- return;
311
- }
312
-
313
- const rows = new Map();
314
- for (const line of rowsOfMap(specFile, mapFile, mapHeading)) {
315
- const cells = line.match(/^\|([^|]+)\|([^|]*)\|\s*$/);
316
- if (!cells) {
317
- continue;
318
- }
319
- const head = cells[1].replace(/\s+/g, ' ').trim();
320
- // Шапка таблицы: у спека колонка называется «Правило», у закона — «Статья».
321
- if (!head || head === 'Правило' || head === 'Статья' || /^-+$/.test(head)) {
322
- continue;
323
- }
324
- const cell = cells[2].trim();
325
- rows.set(head, {
326
- anchors: [...cells[2].matchAll(ANCHOR)],
327
- verdict: VERDICT.test(cell) && cell.length >= VERDICT_MIN,
328
- used: false,
329
- });
330
- }
331
-
332
- for (const bullet of bullets) {
333
- const head = ruleHeadOf(bullet.text);
334
- if (!head) {
335
- report(specFile, `правило без жирного начала: «${bullet.text.replace(/^-\s+/, '').slice(0, 60)}…»`);
336
- continue;
337
- }
338
- const row = rows.get(head);
339
- if (!row) {
340
- report(
341
- mapFile,
342
- `правило без привязки: «${head.slice(0, 60)}…» — допиши строку с \`файл:символ\`, ` +
343
- 'вердиктом «Не исполняется» с причиной либо перенеси правило в «Открытые вопросы» как Q-N'
344
- );
345
- continue;
346
- }
347
- row.used = true;
348
- if (!row.anchors.length && !row.verdict) {
349
- report(
350
- mapFile,
351
- `у правила «${head.slice(0, 60)}…» пустая привязка — поставь \`файл:символ\` ` +
352
- 'либо вердикт «Не исполняется», «Не применимо», «Не проверяется» с причиной'
353
- );
354
- }
355
- for (const [, path, symbol] of row.anchors) {
356
- if (!exists(path)) {
357
- report(mapFile, `привязка ведёт в никуда: нет файла \`${path}\``);
358
- } else if (!fileHasSymbol(path, symbol)) {
359
- report(mapFile, `привязка не сходится: в \`${path}\` нет \`${symbol}\``);
360
- } else {
361
- traced.push({ mapFile, path, symbol });
362
- }
363
- }
364
- }
365
-
366
- // Обратная сторона: строка, под которой правила больше нет, — след переименования.
367
- // Без неё привязка копится и начинает описывать несуществующие обещания
368
- for (const [head, row] of rows) {
369
- if (!row.used) {
370
- report(mapFile, `привязка без пункта: «${head.slice(0, 60)}…» — в \`${specFile}\` такого пункта нет`);
371
- }
372
- }
373
- }
374
-
375
- // ── 5. Мёртвые привязки ───────────────────────────────────────────────────────
376
-
377
- /**
378
- * Код без комментариев. Упоминание символа в пояснении вызовом не является, а
379
- * пояснений у мёртвого кода как раз обычно больше, чем у живого.
380
- */
381
- const codeOf = (text) => text.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:`'"])\/\/.*$/gm, '$1');
382
-
383
- const DECLARATION_MODIFIERS = '(?:export|declare|abstract|public|private|protected|static|readonly|override|async|accessor)';
384
-
385
- /**
386
- * Объявлен ли символ здесь. Проверка идёт только по объявлениям: привязка к
387
- * чужому полю (`HttpStatus.SERVICE_UNAVAILABLE`), ключу словаря или содержимому
388
- * строки законна и встречается ровно один раз по своей природе.
389
- */
390
- function fileDeclaresSymbol(code, symbol) {
391
- const escaped = escapeForRegExp(symbol);
392
-
393
- return (
394
- new RegExp(`\\b(?:const|let|var|function|class|interface|type|enum)\\s+${escaped}\\b`).test(code) ||
395
- new RegExp(`^\\s*(?:${DECLARATION_MODIFIERS}\\s+)*#?${escaped}\\s*[(<:=]`, 'm').test(code)
396
- );
397
- }
398
-
399
- /**
400
- * Файлы, в которых встречается каждый символ. Дефис берётся в токен целиком ради
401
- * атрибутов разметки, а части такого токена добавляются отдельно: иначе
402
- * `resolving` внутри `data-resolving` перестал бы находиться.
403
- */
404
- function symbolOwners() {
405
- const owners = new Map();
406
- const remember = (token, file) => {
407
- let files = owners.get(token);
408
- if (!files) {
409
- files = new Set();
410
- owners.set(token, files);
411
- }
412
- files.add(file);
413
- };
414
-
415
- for (const file of SOURCE_ROOTS.flatMap((root) => walk(root, (name) => name.endsWith('.ts') || name.endsWith('.html')))) {
416
- const text = file.endsWith('.ts') ? codeOf(read(file)) : read(file);
417
- for (const [token] of text.matchAll(/[A-Za-z_][\w-]*/g)) {
418
- remember(token, file);
419
- if (token.includes('-')) {
420
- token.split('-').forEach((part) => part && remember(part, file));
421
- }
422
- }
423
- }
424
-
425
- return owners;
426
- }
427
-
428
- /**
429
- * Символ, объявленный в своём файле и больше нигде не встречающийся, ничего не
430
- * исполняет: правило, привязанное к нему, описывает намерение.
431
- */
432
- function checkTracedAnchors() {
433
- const code = new Map();
434
- const codeAt = (path) => {
435
- if (!code.has(path)) {
436
- code.set(path, codeOf(read(path)));
437
- }
438
-
439
- return code.get(path);
440
- };
441
-
442
- const declared = traced.filter(({ path, symbol }) => path.endsWith('.ts') && fileDeclaresSymbol(codeAt(path), symbol));
443
- if (!declared.length) {
444
- return;
445
- }
446
-
447
- const owners = symbolOwners();
448
- for (const { mapFile, path, symbol } of declared) {
449
- const here = (codeAt(path).match(new RegExp(`\\b${escapeForRegExp(symbol)}\\b`, 'g')) || []).length;
450
- const elsewhere = [...(owners.get(symbol) || [])].filter((file) => file !== path).length;
451
- if (here + elsewhere < 2) {
452
- report(
453
- mapFile,
454
- `привязка ведёт в мёртвый код: \`${symbol}\` объявлен в \`${path}\` и больше нигде не встречается — ` +
455
- 'либо правило исполняется в другом месте, либо ему место в «Открытых вопросах» как Q-N'
456
- );
457
- }
458
- }
459
- }
460
-
461
- // ── 1a. Законы, которые применяет спек ────────────────────────────────────────
462
-
463
- /**
464
- * Связь «закон — правило» и «правило — паттерн» сверяется в обе стороны, а спек до сих пор
465
- * говорил только о домене. Закон при этом он применял: ссылки на `docs/constitution/…`
466
- * лежали внутри строки зависимостей и посреди текста, и по закону нельзя было узнать, какие
467
- * домены на нём стоят, — только грепом.
468
- *
469
- * Отсюда строка `**Законы:**` в шапке и сверка обеих сторон: закон, названный в тексте, но не
470
- * объявленный, и объявленный закон, которого нет.
471
- */
472
- const SPEC_LAWS = /^\*\*Законы:\*\*\s*(.+)$/;
473
- /**
474
- * Ссылка на закон где угодно в тексте спека — по ней считается вторая сторона связи. Слой в
475
- * пути необязателен: законы приложения лежат в `application/`, а называются так же.
476
- */
477
- const LAW_REFERENCE = new RegExp(`\`${CONSTITUTION_DIR}/(?:application/)?([a-z-]+)\\.md\``, 'g');
478
-
479
- function checkSpecLaws(file, text, laws) {
480
- const line = text.split('\n').find((candidate) => SPEC_LAWS.test(candidate));
481
- if (!line) {
482
- report(file, 'в шапке нет строки `**Законы:**` — не видно, какие законы домен применяет');
483
-
484
- return;
485
- }
486
-
487
- const declared = new Set([...line.match(SPEC_LAWS)[1].matchAll(BACKTICKED)].map(([, name]) => name));
488
- for (const name of declared) {
489
- if (!laws.has(name)) {
490
- report(file, `в строке \`**Законы:**\` назван \`${name}\`, а закона с таким именем нет ни в одном слое`);
491
- }
492
- }
493
-
494
- for (const [, name] of text.matchAll(LAW_REFERENCE)) {
495
- if (!declared.has(name)) {
496
- report(file, `закон \`${name}\` назван в тексте, но не объявлен в строке \`**Законы:**\``);
497
- }
498
- }
499
- }
500
-
501
- // ── 2. Контракт против декораторов ────────────────────────────────────────────
502
-
503
- /** Корни либ, чьи процедуры домен обслуживает; объявлены в шапке спека. */
504
- function procedureRootsOf(text) {
505
- const line = text.split('\n').find((candidate) => PROCEDURE_ROOTS.test(candidate));
506
- if (!line) {
507
- return null;
508
- }
509
- const value = line.match(PROCEDURE_ROOTS)[1];
510
- if (/^\s*нет\s*$/i.test(value.replace(/[`.]/g, ''))) {
511
- return [];
512
- }
513
-
514
- return [...value.matchAll(BACKTICKED)].map(([, path]) => path);
515
- }
516
-
517
- /** Что объявлено в самих процедурах: метод контракта и право на него. */
518
- function declaredProcedures(roots) {
519
- const found = new Map();
520
- for (const root of roots) {
521
- for (const file of walk(root, (name) => name.endsWith('.procedure.ts'))) {
522
- const text = read(file);
523
- const method = text.match(/\.method\.([A-Za-z_]\w*)/);
524
- if (!method) {
525
- continue;
526
- }
527
- const service = text.match(/typeof\s+(\w+)\.method\./);
528
- const required = text.match(/@RequiresPermission\(\s*'([^']+)'/);
529
- const isPublic = /@PublicProcedure\(/.test(text);
530
- found.set(method[1].toLowerCase(), {
531
- file,
532
- method: method[1],
533
- service: service ? service[1] : '',
534
- permission: required ? required[1] : isPublic ? 'публично' : '',
535
- });
536
- }
537
- }
538
-
539
- return found;
540
- }
541
-
542
- /** Строки таблицы «Контракта»: первая ячейка — процедура, вторая — право. */
543
- function contractRows(text) {
544
- const rows = [];
545
- for (const [index, line] of sectionOf(text, '## Контракт').entries()) {
546
- if (!line.startsWith('|') || /^\|[\s:|-]+\|$/.test(line)) {
547
- continue;
548
- }
549
- const cells = line
550
- .split('|')
551
- .slice(1, -1)
552
- .map((cell) => cell.trim());
553
- if (cells.length < 2) {
554
- continue;
555
- }
556
- const name = (cells[0].match(/`([^`]+)`/) || [])[1];
557
- if (!name) {
558
- continue;
559
- }
560
- const permission = (cells[1].match(/`([^`]+)`/) || [])[1] || cells[1];
561
- rows.push({ line: index, name, permission: permission.trim(), short: name.split('.').pop() });
562
- }
563
-
564
- return rows;
565
- }
566
-
567
- function checkContract(file, text, roots) {
568
- if (roots === null) {
569
- report(file, 'в шапке нет строки `**Процедуры:**` — нечем сверить таблицу «Контракта» с декораторами');
570
-
571
- return;
572
- }
573
- const declared = declaredProcedures(roots);
574
- const rows = contractRows(text);
575
- const described = new Set();
576
-
577
- for (const row of rows) {
578
- const found = declared.get(row.short.toLowerCase());
579
- if (!found) {
580
- report(file, `в «Контракте» есть \`${row.name}\`, но процедуры с таким методом в ${roots.join(', ')} нет`);
581
- continue;
582
- }
583
- described.add(row.short.toLowerCase());
584
- if (found.permission && row.permission !== found.permission) {
585
- report(
586
- file,
587
- `право у \`${row.name}\` разошлось: в спеке «${row.permission}», ` + `в \`${found.file}\` объявлено «${found.permission}»`
588
- );
589
- }
590
- }
591
-
592
- for (const [key, found] of declared) {
593
- if (!described.has(key)) {
594
- report(
595
- file,
596
- `процедура \`${found.service}.${found.method}\` (${found.file}) домену принадлежит, ` + 'но в таблице «Контракта» её нет'
597
- );
598
- }
599
- }
600
- }
601
-
602
- // ── 3. Коды отказов ───────────────────────────────────────────────────────────
603
-
604
- function checkRefusalCodes(file, text, roots) {
605
- const section = sectionOf(text, '### Коды отказов');
606
- const bullets = bulletsOf(section);
607
- /**
608
- * «Не применимо» — законный ответ и здесь. Домен, у которого есть процедуры,
609
- * но нет ни одного `Code.X`, иначе не описывался вовсе: пустой раздел
610
- * проверка отвергает, а любой выписанный код отвергает тем более — бросать
611
- * его в домене некому. Так живёт проверка живости: отказ она подаёт кодом
612
- * ответа, а не отказом процедуры.
613
- */
614
- // Без `\b`: кириллица не входит в `\w`, и границы слова после «применимо» не возникает
615
- const notApplicable = section.some((line) => /^Не применимо/.test(line.trim()));
616
- if (!bullets.length) {
617
- if (!notApplicable) {
618
- report(file, 'в разделе `### Коды отказов` нет ни одного кода и нет ответа «Не применимо»');
619
- }
620
-
621
- return;
622
- }
623
- if (!roots || !roots.length) {
624
- return;
625
- }
626
- const sources = roots.flatMap((root) => walk(root, (name) => name.endsWith('.ts') && !name.endsWith('.spec.ts')));
627
- const thrown = new Set();
628
- for (const source of sources) {
629
- for (const [, code] of read(source).matchAll(/\bCode\.([A-Za-z]\w*)/g)) {
630
- thrown.add(code);
631
- }
632
- }
633
-
634
- for (const bullet of bullets) {
635
- const code = (bullet.text.match(/`([A-Za-z]\w*)`/) || [])[1];
636
- if (!code) {
637
- report(file, `в «Кодах отказов» строка без кода в кавычках: «${bullet.text.slice(0, 60)}…»`);
638
- continue;
639
- }
640
- if (!thrown.has(code)) {
641
- report(file, `код отказа \`${code}\` в домене нигде не бросается — либо он не отсюда, либо путь его не даёт`);
642
- }
643
- }
644
- }
645
-
646
- // ── 4. Сценарии и уровень привязки ────────────────────────────────────────────
647
-
648
- function parseScenarios(file) {
649
- const lines = read(file).split('\n');
650
- const scenarios = [];
651
- let current = null;
652
- let inPromise = false;
653
-
654
- lines.forEach((line, index) => {
655
- const heading = SCENARIO_HEADING.exec(line);
656
- if (heading) {
657
- current = {
658
- id: heading[1],
659
- prefix: heading[2],
660
- title: heading[4],
661
- file,
662
- line: index + 1,
663
- uncovered: false,
664
- partial: false,
665
- promise: '',
666
- };
667
- inPromise = false;
668
- scenarios.push(current);
669
-
670
- return;
671
- }
672
- if (/^#{1,6}\s/.test(line)) {
673
- current = null;
674
-
675
- return;
676
- }
677
- if (!current) {
678
- return;
679
- }
680
- if (UNCOVERED.test(line)) {
681
- current.uncovered = true;
682
- }
683
- if (PARTIAL.test(line)) {
684
- current.partial = true;
685
- }
686
- // «Тогда» и его продолжения с отступом — то, что сценарий обещает
687
- if (PROMISE.test(line)) {
688
- inPromise = true;
689
- current.promise += ` ${line.trim()}`;
690
-
691
- return;
692
- }
693
- if (inPromise && /^\s+\S/.test(line)) {
694
- current.promise += ` ${line.trim()}`;
695
-
696
- return;
697
- }
698
- inPromise = false;
699
- });
700
-
701
- return scenarios;
702
- }
703
-
704
- /**
705
- * Обещан ли сценарием экран. Признак читается только из «Тогда»: «Дано» описывает
706
- * обстановку, «Когда» — повод, а обещание пользователю стоит именно здесь.
707
- *
708
- * Человек и глагол восприятия требуются вместе, потому что порознь оба ошибаются.
709
- * «Показывается» без человека стоит и там, где показывается запись в базе, а человек без
710
- * восприятия — в каждом втором сценарии приёма заявки. Признак нарочно молчалив: сценарий,
711
- * чьё «Тогда» человека не называет, под него не подпадает вовсе.
712
- */
713
- function promisesScreen(promise) {
714
- return ACTOR.test(promise) && PERCEIVES.test(promise);
715
- }
716
-
717
- /**
718
- * Константы, собранные из окружения, вместе с теми, что собраны из них. Ими выключают
719
- * сквозной тест целиком: без `BASE_URL` или пары входа он не исполняется ни разу.
720
- * Цепочка раскрывается, пока есть что раскрывать: `HAS_ADMIN_SESSION` собран из двух
721
- * других констант, а не из `process.env` напрямую.
722
- */
723
- function environmentSwitches(root) {
724
- // Объявление верхнего уровня: с отступом стоят локальные, и они гасят не тест, а случай
725
- const declaration = /^const\s+([A-Za-z_]\w*)\s*(?::[^=]+)?=\s*([^;]+);/gm;
726
- const assignments = [];
727
- for (const file of walk(root, (name) => name.endsWith('.ts'))) {
728
- for (const [, name, value] of read(file).matchAll(declaration)) {
729
- assignments.push({ name, value });
730
- }
731
- }
732
-
733
- const switches = new Set();
734
- for (let pass = 0; pass <= assignments.length; pass += 1) {
735
- const before = switches.size;
736
- for (const { name, value } of assignments) {
737
- if (value.includes('process.env') || [...switches].some((known) => new RegExp(`\\b${known}\\b`).test(value))) {
738
- switches.add(name);
739
- }
740
- }
741
- if (switches.size === before) {
742
- break;
743
- }
744
- }
745
-
746
- return switches;
747
- }
748
-
749
- /**
750
- * Упоминания сценария в тестах: где стоит, идёт ли тест путём пользователя и не выключен ли
751
- * он переменной окружения.
752
- *
753
- * Выключатель по состоянию стенда («у объекта меньше двух помещений») — это пропуск случая,
754
- * и покрытие он не отменяет. Выключатель по переменной отменяет: тест с ним в обычном
755
- * прогоне значится пропущенным, а сводка без этого читала бы его покрытием.
756
- */
757
- function collectReferences() {
758
- const references = new Map();
759
- const remember = (id, place) => {
760
- if (!references.has(id)) {
761
- references.set(id, []);
762
- }
763
- references.get(id).push(place);
764
- };
765
- const switchesByRoot = new Map();
766
-
767
- for (const root of TEST_ROOTS) {
768
- for (const file of walk(root, (name) => name.endsWith('.spec.ts'))) {
769
- const e2eRoot = E2E_ROOTS.find((dir) => file.startsWith(`${dir}/`));
770
- if (e2eRoot && !switchesByRoot.has(e2eRoot)) {
771
- switchesByRoot.set(e2eRoot, environmentSwitches(e2eRoot));
772
- }
773
- const switches = switchesByRoot.get(e2eRoot) ?? new Set();
774
- const switched = (line) =>
775
- [...line.matchAll(/test\.skip\(([^,]*)/g)].some(
776
- ([, condition]) =>
777
- condition.includes('process.env') || [...switches].some((name) => new RegExp(`\\b${name}\\b`).test(condition))
778
- );
779
-
780
- const found = [];
781
- let test = null;
782
- let describeSwitched = false;
783
-
784
- read(file)
785
- .split('\n')
786
- .forEach((line, index) => {
787
- if (/^\s*test\.describe[.(]/.test(line)) {
788
- describeSwitched = false;
789
- test = null;
790
- } else if (/^\s*test\s*\(/.test(line)) {
791
- test = { off: describeSwitched };
792
- } else if (/test\.skip\(/.test(line)) {
793
- if (test) {
794
- test.off = test.off || switched(line);
795
- } else {
796
- describeSwitched = describeSwitched || switched(line);
797
- }
798
- }
799
-
800
- for (const [id] of line.matchAll(SCENARIO_REFERENCE)) {
801
- found.push({ id, test, place: `${file}:${index + 1}` });
802
- }
803
- });
804
-
805
- // Выключатель стоит первой строкой тела, то есть ниже заголовка теста с
806
- // идентификатором: состояние теста читается, когда файл разобран целиком
807
- found.forEach(({ id, test: own, place }) => remember(id, { place, screen: Boolean(e2eRoot), off: Boolean(own?.off) }));
808
- }
809
-
810
- // Наборы сценариев на shell. Так проверяются исполняемые файлы — гарды, проверки,
811
- // умолчания: они не на TypeScript, и набор к ним пишут на том же языке, что и их
812
- // самих. Выключателей здесь нет: пропустить сценарий в таком наборе нечем, поэтому
813
- // достаточно найти идентификатор.
814
- for (const file of walk(root, (name) => name.endsWith('.test.sh'))) {
815
- read(file)
816
- .split('\n')
817
- .forEach((line, index) => {
818
- for (const [id] of line.matchAll(SCENARIO_REFERENCE)) {
819
- remember(id, { place: `${file}:${index + 1}`, screen: false, off: false });
820
- }
821
- });
822
- }
823
- }
824
-
825
- return references;
826
- }
51
+ import { ROOT } from './rt-kit-checks.config.mjs';
52
+ import { checkRuleImplementation, checkSpecLaws, checkTracedAnchors } from './spec-anchors.mjs';
53
+ import { CONSTITUTION_DIR, REQUIRED_HEADINGS, SPECS_DIR, collectDomains, exists, problems, read, report, walk } from './spec-common.mjs';
54
+ import { checkContract, checkRefusalCodes, procedureRootsOf } from './spec-contract.mjs';
55
+ import { collectReferences, parseScenarios, promisesScreen } from './spec-scenarios.mjs';
827
56
 
828
57
  // ── прогон ────────────────────────────────────────────────────────────────────
829
58