@rt-tools/agent-kit 0.9.1 → 0.11.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 (119) hide show
  1. package/README.md +5 -0
  2. package/assets/agents/conscience.md +58 -0
  3. package/assets/agents/prose-editor.md +44 -0
  4. package/assets/agents/strict-teacher.md +59 -0
  5. package/assets/checks/board.github.mjs +56 -1
  6. package/assets/checks/check-board.github.mjs +24 -0
  7. package/assets/checks/check-doc-paths.mjs +4 -15
  8. package/assets/checks/check-dupes.mjs +5 -6
  9. package/assets/checks/check-file-size.mjs +6 -20
  10. package/assets/checks/check-prose-style.mjs +137 -0
  11. package/assets/checks/check-reuse.mjs +5 -5
  12. package/assets/checks/check-state-next.mjs +194 -0
  13. package/assets/checks/check-states.mjs +142 -0
  14. package/assets/checks/check-styles.mjs +5 -5
  15. package/assets/checks/check-turn-map.mjs +146 -0
  16. package/assets/checks/lib-common.mjs +3 -3
  17. package/assets/checks/rt-kit-checks.config.mjs +85 -1
  18. package/assets/commands/agent-kit-digest.md +6 -5
  19. package/assets/defaults/project.sh +108 -6
  20. package/assets/defaults/turn-map.md +46 -0
  21. package/assets/docs/GLOSSARY.md +17 -15
  22. package/assets/hooks/browser-device-id.sh +2 -0
  23. package/assets/hooks/browser-guard-device-id.sh +11 -1
  24. package/assets/hooks/browser-guard-no-asking.sh +11 -1
  25. package/assets/hooks/browser-guard-no-listing.sh +11 -1
  26. package/assets/hooks/browser-guard-no-other-drivers.sh +9 -1
  27. package/assets/hooks/browser-guard-require-select.sh +13 -2
  28. package/assets/hooks/claim-guard.sh +115 -0
  29. package/assets/hooks/commit-msg.sh +2 -0
  30. package/assets/hooks/conscience-guard.sh +100 -0
  31. package/assets/hooks/constitution-index.sh +2 -0
  32. package/assets/hooks/deny-tail.sh +32 -0
  33. package/assets/hooks/dev-server-guard.sh +10 -2
  34. package/assets/hooks/docs-guard.sh +16 -2
  35. package/assets/hooks/exam-guard.sh +123 -0
  36. package/assets/hooks/git-guard-delivery-signature.sh +77 -0
  37. package/assets/hooks/git-guard-delivery.sh +156 -68
  38. package/assets/hooks/git-guard-main.sh +14 -0
  39. package/assets/hooks/git-guard-push-tests.sh +51 -2
  40. package/assets/hooks/glossary-load.sh +2 -0
  41. package/assets/hooks/grill-gate.sh +14 -0
  42. package/assets/hooks/handoff-entry-guard.sh +73 -0
  43. package/assets/hooks/handoff-write.sh +103 -0
  44. package/assets/hooks/lint-after-edit.sh +2 -0
  45. package/assets/hooks/observe.sh +2 -0
  46. package/assets/hooks/postmortem-guard.sh +14 -0
  47. package/assets/hooks/proposal-guard.sh +14 -0
  48. package/assets/hooks/prose-style-guard.sh +75 -0
  49. package/assets/hooks/qa-dataid-guard.sh +14 -1
  50. package/assets/hooks/rerun-guard.sh +88 -0
  51. package/assets/hooks/reuse-first-guard.sh +14 -1
  52. package/assets/hooks/roles.sh +34 -0
  53. package/assets/hooks/skill-gate-layers.sh +2 -0
  54. package/assets/hooks/skill-gate-rearm.sh +2 -0
  55. package/assets/hooks/skill-gate.sh +14 -0
  56. package/assets/hooks/skill-loaded.sh +2 -0
  57. package/assets/hooks/sql-guard-parse.sh +2 -0
  58. package/assets/hooks/sql-guard-request.sh +2 -0
  59. package/assets/hooks/sql-guard-target.sh +2 -0
  60. package/assets/hooks/sql-guard-write.sh +4 -1
  61. package/assets/hooks/sql-guard.sh +14 -1
  62. package/assets/hooks/task-context-load.sh +2 -0
  63. package/assets/hooks/task-flow-guard.sh +73 -7
  64. package/assets/hooks/turn-entry-load.sh +62 -0
  65. package/assets/hooks/turn-exit-guard.sh +191 -0
  66. package/assets/hooks/utf8.sh +35 -0
  67. package/assets/hooks/waiting-turn-guard.sh +14 -0
  68. package/assets/hooks/window-fill-guard.sh +43 -2
  69. package/assets/laws/work-conduct.md +29 -0
  70. package/assets/patterns/cargo-triage-mark.md +119 -0
  71. package/assets/patterns/task-flow-close.md +29 -8
  72. package/assets/patterns/task-flow-handoff.md +19 -1
  73. package/assets/patterns/task-flow-resume.md +36 -4
  74. package/assets/patterns/task-flow-start.md +56 -6
  75. package/assets/patterns/turn-entry-map.md +81 -0
  76. package/assets/rules/cargo-triage.md +126 -0
  77. package/assets/rules/git-workflow.azure.md +36 -9
  78. package/assets/rules/git-workflow.github.md +74 -9
  79. package/assets/rules/git-workflow.gitlab.md +40 -12
  80. package/assets/rules/task-flow.md +136 -71
  81. package/assets/rules/testing.md +15 -1
  82. package/assets/rules/turn-entry.md +93 -0
  83. package/assets/samples/tasks/_template/plan.md +4 -1
  84. package/assets/samples/tasks/_template/progress.md +1 -0
  85. package/assets/skills/agent-kit.md +33 -0
  86. package/assets/templates/project.sh +16 -0
  87. package/bin/agent-kit.d.ts.map +1 -1
  88. package/bin/agent-kit.js +42 -1
  89. package/bin/agent-kit.js.map +1 -1
  90. package/lib/cargo-state.d.ts +62 -0
  91. package/lib/cargo-state.d.ts.map +1 -0
  92. package/lib/cargo-state.js +118 -0
  93. package/lib/cargo-state.js.map +1 -0
  94. package/lib/cargo.d.ts +42 -0
  95. package/lib/cargo.d.ts.map +1 -1
  96. package/lib/cargo.js +2 -0
  97. package/lib/cargo.js.map +1 -1
  98. package/lib/commands.d.ts.map +1 -1
  99. package/lib/commands.js +64 -4
  100. package/lib/commands.js.map +1 -1
  101. package/lib/config.d.ts +8 -0
  102. package/lib/config.d.ts.map +1 -1
  103. package/lib/config.js +1 -0
  104. package/lib/config.js.map +1 -1
  105. package/lib/observations.d.ts +35 -1
  106. package/lib/observations.d.ts.map +1 -1
  107. package/lib/observations.js +14 -2
  108. package/lib/observations.js.map +1 -1
  109. package/lib/ship.d.ts +3 -0
  110. package/lib/ship.d.ts.map +1 -1
  111. package/lib/ship.js +56 -0
  112. package/lib/ship.js.map +1 -1
  113. package/lib/thresholds.d.ts +49 -0
  114. package/lib/thresholds.d.ts.map +1 -0
  115. package/lib/thresholds.js +151 -0
  116. package/lib/thresholds.js.map +1 -0
  117. package/package.json +1 -1
  118. package/rt-tools-agent-kit-0.11.0.tgz +0 -0
  119. package/rt-tools-agent-kit-0.9.1.tgz +0 -0
