@rt-tools/agent-kit 0.16.1 → 0.18.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 (136) hide show
  1. package/assets/checks/board-epics.github.mjs +123 -0
  2. package/assets/checks/board-gh.github.mjs +121 -0
  3. package/assets/checks/board-paths.github.mjs +88 -0
  4. package/assets/checks/board-runs.github.mjs +8 -0
  5. package/assets/checks/board-titles.github.mjs +66 -0
  6. package/assets/checks/board.github.mjs +77 -73
  7. package/assets/checks/check-board.github.mjs +56 -7
  8. package/assets/checks/check-file-size.mjs +10 -2
  9. package/assets/checks/check-glossary.mjs +170 -0
  10. package/assets/checks/check-hook-scope.mjs +126 -0
  11. package/assets/checks/check-profile-drift.mjs +195 -0
  12. package/assets/checks/check-push-gate.mjs +59 -1
  13. package/assets/checks/check-schema-drift.mjs +12 -5
  14. package/assets/checks/check-specs.mjs +1 -1
  15. package/assets/checks/rt-kit-checks.config.mjs +13 -0
  16. package/assets/checks/spec-anchors.mjs +2 -2
  17. package/assets/checks/spec-contract.mjs +9 -0
  18. package/assets/defaults/gate-map.sh +13 -4
  19. package/assets/defaults/project.sh +31 -108
  20. package/assets/defaults/shell.sh +139 -0
  21. package/assets/docs/GLOSSARY.md +1 -1
  22. package/assets/hooks/browser-device-id.sh +20 -4
  23. package/assets/hooks/browser-guard-device-id.sh +5 -2
  24. package/assets/hooks/browser-guard-no-other-drivers.sh +41 -2
  25. package/assets/hooks/claim-guard.sh +22 -1
  26. package/assets/hooks/docs-guard.sh +1 -1
  27. package/assets/hooks/exam-guard.sh +85 -12
  28. package/assets/hooks/git-guard-delivery-conflict.sh +85 -0
  29. package/assets/hooks/git-guard-delivery-folder.sh +19 -0
  30. package/assets/hooks/git-guard-delivery-signature.sh +14 -5
  31. package/assets/hooks/git-guard-delivery.sh +54 -2
  32. package/assets/hooks/git-guard-push-tests.sh +21 -1
  33. package/assets/hooks/grill-gate-ask.sh +25 -0
  34. package/assets/hooks/grill-gate.sh +75 -11
  35. package/assets/hooks/lint-after-edit.sh +44 -16
  36. package/assets/hooks/proposal-guard.sh +11 -4
  37. package/assets/hooks/rule-source-guard.sh +8 -13
  38. package/assets/hooks/skill-gate-layers.sh +7 -0
  39. package/assets/hooks/skill-gate.sh +29 -0
  40. package/assets/hooks/task-context-load.sh +32 -0
  41. package/assets/hooks/task-flow-context.sh +194 -0
  42. package/assets/hooks/task-flow-draft-guard.sh +109 -0
  43. package/assets/hooks/task-flow-guard.sh +27 -159
  44. package/assets/hooks/turn-exit-guard.sh +53 -88
  45. package/assets/hooks/waiting-turn-guard.sh +65 -2
  46. package/assets/hooks/window-fill-guard.sh +5 -1
  47. package/assets/hooks/work-start-guard.sh +166 -0
  48. package/assets/hooks/write-targets.sh +24 -0
  49. package/assets/laws/delivery.md +19 -0
  50. package/assets/laws/frontend-application.md +4 -0
  51. package/assets/laws/verifiability.md +44 -0
  52. package/assets/laws/work-conduct.md +15 -0
  53. package/assets/patterns/browser-verification-measure.md +3 -1
  54. package/assets/patterns/browser-verification-stand.md +17 -4
  55. package/assets/patterns/doc-style-write.md +62 -2
  56. package/assets/patterns/git-workflow-commit.github.md +33 -5
  57. package/assets/patterns/git-workflow-docker.md +22 -0
  58. package/assets/patterns/git-workflow-freshness.md +87 -0
  59. package/assets/patterns/git-workflow-merge.md +41 -0
  60. package/assets/patterns/git-workflow-migration.md +11 -0
  61. package/assets/patterns/git-workflow-pr.github.md +12 -1
  62. package/assets/patterns/git-workflow-restart.md +18 -0
  63. package/assets/patterns/git-workflow-secrets.md +14 -0
  64. package/assets/patterns/git-workflow-stack.md +93 -1
  65. package/assets/patterns/lib-layers-move.md +4 -0
  66. package/assets/patterns/spec-driven-domain.md +47 -2
  67. package/assets/patterns/spec-driven-rule.md +16 -4
  68. package/assets/patterns/spec-driven-sweep.md +57 -0
  69. package/assets/patterns/status-report-table.github.md +1 -1
  70. package/assets/patterns/task-flow-archive.md +58 -16
  71. package/assets/patterns/task-flow-close.md +106 -23
  72. package/assets/patterns/task-flow-handoff.md +14 -2
  73. package/assets/patterns/task-flow-resume.md +35 -8
  74. package/assets/patterns/task-flow-start.md +60 -22
  75. package/assets/patterns/testing-e2e.md +23 -0
  76. package/assets/patterns/turn-entry-map.md +1 -1
  77. package/assets/pitfalls/agent-kit.md +71 -3
  78. package/assets/pitfalls/doc-style.md +15 -0
  79. package/assets/pitfalls/git-workflow.github.md +88 -0
  80. package/assets/pitfalls/spec-driven.md +22 -0
  81. package/assets/pitfalls/task-flow.md +113 -0
  82. package/assets/pitfalls/testing.md +6 -0
  83. package/assets/pitfalls/turn-conduct.md +49 -0
  84. package/assets/rules/browser-verification.md +58 -0
  85. package/assets/rules/deploy-flow.azure.md +8 -0
  86. package/assets/rules/deploy-flow.github.md +27 -0
  87. package/assets/rules/deploy-flow.gitlab.md +8 -0
  88. package/assets/rules/doc-style.md +20 -0
  89. package/assets/rules/git-workflow.azure.md +22 -2
  90. package/assets/rules/git-workflow.github.md +127 -118
  91. package/assets/rules/git-workflow.gitlab.md +17 -4
  92. package/assets/rules/lists.md +5 -0
  93. package/assets/rules/observability.needs-app.md +4 -0
  94. package/assets/rules/reuse-first.md +14 -0
  95. package/assets/rules/shared-code.md +5 -0
  96. package/assets/rules/spec-driven.md +33 -15
  97. package/assets/rules/task-flow.md +132 -126
  98. package/assets/rules/testing.md +41 -2
  99. package/assets/rules/turn-conduct.md +73 -57
  100. package/assets/rules/turn-entry.md +6 -0
  101. package/assets/samples/tasks/_template/grill.md +5 -0
  102. package/assets/samples/tasks/_template/plan.md +3 -0
  103. package/assets/skills/agent-kit.md +108 -76
  104. package/assets/templates/postmortem.md +5 -1
  105. package/bin/agent-kit.d.ts.map +1 -1
  106. package/bin/agent-kit.js +30 -6
  107. package/bin/agent-kit.js.map +1 -1
  108. package/lib/catalog.d.ts.map +1 -1
  109. package/lib/catalog.js +2 -1
  110. package/lib/catalog.js.map +1 -1
  111. package/lib/commands.d.ts.map +1 -1
  112. package/lib/commands.js +96 -7
  113. package/lib/commands.js.map +1 -1
  114. package/lib/hooks-map.d.ts +26 -0
  115. package/lib/hooks-map.d.ts.map +1 -1
  116. package/lib/hooks-map.js +58 -2
  117. package/lib/hooks-map.js.map +1 -1
  118. package/lib/sections.d.ts +6 -0
  119. package/lib/sections.d.ts.map +1 -1
  120. package/lib/sections.js +19 -0
  121. package/lib/sections.js.map +1 -1
  122. package/lib/shipment.d.ts +2 -0
  123. package/lib/shipment.d.ts.map +1 -1
  124. package/lib/shipment.fixture.d.ts +5 -0
  125. package/lib/shipment.fixture.d.ts.map +1 -1
  126. package/lib/shipment.fixture.js +7 -0
  127. package/lib/shipment.fixture.js.map +1 -1
  128. package/lib/shipment.js +13 -1
  129. package/lib/shipment.js.map +1 -1
  130. package/lib/sync.d.ts +35 -3
  131. package/lib/sync.d.ts.map +1 -1
  132. package/lib/sync.js +59 -8
  133. package/lib/sync.js.map +1 -1
  134. package/package.json +1 -1
  135. package/rt-tools-agent-kit-0.18.0.tgz +0 -0
  136. package/rt-tools-agent-kit-0.16.1.tgz +0 -0
