@rt-tools/agent-kit 0.16.1 → 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.
- package/assets/checks/board-paths.github.mjs +88 -0
- package/assets/checks/board-runs.github.mjs +8 -0
- package/assets/checks/board-titles.github.mjs +66 -0
- package/assets/checks/check-board.github.mjs +17 -4
- package/assets/checks/check-file-size.mjs +10 -2
- package/assets/checks/check-glossary.mjs +170 -0
- package/assets/checks/check-push-gate.mjs +59 -1
- package/assets/checks/check-schema-drift.mjs +12 -5
- package/assets/checks/spec-contract.mjs +9 -0
- package/assets/defaults/gate-map.sh +13 -4
- package/assets/defaults/project.sh +22 -3
- package/assets/docs/GLOSSARY.md +1 -1
- package/assets/hooks/browser-device-id.sh +20 -4
- package/assets/hooks/browser-guard-device-id.sh +5 -2
- package/assets/hooks/git-guard-delivery-folder.sh +19 -0
- package/assets/hooks/git-guard-delivery.sh +39 -0
- package/assets/hooks/git-guard-push-tests.sh +21 -1
- package/assets/hooks/grill-gate-ask.sh +25 -0
- package/assets/hooks/grill-gate.sh +12 -5
- package/assets/hooks/lint-after-edit.sh +44 -16
- package/assets/hooks/skill-gate-layers.sh +7 -0
- package/assets/hooks/skill-gate.sh +29 -0
- package/assets/hooks/task-context-load.sh +10 -0
- package/assets/hooks/task-flow-context.sh +185 -0
- package/assets/hooks/task-flow-draft-guard.sh +92 -0
- package/assets/hooks/task-flow-guard.sh +27 -159
- package/assets/hooks/turn-exit-guard.sh +8 -1
- package/assets/hooks/window-fill-guard.sh +5 -1
- package/assets/laws/delivery.md +19 -0
- package/assets/laws/frontend-application.md +4 -0
- package/assets/laws/verifiability.md +21 -0
- package/assets/laws/work-conduct.md +15 -0
- package/assets/patterns/browser-verification-stand.md +7 -2
- package/assets/patterns/doc-style-write.md +41 -1
- package/assets/patterns/git-workflow-commit.github.md +15 -2
- package/assets/patterns/git-workflow-docker.md +14 -0
- package/assets/patterns/git-workflow-merge.md +18 -0
- package/assets/patterns/git-workflow-migration.md +11 -0
- package/assets/patterns/git-workflow-pr.github.md +6 -1
- package/assets/patterns/git-workflow-secrets.md +14 -0
- package/assets/patterns/git-workflow-stack.md +63 -1
- package/assets/patterns/spec-driven-domain.md +34 -1
- package/assets/patterns/spec-driven-rule.md +11 -0
- package/assets/patterns/spec-driven-sweep.md +57 -0
- package/assets/patterns/task-flow-archive.md +46 -14
- package/assets/patterns/task-flow-close.md +78 -2
- package/assets/patterns/task-flow-resume.md +17 -5
- package/assets/patterns/task-flow-start.md +42 -4
- package/assets/pitfalls/agent-kit.md +73 -3
- package/assets/pitfalls/doc-style.md +5 -0
- package/assets/pitfalls/git-workflow.github.md +68 -0
- package/assets/pitfalls/spec-driven.md +16 -0
- package/assets/pitfalls/task-flow.md +93 -0
- package/assets/pitfalls/turn-conduct.md +14 -0
- package/assets/rules/browser-verification.md +39 -0
- package/assets/rules/deploy-flow.azure.md +8 -0
- package/assets/rules/deploy-flow.github.md +18 -0
- package/assets/rules/deploy-flow.gitlab.md +8 -0
- package/assets/rules/doc-style.md +14 -0
- package/assets/rules/git-workflow.azure.md +5 -0
- package/assets/rules/git-workflow.github.md +85 -75
- package/assets/rules/git-workflow.gitlab.md +5 -0
- package/assets/rules/reuse-first.md +8 -0
- package/assets/rules/shared-code.md +5 -0
- package/assets/rules/spec-driven.md +14 -0
- package/assets/rules/task-flow.md +112 -112
- package/assets/rules/testing.md +14 -1
- package/assets/rules/turn-conduct.md +40 -27
- package/assets/rules/turn-entry.md +6 -0
- package/assets/samples/tasks/_template/grill.md +5 -0
- package/assets/samples/tasks/_template/plan.md +3 -0
- package/assets/skills/agent-kit.md +99 -76
- package/assets/templates/postmortem.md +5 -1
- package/bin/agent-kit.d.ts.map +1 -1
- package/bin/agent-kit.js +30 -6
- package/bin/agent-kit.js.map +1 -1
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +2 -1
- package/lib/catalog.js.map +1 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +96 -7
- package/lib/commands.js.map +1 -1
- package/lib/hooks-map.d.ts +26 -0
- package/lib/hooks-map.d.ts.map +1 -1
- package/lib/hooks-map.js +58 -2
- package/lib/hooks-map.js.map +1 -1
- package/lib/sections.d.ts +6 -0
- package/lib/sections.d.ts.map +1 -1
- package/lib/sections.js +19 -0
- package/lib/sections.js.map +1 -1
- package/lib/shipment.d.ts +2 -0
- package/lib/shipment.d.ts.map +1 -1
- package/lib/shipment.fixture.d.ts +5 -0
- package/lib/shipment.fixture.d.ts.map +1 -1
- package/lib/shipment.fixture.js +7 -0
- package/lib/shipment.fixture.js.map +1 -1
- package/lib/shipment.js +13 -1
- package/lib/shipment.js.map +1 -1
- package/lib/sync.d.ts +35 -3
- package/lib/sync.d.ts.map +1 -1
- package/lib/sync.js +59 -8
- package/lib/sync.js.map +1 -1
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.17.0.tgz +0 -0
- package/rt-tools-agent-kit-0.16.1.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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|