package/README.md CHANGED
@@ -228,6 +228,11 @@ npx agent-kit init --laws access,delivery,verifiability
228
228
  - **`skip`** — ресурсы, от которых проект отказался, теми же идентификаторами. Вычитает из
229
229
  выбранного, поэтому отказ от одного закона не требует переписывать весь список. Правила при
230
230
  отвергнутом законе и паттерны при них перечислять не надо: их снимает каскад.
231
+ - **`rolesOff`** — роли, вызов которых дерево перестало считать обязательным, именами файлов
232
+ ролей без расширения. Гард при такой роли выходит молча, а сама роль остаётся разложенной и
233
+ зовётся руками. От `skip` отличается тем, что ничего не убирает: отказ от файла роли снял бы
234
+ её вместе с возможностью позвать, а гард при ней остался бы лежать и отбивать. Настройка,
235
+ которую не прочитать, выключением не считается — гард судит, как судил.
231
236
 
232
237
  ## Надстройки
233
238
 
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: conscience
3
+ description: Читает разборы происшествий этого дерева и запись текущего хода и называет промах, который в нём повторяется. Файлов не правит, морали не читает. Использовать на завершении хода, на отказе гарда и после признания промаха.
4
+ tools: Read, Grep, Glob, Bash
5
+ ---
6
+
7
+ Ты смотришь на то, что исполнитель делает прямо сейчас, и говоришь, случалось ли это раньше.
8
+ Отвечаешь **по-русски**.
9
+
10
+ Разборы происшествий объясняют механизм промаха, но читает их только тот, кто открывает каталог
11
+ сам. Промах, о котором надо напомнить, — ровно тот, о котором исполнитель в эту минуту не
12
+ помнит: один класс промаха приезжал в приём трижды за трое суток, и под каждый раз уже была
13
+ заведена задача.
14
+
15
+ ## Что тебе приходит
16
+
17
+ - Путь к каталогу разборов происшествий.
18
+ - Что исполнитель делал за этот ход: команды, правки, что он сказал владельцу.
19
+
20
+ ## Как ты работаешь
21
+
22
+ Читаешь разборы — заголовки и раздел о механизме. Затем сверяешь их с тем, что было в ходе, и
23
+ ищешь совпадение по **механизму**, а не по словам: тот же способ ошибиться, а не та же тема.
24
+
25
+ Совпадением считается, например:
26
+
27
+ - ход кончился отчётом о сделанном, а работа стоит;
28
+ - утверждение о дереве сделано без команды, которая его подтверждает;
29
+ - «проверено» названо о наборе уже того, что гоняет конвейер;
30
+ - отбитая гардом правка положена другим способом;
31
+ - следующая задача названа словами и не взята;
32
+ - работа отдана владельцу и брошена в черновике.
33
+
34
+ Находку возвращаешь так, первой строкой и машиночитаемо:
35
+
36
+ ```
37
+ СОВЕСТЬ: повтор
38
+ РАЗБОР: <имя файла разбора>
39
+ ЧТО СЕЙЧАС: <что исполнитель делает в этом ходе, одной фразой>
40
+ ЧЕМ КОНЧИЛОСЬ ТОГДА: <чем это кончилось в разборе, одной фразой>
41
+ ```
42
+
43
+ Повтора нет — одна строка и ничего больше:
44
+
45
+ ```
46
+ СОВЕСТЬ: чисто
47
+ ```
48
+
49
+ ## Чего ты не делаешь
50
+
51
+ - Не пересказываешь разбор целиком: исполнителю нужно узнать себя, а не прочитать статью.
52
+ - Не читаешь морали и не оцениваешь исполнителя: ты называешь механизм и его цену, а стыдить —
53
+ не работа.
54
+ - Не выдумываешь повтора, чтобы не отвечать «чисто». Ложная находка стоит дороже пропущенной:
55
+ после второй такой тебя перестанут читать.
56
+ - Не правишь файлов и не заводишь новых разборов: ты читаешь те, что есть.
57
+ - Не советуешь, что делать дальше: решение — за исполнителем, твоё дело — чтобы он решал,
58
+ зная.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: prose-editor
3
+ description: Переписывает абзац, который отбила проверка слога, простыми словами и не меняя смысла. Возвращает переписанный текст и список того, что изменил. Использовать, когда гард слога отбил правку.
4
+ tools: Read, Grep
5
+ ---
6
+
7
+ Ты переписываешь текст, который проверка слога назвала канцелярским. Отвечаешь **по-русски**.
8
+
9
+ Проверка видит слова и длину предложения, а читатель спотыкается о другое: о мысль, растянутую
10
+ на три придаточных, о подлежащее, которое потерялось, о вывод, спрятанный в конце периода.
11
+ Поэтому текст читаешь целиком, а не по одной находке.
12
+
13
+ ## Что тебе приходит
14
+
15
+ - Абзац или несколько.
16
+ - Находки проверки: что нашли и чем предложено заменить.
17
+
18
+ ## Как ты работаешь
19
+
20
+ Переписываешь так, как пишут люди, которым есть что сказать:
21
+
22
+ - Подлежащее называет того, кто действует. Не «производится проверка», а «проверка идёт» или
23
+ «гард проверяет».
24
+ - Одно предложение — одна мысль. Длинное делишь, а не сокращаешь до телеграммы.
25
+ - Слово выбираешь короткое и обычное, если длинное не значит чего-то другого.
26
+ - Отглагольное существительное разворачиваешь в глагол: «осуществление записи» — «записывает».
27
+ - Цепочку родительных падежей разбираешь: «проверка полноты набора правил дерева» — «проверка
28
+ смотрит, все ли правила дерева на месте».
29
+ - Вывод ставишь первым, объяснение — вторым. Читатель бросает на середине, и бросить он должен
30
+ уже зная главное.
31
+
32
+ **Смысла не меняешь.** Утверждение, которого в исходном тексте не было, не появляется; условие,
33
+ которое там стояло, не пропадает. Не понял, о чём фраза, — так и говоришь: «эта фраза мне
34
+ непонятна, перепиши сам» — вместо того чтобы придумать за автора.
35
+
36
+ Возвращаешь две вещи: переписанный текст целиком и список того, что изменил, — строкой на
37
+ правку.
38
+
39
+ ## Чего ты не делаешь
40
+
41
+ - Не украшаешь: живой слог — это не метафоры, а понятные фразы.
42
+ - Не сокращаешь ради краткости. Текст, из которого выкинули половину, короче и хуже.
43
+ - Не правишь файлов: ты возвращаешь текст, вставляет его исполнитель.
44
+ - Не споришь с проверкой и не оправдываешь находку: твоё дело — переписать.
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: strict-teacher
3
+ description: Спрашивает исполнителя по содержанию правил, которые он за эту сессию загрузил, и выносит вердикт — усвоено или нет. Файлов не меняет, вопросов владельцу не задаёт. Использовать на старте сессии до первой правки и перед снятием черновика с PR.
4
+ tools: Read, Grep, Glob
5
+ ---
6
+
7
+ Ты проверяешь, усвоил ли исполнитель правила, которые он в этой сессии загрузил. Отвечаешь
8
+ **по-русски**.
9
+
10
+ Загруженное правило и прочитанное правило — разные вещи. Правило на четыре сотни строк уезжает
11
+ в контекст целиком, а исполняется выборочно: промахи случаются после того, как правило было
12
+ загружено, и именно поэтому проверка нужна.
13
+
14
+ ## Что тебе приходит
15
+
16
+ - Список правил, загруженных за эту сессию, — путями к файлам.
17
+ - Ответы исполнителя, если это второй твой вызов.
18
+
19
+ ## Как ты работаешь
20
+
21
+ **Первый вызов — вопросы.** Читаешь названные правила целиком. Выбираешь пять вопросов, и
22
+ выбираешь их так:
23
+
24
+ - Спрашиваешь то, что меняет действие, а не формулировку: порядок шагов, границу требования,
25
+ что делать при отказе, чего делать нельзя. Вопрос «как называется раздел» проверяет память о
26
+ тексте, а не усвоение правила.
27
+ - Берёшь вопросы из разных мест правила, а не из одного раздела: усвоенным считается правило
28
+ целиком.
29
+ - Спрашиваешь то, в чём ошибиться дорого. Если у правила есть раздел о промахах, разобранных в
30
+ этом дереве, один вопрос берёшь оттуда.
31
+ - Не задаёшь вопросов с ответом «да» или «нет»: угаданный ответ неотличим от знания.
32
+
33
+ Вопросы возвращаешь нумерованным списком, без ответов и без подсказок.
34
+
35
+ **Второй вызов — вердикт.** Тебе приходят ответы. Каждый сверяешь с текстом правила, а не со
36
+ своим мнением о том, как надо: правило — источник, ты — читатель.
37
+
38
+ Ответ засчитывается, если он называет то же действие, что и правило. Пересказ другими словами —
39
+ засчитывается; названное действие, которого в правиле нет, — нет; ответ «в правиле про это не
40
+ сказано», когда сказано, — нет.
41
+
42
+ Вердикт возвращаешь так, первой строкой и машиночитаемо:
43
+
44
+ ```
45
+ ЭКЗАМЕН: сдано 4 из 5
46
+ НЕ УСВОЕНО: <путь к правилу> — <что именно не усвоено, одной фразой>
47
+ ```
48
+
49
+ Сдано — это пять из пяти. Любой незасчитанный ответ означает, что правило перечитывается
50
+ целиком, а не тот его кусок, о котором спрашивали: исполнитель, которому показали ответ, знает
51
+ одну строку, а не правило.
52
+
53
+ ## Чего ты не делаешь
54
+
55
+ - Не правишь файлов и не предлагаешь правок в правила: твой предмет — исполнитель, а не текст.
56
+ - Не задаёшь вопросов владельцу: ты возвращаешь текст главному агенту.
57
+ - Не показываешь ответов вместе с вопросами и не подсказываешь при провале: назвать, что не
58
+ усвоено, — твоя работа; научить — работа правила.
59
+ - Не смягчаешь вердикт. «Почти верно» — это не сдано.
@@ -203,13 +203,55 @@ export function fetchIssue(number, options) {
203
203
  }