@@ -28,7 +28,7 @@
28
28
  *
29
29
  * Ненулевой код возврата и перечень расхождений.
30
30
  */
31
- import { existsSync, statSync } from 'node:fs';
31
+ import { existsSync, readFileSync, statSync } from 'node:fs';
32
32
  import { join } from 'node:path';
33
33
 
34
34
  import {
@@ -42,12 +42,17 @@ import {
42
42
  fetchBoard,
43
43
  fetchIssues,
44
44
  fetchOpenPulls,
45
+ behindMain,
45
46
  gh,
47
+ ghJson,
46
48
  numberFromTaskDir,
47
49
  numberFromTitle,
48
50
  taskDirs,
49
51
  } from './board.mjs';
50
- import { deployLag, evictedOnHead, headCommittedAt, runsOnHead, verdictOnHead } from './board-runs.mjs';
52
+ import { onlyIgnoredPaths } from './board-paths.mjs';
53
+ import { HAS_PIPELINE, deployLag, evictedOnHead, headCommittedAt, runsOnHead, verdictOnHead } from './board-runs.mjs';
54
+ import { checkEpicLinks } from './board-epics.mjs';
55
+ import { similarTitles } from './board-titles.mjs';
51
56
  import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
52
57
 
53
58
  const IN_REVIEW = STATUS_OPTIONS[IN_REVIEW_STATUS].name;
@@ -63,12 +68,22 @@ const RUN_GRACE_MINUTES = 10;
63
68
  * Конвейер дерева. Прогоны спрашиваются только там, где ему есть откуда взяться: дерево без
64
69
  * конвейера получило бы строку на каждый открытый PR, и не о чём.
65
70
  */
66
- const PIPELINE = CONFIG.pushGate?.pipelineFile ?? '';
67
- const HAS_PIPELINE = PIPELINE !== '' && existsSync(join(ROOT, PIPELINE));
68
71
  /**
69
72
  * Рабочий поток выкатки и ветка, с которой прод сравнивают. Не назвав потока, дерево сверки
70
73
  * прода не получает — и сверка говорит об этом вслух: молчание читалось бы как «прод сошёлся».
71
74
  */
75
+ /**
76
+ * Метки, которыми в очереди работ помечен груз — присланные деревьями разборы происшествий и
77
+ * предложения. Задачами они не являются: заголовок у них без номера, исполнителя нет, на борде
78
+ * их нет, — и сверка печатала на каждую по три строки, среди которых настоящее расхождение уже
79
+ * не читалось. Заводить такие записи в очередь перестали, а заведённые прежним порядком оттуда
80
+ * никуда не денутся: закрывать их — решение владельца, а не сверки.
81
+ *
82
+ * Имя метки называет дерево: у каждого оно своё, а выдуманное умолчание не совпало бы ни с чем
83
+ * и молча выключило бы отсев. Дерево, метки не назвавшее, судится как прежде.
84
+ */
85
+ const CARGO_LABELS = new Set(CONFIG.board?.cargoLabels ?? []);
86
+
72
87
  const DEPLOY_WORKFLOW = CONFIG.deploy?.workflow ?? '';
73
88
  const MAIN_BRANCH = CONFIG.deploy?.mainBranch ?? 'main';
74
89
 
@@ -94,6 +109,12 @@ function checkDrafts() {
94
109
  }
95
110
  }
