@rt-tools/agent-kit 0.16.0 → 0.17.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 (106) hide show
  1. package/assets/checks/board-paths.github.mjs +88 -0
  2. package/assets/checks/board-runs.github.mjs +8 -0
  3. package/assets/checks/board-titles.github.mjs +66 -0
  4. package/assets/checks/check-board.github.mjs +17 -4
  5. package/assets/checks/check-file-size.mjs +10 -2
  6. package/assets/checks/check-glossary.mjs +170 -0
  7. package/assets/checks/check-push-gate.mjs +59 -1
  8. package/assets/checks/check-schema-drift.mjs +12 -5
  9. package/assets/checks/spec-contract.mjs +9 -0
  10. package/assets/defaults/gate-map.sh +13 -4
  11. package/assets/defaults/project.sh +22 -3
  12. package/assets/docs/GLOSSARY.md +1 -1
  13. package/assets/hooks/browser-device-id.sh +20 -4
  14. package/assets/hooks/browser-guard-device-id.sh +5 -2
  15. package/assets/hooks/git-guard-delivery-folder.sh +19 -0
  16. package/assets/hooks/git-guard-delivery.sh +39 -0
  17. package/assets/hooks/git-guard-push-tests.sh +21 -1
  18. package/assets/hooks/grill-gate-ask.sh +25 -0
  19. package/assets/hooks/grill-gate.sh +12 -5
  20. package/assets/hooks/lint-after-edit.sh +44 -16
  21. package/assets/hooks/proposal-guard.sh +17 -1
  22. package/assets/hooks/skill-gate-layers.sh +7 -0
  23. package/assets/hooks/skill-gate.sh +29 -0
  24. package/assets/hooks/task-context-load.sh +10 -0
  25. package/assets/hooks/task-flow-context.sh +185 -0
  26. package/assets/hooks/task-flow-draft-guard.sh +92 -0
  27. package/assets/hooks/task-flow-guard.sh +27 -159
  28. package/assets/hooks/turn-exit-guard.sh +8 -1
  29. package/assets/hooks/window-fill-guard.sh +5 -1
  30. package/assets/laws/delivery.md +19 -0
  31. package/assets/laws/frontend-application.md +4 -0
  32. package/assets/laws/verifiability.md +21 -0
  33. package/assets/laws/work-conduct.md +15 -0
  34. package/assets/patterns/browser-verification-stand.md +7 -2
  35. package/assets/patterns/doc-style-write.md +41 -1
  36. package/assets/patterns/git-workflow-commit.github.md +15 -2
  37. package/assets/patterns/git-workflow-docker.md +14 -0
  38. package/assets/patterns/git-workflow-merge.md +18 -0
  39. package/assets/patterns/git-workflow-migration.md +11 -0
  40. package/assets/patterns/git-workflow-pr.github.md +6 -1
  41. package/assets/patterns/git-workflow-secrets.md +14 -0
  42. package/assets/patterns/git-workflow-stack.md +63 -1
  43. package/assets/patterns/spec-driven-domain.md +34 -1
  44. package/assets/patterns/spec-driven-rule.md +11 -0
  45. package/assets/patterns/spec-driven-sweep.md +57 -0
  46. package/assets/patterns/task-flow-archive.md +46 -14
  47. package/assets/patterns/task-flow-close.md +78 -2
  48. package/assets/patterns/task-flow-resume.md +17 -5
  49. package/assets/patterns/task-flow-start.md +42 -4
  50. package/assets/pitfalls/agent-kit.md +73 -3
  51. package/assets/pitfalls/doc-style.md +5 -0
  52. package/assets/pitfalls/git-workflow.github.md +68 -0
  53. package/assets/pitfalls/spec-driven.md +16 -0
  54. package/assets/pitfalls/task-flow.md +93 -0
  55. package/assets/pitfalls/turn-conduct.md +14 -0
  56. package/assets/rules/browser-verification.md +39 -0
  57. package/assets/rules/deploy-flow.azure.md +8 -0
  58. package/assets/rules/deploy-flow.github.md +18 -0
  59. package/assets/rules/deploy-flow.gitlab.md +8 -0
  60. package/assets/rules/doc-style.md +14 -0
  61. package/assets/rules/git-workflow.azure.md +5 -0
  62. package/assets/rules/git-workflow.github.md +85 -75
  63. package/assets/rules/git-workflow.gitlab.md +5 -0
  64. package/assets/rules/reuse-first.md +8 -0
  65. package/assets/rules/shared-code.md +5 -0
  66. package/assets/rules/spec-driven.md +14 -0
  67. package/assets/rules/task-flow.md +112 -112
  68. package/assets/rules/testing.md +14 -1
  69. package/assets/rules/turn-conduct.md +40 -27
  70. package/assets/rules/turn-entry.md +6 -0
  71. package/assets/samples/tasks/_template/grill.md +5 -0
  72. package/assets/samples/tasks/_template/plan.md +3 -0
  73. package/assets/skills/agent-kit.md +99 -76
  74. package/assets/templates/postmortem.md +5 -1
  75. package/bin/agent-kit.d.ts.map +1 -1
  76. package/bin/agent-kit.js +30 -6
  77. package/bin/agent-kit.js.map +1 -1
  78. package/lib/catalog.d.ts.map +1 -1
  79. package/lib/catalog.js +2 -1
  80. package/lib/catalog.js.map +1 -1
  81. package/lib/commands.d.ts.map +1 -1
  82. package/lib/commands.js +96 -7
  83. package/lib/commands.js.map +1 -1
  84. package/lib/hooks-map.d.ts +26 -0
  85. package/lib/hooks-map.d.ts.map +1 -1
  86. package/lib/hooks-map.js +58 -2
  87. package/lib/hooks-map.js.map +1 -1
  88. package/lib/sections.d.ts +6 -0
  89. package/lib/sections.d.ts.map +1 -1
  90. package/lib/sections.js +19 -0
  91. package/lib/sections.js.map +1 -1
  92. package/lib/shipment.d.ts +2 -0
  93. package/lib/shipment.d.ts.map +1 -1
  94. package/lib/shipment.fixture.d.ts +5 -0
  95. package/lib/shipment.fixture.d.ts.map +1 -1
  96. package/lib/shipment.fixture.js +7 -0
  97. package/lib/shipment.fixture.js.map +1 -1
  98. package/lib/shipment.js +13 -1
  99. package/lib/shipment.js.map +1 -1
  100. package/lib/sync.d.ts +35 -3
  101. package/lib/sync.d.ts.map +1 -1
  102. package/lib/sync.js +59 -8
  103. package/lib/sync.js.map +1 -1
  104. package/package.json +1 -1
  105. package/rt-tools-agent-kit-0.17.0.tgz +0 -0
  106. package/rt-tools-agent-kit-0.16.0.tgz +0 -0