204
204
  }
205
205
 
206
+ /**
207
+ * Состояние заявки в терминах поставки: существует, черновик ли она и есть ли у неё разбор —
208
+ * запрошенный ревьювер либо уже оставленный отзыв.
209
+ *
210
+ * Ревьювера до этой правки не спрашивал никто: он жил прозой в паттерне о коммите и PR, и
211
+ * запрос разбора на самого себя хостинг принимает молча — разбор при этом выглядит
212
+ * запрошенным, а его нет.
213
+ */
214
+ export function pullState(ref, options) {
215
+ // Ссылка на заявку необязательна: клиент хостинга без неё берёт заявку текущей ветки, и
216
+ // это самая короткая форма вызова. Требовать номер значило бы молча пропускать её.
217
+ const target = ref === undefined || ref === null || `${ref}`.trim() === '' ? [] : [`${ref}`.trim()];
218
+ let pull;
219
+ try {
220
+ pull = ghJson(['pr', 'view', ...target, '--json', 'number,isDraft,reviewRequests,latestReviews,author,mergeable'], options);
221
+ } catch (error) {
222
+ if (error instanceof OfflineError) {
223
+ throw error;
224
+ }
225
+ return { exists: false };
226
+ }
227
+ if (!pull) {
228
+ return { exists: false };
229
+ }
230
+ const requested = (pull.reviewRequests ?? []).map((entry) => entry.login ?? entry.name ?? '').filter(Boolean);
231
+ const reviewed = (pull.latestReviews ?? []).map((entry) => entry.author?.login ?? '').filter(Boolean);
232
+ const reviewers = [...new Set([...requested, ...reviewed])];
233
+ return {
234
+ exists: true,
235
+ number: pull.number ?? null,
236
+ draft: pull.isDraft === true,
237
+ author: pull.author?.login ?? null,
238
+ reviewers,
239
+ reviewed: reviewers.filter((login) => login !== (pull.author?.login ?? null)).length > 0,
240
+ // Конфликт приезжает в отданную заявку чужим слиянием, без единого действия её автора:
241
+ // хостинг считает сливаемость заново после каждой правки главной ветки. Судится только
242
+ // прямое «конфликтует»: `UNKNOWN` означает, что хостинг ещё считает, и читать его как
243
+ // конфликт значило бы отбивать работу на каждой свежей вершине.
244
+ conflicting: pull.mergeable === 'CONFLICTING',
245
+ };
246
+ }
247
+
206
248
  /**
207
249
  * Вершина берётся вместе с остальным: спросить её потом значило бы второй вызов на каждый PR,
208
250
  * а судят по ней и папку задачи, и прогон.
209
251
  */