96
111
 
112
+ /**
113
+ * Значимые слова заголовка: ключ задачи и короткие служебные слова выброшены.
114
+ *
115
+ * Разбор нарочно грубый — совпадение слов здесь не приговор, а повод посмотреть: точное
116
+ * сравнение заголовков не находит ничего, потому что дубль пишут другими словами.
117
+ */
97
118
  function closesNumbers(body) {
98
119
  return [...String(body ?? '').matchAll(/\bCloses\s+#(\d+)\b/gi)].map((match) => Number(match[1]));
99
120
  }
@@ -160,6 +181,10 @@ function checkHeadRun(pull, options) {
160
181
  return;
161
182
  }
162
183
 
184
+ if (onlyIgnoredPaths(pull, options)) {
185
+ return;
186
+ }
187
+
163
188
  if (pull.mergeable === 'CONFLICTING') {
164
189
  report(
165
190
  `PR #${pull.number}: на вершине ${pull.headRefOid.slice(0, 8)} прогона нет и не будет, пока она конфликтует — ` +
@@ -254,7 +279,7 @@ function checkConflicting(pull) {
254
279
  );
255
280
  }
256
281
 
257
- let checked = { issues: 0, pulls: 0 };
282
+ let checked = { issues: 0, pulls: 0, cargo: 0 };
258
283
 
259
284
  // Черновики судятся по диску и потому проверяются всегда: связи для этого не нужно.
260
285
  checkDrafts();
@@ -264,9 +289,13 @@ try {
264
289
  const options = { token: botToken() ?? undefined };
265
290
  const board = fetchBoard(options);
266
291
  const issues = fetchIssues('all', options);
267
- const open = issues.filter((issue) => issue.state === 'OPEN');
292
+ const allOpen = issues.filter((issue) => issue.state === 'OPEN');
293
+ // Отсев идёт один раз и до всех проверок задачи: помеченная запись не задача целиком, а не
294
+ // наполовину, — её не судят ни заголовком, ни исполнителем, ни бордой, ни сводкой совпавших
295
+ // заголовков, ни связью с эпиком.
296
+ const open = CARGO_LABELS.size === 0 ? allOpen : allOpen.filter((issue) => !(issue.labels ?? []).some((label) => CARGO_LABELS.has(label.name)));
268
297
  const pulls = fetchOpenPulls(options);
269
- checked = { issues: issues.length, pulls: pulls.length };
298
+ checked = { issues: issues.length, pulls: pulls.length, cargo: allOpen.length - open.length };
270
299
 
271
300
  for (const item of board.foreign) {
272
301
  report(`борда: ${item} — на борде стоят задачи, а не PR о них`);
@@ -286,6 +315,9 @@ try {
286
315
  }
287
316
  }
288
317
 
318
+ similarTitles(open);
319
+ checkEpicLinks(open, report);
320
+
289
321
  const openNumbers = new Set(open.map((issue) => issue.number));
290
322
  const claimed = new Map();
291
323
  for (const pull of pulls) {
@@ -317,6 +349,17 @@ try {
317
349
 
318
350
  checkConflicting(pull);
319
351
 
352
+ // Отставание заявки от главной ветки: гард судит основание один раз, в минуту открытия,
353
+ // а заявка стоит днями. Влитая с отставанием, она везёт в главную сочетание, которого не
354
+ // проверял никто, — и по странице заявки этого не видно: прогон на ней зелёный.
355
+ const behind = behindMain(pull.headRefName, MAIN_BRANCH, options);
356
+ if (behind > 0) {
357
+ report(
358
+ `PR #${pull.number}: ветка отстала от «${MAIN_BRANCH}» на ${behind} коммитов — прогон шёл от основания, ` +
359
+ `которого в главной ветке уже нет. Влей главную, пересмотри набор проверок по тому, что ветка везёт теперь, и прогони заново`
360
+ );
361
+ }
362
+
320
363
  if (!FOLDER_SKIP.test(String(pull.body ?? '')) && pull.headRefName) {
321
364
  const folder = folderInBranch(pull.headRefName, options);
322
365
  if (folder !== null) {
@@ -394,6 +437,12 @@ if (!offline && !DEPLOY_WORKFLOW) {
394
437
  console.log('check-board: прод с главной веткой не сверялся — рабочий поток выкатки в настройке дерева не назван');
395
438
  }
396
439
 
440
+ // Отсеянное называется числом: молчаливый отсев неотличим от сломанной сверки — метка,
441
+ // названная с опечаткой, выключила бы проверку целиком и не сказала бы об этом.
442
+ if (!offline && checked.cargo > 0) {
443
+ console.log(`check-board: записей груза в очереди ${checked.cargo} — задачами они не судятся`);
444
+ }
445
+
397
446
  if (!offline && !HAS_PIPELINE) {
398
447
  console.log('check-board: прогоны на вершинах не спрашивались — файла конвейера в дереве нет');
399
448
  }
@@ -62,11 +62,19 @@ const JUDGED = ['.md', '.scss', '.html', '.js', '.mjs', '.sh'];
62
62
  /** Описание прошлого, папка задачи и то, что переписывает генератор. */
63
63
  const SKIPPED_PREFIXES = [CONFIG.archiveDir, `${CONFIG.tasksDir}/`, ...CONFIG.generatedDirs];
64
64
 
65
- /** Дерево спрашивается у системы контроля версий: иначе каталоги с точки не видны, а сборка видна. */
65
+ /**
66
+ * Дерево спрашивается у системы контроля версий: иначе каталоги с точки не видны, а сборка видна.
67
+ *
68
+ * Снятое из рабочего дерева отсеивается здесь, а не в счётчиках: система контроля версий помнит
69
+ * файл, пока снос не заведён в историю, а прочитать его нечем — проверка падала трассировкой
70
+ * `ENOENT` и читалась как сломанная, хотя сломан был только незаведённый снос. Счётчиков два, и
71
+ * охрана при каждом разошлась бы.
72
+ */
66
73
  function trackedFiles() {
67
74
  return execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1024 * 1024 * 32 })
68
75
  .split('\n')
69
- .filter(Boolean);
76
+ .filter(Boolean)
77
+ .filter((path) => existsSync(join(ROOT, path)));
70
78
  }
71
79
 
72
80
  function judged(path) {
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Слова, которых здесь не пишут: раздел «Так не пишем» словаря против дерева.
4
+ *
5
+ * Раздел лежал без единой проверки, и ровно поэтому расхождение росло годами: словарь звал
6
+ * службу одним словом, дерево — другим, и обе стороны выглядели действующими. Правило прямо
7
+ * велит брать слово из словаря или заводить его там же, а не краснело ничто — сверка адресов
8
+ * читает пути, сверка спеков читает сценарии, а словарь не читает никто.
9
+ *
10
+ * Проверка едет пакетом, а не пишется деревом: раздел «Так не пишем» везёт он же, и дерево,
11
+ * которое словарь получило, а проверки на него нет, живёт ровно с той дырой, ради которой она
12
+ * заведена. Слова при этом принадлежат словарю: пакет их не перечисляет, а читает из раздела.
13
+ * Общее для проверок — разбор списка известного и список пропускаемых каталогов — приезжает
14
+ * модулем настройки проверок, а не пишется здесь заново.
15
+ *
16
+ * Ищется левая колонка пар «слева запретное — справа принятое», и не вся: слово со скобочным
17
+ * уточнением («приём (о службе)») поиском не судится вовсе. Уточнение и означает, что запрещено
18
+ * одно значение из двух, а различить их в строке машине нечем: «операция приёма» законна, а
19
+ * «приём принимает груз» — нет, и обе строки для поиска одинаковы. Такое слово остаётся
20
+ * требованием к читателю; проверка о нём говорит вслух, чтобы молчание не читалось как охват.
21
+ *
22
+ * Сам словарь из поиска выведен: в нём запретное слово стоит по делу — тем и живёт левая
23
+ * колонка. Выведено и описание прошлого: оно по устройству называет то, чего в дереве уже нет.
24
+ *
25
+ * Ненулевой код возврата и перечень мест; накопленное к дню заведения проверки перечислено
26
+ * поимённо, с причиной и номером задачи.
27
+ */
28
+ import { spawnSync } from 'node:child_process';
29
+ import { readFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import process from 'node:process';
32
+
33
+ import { CONFIG, ROOT, baselineOf, parseAllowlist } from './rt-kit-checks.config.mjs';
34
+
35
+ const GLOSSARY = 'docs/GLOSSARY.md';
36
+ const SECTION = '## Так не пишем';
37
+ /** Пара словаря: `- **слева** — справа`. Слева бывает несколько слов через запятую. */
38
+ const PAIR = /^-\s+\*\*(.+?)\*\*\s+—/;
39
+ /** Скобочное уточнение при слове: запрещено одно значение из двух, и поиском их не развести. */
40
+ const HINT = /\([^)]*\)\s*$/;
41
+
42
+ /**
43
+ * Что проверка не читает.
44
+ *
45
+ * Всякий словарь — здешний, его надстройка и источник в пакете — выведен по делу: левая колонка
46
+ * тем и живёт, что называет запретное слово вслух. Описание прошлого и папки задач выведены по
47
+ * устройству: первое перечисляет то, чего в дереве уже нет, вторая умирает со слиянием.
48
+ */
49
+ const UNREAD = ['docs/archive/', 'docs/tasks/', 'CHANGELOG'];
50
+ const GLOSSARY_NAME = /(^|\/)GLOSSARY\.md$/;
51
+
52
+ /** Левая колонка раздела «Так не пишем»: слова, которых в дереве быть не должно. */
53
+ function forbiddenWords(text) {
54
+ const lines = text.split('\n');
55
+ const from = lines.findIndex((line) => line.trim() === SECTION);
56
+ if (from < 0) {
57
+ return { words: [], byReader: [] };
58
+ }
59
+
60
+ const words = [];
61
+ const byReader = [];
62
+ for (const line of lines.slice(from + 1)) {
63
+ if (line.startsWith('## ')) {
64
+ break;
65
+ }
66
+ const found = PAIR.exec(line);
67
+ if (!found) {
68
+ continue;
69
+ }
70
+ for (const part of found[1].split(',')) {
71
+ const word = part.trim();
72
+ if (!word) {
73
+ continue;
74
+ }
75
+ if (HINT.test(word)) {
76
+ byReader.push(word);
77
+ continue;
78
+ }
79
+ words.push(word);
80
+ }
81
+ }
82
+
83
+ return { words, byReader };
84
+ }
85
+
86
+ /** Файлы дерева, которые проверка читает. Список берётся у системы контроля версий. */
87
+ function readableFiles() {
88
+ const listed = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], {
89
+ cwd: ROOT,
90
+ encoding: 'utf8',
91
+ maxBuffer: 64 * 1024 * 1024,
92
+ });
93
+ if (listed.status !== 0) {
94
+ return [];
95
+ }
96
+
97
+ const skip = CONFIG.skipDirs ?? [];
98
+
99
+ return listed.stdout
100
+ .split('\n')
101
+ .map((line) => line.trim())
102
+ .filter(Boolean)
103
+ .filter((file) => file.endsWith('.md'))
104
+ .filter((file) => !GLOSSARY_NAME.test(file))
105
+ .filter((file) => !UNREAD.some((where) => file === where || file.startsWith(where) || file.includes(`/${where}`)))
106
+ .filter((file) => !skip.some((where) => file.includes(`/${where}/`) || file.startsWith(`${where}/`)));
107
+ }
108
+
109
+ function main() {
110
+ let glossary;
111
+ try {
112
+ glossary = readFileSync(join(ROOT, GLOSSARY), 'utf8');
113
+ } catch {
114
+ console.log(`check-glossary: словаря нет по адресу ${GLOSSARY} — сверять нечем`);
115
+
116
+ return Number(process.env.RT_SKIP_CODE ?? 7);
117
+ }
118
+
119
+ const { words, byReader } = forbiddenWords(glossary);
120
+ if (!words.length && !byReader.length) {
121
+ console.log('check-glossary: раздела запретных слов в словаре нет — сверять нечем');
122
+
123
+ return Number(process.env.RT_SKIP_CODE ?? 7);
124
+ }
125
+
126
+ const parsed = parseAllowlist('glossary');
127
+ const found = [];
128
+ for (const file of readableFiles()) {
129
+ let text;
130
+ try {
131
+ text = readFileSync(join(ROOT, file), 'utf8');
132
+ } catch {
133
+ continue;
134
+ }
135
+ for (const [index, line] of text.split('\n').entries()) {
136
+ for (const word of words) {
137
+ if (!new RegExp(`(^|[^\\p{L}])${word}([^\\p{L}]|$)`, 'iu').test(line)) {
138
+ continue;
139
+ }
140
+ found.push({ key: `${file}:${word}`, file, line: index + 1, word });
141
+ }
142
+ }
143
+ }
144
+
145
+ const fresh = baselineOf(
146
+ found.map((one) => one.key),
147
+ parsed,
148
+ );
149
+ const news = found.filter((one) => fresh.includes(one.key));
150
+
151
+ if (news.length) {
152
+ console.error(`check-glossary: расхождений ${news.length}`);
153
+ for (const one of news) {
154
+ console.error(` ${one.file}:${one.line} — «${one.word}»: слово стоит в разделе «Так не пишем» словаря`);
155
+ }
156
+ console.error(' либо слово меняется на принятое здесь, либо словарь перестаёт его запрещать');
157
+
158
+ return 1;
159
+ }
160
+
161
+ console.log(`check-glossary: запретных слов ${words.length}, читано документов ${readableFiles().length}, расхождений нет`);
162
+ if (byReader.length) {
163
+ // Молчание о невыполнимом поиске читалось бы как охват: эти слова не судит ничто.
164
+ console.log(` поиском не судятся, остаются требованием к читателю: ${byReader.join(', ')}`);
165
+ }
166
+
167
+ return 0;
168
+ }
169
+
170
+ process.exit(main());
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Сверка объявления гарда с тем, что разбирает его тело.
4
+ *
5
+ * Гард несёт своё событие и образец вызова сам — строкой `# rt-hook:` во второй строке файла; по
6
+ * ней его подписывают в настройке агента. Тело при этом умеет больше, чем объявлено: разбирает
7
+ * имя инструмента, которого в образце нет, — и до этой ветки вызов не доходит никогда. Снаружи
8
+ * гард выглядит работающим: путь назван, файл разложен, набор сценариев зелёный, потому что зовёт
9
+ * гард напрямую с подставленным вводом и объявления не читает вовсе.
10
+ *
11
+ * Хуже того, расхождение читается как промах дерева. Дерево, подписавшее гард шире объявления,
12
+ * получает отказ сверки раскладки и сужает подписку до объявления — вместе с расхождением снимая
13
+ * работавшее покрытие. Чинится это в пакете, а платит за него дерево.
14
+ *
15
+ * Что сверяется: имена инструментов из веток `case` по имени инструмента против образца
16
+ * объявления. Комментарии и тексты отказов не читаются: имя инструмента упоминают и там, а
17
+ * судится то, на что гард ветвится.
18
+ *
19
+ * FAIL-OPEN: каталога хуков нет — сверять нечего, нулевой код.
20
+ *
21
+ * Ненулевой код возврата и перечень расхождений.
22
+ */
23
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+
26
+ const ROOT = process.cwd();
27
+ const HOOKS = join(ROOT, '.claude/hooks');
28
+
29
+ /** Объявление гарда: событие и образец вызова. */
30
+ const DECLARATION = /^#\s*rt-hook:\s*(\S+)(?:[ \t]+(\S.*))?$/m;
31
+
32
+ /** Ветка `case` по имени инструмента: `Bash | mcp__webstorm__execute_tool)`. */
33
+ const TOOL_CASE = /^[ \t]*([A-Za-z_][\w-]*(?:__[\w-]+)*(?:[ \t]*\|[ \t]*[A-Za-z_][\w-]*(?:__[\w-]+)*)*)\)/;
34
+
35
+ /** Имена инструментов, на которые ветвится тело гарда. */
36
+ function branchedTools(text) {
37
+ const lines = text.split('\n');
38
+ const tools = new Set();
39
+ let inside = false;
40
+
41
+ for (const line of lines) {
42
+ const code = line.replace(/^([^#]*)#.*$/, '$1');
43
+
44
+ if (/case[ \t]+"?\$(\{)?(tool|RT_HOOK_TOOL)/.test(code) || /case[ \t]+"\$\(rt_hook_tool\)"/.test(code)) {
45
+ inside = true;
46
+ continue;
47
+ }
48
+
49
+ if (inside && /^[ \t]*esac\b/.test(code)) {
50
+ inside = false;
51
+ continue;
52
+ }
53
+
54
+ if (!inside) {
55
+ continue;
56
+ }
57
+
58
+ const branch = code.match(TOOL_CASE);
59
+ if (branch) {
60
+ for (const name of branch[1].split('|')) {
61
+ const tool = name.trim();
62
+ // Звёздочка — ветка «всё остальное», именем инструмента она не является.
63
+ if (tool && tool !== '*') {
64
+ tools.add(tool);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ return [...tools];
71
+ }
72
+
73
+ /** Покрывает ли образец объявления это имя инструмента. */
74
+ function covers(matcher, tool) {
75
+ try {
76
+ return new RegExp(`^(?:${matcher})$`).test(tool);
77
+ } catch {
78
+ // Образец, который не разобрать, — своё расхождение, и молчать о нём нельзя: имя
79
+ // инструмента под ним не совпадёт ни с чем, а выглядит объявление написанным.
80
+ return false;
81
+ }
82
+ }
83
+
84
+ function main() {
85
+ if (!existsSync(HOOKS)) {
86
+ console.log('check-hook-scope: каталога хуков в дереве нет — сверять нечего');
87
+
88
+ return 0;
89
+ }
90
+
91
+ const faults = [];
92
+
93
+ for (const file of readdirSync(HOOKS).filter((one) => one.endsWith('.sh')).sort()) {
94
+ const text = readFileSync(join(HOOKS, file), 'utf8');
95
+ const declared = text.match(DECLARATION);
96
+ if (!declared || !declared[2]) {
97
+ continue;
98
+ }
99
+
100
+ const matcher = declared[2].trim();
101
+ for (const tool of branchedTools(text)) {
102
+ if (!covers(matcher, tool)) {
103
+ faults.push(`${file}: тело ветвится на «${tool}», а объявление его не называет`);
104
+ }
105
+ }
106
+ }
107
+
108
+ if (faults.length > 0) {
109
+ console.log(`check-hook-scope: расхождений ${faults.length}\n`);
110
+
111
+ for (const fault of faults) {
112
+ console.log(` ${fault}`);
113
+ }
114
+
115
+ console.log('\nВетка тела, которой нет в объявлении, не исполняется ни разу: под этим именем гард');
116
+ console.log('не зовут. Объявление правится в источнике пакета, а не в разложенной копии.');
117
+
118
+ return 1;
119
+ }
120
+
121
+ console.log('check-hook-scope: объявления гардов покрывают то, на что ветвятся их тела — сошлось');
122
+
123
+ return 0;
124
+ }
125
+
126
+ process.exit(main());
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Сверка надстроек профиля с таблицами компаньонов.
4
+ *
5
+ * Правило говорит одно, а дерево работает по-другому — и оба места законны. Расхождение
6
+ * объявляется переменной профиля, а читатель ищет его в компаньоне правила, где перечислено, чем
7
+ * здесь зовётся сказанное правилом. Пока эти два места никто не сравнивает, компаньон обещает
8
+ * пакетное умолчание там, где дерево давно работает по-своему: правило требовало одной формы
9
+ * заголовка заявки, дерево замещало её переменной, и гард, читавший форму, номера не доставал
10
+ * вовсе — сверка номера молча не выполнялась, выглядя сошедшейся.
11
+ *
12
+ * Что сверяется: имя каждой переменной профиля, чьё значение разошлось с умолчанием пакета,
13
+ * против текстов компаньонов правил. Значение не сверяется ни с чем: сказать, верно ли оно,
14
+ * машине нечем, а назвать замещённое в компаньоне — можно.
15
+ *
16
+ * FAIL-OPEN: нет профиля, нет умолчаний, нет каталога правил — сверять нечего, нулевой код.
17
+ * Дерево вправе не держать ни того, ни другого.
18
+ *
19
+ * Ненулевой код возврата и перечень расхождений.
20
+ */
21
+ import { spawnSync } from 'node:child_process';
22
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
23
+ import { basename, join } from 'node:path';
24
+
25
+ const ROOT = process.cwd();
26
+ const PROFILE = join(ROOT, '.claude/rt-kit/project.sh');
27
+ const DEFAULTS = join(ROOT, '.claude/rt-kit/defaults/project.sh');
28
+ const RULES = join(ROOT, '.claude/skills');
29
+
30
+ /** Присвоение верхнего уровня: `RT_ИМЯ='значение'` либо `RT_ИМЯ="значение"`. */
31
+ const ASSIGN = /^(RT_[A-Z0-9_]+)=(.*)$/;
32
+
33
+ /** Умолчание пакета: `RT_ИМЯ="${RT_ИМЯ:-значение}"` — значение берётся из подстановки. */
34
+ const FALLBACK = /^RT_[A-Z0-9_]+="\$\{RT_[A-Z0-9_]+:-(.*)\}"$/;
35
+
36
+ /** Значение без окружающих кавычек: сравниваются строки, а не их запись. */
37
+ function unquote(text) {
38
+ const trimmed = text.trim();
39
+ const paired = trimmed.length > 1 && (trimmed.startsWith("'") || trimmed.startsWith('"')) && trimmed.endsWith(trimmed[0]);
40
+
41
+ return paired ? trimmed.slice(1, -1) : trimmed;
42
+ }
43
+
44
+ /** Карта «имя переменной → значение» по присвоениям верхнего уровня. */
45
+ function valuesOf(path) {
46
+ const values = new Map();
47
+
48
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
49
+ const assign = line.match(ASSIGN);
50
+ if (!assign) {
51
+ continue;
52
+ }
53
+
54
+ const fallback = line.match(FALLBACK);
55
+ values.set(assign[1], fallback ? unquote(fallback[1]) : unquote(assign[2]));
56
+ }
57
+
58
+ return values;
59
+ }
60
+
61
+ /** Тексты всех компаньонов правил дерева, склеенные в один: ищется в них имя, а не место. */
62
+ function companionsText() {
63
+ if (!existsSync(RULES)) {
64
+ return null;
65
+ }
66
+
67
+ const texts = [];
68
+
69
+ for (const rule of readdirSync(RULES, { withFileTypes: true })) {
70
+ if (!rule.isDirectory()) {
71
+ continue;
72
+ }
73
+
74
+ const companion = join(RULES, rule.name, 'implementation.md');
75
+ if (existsSync(companion)) {
76
+ texts.push(readFileSync(companion, 'utf8'));
77
+ }
78
+ }
79
+
80
+ return texts.length > 0 ? texts.join('\n') : null;
81
+ }
82
+
83
+ /**
84
+ * Набор гейта пуша, собранный оболочкой: с надстройкой дерева и без неё.
85
+ *
86
+ * Собирается вызовом, а не чтением текста: набор — функция, и она смотрит на дерево — есть ли
87
+ * настройка раскладки, лежит ли конфиг оформления, исполним ли набор сценариев. Прочитанный
88
+ * текстом, он назвал бы командами то, чего в этом дереве нет вовсе.
89
+ *
90
+ * Пусто — собрать нечем: нет оболочки, нет функции, отказ вызова. Сверять тогда нечего.
91
+ */
92
+ function gateSet(withProfile) {
93
+ const source = withProfile ? `. '${DEFAULTS}'; . '${PROFILE}';` : `. '${DEFAULTS}';`;
94
+ const run = spawnSync('bash', ['-c', `${source} command -v rt_push_checks >/dev/null 2>&1 || exit 9; rt_push_checks ''`], {
95
+ cwd: ROOT,
96
+ encoding: 'utf8',
97
+ });
98
+
99
+ return run.status === 0 && typeof run.stdout === 'string' ? run.stdout.split('\n').filter(Boolean) : null;
100
+ }
101
+
102
+ /**
103
+ * Проверки, названные в наборе, — по имени файла, а не по всей строке команды.
104
+ *
105
+ * Строка целиком сверке не годится: дерево вправе позвать ту же проверку другим запускателем или
106
+ * с другим доводом, и расхождением это не является. Пропажа самой проверки — является.
107
+ */
108
+ function checksIn(lines) {
109
+ const names = new Set();
110
+
111
+ for (const line of lines) {
112
+ for (const [word] of line.matchAll(/[\w./-]+\.(?:mjs|sh)/g)) {
113
+ names.add(basename(word));
114
+ }
115
+ }
116
+
117
+ return names;
118
+ }
119
+
120
+ /**
121
+ * Проверки, которые умолчание пакета зовёт, а набор дерева — нет.
122
+ *
123
+ * Набор гейта собирается умолчанием и надстройкой, и надстройка вправе объявить функцию заново.
124
+ * Выкушенная так проверка ничем не отличима от проверки, которой в дереве нет вовсе: гейт зелен,
125
+ * потому что её никто не звал, а сводка раскладки о наборе не знает ничего — она сличает
126
+ * переменные, а тут заменена функция.
127
+ *
128
+ * Отказ в пользу работы: собрать набор нечем — строк нет, и сверка идёт дальше своим делом.
129
+ */
130
+ function cutFromGate() {
131
+ const packaged = gateSet(false);
132
+ const here = gateSet(true);
133
+ if (packaged === null || here === null) {
134
+ return [];
135
+ }
136
+
137
+ const mine = checksIn(here);
138
+
139
+ return [...checksIn(packaged)].filter((name) => !mine.has(name)).sort((first, second) => first.localeCompare(second, 'ru'));
140
+ }
141
+
142
+ function main() {
143
+ if (!existsSync(PROFILE) || !existsSync(DEFAULTS)) {
144
+ console.log('check-profile-drift: профиля дерева или умолчаний пакета нет — сверять нечего');
145
+
146
+ return 0;
147
+ }
148
+
149
+ const companions = companionsText();
150
+ if (companions === null) {
151
+ console.log('check-profile-drift: компаньонов правил в дереве нет — сверять не с чем');
152
+
153
+ return 0;
154
+ }
155
+
156
+ const cut = cutFromGate();
157
+ if (cut.length > 0) {
158
+ console.log(`check-profile-drift: набор гейта пуша не зовёт проверок умолчания: ${cut.length}\n`);
159
+
160
+ for (const name of cut) {
161
+ console.log(` ${name}`);
162
+ }
163
+
164
+ console.log('\nНабор гейта собирается умолчанием пакета и надстройкой дерева. Проверка, выкушенная');
165
+ console.log('надстройкой, ничем не отличима от проверки, которой в дереве нет: гейт зелен потому,');
166
+ console.log('что её никто не звал. Верни её в набор либо объясни отказ в компаньоне правила поставки.');
167
+
168
+ return 1;
169
+ }
170
+
171
+ const defaults = valuesOf(DEFAULTS);
172
+ const profile = valuesOf(PROFILE);
173
+ const overridden = [...profile].filter(([name, value]) => !defaults.has(name) || defaults.get(name) !== value);
174
+ const unnamed = overridden.filter(([name]) => !companions.includes(name));
175
+
176
+ if (unnamed.length > 0) {
177
+ console.log(`check-profile-drift: замещено ${overridden.length}, не названо компаньоном ${unnamed.length}\n`);
178
+
179
+ for (const [name, value] of unnamed) {
180
+ console.log(` ${name} = ${value.length > 60 ? `${value.slice(0, 57)}…` : value}`);
181
+ }
182
+
183
+ console.log('\nЗамещённое читают в компаньоне правила: там перечислено, чем здесь зовётся сказанное');
184
+ console.log('правилом. Не названное там, оно оставляет читателю пакетное умолчание вместо того, по');
185
+ console.log('чему дерево работает на самом деле.');
186
+
187
+ return 1;
188
+ }
189
+
190
+ console.log(`check-profile-drift: замещено ${overridden.length}, все названы компаньонами — сошлось`);
191
+
192
+ return 0;
193
+ }
194
+
195
+ process.exit(main());