@@ -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
+ }
@@ -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 {
@@ -43,11 +43,14 @@ import {
43
43
  fetchIssues,
44
44
  fetchOpenPulls,
45
45
  gh,
46
+ ghJson,
46
47
  numberFromTaskDir,
47
48
  numberFromTitle,
48
49
  taskDirs,
49
50
  } from './board.mjs';
50
- import { deployLag, evictedOnHead, headCommittedAt, runsOnHead, verdictOnHead } from './board-runs.mjs';
51
+ import { onlyIgnoredPaths } from './board-paths.mjs';
52
+ import { HAS_PIPELINE, deployLag, evictedOnHead, headCommittedAt, runsOnHead, verdictOnHead } from './board-runs.mjs';
53
+ import { similarTitles } from './board-titles.mjs';
51
54
  import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
52
55
 
53
56
  const IN_REVIEW = STATUS_OPTIONS[IN_REVIEW_STATUS].name;
@@ -63,8 +66,6 @@ const RUN_GRACE_MINUTES = 10;
63
66
  * Конвейер дерева. Прогоны спрашиваются только там, где ему есть откуда взяться: дерево без
64
67
  * конвейера получило бы строку на каждый открытый PR, и не о чём.
65
68
  */
66
- const PIPELINE = CONFIG.pushGate?.pipelineFile ?? '';
67
- const HAS_PIPELINE = PIPELINE !== '' && existsSync(join(ROOT, PIPELINE));
68
69
  /**
69
70
  * Рабочий поток выкатки и ветка, с которой прод сравнивают. Не назвав потока, дерево сверки
70
71
  * прода не получает — и сверка говорит об этом вслух: молчание читалось бы как «прод сошёлся».
@@ -94,6 +95,12 @@ function checkDrafts() {
94
95
  }
95
96
  }
96
97
 
98
+ /**
99
+ * Значимые слова заголовка: ключ задачи и короткие служебные слова выброшены.
100
+ *
101
+ * Разбор нарочно грубый — совпадение слов здесь не приговор, а повод посмотреть: точное
102
+ * сравнение заголовков не находит ничего, потому что дубль пишут другими словами.
103
+ */
97
104
  function closesNumbers(body) {
98
105
  return [...String(body ?? '').matchAll(/\bCloses\s+#(\d+)\b/gi)].map((match) => Number(match[1]));
99
106
  }
@@ -160,6 +167,10 @@ function checkHeadRun(pull, options) {
160
167
  return;
161
168
  }
162
169
 
170
+ if (onlyIgnoredPaths(pull, options)) {
171
+ return;
172
+ }
173
+
163
174
  if (pull.mergeable === 'CONFLICTING') {
164
175
  report(
165
176
  `PR #${pull.number}: на вершине ${pull.headRefOid.slice(0, 8)} прогона нет и не будет, пока она конфликтует — ` +
@@ -286,6 +297,8 @@ try {
286
297
  }
287
298
  }
288
299
 
300
+ similarTitles(open);
301
+
289
302
  const openNumbers = new Set(open.map((issue) => issue.number));
290
303
  const claimed = new Map();
291
304
  for (const pull of pulls) {
@@ -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());
@@ -18,7 +18,9 @@
18
18
  * `rt_push_checks` профиля; она сверяется с его выводом, потому что объявление
19
19
  * без исполнения — та же дыра. Исключение — причина, по которой шага в гейте
20
20
  * нет; пустая причина исключением не считается: она единственное, чем
21
- * постоянная дыра отличается от забытой строки.
21
+ * постоянная дыра отличается от забытой строки. Причина, называющая задачу,
22
+ * судится ещё и на живость этой задачи: отсрочка со сроком и отсрочка без срока
23
+ * выглядят одинаково, пока номер никто не спросил.
22
24
  *
23
25
  * Дерево без файла конвейера сверки не получает: проверка, падающая там, где
24
26
  * конвейера нет, отбивала бы работу вместо промаха.
@@ -34,6 +36,8 @@ import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
34
36
  const GATE = CONFIG.pushGate ?? {};
35
37
  const PIPELINE = GATE.pipelineFile ?? '';
36
38
  const DECLARED = GATE.steps ?? {};
39
+ /** Ключ задач дерева: по нему номер в причине отличается от версии, порта и года. */
40
+ const TASK_KEY = CONFIG.board?.taskKey ?? '';
37
41
 
38
42
  /**
39
43
  * Профиль дерева ищется той же цепочкой, что и у гарда пуша: умолчание пакета, поверх него
@@ -85,9 +89,39 @@ rt_push_checks ""`;
85
89
  }
86
90
  }
87
91
 
92
+ /**
93
+ * Номера задач, названные причиной. Ключ дерева обязателен: без него «663» неотличимо от порта,
94
+ * года и номера редакции, и проверка спрашивала бы очередь работ обо всём подряд.
95
+ */
96
+ function taskNumbers(reason) {
97
+ if (!TASK_KEY) {
98
+ return [];
99
+ }
100
+ return [...reason.matchAll(new RegExp(`\\b${TASK_KEY}-(\\d+)\\b`, 'g'))].map((match) => Number(match[1]));
101
+ }
102
+
103
+ /**
104
+ * Очередь работ ищется разрешением модуля, а не собирается здесь: у каждого хостинга она своя, и
105
+ * дерево, у которого её нет, судится как прежде. Ярус тот же, что у гарда поставки: есть чем
106
+ * спросить — спрашивает, нет — молчит.
107
+ */
108
+ async function boardModule() {
109
+ if (!TASK_KEY) {
110
+ return null;
111
+ }
112
+ try {
113
+ const module = await import('./board.mjs');
114
+ return typeof module.taskState === 'function' ? module : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
88
120
  const steps = pipelineSteps();
89
121
  const checks = gateChecks();
90
122
  const problems = [];
123
+ /** Задачи, названные причинами исключений: спрашиваются пачкой после разбора всех шагов. */
124
+ const deferrals = [];
91
125
 
92
126
  for (const step of steps) {
93
127
  const declaration = DECLARED[step];
@@ -103,6 +137,10 @@ for (const step of steps) {
103
137
  const reason = String(declaration.skip ?? '').trim();
104
138
  if (!reason) {
105
139
  problems.push(`шаг конвейера «${step}» объявлен исключением без причины — пустая причина не считается`);
140
+ continue;
141
+ }
142
+ for (const number of taskNumbers(reason)) {
143
+ deferrals.push({ step, number });
106
144
  }
107
145
  continue;
108
146
  }
@@ -126,6 +164,26 @@ for (const step of Object.keys(DECLARED)) {
126
164
  }
127
165
  }
128
166
 
167
+ /**
168
+ * Мёртвый номер в причине делает исключение бессрочным, не сказав об этом ни строкой. Первый же
169
+ * отказ сети кончает опрос целиком: спрашивать остальные незачем, а падать проверке, которую
170
+ * гоняют в самолёте, — тем более.
171
+ */
172
+ const board = deferrals.length > 0 ? await boardModule() : null;
173
+ if (board) {
174
+ for (const { step, number } of deferrals) {
175
+ let state;
176
+ try {
177
+ state = board.taskState(number);
178
+ } catch {
179
+ break;
180
+ }
181
+ if (state && state.exists === false) {
182
+ problems.push(`шаг конвейера «${step}» отложен до задачи ${TASK_KEY}-${number}, а такой задачи в очереди работ нет — отсрочка бессрочная`);
183
+ }
184
+ }
185
+ }
186
+
129
187
  if (problems.length > 0) {
130
188
  console.error(`check-push-gate: расхождений ${problems.length}\n`);
131
189
  problems.forEach((problem) => console.error(` ${problem}`));
@@ -76,6 +76,13 @@ function shadowAddresses(url) {
76
76
  return { shadowName, shadowUrl: shadow.toString(), serviceUrl: service.toString() };
77
77
  }
78
78
 
79
+ // Код, которым проверка объявляет, что смотреть было не на что. Прежде каждый такой выход был
80
+ // нулём: строка о пропуске уходила в вывод, а в сводке гейта пуша ноль стоял рядом с
81
+ // пройденными проверками и ничем от них не отличался — набор читался как проверенный целиком.
82
+ // Число знает и гард пуша: он называет пропущенное вслух, не отбивая пуш, потому что проверка,
83
+ // которой нечего смотреть, поломкой не является.
84
+ const SKIP = Number(process.env.RT_SKIP_CODE ?? 7);
85
+
79
86
  function prisma(args, url) {
80
87
  return spawnSync('npx', ['prisma', ...args], {
81
88
  cwd: ROOT,
@@ -98,7 +105,7 @@ async function withServiceClient(serviceUrl, run) {
98
105
  if (SERVER_DOWN_CODES.includes(error?.code)) {
99
106
  console.log('check-schema-drift: база недоступна — сверять негде');
100
107
 
101
- return 0;
108
+ return SKIP;
102
109
  }
103
110
  throw error;
104
111
  }
@@ -117,26 +124,26 @@ async function main() {
117
124
  if (!CONFIG.schemaFile) {
118
125
  console.log('check-schema-drift: имя файла схемы не задано — сверять нечего');
119
126
 
120
- return 0;
127
+ return SKIP;
121
128
  }
122
129
 
123
130
  if (!existsSync(join(ROOT, CONFIG.schemaFile))) {
124
131
  console.log('check-schema-drift: схемы нет — сверять нечего');
125
132
 
126
- return 0;
133
+ return SKIP;
127
134
  }
128
135
 
129
136
  const url = databaseUrl();
130
137
  if (!url) {
131
138
  console.log('check-schema-drift: адрес базы не задан — сверять негде');
132
139
 
133
- return 0;
140
+ return SKIP;
134
141
  }
135
142
 
136
143
  if (PRODUCTION_MARKS.some((mark) => url.includes(mark))) {
137
144
  console.log('check-schema-drift: адрес боевой — проверка туда не ходит');
138
145
 
139
- return 0;
146
+ return SKIP;
140
147
  }
141
148
 
142
149
  const { shadowName, shadowUrl, serviceUrl } = shadowAddresses(url);
@@ -64,6 +64,15 @@ function contractRows(text) {
64
64
  continue;
65
65
  }
66
66
  const permission = (cells[1].match(/`([^`]+)`/) || [])[1] || cells[1];
67
+ // Строка с кодом отказа во второй ячейке процедурой не считается. Раздел «Контракта»
68
+ // держит две разные таблицы: список процедур с правами и список кодов отказа с поводами,
69
+ // — и по форме строки они неотличимы, обе несут значение в кавычках первой ячейкой.
70
+ // Пока разбор брал любую, спек с объявленными процедурами получал свою таблицу кодов
71
+ // прочитанной как список процедур: каждая её строка становилась процедурой, которой в
72
+ // домене нет.
73
+ if (/^\d{3}$/.test(permission.trim())) {
74
+ continue;
75
+ }
67
76
  rows.push({ line: index, name, permission: permission.trim(), short: name.split('.').pop() });
68
77
  }
69
78
 
@@ -40,6 +40,12 @@ skill_for_default() {
40
40
  case "$kind" in
41
41
  edit)
42
42
  case "$target" in
43
+ # Собранное дерево кодом не бывает, и правкой его никто не занимается: путь к
44
+ # артефакту приходит из команды, которая его ЗАПУСКАЕТ. Под артефактом лежат те
45
+ # же имена каталогов, что и под исходником, поэтому ветка стоит первой — любая
46
+ # ниже совпала бы с ним и потребовала правило, которое работе ничего не даёт.
47
+ */node_modules/* | */dist/* | */build/* | */.nx/* | */coverage/*) return 0 ;;
48
+
43
49
  # Надстройка над разложенным текстом — правка того же текста: путь другой, а
44
50
  # предмет тот же. Ветка стоит первой, потому что путь надстройки кончается тем
45
51
  # же именем файла, что и разложенная копия, и ветки ниже разобрали бы её по
@@ -93,10 +99,13 @@ skill_for_default() {
93
99
  # Своё правило линтера кода пишется по тем же соглашениям, что и код под ним.
94
100
  */eslint-rules/*) printf '%s\n' 'typescript-conventions' ;;
95
101
 
96
- # Схема хранилища и её миграции: порядок каталогов лексикографический, а метку
97
- # времени ставит инструмент в момент заведения — цепочка ломается молча и падает
98
- # только накатом с нуля, то есть уже после слияния. Правило живёт при поставке.
99
- */schema.prisma | */prisma/migrations/*) printf '%s\n' 'git-workflow' ;;
102
+ # Схема хранилища, её миграции и настройка клиента: порядок каталогов
103
+ # лексикографический, а метку времени ставит инструмент в момент заведения —
104
+ # цепочка ломается молча и падает только накатом с нуля, то есть уже после
105
+ # слияния. Настройка клиента стоит здесь же: её читает тот же накат, а правилась
106
+ # она без правила под рукой. Правило живёт при поставке.
107
+ */schema.prisma | */prisma/migrations/* | */prisma.config.ts)
108
+ printf '%s\n' 'git-workflow' ;;
100
109
  # Конвейер и образ: проверки решают, что вообще гоняется до слияния, а образ —
101
110
  # что приезжает на прод. И то и другое правилось без единого правила поставки.
102
111
  */.github/workflows/*.yml | */.gitlab-ci.yml | */azure-pipelines*.yml)