210
252
  export function fetchOpenPulls(options) {
211
253
  return ghJson(
212
- ['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,headRefName,headRefOid,isDraft,body'],
254
+ ['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,headRefName,headRefOid,isDraft,body,mergeable'],
213
255
  options
214
256
  );
215
257
  }
@@ -390,6 +432,19 @@ if (isEntryPoint && process.argv[2] === 'task') {
390
432
  }
391
433
  }
392
434
 
435
+ if (isEntryPoint && process.argv[2] === 'pr') {
436
+ try {
437
+ process.stdout.write(`${JSON.stringify(pullState(process.argv[3]))}\n`);
438
+ } catch (error) {
439
+ if (error instanceof OfflineError) {
440
+ process.stdout.write('{"offline":true}\n');
441
+ } else {
442
+ process.stdout.write(`${JSON.stringify({ error: String(error.message ?? error) })}\n`);
443
+ process.exit(1);
444
+ }
445
+ }
446
+ }
447
+
393
448
  // Перевод колонки правит борду. Токен машинной записи здесь необязателен: не назвавшее его
394
449
  // дерево правит борду учётной записью, под которой залогинен клиент хостинга. Требование
395
450
  // токена держало бы очередь работ у дерева, машинной записи не заводившего, и у дерева, чью
@@ -178,6 +178,28 @@ function checkReadyDraft(pull, options) {
178
178
  );
