@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
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Связь задачи с эпиком. Живёт своим файлом: сверка очереди работ и без неё стоит у предела
3
+ * длины, а читают эти две проверки порознь.
4
+ */
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ import { TASK_KEY } from './board.mjs';
9
+ import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
10
+
11
+ /**
12
+ * Метка карточки эпика. Не названа — связь не судится: отличить карточку эпика от обычной
13
+ * задачи станет нечем.
14
+ */
15
+ const EPIC_LABEL = CONFIG.board?.epicLabel ?? '';
16
+
17
+ /**
18
+ * Строки состава эпика — те, что стоят в таблице с колонкой «Задача».
19
+ *
20
+ * Замысел эпика держит и другие таблицы: источники находок, состав семей, счёт пунктов. Номера,
21
+ * взятые из всего текста и даже из всех таблиц, делали бы задачей эпика всё, что он упомянул, —
22
+ * прошлый эпик, из находок которого он вырос, разбор, задачу соседнего дерева.
23
+ */
24
+ function planRows(plan) {
25
+ const rows = [];
26
+ let inside = false;
27
+ for (const line of plan.split('\n')) {
28
+ const isRow = line.trimStart().startsWith('|');
29
+ if (!isRow) {
30
+ inside = false;
31
+ continue;
32
+ }
33
+ if (!inside) {
34
+ // Граница слова здесь не годится: `\b` знает только латиницу, и с кириллицей она не
35
+ // совпадает никогда — проверка молчала бы на любом замысле.
36
+ inside = /\|[^|]*Задача/.test(line);
37
+ continue;
38
+ }
39
+ rows.push(line);
40
+ }
41
+ return rows.join('\n');
42
+ }
43
+
44
+ /**
45
+ * Связь задачи с эпиком, прочитанная в обе стороны.
46
+ *
47
+ * Задача, заведённая под эпик, называет его в теле, а замысел эпика называет её со своей
48
+ * стороны. Односторонняя привязка выглядит целой ровно так же, как двусторонняя: читатель
49
+ * приходит то от замысла эпика, то от его карточки, и вторая сторона существует только для одного из
50
+ * них. Держалась она подражанием — пока тело писали с образца соседней задачи того же эпика,
51
+ * строка ехала вместе с формой, а задача, заведённая посреди работы находкой, писалась не с
52
+ * образца.
53
+ *
54
+ * Путь к замыслу берётся из тела карточки эпика: называть его она обязана и так, а настройка
55
+ * каталога завела бы второй источник правды. Путём считается написание с каталогом — голое имя
56
+ * файла в теле встречается прозой и уводило бы проверку на первое же упоминание. Замысла нет на
57
+ * диске — это своё расхождение: карточка ссылается в пустоту.
58
+ *
59
+ * Судится только открытое, как и вся остальная сверка: закрытая задача эпика — история, и
60
+ * строку о ней нечем закрыть.
61
+ */
62
+ export function checkEpicLinks(open, report) {
63
+ // Метка не названа — карточку эпика отличить от обычной задачи нечем, и проверка молчит.
64
+ // Молчит именно так, а не «эпиков нет»: дерево без эпиков и дерево, не назвавшее метки,
65
+ // здесь неразличимы.
66
+ if (!EPIC_LABEL) {
67
+ return;
68
+ }
69
+
70
+ const byNumber = new Map(open.map((issue) => [issue.number, issue]));
71
+ const epics = open.filter((issue) => (issue.labels ?? []).some((label) => label.name === EPIC_LABEL));
72
+ const listedBy = new Map();
73
+ // Эпики, состав которых прочитать не вышло. Их задачи обратной стороной не судятся: о
74
+ // непрочитанном замысле уже сказано своей строкой, и четыре строки «задачи нет в замысле»
75
+ // рядом с ней говорят о том же промахе ещё раз, называя виноватыми чужие задачи.
76
+ const unreadable = new Set();
77
+
78
+ for (const epic of epics) {
79
+ const planPath = String(epic.body ?? '').match(/(?:^|[\s(`])([\w.-]+(?:\/[\w.-]+)+\.md)/)?.[1] ?? null;
80
+ if (planPath === null) {
81
+ report(`#${epic.number}: карточка эпика не называет путь к замыслу — состав эпика читать негде`);
82
+ unreadable.add(epic.number);
83
+ continue;
84
+ }
85
+ if (!existsSync(join(ROOT, planPath))) {
86
+ report(`#${epic.number}: замысла эпика «${planPath}» нет на диске — карточка ссылается в пустоту`);
87
+ unreadable.add(epic.number);
88
+ continue;
89
+ }
90
+
91
+ const plan = readFileSync(join(ROOT, planPath), 'utf8');
92
+ const mentions = planRows(plan).matchAll(new RegExp(`(?:#|${TASK_KEY}-)(\\d+)`, 'g'));
93
+ const numbers = new Set([...mentions].map((match) => Number(match[1])));
94
+ for (const number of numbers) {
95
+ if (number === epic.number || !byNumber.has(number)) {
96
+ continue;
97
+ }
98
+ listedBy.set(number, epic.number);
99
+ const body = String(byNumber.get(number).body ?? '');
100
+ if (!new RegExp(`(?:#|${TASK_KEY}-)${epic.number}\\b`).test(body)) {
101
+ report(
102
+ `#${number}: замысел эпика #${epic.number} задачу называет, а её тело эпика — нет. Допиши строку «Задача эпика #${epic.number}, замысел — ${planPath}»`
103
+ );
104
+ }
105
+ }
106
+ }
107
+
108
+ // Обратная сторона: тело назвало эпик, а замысел эпика этой задачи не знает. Читается
109
+ // это как задача под эпиком, но «взять следующую» её не отдаст никогда.
110
+ const epicNumbers = new Set(epics.map((issue) => issue.number));
111
+ for (const issue of open) {
112
+ if (epicNumbers.has(issue.number)) {
113
+ continue;
114
+ }
115
+ const named = String(issue.body ?? '').match(new RegExp(`эпика?\\s+(?:#|${TASK_KEY}-)(\\d+)`, 'i'))?.[1];
116
+ if (named === undefined || !epicNumbers.has(Number(named)) || unreadable.has(Number(named))) {
117
+ continue;
118
+ }
119
+ if (listedBy.get(issue.number) !== Number(named)) {
120
+ report(`#${issue.number}: тело называет эпик #${named}, а в его замысле задачи нет — «взять следующую» её не отдаст`);
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Вызов клиента хостинга: как его находят, чем подписывают и что считается временным отказом.
4
+ *
5
+ * Отделено от работы с очередью работ потому, что предмет здесь другой — не задача и не колонка,
6
+ * а сам вызов: где взять исполняемый файл, каким токеном его подписать, что читать как «сети
7
+ * нет» и что повторить. Вместе с очередью это переросло предел длины файла.
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+
13
+ import { CONFIG } from './rt-kit-checks.config.mjs';
14
+
15
+ const BOARD = CONFIG.board ?? {};
16
+ const BOT_TOKEN_FILE = BOARD.tokenPath ? BOARD.tokenPath.replace(/^~/, homedir()) : '';
17
+
18
+ /**
19
+ * `gh` у владельца подменён обёрткой менеджера паролей, и вызов по имени уходит в неё.
20
+ * Поэтому сначала пробуется настоящий исполняемый файл, и только потом имя из PATH.
21
+ */
22
+ function ghBinary() {
23
+ if (process.env.GH_BIN) {
24
+ return process.env.GH_BIN;
25
+ }
26
+ const homebrew = '/opt/homebrew/bin/gh';
27
+ return existsSync(homebrew) ? homebrew : 'gh';
28
+ }
29
+
30
+ /**
31
+ * Токен машинной записи лежит вне репозитория и в вывод не попадает. Его отсутствие — не отказ:
32
+ * дерево, не назвавшее токена в `board.tokenPath`, работает с очередью учётной записью, под
33
+ * которой залогинен клиент хостинга.
34
+ */
35
+ export function botToken() {
36
+ if (!existsSync(BOT_TOKEN_FILE)) {
37
+ return null;
38
+ }
39
+ const token = readFileSync(BOT_TOKEN_FILE, 'utf8').trim();
40
+ return token.length > 0 ? token : null;
41
+ }
42
+
43
+ export class OfflineError extends Error {}
44
+
45
+ /**
46
+ * Отказ сети от отказа по существу отличается только текстом: `gh` на оба отвечает
47
+ * ненулевым кодом. Сюда попадает то, после чего проверять нечем, — а не то, что
48
+ * проверено и оказалось не так.
49
+ */
50
+ function isOffline(stderr) {
51
+ return /dial tcp|no such host|network is unreachable|timeout|TLS handshake|connection refused|Bad credentials|authentication|not logged/i.test(
52
+ stderr
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Отказ, который проходит сам: хостинг ответил, но не смог.
58
+ *
59
+ * Отличается от отказа по существу тем, что повтор его снимает: пятисотые коды, шлюз и его
60
+ * таймаут. Отказ по праву, по несуществующей записи и по незнакомой колонке сюда не попадают —
61
+ * они не пройдут и на третий раз, а ждать заставят.
62
+ */
63
+ function isUnavailable(stderr) {
64
+ return /HTTP 50[0234]\b|Bad Gateway|Service Unavailable|Gateway Time-?out|Server Error/i.test(stderr);
65
+ }
66
+
67
+ /** Сколько раз пробовать вызов, который отбит недоступностью хостинга. */
68
+ const TRIES = 3;
69
+
70
+ /**
71
+ * Пауза перед следующей попыткой, вдвое длиннее прежней. Синхронная: вызов хостинга здесь тоже
72
+ * синхронный, и уводить его в обещание пришлось бы вместе со всеми, кто его зовёт.
73
+ *
74
+ * Число берётся из окружения ради проб: своей паузы им не нужно, а ждать три секунды на каждый
75
+ * сценарий они не обязаны.
76
+ */
77
+ const PAUSE_MS = Number(process.env.RT_GH_RETRY_MS ?? 1000) || 0;
78
+
79
+ function pause(ms) {
80
+ if (ms > 0) {
81
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
82
+ }
83
+ }
84
+
85
+ export function gh(args, { token } = {}) {
86
+ const env = { ...process.env };
87
+ if (token) {
88
+ env.GH_TOKEN = token;
89
+ }
90
+
91
+ let waited = PAUSE_MS;
92
+ for (let attempt = 1; ; attempt += 1) {
93
+ try {
94
+ return execFileSync(ghBinary(), args, { env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
95
+ } catch (error) {
96
+ const stderr = String(error.stderr ?? error.message ?? '');
97
+ if (error.code === 'ENOENT' || isOffline(stderr)) {
98
+ throw new OfflineError(stderr.trim() || 'gh недоступен');
99
+ }
100
+ // Правило требует двигать колонку тем же движением, что и работу, а хостинг отвечал
101
+ // недоступностью час подряд: без повтора исполнитель либо крутит вызов руками, либо
102
+ // оставляет колонку отставшей — и находит это сверка уже после открытия заявки.
103
+ if (isUnavailable(stderr) && attempt < TRIES) {
104
+ pause(waited);
105
+ waited *= 2;
106
+ continue;
107
+ }
108
+ const failure = new Error(stderr.trim() || `gh ${args[0]} завершился с ошибкой`);
109
+ failure.stderr = stderr;
110
+ throw failure;
111
+ }
112
+ }
113
+ }
114
+
115
+ export function ghJson(args, options) {
116
+ return JSON.parse(gh(args, options));
117
+ }
118
+
119
+ export function graphql(query, options) {
120
+ return ghJson(['api', 'graphql', '-f', `query=${query}`], options);
121
+ }
@@ -0,0 +1,88 @@
1
+ // Пути, которых не слушает конвейер, и заявка, чей вклад целиком под ними.
2
+ //
3
+ // Вынесено из сверки очереди отдельным модулем: разбор образцов конвейера к состоянию борды
4
+ // отношения не имеет и читается сам по себе, а сверка от него росла быстрее предела длины.
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ import { ghJson } from './board.mjs';
9
+ import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
10
+
11
+ const PIPELINE = CONFIG.pushGate?.pipelineFile ?? '';
12
+ const HAS_PIPELINE = PIPELINE !== '' && existsSync(join(ROOT, PIPELINE));
13
+
14
+ /**
15
+ * Пути, которых конвейер не слушает: `paths-ignore` у его событий.
16
+ *
17
+ * Разбирается построчно, а не разборщиком разметки: у проверки его нет, а список — плоский
18
+ * перечень строк под одним ключом. Ключей в файле бывает несколько — по событию, — и все они
19
+ * складываются в один набор: ветка, чей вклад целиком лежит под ними, прогона не создаёт ни на
20
+ * одном событии.
21
+ */
22
+ export function ignoredPaths() {
23
+ if (!HAS_PIPELINE) {
24
+ return [];
25
+ }
26
+
27
+ const lines = readFileSync(join(ROOT, PIPELINE), 'utf8').split('\n');
28
+ const found = [];
29
+ let inside = false;
30
+
31
+ for (const line of lines) {
32
+ if (/^\s*paths-ignore:\s*$/.test(line)) {
33
+ inside = true;
34
+ continue;
35
+ }
36
+ if (!inside) {
37
+ continue;
38
+ }
39
+ const item = /^\s*-\s+['"]?([^'"\s]+)['"]?\s*$/.exec(line);
40
+ if (item) {
41
+ found.push(item[1]);
42
+ continue;
43
+ }
44
+ inside = false;
45
+ }
46
+
47
+ return found;
48
+ }
49
+
50
+ const IGNORED_PATHS = ignoredPaths();
51
+
52
+ /** Знаки образца, у которых в выражении своё значение: кроме звёздочек, они значат себя. */
53
+ const escapeForRegExp = (value) => value.replace(/[.+?^${}()|[\]\\-]/g, '\\$&');
54
+
55
+ /** Подпадает ли путь под образец конвейера: `**` — любой хвост, `*` — кусок имени. */
56
+ function underPattern(path, pattern) {
57
+ const body = pattern
58
+ .split('**')
59
+ .map((piece) => piece.split('*').map(escapeForRegExp).join('[^/]*'))
60
+ .join('.*');
61
+
62
+ return new RegExp(`^${body}$`).test(path);
63
+ }
64
+
65
+ /**
66
+ * Вклад заявки целиком лежит под путями, которых конвейер не слушает.
67
+ *
68
+ * Такой ветке прогона не будет никогда, и требовать его — то же, что требовать его у ветки без
69
+ * единого коммита: признак верен по букве и лжёт по существу, а действие, которое он советует,
70
+ * не исполнимо. Красная строка при этом стоит рядом с настоящими расхождениями и учит
71
+ * пропускать сверку целиком.
72
+ *
73
+ * Состав не прочитать — отвечаем «нет»: молчать наугад дороже одной лишней строки.
74
+ */
75
+ export function onlyIgnoredPaths(pull, options) {
76
+ if (!IGNORED_PATHS.length) {
77
+ return false;
78
+ }
79
+
80
+ let files = [];
81
+ try {
82
+ files = ghJson(['pr', 'view', String(pull.number), '--json', 'files'], options).files ?? [];
83
+ } catch {
84
+ return false;
85
+ }
86
+
87
+ return files.length > 0 && files.every((file) => IGNORED_PATHS.some((pattern) => underPattern(file.path, pattern)));
88
+ }
@@ -10,7 +10,15 @@
10
10
  * Нет сети или нет токена — вызовы бросают `OfflineError`, как и остальная работа с хостингом:
11
11
  * невозможность спросить расхождением не считается.
12
12
  */
13
+ import { existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
13
16
  import { OWNER, REPO, gh } from './board.mjs';
17
+ import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
18
+
19
+ /** Файл конвейера этого дерева; его нет — путей он не слушает вовсе. */
20
+ const PIPELINE = CONFIG.pushGate?.pipelineFile ?? '';
21
+ export const HAS_PIPELINE = PIPELINE !== '' && existsSync(join(ROOT, PIPELINE));
14
22
 
15
23
  /**
16
24
  * Сколько прогонов завелось на этой вершине.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Открытые задачи, чьи заголовки сильно совпали.
3
+ *
4
+ * Отдельным файлом, а не внутри сверки очереди: у очереди свой предмет — задачи, колонки и
5
+ * заявки, — а здесь сравнивают слова заголовков между собой. Вместе они переросли предел длины
6
+ * файла, и делить их по предмету дешевле, чем по числу строк.
7
+ */
8
+
9
+ function titleWords(title) {
10
+ return [
11
+ ...new Set(
12
+ String(title)
13
+ .replace(/^\s*\[[^\]]+\]\s*/, '')
14
+ .toLowerCase()
15
+ .split(/[^\p{L}\p{N}]+/u)
16
+ .filter((word) => word.length > 3)
17
+ ),
18
+ ];
19
+ }
20
+
21
+ /** Доля общих слов, начиная с которой две задачи стоит посмотреть глазами. */
22
+ const TITLE_OVERLAP = 0.6;
23
+ /** Меньше трёх общих слов совпадением не считается: два длинных слова совпадают у любой пары. */
24
+ const TITLE_COMMON_MIN = 3;
25
+ /** Больше скольких задач в группе — это серия эпика, а не дубль. */
26
+ const TITLE_GROUP_MAX = 4;
27
+
28
+ /**
29
+ * Открытые задачи, чьи заголовки сильно совпали.
30
+ *
31
+ * Печатается строкой сводки, а не отказом: серия однотипных задач эпика — законное состояние
32
+ * очереди, и отказ отбивал бы работу на каждой такой серии. Дубль же по отдельности исправен —
33
+ * у обеих задач номер, исполнитель и колонка, — и не находит его ничто: разошлись они словами
34
+ * заголовка, а совпадают дефектом и признаком закрытия.
35
+ */
36
+ export function similarTitles(open) {
37
+ const words = new Map(open.map((issue) => [issue.number, titleWords(issue.title)]));
38
+ // Задачи сводятся в группы, а не в пары: серия однотипных задач эпика — законное состояние
39
+ // очереди, и парами она даёт по строке на каждое сочетание, то есть заглушает сама себя.
40
+ const groups = [];
41
+
42
+ for (const issue of open) {
43
+ const mine = words.get(issue.number);
44
+ const near = groups.find((group) =>
45
+ group.some((other) => {
46
+ const theirs = words.get(other.number);
47
+ const common = mine.filter((word) => theirs.includes(word)).length;
48
+ const smaller = Math.min(mine.length, theirs.length);
49
+
50
+ return smaller > 0 && common >= TITLE_COMMON_MIN && common / smaller >= TITLE_OVERLAP;
51
+ })
52
+ );
53
+ if (near) {
54
+ near.push(issue);
55
+ continue;
56
+ }
57
+ groups.push([issue]);
58
+ }
59
+
60
+ // Группа больше предела — это серия однотипных задач, а не дубль: у эпика их бывает
61
+ // полтора десятка, и строка о них говорит только то, что эпик существует.
62
+ for (const group of groups.filter((one) => one.length > 1 && one.length <= TITLE_GROUP_MAX)) {
63
+ const numbers = group.map((issue) => `#${issue.number}`).join(', ');
64
+ console.log(`check-board: ${numbers} — заголовки сильно совпадают, посмотри, не одна ли это работа: ` + `«${group[0].title}»`);
65
+ }
66
+ }
@@ -18,13 +18,16 @@
18
18
  * Нет сети или нет токена — это не расхождение, а невозможность проверить:
19
19
  * функции возвращают `null`, командный режим печатает `{"offline":true}`.
20
20
  */
21
- import { execFileSync } from 'node:child_process';
22
21
  import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
23
- import { homedir } from 'node:os';
24
22
  import { join } from 'node:path';
25
23
 
24
+ import { OfflineError, botToken, gh, ghJson, graphql } from './board-gh.mjs';
26
25
  import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
27
26
 
27
+ // Наружу клиент уезжает отсюда прежним именем: его зовут у борды три соседних модуля и команды
28
+ // дерева, и переписывать их ради переезда объявления значило бы платить за деление файла дважды.
29
+ export { OfflineError, botToken, gh, ghJson, graphql };
30
+
28
31
  /**
29
32
  * Адрес борды и её колонки живут в `.claude/rt-kit/checks.json`: идентификаторы проекта, поля
30
33
  * и вариантов выдаёт сам GitHub при заведении борды, и угадать их нельзя. Пустое значение
@@ -69,72 +72,6 @@ if (PROJECT_ID && !TASK_KEY) {
69
72
  }
70
73
  /** Кого запрашивают на разбор: без ревьювера PR не попадает во входящие владельца. */
71
74
  export const REVIEWER = BOARD.reviewer ?? '';
72
- const BOT_TOKEN_FILE = BOARD.tokenPath ? BOARD.tokenPath.replace(/^~/, homedir()) : '';
73
-
74
- /**
75
- * `gh` у владельца подменён обёрткой менеджера паролей, и вызов по имени уходит в неё.
76
- * Поэтому сначала пробуется настоящий исполняемый файл, и только потом имя из PATH.
77
- */
78
- function ghBinary() {
79
- if (process.env.GH_BIN) {
80
- return process.env.GH_BIN;
81
- }
82
- const homebrew = '/opt/homebrew/bin/gh';
83
- return existsSync(homebrew) ? homebrew : 'gh';
84
- }
85
-
86
- /**
87
- * Токен машинной записи лежит вне репозитория и в вывод не попадает. Его отсутствие — не отказ:
88
- * дерево, не назвавшее токена в `board.tokenPath`, работает с очередью учётной записью, под
89
- * которой залогинен клиент хостинга.
90
- */
91
- export function botToken() {
92
- if (!existsSync(BOT_TOKEN_FILE)) {
93
- return null;
94
- }
95
- const token = readFileSync(BOT_TOKEN_FILE, 'utf8').trim();
96
- return token.length > 0 ? token : null;
97
- }
98
-
99
- export class OfflineError extends Error {}
100
-
101
- /**
102
- * Отказ сети от отказа по существу отличается только текстом: `gh` на оба отвечает
103
- * ненулевым кодом. Сюда попадает то, после чего проверять нечем, — а не то, что
104
- * проверено и оказалось не так.
105
- */
106
- function isOffline(stderr) {
107
- return /dial tcp|no such host|network is unreachable|timeout|TLS handshake|connection refused|Bad credentials|authentication|not logged/i.test(
108
- stderr
109
- );
110
- }
111
-
112
- export function gh(args, { token } = {}) {
113
- const env = { ...process.env };
114
- if (token) {
115
- env.GH_TOKEN = token;
116
- }
117
- try {
118
- return execFileSync(ghBinary(), args, { env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
119
- } catch (error) {
120
- const stderr = String(error.stderr ?? error.message ?? '');
121
- if (error.code === 'ENOENT' || isOffline(stderr)) {
122
- throw new OfflineError(stderr.trim() || 'gh недоступен');
123
- }
124
- const failure = new Error(stderr.trim() || `gh ${args[0]} завершился с ошибкой`);
125
- failure.stderr = stderr;
126
- throw failure;
127
- }
128
- }
129
-
130
- export function ghJson(args, options) {
131
- return JSON.parse(gh(args, options));
132
- }
133
-
134
- export function graphql(query, options) {
135
- return ghJson(['api', 'graphql', '-f', `query=${query}`], options);
136
- }
137
-
138
75
  /**
139
76
  * Тикеты, стоящие на борде, и элементы борды, тикетами не являющиеся. У каждого тикета —
140
77
  * его элемент борды и колонка: переставить задачу можно только по идентификатору элемента,
@@ -189,7 +126,12 @@ export function moveTask(number, status, options) {
189
126
  }
190
127
 
191
128
  export function fetchIssues(state, options) {
192
- return ghJson(['issue', 'list', '--state', state, '--limit', '400', '--json', 'number,title,state,assignees,labels'], options);
129
+ // Тело берётся вместе со списком, а не поштучным вызовом на задачу: связь с эпиком читается
130
+ // как раз в нём, а четыреста вызовов вида «покажи одну задачу» стоили бы дороже всей сверки.
131
+ return ghJson(
132
+ ['issue', 'list', '--state', state, '--limit', '400', '--json', 'number,title,state,assignees,labels,body'],
133
+ options,
134
+ );
193
135
  }
194
136
 
195
137
  export function fetchIssue(number, options) {
@@ -237,8 +179,7 @@ export function pullState(ref, options) {
237
179
  author: pull.author?.login ?? null,
238
180
  reviewers,
239
181
  reviewed: reviewers.filter((login) => login !== (pull.author?.login ?? null)).length > 0,
240
- // Конфликт приезжает в отданную заявку чужим слиянием, без единого действия её автора:
241
- // хостинг считает сливаемость заново после каждой правки главной ветки. Судится только
182
+ // Конфликт приезжает чужим слиянием, без единого действия автора заявки. Судится только
242
183
  // прямое «конфликтует»: `UNKNOWN` означает, что хостинг ещё считает, и читать его как
243
184
  // конфликт значило бы отбивать работу на каждой свежей вершине.
244
185
  conflicting: pull.mergeable === 'CONFLICTING',
@@ -246,9 +187,30 @@ export function pullState(ref, options) {
246
187
  }
247
188
 
248
189
  /**
249
- * Вершина берётся вместе с остальным: спросить её потом значило бы второй вызов на каждый PR,
250
- * а судят по ней и папку задачи, и прогон.
190
+ * На сколько коммитов ветка заявки позади главной. Гард судит основание в минуту открытия, а
191
+ * заявка стоит днями: влитого за это время не видит ни он, ни зелёный прогон. Сравнить нечем —
192
+ * ноль: сверка без доступа отбивала бы работу вместо промаха; без сети летит отказ, как везде.
251
193
  */
194
+ export function behindMain(branch, mainBranch, options) {
195
+ if (!OWNER || !REPO || !branch || !mainBranch) {
196
+ return 0;
197
+ }
198
+ try {
199
+ const behind = gh(
200
+ ['api', `repos/${OWNER}/${REPO}/compare/${encodeURIComponent(mainBranch)}...${encodeURIComponent(branch)}`, '--jq', '.behind_by'],
201
+ options
202
+ );
203
+ return Number(String(behind).trim()) || 0;
204
+ } catch (error) {
205
+ if (error instanceof OfflineError) {
206
+ throw error;
207
+ }
208
+ return 0;
209
+ }
210
+ }
211
+
212
+ /** Вершина берётся вместе с остальным: по ней судят и папку задачи, и прогон, а спросить её
213
+ * потом значило бы второй вызов на каждый PR. */
252
214
  export function fetchOpenPulls(options) {
253
215
  return ghJson(
254
216
  ['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,headRefName,headRefOid,isDraft,body,mergeable'],
@@ -256,6 +218,32 @@ export function fetchOpenPulls(options) {
256
218
  );
257
219
  }
258
220
 
221
+ /**
222
+ * Свои открытые заявки, помеченные конфликтующими. Спрашивается это в минуту, когда берётся
223
+ * новая работа: пока отданное конфликтует, влить его человек не может, и каждая следующая
224
+ * заявка прибавляет к очереди ещё одну, которую придётся догонять.
225
+ *
226
+ * Свои — значит открытые машинной записью дерева. Дерево, её не назвавшее, не спрашивается
227
+ * вовсе: `@me` отвечал бы учётной записью, под которой залогинен клиент хостинга, а это чаще
228
+ * всего владелец, и его заявки исполнителю не чинить.
229
+ *
230
+ * Судится только прямое `CONFLICTING`. `UNKNOWN` означает, что хостинг ещё считает сливаемость
231
+ * — он пересчитывает её после каждой правки главной ветки, — и читать его как конфликт значило
232
+ * бы отбивать работу на каждой свежей вершине.
233
+ */
234
+ export function conflictingPulls(options) {
235
+ if (!BOT) {
236
+ return null;
237
+ }
238
+ const pulls = ghJson(['pr', 'list', '--author', BOT, '--state', 'open', '--limit', '100', '--json', 'number,headRefName,mergeable'], options);
239
+ if (!Array.isArray(pulls)) {
240
+ return null;
241
+ }
242
+ return pulls
243
+ .filter((pull) => pull.mergeable === 'CONFLICTING')
244
+ .map((pull) => ({ number: pull.number ?? null, branch: pull.headRefName ?? '' }));
245
+ }
246
+
259
247
  /** `[<КЛЮЧ>-<номер>]` в начале заголовка — единственная форма номера в названиях */
260
248
  export const TITLE_NUMBER = new RegExp(`^\\[${TASK_KEY}-(\\d+)\\]\\s+\\S`);
261
249
  /** `<КЛЮЧ>-<номер>-<slug>` — имя ветки, отведённой под задачу */
@@ -447,6 +435,22 @@ if (isEntryPoint && process.argv[2] === 'pr') {
447
435
  }
448
436
  }
449
437
 
438
+ // Свои конфликтующие заявки одной строкой JSON: `{"conflicting":[{"number":…,"branch":…}]}`.
439
+ // Зовёт её гард поставки перед тем, как пустить взятие новой работы. Дерево без машинной записи
440
+ // отвечает пустым списком: спрашивать не о ком.
441
+ if (isEntryPoint && process.argv[2] === 'conflicts') {
442
+ try {
443
+ process.stdout.write(`${JSON.stringify({ conflicting: conflictingPulls() ?? [] })}\n`);
444
+ } catch (error) {
445
+ if (error instanceof OfflineError) {
446
+ process.stdout.write('{"offline":true}\n');
447
+ } else {
448
+ process.stdout.write(`${JSON.stringify({ error: String(error.message ?? error) })}\n`);
449
+ process.exit(1);
450
+ }
451
+ }
452
+ }
453
+
450
454
  // Перевод колонки правит борду. Токен машинной записи здесь необязателен: не назвавшее его
451
455
  // дерево правит борду учётной записью, под которой залогинен клиент хостинга. Требование
452
456
  // токена держало бы очередь работ у дерева, машинной записи не заводившего, и у дерева, чью