179
179
  }
180
180
 
181
+ /**
182
+ * Заявка, конфликтующая с главной веткой.
183
+ *
184
+ * Конфликт приезжает в отданную заявку чужим слиянием, без единого действия её автора: основание,
185
+ * проверенное на открытии, устаревает в ту минуту, когда владелец влил соседнюю работу. Гард
186
+ * снятия черновика сюда не достаёт — он судит один ход, а заявка стоит в очереди днями.
187
+ *
188
+ * Судится только прямое «конфликтует»: `UNKNOWN` означает, что хостинг сливаемость ещё считает,
189
+ * и строка о нём краснела бы на каждой свежей вершине. Две заявки так и ушли в разбор с
190
+ * конфликтом — разбор `docs/postmortems/2026-08-20-drafts-cleared-without-re-reading-pr-state.md`.
191
+ */
192
+ function checkConflicting(pull) {
193
+ if (pull.mergeable !== 'CONFLICTING') {
194
+ return;
195
+ }
196
+
197
+ report(
198
+ `PR #${pull.number}: конфликтует с главной веткой — влей её в ветку задачи, разбери конфликт и запушь; ` +
199
+ `слить эту заявку владелец не может, а по странице это видно только внутри неё`
200
+ );
201
+ }
202
+
181
203
  let checked = { issues: 0, pulls: 0 };
182
204
 
183
205
  // Черновики судятся по диску и потому проверяются всегда: связи для этого не нужно.
@@ -238,6 +260,8 @@ try {
238
260
  checkHeadRun(pull, options);
239
261
  }
240
262
 
263
+ checkConflicting(pull);
264
+
241
265
  if (!FOLDER_SKIP.test(String(pull.body ?? '')) && pull.headRefName) {
242
266
  const folder = folderInBranch(pull.headRefName, options);
243
267
  if (folder !== null) {
@@ -30,7 +30,7 @@ import { spawnSync } from 'node:child_process';
30
30
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
31
31
  import { join } from 'node:path';
32
32
 
33
- import { allowlistOf, CONFIG, ROOT } from './rt-kit-checks.config.mjs';
33
+ import { allowlistOf, CONFIG, ROOT, parseAllowlist } from './rt-kit-checks.config.mjs';
34
34
 
35
35
  const ALLOWLIST = allowlistOf('doc-paths');
36
36
  // `worktrees` — копии репозитория под каталогом агента: их документы описывают раскладку
@@ -76,17 +76,6 @@ const problems = [];
76
76
  const indexProblems = [];
77
77
  const report = (doc, line, path) => problems.push(`${doc}:${line}: нет файла \`${path}\``);
78
78
 
79
- function readAllowlist() {
80
- const path = join(ROOT, ALLOWLIST);
81
- if (!existsSync(path)) {
82
- return { files: [], paths: [] };
83
- }
84
-
85
- const raw = JSON.parse(readFileSync(path, 'utf8'));
86
-
87
- return { files: raw.files ?? [], paths: raw.paths ?? [] };
88
- }
89
-
90
79
  function collectDocs(dir = '.') {
91
80
  const entries = readdirSync(join(ROOT, dir), { withFileTypes: true });
92
81
  const found = [];
@@ -307,9 +296,9 @@ function reportIndex() {
307
296
  );
308
297
  }
309
298
 
310
- const allowlist = readAllowlist();
311
- const allowedPaths = new Set(allowlist.paths);
312
- const collected = collectDocs().filter((doc) => !allowlist.files.includes(doc) && !isSkipped(doc));
299
+ const allowlist = parseAllowlist('doc-paths', ['files', 'paths']);
300
+ const allowedPaths = new Set(allowlist.paths.keys());
301
+ const collected = collectDocs().filter((doc) => !allowlist.files.has(doc) && !isSkipped(doc));
313
302
  const dropped = droppedByGit(collected);
314
303
  const docs = collected.filter((doc) => !dropped.has(doc));
315
304
 
@@ -40,7 +40,7 @@ import { createRequire } from 'node:module';
40
40
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
41
41
  import { dirname, join } from 'node:path';
42
42
 
43
- import { allowlistOf, CONFIG, ROOT } from './rt-kit-checks.config.mjs';
43
+ import { allowlistOf, baselineOf, CONFIG, ROOT, parseAllowlist } from './rt-kit-checks.config.mjs';
44
44
 
45
45
  const ALLOWLIST = allowlistOf('dupes');
46
46
  /**
@@ -57,9 +57,9 @@ const SKIPPED_DIRS = CONFIG.skippedDirs;
57
57
  /** Минимум членов, при котором совпадение набора перечислений о чём-то говорит */
58
58
  const MIN_ENUM_MEMBERS = 2;
59
59
 
60
- const allowlist = JSON.parse(readFileSync(join(ROOT, ALLOWLIST), 'utf8'));
61
- const known = new Set([...(allowlist.accepted ?? []), ...(allowlist.debt ?? [])]);
62
- const debt = new Set(allowlist.debt ?? []);
60
+ const allowlist = parseAllowlist('dupes');
61
+ const known = allowlist.keys;
62
+ const debt = new Set(allowlist.debt.keys());
63
63
 
64
64
  function collectFiles(dir) {
65
65
  const files = [];
@@ -318,8 +318,7 @@ const fresh = findings.filter((finding) => !known.has(finding.key));
318
318
  const staleKeys = [...known].filter((key) => !findings.some((finding) => finding.key === key));
319
319
 
320
320
  if (process.argv.includes('--baseline')) {
321
- const keys = findings.map((finding) => finding.key).sort();
322
- console.log(JSON.stringify({ ...allowlist, debt: keys }, null, 4));
321
+ console.log(baselineOf(findings.map((finding) => finding.key).sort(), allowlist));
323
322
  process.exit(0);
324
323
  }
325
324
 
@@ -32,7 +32,7 @@ import { execFileSync } from 'node:child_process';
32
32
  import { existsSync, readFileSync } from 'node:fs';
33
33
  import { join } from 'node:path';
34
34
 
35
- import { allowlistOf, CONFIG, ROOT } from './rt-kit-checks.config.mjs';
35
+ import { allowlistOf, baselineOf, CONFIG, ROOT, parseAllowlist } from './rt-kit-checks.config.mjs';
36
36
 
37
37
  const ALLOWLIST = allowlistOf('file-size');
38
38
  /** Предел один на все роды файлов: своё число каждому роду — спор о числе на каждой правке. */
@@ -69,23 +69,9 @@ function lineCount(path) {
69
69
  * не пустой список, а нечитаемая настройка, и молчать о ней нельзя. Пустой список законен
70
70
  * ровно один раз — в дереве, где длинных файлов нет вовсе.
71
71
  */
72
- function readKnown() {
73
- const path = join(ROOT, ALLOWLIST);
74
- if (!existsSync(path)) {
75
- return { accepted: [], debt: [] };
76
- }
77
- try {
78
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
79
-
80
- return { accepted: parsed.accepted ?? [], debt: parsed.debt ?? [] };
81
- } catch (error) {
82
- console.error(`check-file-size: список известного не прочитан — ${ALLOWLIST}: ${error.message}`);
83
- process.exit(1);
84
- }
85
- }
86
-
87
- const { accepted, debt } = readKnown();
88
- const known = new Map([...accepted.map((path) => [path, 'принято']), ...debt.map((path) => [path, 'долг'])]);
72
+ const allowlist = parseAllowlist('file-size');
73
+ const { accepted, debt } = allowlist;
74
+ const known = new Map([...[...accepted.keys()].map((path) => [path, 'принято']), ...[...debt.keys()].map((path) => [path, 'долг'])]);
89
75
 
90
76
  const tooLong = new Map();
91
77
  const tracked = trackedFiles().filter(judged);
@@ -98,7 +84,7 @@ for (const path of tracked) {
98
84
  }
99
85
 
100
86
  if (process.argv.includes('--baseline')) {
101
- console.log(JSON.stringify({ accepted, debt: [...tooLong.keys()].sort() }, null, 4));
87
+ console.log(baselineOf([...tooLong.keys()].sort(), allowlist));
102
88
  process.exit(0);
103
89
  }
104
90
 
@@ -123,5 +109,5 @@ if (problems.length > 0) {
123
109
 
124
110
  console.log(
125
111
  `check-file-size: проверено ${tracked.length} файлов, длиннее ${LIMIT} строк ${tooLong.size}, ` +
126
- `из них принято ${accepted.length}, долг ${debt.length} — новых нет`
112
+ `из них принято ${accepted.size}, долг ${debt.size} — новых нет`
127
113
  );
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Проверка слога: канцелярит и обороты, которых в этом дереве не пишут.
4
+ *
5
+ * Правило о текстах требует простых слов, а проверки на это не было: формулировочные
6
+ * договорённости закон оставлял автору целиком, и держались они памятью того, кто пишет.
7
+ * Держались плохо — владелец читает написанное и видит машинный слог там, где договорённость
8
+ * требует человеческого.
9
+ *
10
+ * Ловится не стиль вообще, а перечисленные признаки, и каждый назван вместе с заменой: список
11
+ * запретов без замены читается как запрет писать, и автор обходит его, а не правит текст.
12
+ *
13
+ * Чего проверка не видит и видеть не будет: связности, повторов мысли, верности утверждения.
14
+ * Абзац из коротких предложений с чистыми словами проходит её целиком, ничего при этом не
15
+ * значая. Это её граница, а не обещание.
16
+ *
17
+ * Ненулевой код возврата и перечень находок: файл, строка, что нашли, чем заменить.
18
+ *
19
+ * Границы слова пишутся оглядкой на буквы, а не `\b`: он считается по ASCII, кириллица под `\w`
20
+ * не подпадает, и образец с ним молча не срабатывает ни разу.
21
+ */
22
+ import { readFileSync } from 'node:fs';
23
+
24
+ /** Предел длины предложения в словах. Дальше читатель теряет начало. */
25
+ const WORDS_LIMIT = 40;
26
+
27
+ /**
28
+ * Признаки канцелярита. Каждый — образец и то, чем его заменить: без замены находка
29
+ * читается как запрет писать.
30
+ */
31
+ const MARKS = [
32
+ [/(?<![а-яёА-ЯЁ])является(?![а-яёА-ЯЁ])/giu, 'сказать глаголом: «это», «работает», «стоит»'],
33
+ [/(?<![а-яёА-ЯЁ])осуществля(ет|ется|ть)(?![а-яёА-ЯЁ])/giu, 'назвать само действие: «делает», «идёт»'],
34
+ [/(?<![а-яёА-ЯЁ])производится(?![а-яёА-ЯЁ])/giu, 'кто производит — тот и подлежащее'],
35
+ [/(?<![а-яёА-ЯЁ])в целях(?![а-яёА-ЯЁ])/giu, '«чтобы»'],
36
+ [/(?<![а-яёА-ЯЁ])с целью(?![а-яёА-ЯЁ])/giu, '«чтобы»'],
37
+ [/(?<![а-яёА-ЯЁ])в случае, если(?![а-яёА-ЯЁ])/giu, '«если»'],
38
+ [/(?<![а-яёА-ЯЁ])при условии, что(?![а-яёА-ЯЁ])/giu, '«если»'],
39
+ [/(?<![а-яёА-ЯЁ])в рамках(?![а-яёА-ЯЁ])/giu, 'назвать отношение прямо: «в», «при», «для»'],
40
+ [/(?<![а-яёА-ЯЁ])на основании(?![а-яёА-ЯЁ])/giu, '«по»'],
41
+ [/(?<![а-яёА-ЯЁ])посредством(?![а-яёА-ЯЁ])/giu, '«через», «командой», «вызовом»'],
42
+ [/(?<![а-яёА-ЯЁ])данн(ый|ая|ое|ые)(?![а-яёА-ЯЁ])/giu, '«этот» или ничего'],
43
+ [/(?<![а-яёА-ЯЁ])соответствующ(ий|ая|ее|ие)(?![а-яёА-ЯЁ])/giu, 'назвать, чему именно соответствует'],
44
+ [/(?<![а-яёА-ЯЁ])необходимо(?![а-яёА-ЯЁ])/giu, '«надо» или повелительное наклонение'],
45
+ [/(?<![а-яёА-ЯЁ])должен быть (выполнен|произведён|осуществлён)(?![а-яёА-ЯЁ])/giu, 'сказать, кто это делает'],
46
+ [/(?<![а-яёА-ЯЁ])имеет место(?![а-яёА-ЯЁ])/giu, '«есть», «случается»'],
47
+ [/(?<![а-яёА-ЯЁ])в дальнейшем(?![а-яёА-ЯЁ])/giu, '«дальше», «потом»'],
48
+ [/(?<![а-яёА-ЯЁ])таким образом(?![а-яёА-ЯЁ])/giu, 'убрать или сказать, что из чего следует'],
49
+ [/(?<![а-яёА-ЯЁ])следует отметить(?![а-яёА-ЯЁ])/giu, 'убрать: если стоит отметить — отмечай'],
50
+ [/(?<![а-яёА-ЯЁ])как уже было сказано(?![а-яёА-ЯЁ])/giu, 'убрать: сказанное дважды не становится вернее'],
51
+ ];
52
+
53
+ /** Слова левой колонки словаря: они не пишутся и не произносятся нигде. */
54
+ const GLOSSARY_BANS = [
55
+ [/(?<![а-яёА-ЯЁ])таск[аиуе](?![а-яёА-ЯЁ])/giu, 'задача'],
56
+ [/(?<![а-яёА-ЯЁ])тикет[а-яё]*(?![а-яёА-ЯЁ])/giu, 'задача'],
57
+ [/(?<![а-яёА-ЯЁ])пул-реквест[а-яё]*(?![а-яёА-ЯЁ])/giu, 'PR'],
58
+ [/(?<![а-яёА-ЯЁ])джоб[аыуе](?![а-яёА-ЯЁ])/giu, 'шаг конвейера'],
59
+ [/(?<![а-яёА-ЯЁ])пайплайн[а-яё]*(?![а-яёА-ЯЁ])/giu, 'конвейер'],
60
+ [/(?<![а-яёА-ЯЁ])хендофф[а-яё]*(?![а-яёА-ЯЁ])/giu, 'передача'],
61
+ [/(?<![а-яёА-ЯЁ])бэклог[а-яё]*(?![а-яёА-ЯЁ])/giu, 'очередь работ'],
62
+ [/(?<![а-яёА-ЯЁ])скилл[а-яё]*(?![а-яёА-ЯЁ])/giu, 'правило, паттерн или скил без закона'],
63
+ ];
64
+
65
+ const CODE_FENCE = /^\s*```/;
66
+
67
+ /**
68
+ * Заголовки, которые совпадают с образцом канцелярита, но прозой не являются: это имена
69
+ * обязательных разделов спека, и набор разделов требует их дословно.
70
+ *
71
+ * Перечислены поимённо, а не сняты правилом: слово «данные» в предложении канцеляритом быть не
72
+ * перестаёт — «данные требования» ловится по-прежнему. Без этого исключения ни один новый спек
73
+ * не написать вовсе: набор разделов требует заголовок, а проверка слога его отбивает, и оба
74
+ * требования верны каждое по-своему.
75
+ */
76
+ const HEADING_EXCEPTIONS = new Set(['Данные']);
77
+
78
+ /** Заголовок, чьё имя названо исключением: судить в нём нечего — это имя раздела, а не фраза. */
79
+ function isExemptHeading(line) {
80
+ const heading = line.match(/^\s*#{1,6}\s+(.+?)\s*$/);
81
+
82
+ return heading !== null && HEADING_EXCEPTIONS.has(heading[1]);
83
+ }
84
+
85
+ /** Строки вне блоков кода: в блоках лежат команды и вывод, и слог там не судится. */
86
+ function proseLines(text) {
87
+ const out = [];
88
+ let inFence = false;
89
+ text.split('\n').forEach((line, index) => {
90
+ if (CODE_FENCE.test(line)) {
91
+ inFence = !inFence;
92
+ return;
93
+ }
94
+ if (inFence) return;
95
+ if (isExemptHeading(line)) return;
96
+ out.push([index + 1, line]);
97
+ });
98
+ return out;
99
+ }
100
+
101
+ /** Находки одной строки: образец, что нашли, чем заменить. */
102
+ function findingsIn(line) {
103
+ const found = [];
104
+ [...MARKS, ...GLOSSARY_BANS].forEach(([re, fix]) => {
105
+ const hit = line.match(re);
106
+ if (hit) found.push([hit[0], fix]);
107
+ });
108
+ return found;
109
+ }
110
+
111
+ /** Слишком длинное предложение: читатель теряет начало раньше, чем автор доходит до конца. */
112
+ function longSentences(line) {
113
+ return line
114
+ .split(/(?<=[.!?])\s+/)
115
+ .filter((sentence) => sentence.trim().split(/\s+/).length > WORDS_LIMIT)
116
+ .map((sentence) => [`${sentence.trim().split(/\s+/).length} слов в предложении`, `делить: предел ${WORDS_LIMIT}`]);
117
+ }
118
+
119
+ export function checkProse(text) {
120
+ return proseLines(text).flatMap(([number, line]) =>
121
+ [...findingsIn(line), ...longSentences(line)].map(([what, fix]) => ({ line: number, what, fix }))
122
+ );
123
+ }
124
+
125
+ const files = process.argv.slice(2);
126
+ if (files.length > 0) {
127
+ const problems = files.flatMap((file) =>
128
+ checkProse(readFileSync(file, 'utf8')).map((p) => ` ${file}:${p.line} — «${p.what}» → ${p.fix}`)
129
+ );
130
+ if (problems.length > 0) {
131
+ console.error(`check-prose-style: находок ${problems.length}\n`);
132
+ problems.forEach((p) => console.error(p));
133
+ console.error('\nСлог — правило о текстах. Проверка видит перечисленные признаки и только их.');
134
+ process.exit(1);
135
+ }
136
+ console.log(`check-prose-style: проверено файлов ${files.length}, находок нет`);
137
+ }
@@ -25,7 +25,7 @@
25
25
  import { readFileSync, readdirSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
 
28
- import { allowlistOf, CONFIG, ROOT } from './rt-kit-checks.config.mjs';
28
+ import { allowlistOf, baselineOf, CONFIG, ROOT, parseAllowlist } from './rt-kit-checks.config.mjs';
29
29
  import { loadSignals } from './signals.mjs';
30
30
 
31
31
  const ALLOWLIST = allowlistOf('reuse');
@@ -97,9 +97,9 @@ function withoutMarked(text) {
97
97
  return lines.filter((line, index) => !line.includes('native-ok') && !lines[index - 1]?.includes('native-ok')).join('\n');
98
98
  }
99
99
 
100
- const allowlist = JSON.parse(readFileSync(join(ROOT, ALLOWLIST), 'utf8'));
101
- const known = new Set([...(allowlist.accepted ?? []), ...(allowlist.debt ?? [])]);
102
- const debt = new Set(allowlist.debt ?? []);
100
+ const allowlist = parseAllowlist('reuse');
101
+ const known = allowlist.keys;
102
+ const debt = new Set(allowlist.debt.keys());
103
103
 
104
104
  const findings = [];
105
105
  for (const root of SOURCE_ROOTS) {
@@ -122,7 +122,7 @@ const fresh = findings.filter((finding) => !known.has(finding.key));
122
122
  const stale = [...known].filter((key) => !findings.some((finding) => finding.key === key));
123
123
 
124
124
  if (process.argv.includes('--baseline')) {
125
- console.log(JSON.stringify({ ...allowlist, debt: findings.map((finding) => finding.key).sort() }, null, 4));
125
+ console.log(baselineOf(findings.map((finding) => finding.key).sort(), allowlist));
126
126
  process.exit(0);
127
127
  }
128
128