@rt-tools/agent-kit 0.4.0 → 0.5.1
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/README.md +5 -0
- package/assets/checks/board.github.mjs +45 -2
- package/assets/checks/check-board.github.mjs +7 -14
- package/assets/checks/check-doc-paths.mjs +200 -30
- package/assets/checks/check-specs.mjs +131 -17
- package/assets/checks/rt-kit-checks.config.mjs +12 -0
- package/assets/commands/next-session.md +122 -0
- package/assets/defaults/gate-map.sh +29 -3
- package/assets/defaults/project.sh +50 -0
- package/assets/docs/GLOSSARY.md +74 -0
- package/assets/hooks/git-guard-delivery.sh +87 -4
- package/assets/hooks/grill-gate.sh +96 -0
- package/assets/hooks/task-flow-guard.sh +14 -3
- package/assets/hooks/window-fill-guard.sh +150 -0
- package/assets/laws/code-structure.md +10 -0
- package/assets/laws/delivery.md +8 -1
- package/assets/laws/project-documentation.md +14 -0
- package/assets/laws/verifiability.md +13 -0
- package/assets/laws/work-conduct.md +19 -0
- package/assets/patterns/git-workflow-commit.github.md +4 -0
- package/assets/patterns/spec-driven-domain.md +35 -0
- package/assets/patterns/task-flow-close.md +72 -8
- package/assets/patterns/task-flow-handoff.md +115 -0
- package/assets/patterns/task-flow-resume.md +2 -2
- package/assets/patterns/task-flow-start.md +18 -2
- package/assets/rules/angular-patterns.md +4 -0
- package/assets/rules/browser-verification.md +4 -3
- package/assets/rules/doc-style.md +53 -1
- package/assets/rules/git-workflow.azure.md +24 -0
- package/assets/rules/git-workflow.github.md +23 -0
- package/assets/rules/git-workflow.gitlab.md +23 -0
- package/assets/rules/spec-driven.md +19 -1
- package/assets/rules/task-flow.md +88 -2
- package/assets/skills/agent-kit.md +4 -0
- package/assets/templates/rule.md +1 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +2 -1
- package/lib/commands.js.map +1 -1
- package/lib/config.d.ts +3 -1
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +2 -0
- package/lib/config.js.map +1 -1
- package/lib/hooks-map.d.ts +20 -5
- package/lib/hooks-map.d.ts.map +1 -1
- package/lib/hooks-map.js +56 -15
- package/lib/hooks-map.js.map +1 -1
- package/lib/sync.d.ts.map +1 -1
- package/lib/sync.js +2 -5
- package/lib/sync.js.map +1 -1
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.5.1.tgz +0 -0
- package/rt-tools-agent-kit-0.4.0.tgz +0 -0
package/README.md
CHANGED
|
@@ -260,6 +260,11 @@ npx agent-kit init --laws access,delivery,verifiability
|
|
|
260
260
|
`skill-curator` приезжает и слеш-командой: она собирает сводку о задаче и список загруженных
|
|
261
261
|
правил, без которых разбор выродится в пересказ.
|
|
262
262
|
|
|
263
|
+
Вторая команда, `next-session`, закрывает заход: приводит дерево к главной ветке — переходом на
|
|
264
|
+
неё, если работа шла по правилу и отчёт влит, и вливанием в текущую ветку во всех прочих
|
|
265
|
+
случаях, — снимает влитые локальные ветки, называет невлитые и пишет передачу для следующего
|
|
266
|
+
захода. Незакоммиченная правка её останавливает до первого действия; поставки она не касается.
|
|
267
|
+
|
|
263
268
|
## Как этим пользуются в дереве
|
|
264
269
|
|
|
265
270
|
Всё, что выше, пакет объясняет человеку. Агенту то же самое объясняет скил `agent-kit` —
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
* функции возвращают `null`, командный режим печатает `{"offline":true}`.
|
|
20
20
|
*/
|
|
21
21
|
import { execFileSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
23
23
|
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
24
25
|
|
|
25
|
-
import { CONFIG } from './rt-kit-checks.config.mjs';
|
|
26
|
+
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Адрес борды и её колонки живут в `.claude/rt-kit/checks.json`: идентификаторы проекта, поля
|
|
@@ -218,6 +219,48 @@ export function numberFromTitle(title) {
|
|
|
218
219
|
return match ? Number(match[1]) : null;
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Номер задачи по имени папки. Ключ впереди необязателен: имя ветки вида `chore/312-slug`
|
|
224
|
+
* тоже законно, и папка под ним называется голым числом. Если сверка не распознает в имени
|
|
225
|
+
* номер, папка будет лежать среди текущих сколько угодно — одну такую нашли грепом, а не
|
|
226
|
+
* проверкой.
|
|
227
|
+
*/
|
|
228
|
+
export function numberFromTaskDir(name) {
|
|
229
|
+
// Ключ подставляем, только если дерево его задало: из пустого получилось бы `^(?:-)?`, и
|
|
230
|
+
// папка с ключом в имени вообще перестала бы распознаваться.
|
|
231
|
+
const prefix = TASK_KEY ? `(?:${TASK_KEY}-)?` : '';
|
|
232
|
+
const match = new RegExp(`^${prefix}(\\d+)-`).exec(name ?? '');
|
|
233
|
+
return match ? Number(match[1]) : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Папки задач, включая вложенные. Путь повторяет имя ветки целиком, вместе с косой, поэтому
|
|
238
|
+
* папка ветки `chore/312-slug` лежит на втором уровне — обход только по верхнему её не видит.
|
|
239
|
+
*
|
|
240
|
+
* Вглубь спускаемся ровно на один уровень: в имени ветки одна косая, а всё, что глубже, папкой
|
|
241
|
+
* задачи уже не будет — зато туда попал бы архив, если дерево держит его внутри.
|
|
242
|
+
*/
|
|
243
|
+
export function taskDirs(dir = join(ROOT, CONFIG.tasksDir), prefix = '') {
|
|
244
|
+
if (!existsSync(dir)) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
const found = [];
|
|
248
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
249
|
+
if (!entry.isDirectory() || entry.name === '_template') {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
253
|
+
if (entry.name.startsWith('_draft-') || numberFromTaskDir(entry.name) !== null) {
|
|
254
|
+
found.push(name);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (!prefix) {
|
|
258
|
+
found.push(...taskDirs(join(dir, entry.name), name));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return found;
|
|
262
|
+
}
|
|
263
|
+
|
|
221
264
|
export function numberFromBranch(branch) {
|
|
222
265
|
const match = BRANCH_NUMBER.exec(branch ?? '');
|
|
223
266
|
return match ? Number(match[1]) : null;
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Ненулевой код возврата и перечень расхождений.
|
|
26
26
|
*/
|
|
27
|
-
import {
|
|
27
|
+
import { statSync } from 'node:fs';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
29
|
|
|
30
30
|
import {
|
|
@@ -36,7 +36,9 @@ import {
|
|
|
36
36
|
fetchBoard,
|
|
37
37
|
fetchIssues,
|
|
38
38
|
fetchOpenPulls,
|
|
39
|
+
numberFromTaskDir,
|
|
39
40
|
numberFromTitle,
|
|
41
|
+
taskDirs,
|
|
40
42
|
} from './board.mjs';
|
|
41
43
|
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
42
44
|
|
|
@@ -48,15 +50,6 @@ const DRAFT_DAYS = 7;
|
|
|
48
50
|
const problems = [];
|
|
49
51
|
const report = (message) => problems.push(message);
|
|
50
52
|
|
|
51
|
-
function taskDirs() {
|
|
52
|
-
if (!existsSync(TASKS_DIR)) {
|
|
53
|
-
return [];
|
|
54
|
-
}
|
|
55
|
-
return readdirSync(TASKS_DIR, { withFileTypes: true })
|
|
56
|
-
.filter((entry) => entry.isDirectory() && entry.name !== '_template')
|
|
57
|
-
.map((entry) => entry.name);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
53
|
/**
|
|
61
54
|
* Разбор просьбы владельца идёт до заведения задачи и лежит в `_draft-<slug>`. Заглохший на
|
|
62
55
|
* середине, он остаётся на диске вне истории и выглядит так же, как начатый сегодня: второй
|
|
@@ -71,7 +64,7 @@ function checkDrafts() {
|
|
|
71
64
|
}
|
|
72
65
|
const age = Math.floor((now - statSync(join(TASKS_DIR, name)).mtimeMs) / 86400000);
|
|
73
66
|
if (age >= DRAFT_DAYS) {
|
|
74
|
-
report(
|
|
67
|
+
report(`${CONFIG.tasksDir}/${name}/: разбор брошен ${age} дн. назад — заведи задачу или удали папку`);
|
|
75
68
|
}
|
|
76
69
|
}
|
|
77
70
|
}
|
|
@@ -153,12 +146,12 @@ try {
|
|
|
153
146
|
// в `docs/archive/`, остальное удаляется. Оставленная рядом с текущими, она читается как
|
|
154
147
|
// текущая — тем убедительнее, чем старше.
|
|
155
148
|
for (const name of taskDirs()) {
|
|
156
|
-
const number =
|
|
157
|
-
if (
|
|
149
|
+
const number = numberFromTaskDir(name.split('/').pop());
|
|
150
|
+
if (number === null || openNumbers.has(number)) {
|
|
158
151
|
continue;
|
|
159
152
|
}
|
|
160
153
|
if (issues.some((issue) => issue.number === number)) {
|
|
161
|
-
report(
|
|
154
|
+
report(`${CONFIG.tasksDir}/${name}/: задача #${number} закрыта, а папка лежит среди текущих — разбери её`);
|
|
162
155
|
}
|
|
163
156
|
}
|
|
164
157
|
} catch (error) {
|
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Проверка того, что
|
|
3
|
+
* Проверка того, что адреса, названные в документации, существуют.
|
|
4
4
|
*
|
|
5
5
|
* Документ, ссылающийся на исчезнувший файл, хуже отсутствующего: он выглядит
|
|
6
6
|
* действующей справкой и уводит читателя в каталог, которого нет. Накапливается
|
|
7
7
|
* это молча — перекладка дерева правит код и ломает текст, а текст никто не
|
|
8
|
-
* собирает.
|
|
9
|
-
* не существовало 80.
|
|
8
|
+
* собирает.
|
|
10
9
|
*
|
|
11
|
-
* Считаются только
|
|
12
|
-
* команды и вывод, где
|
|
13
|
-
* репозитория. Шаблоны (`*`, `<…>`, `{…}`) пропускаются: это форма
|
|
10
|
+
* Считаются только адреса в обратных кавычках и вне блоков кода: в блоках лежат
|
|
11
|
+
* команды и вывод, где путь до собранного — результат сборки, а не файл
|
|
12
|
+
* репозитория. Шаблоны (`*`, `<…>`, `{…}`) пропускаются: это форма адреса, а не адрес.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Адрес бывает трёх родов, и все три судятся одинаково: укоренённый в дереве путь,
|
|
15
|
+
* голое имя файла и каталог. Каталогами занята половина таблиц «Где это лежит», и
|
|
16
|
+
* проверка, знающая только строку с расширением, их не видит вовсе.
|
|
17
|
+
*
|
|
18
|
+
* Документы, которые по устройству говорят о несуществующем — планы будущего, архив
|
|
19
|
+
* и папки задач, — из проверки выведены. Там же переносимый текст: его адреса
|
|
20
|
+
* принадлежат тому дереву, куда правило ложится, и в этом они примеры, а не ссылки.
|
|
21
|
+
*
|
|
22
|
+
* Вторым заходом сверяется полнота указателя каталога: обзорный документ перечисляет
|
|
23
|
+
* записи таблицей, и читатель ищет по ней, а не обходом. Это обратная сторона той же
|
|
24
|
+
* договорённости — не только адрес из текста ведёт в файл, но и файл назван в тексте,
|
|
25
|
+
* по которому его ищут.
|
|
17
26
|
*
|
|
18
27
|
* Ненулевой код возврата и перечень расхождений.
|
|
19
28
|
*/
|
|
@@ -24,20 +33,47 @@ import { join } from 'node:path';
|
|
|
24
33
|
import { allowlistOf, CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
25
34
|
|
|
26
35
|
const ALLOWLIST = allowlistOf('doc-paths');
|
|
27
|
-
// `worktrees` — копии репозитория под
|
|
28
|
-
// ветки, а проверка ищет
|
|
29
|
-
// красный сквозной прогон на ветке, которая её не заводила.
|
|
36
|
+
// `worktrees` — копии репозитория под каталогом агента: их документы описывают раскладку
|
|
37
|
+
// своей ветки, а проверка ищет адреса в дереве текущей. Одна брошенная копия дала 73
|
|
38
|
+
// расхождения и красный сквозной прогон на ветке, которая её не заводила.
|
|
30
39
|
const SKIPPED_DIRS = CONFIG.skippedDirs;
|
|
31
40
|
/**
|
|
32
|
-
* Архив описывает раскладку, бывшую на момент записи. Править в нём
|
|
41
|
+
* Архив описывает раскладку, бывшую на момент записи. Править в нём адреса — значит
|
|
33
42
|
* переписывать историю задним числом, поэтому он выведен из проверки целиком.
|
|
34
43
|
*/
|
|
35
44
|
const ARCHIVE_DIR = CONFIG.archiveDir;
|
|
36
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Папка задачи описывает ход работы, и снятое она называет по имени: раздел находок
|
|
47
|
+
* перечисляет ровно то, чего в дереве нет. Отличить такое упоминание от ссылки машине
|
|
48
|
+
* нечем, а живёт папка до слияния — поэтому она выведена из проверки, как архив.
|
|
49
|
+
*/
|
|
50
|
+
const TASKS_DIR = CONFIG.tasksDir.endsWith('/') ? CONFIG.tasksDir : `${CONFIG.tasksDir}/`;
|
|
51
|
+
/**
|
|
52
|
+
* Каталоги, чей указатель сверяется с содержимым. Каталог, выведенный из проверки адресов,
|
|
53
|
+
* иначе не судит ничто: запись, приехавшая слиянием соседней ветки, остаётся неназванной, а
|
|
54
|
+
* читатель ищет по указателю. Сверенный руками указатель расходится снова через сутки.
|
|
55
|
+
*/
|
|
56
|
+
const INDEXED_DIRS = (CONFIG.indexedDirs ?? []).map((dir) => (dir.endsWith('/') ? dir : `${dir}/`));
|
|
57
|
+
/**
|
|
58
|
+
* Исходники переносимых текстов: правило, которое ложится в другое дерево, называет адреса
|
|
59
|
+
* того дерева. Разложенная копия узнаётся по шапке, а исходник шапки не несёт — её ставит
|
|
60
|
+
* раскладка, — поэтому его каталог называется настройкой.
|
|
61
|
+
*/
|
|
62
|
+
const PORTABLE_DIRS = (CONFIG.portableDirs ?? []).map((dir) => (dir.endsWith('/') ? dir : `${dir}/`));
|
|
63
|
+
/** Шапка разложенного файла: версия пакета, ресурс и сумма тела. */
|
|
64
|
+
const STAMP_LINE = /rt-kit\s+v\S+\s+·\s+\S+\s+·\s+[0-9a-f]{12}/;
|
|
65
|
+
/** Шапка встаёт первой строкой тела, а тело начинается после вступления скила. */
|
|
66
|
+
const STAMP_LOOKAHEAD = 12;
|
|
67
|
+
/** Расширения, по которым голое имя считается файлом, а не именем сущности */
|
|
37
68
|
const EXTENSIONS = 'ts|mts|cts|js|mjs|cjs|json|jsonc|scss|css|html|proto|conf|ya?ml|sh|md|sql|txt|xml|svg|webp|png|ico|env|Dockerfile|lock';
|
|
38
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Берётся любая строка в кавычках: каталог расширения не несёт, и требовать его в самой
|
|
71
|
+
* выборке значило бы не видеть половину таблиц «Где это лежит». Отсев — в `looksLikePath`.
|
|
72
|
+
*/
|
|
73
|
+
const PATH_IN_BACKTICKS = /`([^`\n]+?)`/g;
|
|
39
74
|
|
|
40
75
|
const problems = [];
|
|
76
|
+
const indexProblems = [];
|
|
41
77
|
const report = (doc, line, path) => problems.push(`${doc}:${line}: нет файла \`${path}\``);
|
|
42
78
|
|
|
43
79
|
function readAllowlist() {
|
|
@@ -71,9 +107,9 @@ function collectDocs(dir = '.') {
|
|
|
71
107
|
|
|
72
108
|
/**
|
|
73
109
|
* Документы, которые в репозиторий не попадут: личный черновик, лежащий в дереве и
|
|
74
|
-
* закрытый
|
|
75
|
-
*
|
|
76
|
-
*
|
|
110
|
+
* закрытый настройкой неотслеживаемого. Проверка судит репозиторий, а не рабочий стол того,
|
|
111
|
+
* кто её запустил: мёртвая ссылка в чужом черновике держала гейт пуша, хотя ни в одну ветку
|
|
112
|
+
* этот файл не едет.
|
|
77
113
|
*/
|
|
78
114
|
function droppedByGit(docs) {
|
|
79
115
|
if (docs.length === 0) {
|
|
@@ -91,12 +127,7 @@ function droppedByGit(docs) {
|
|
|
91
127
|
return new Set((ignored.stdout ?? '').split('\n').filter(Boolean));
|
|
92
128
|
}
|
|
93
129
|
|
|
94
|
-
/**
|
|
95
|
-
* Верхний уровень дерева. Проверяются только адреса, укоренённые в нём: `src/index.ts`
|
|
96
|
-
* в правиле означает «любой файл такого вида», а `services/theme/theme.service.ts` —
|
|
97
|
-
* обрывок чужого пути. Ни то, ни другое адресом не является, и требовать их
|
|
98
|
-
* существования значит ловить форму записи вместо ссылки.
|
|
99
|
-
*/
|
|
130
|
+
/** Верхний уровень дерева: по нему узнаётся адрес, укоренённый в репозитории */
|
|
100
131
|
const ROOTED_IN = new Set(
|
|
101
132
|
readdirSync(ROOT, { withFileTypes: true })
|
|
102
133
|
.filter((entry) => entry.isDirectory() && !SKIPPED_DIRS.includes(entry.name))
|
|
@@ -104,18 +135,138 @@ const ROOTED_IN = new Set(
|
|
|
104
135
|
);
|
|
105
136
|
|
|
106
137
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
138
|
+
* Дерево спрашивается у системы контроля версий, а не обходом каталогов: каталоги агента и
|
|
139
|
+
* конвейера начинаются с точки, и обход мимо них проходит молча — всё, что в них лежит,
|
|
140
|
+
* читалось бы как несуществующее. Неотслеживаемое берётся вместе с отслеживаемым: файл,
|
|
141
|
+
* заведённый этой же веткой и ещё не добавленный, существует ничуть не меньше.
|
|
142
|
+
*/
|
|
143
|
+
function treeOfRepo() {
|
|
144
|
+
const listed = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], {
|
|
145
|
+
cwd: ROOT,
|
|
146
|
+
encoding: 'utf8',
|
|
147
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
148
|
+
});
|
|
149
|
+
const paths = (listed.stdout ?? '').split('\n').filter(Boolean);
|
|
150
|
+
const files = new Set(paths);
|
|
151
|
+
const dirs = new Set();
|
|
152
|
+
const byName = new Map();
|
|
153
|
+
|
|
154
|
+
for (const path of paths) {
|
|
155
|
+
const segments = path.split('/');
|
|
156
|
+
for (let depth = 1; depth < segments.length; depth += 1) {
|
|
157
|
+
dirs.add(segments.slice(0, depth).join('/'));
|
|
158
|
+
}
|
|
159
|
+
byName.set(segments[segments.length - 1], true);
|
|
160
|
+
}
|
|
161
|
+
for (const dir of dirs) {
|
|
162
|
+
byName.set(dir.split('/').pop(), true);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { files, dirs, byName };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const TREE = treeOfRepo();
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Адрес ли это вообще. Отсекается всё, что описывает форму, а не адрес: шаблоны, сетевые
|
|
172
|
+
* ссылки, имена пакетов, флаги и привязка «путь:символ» — её судит сверка спеков. Дальше
|
|
173
|
+
* кандидат бывает двух родов: укоренённый в дереве и голое имя. Голым именем зовут и файл, и
|
|
174
|
+
* каталог — оба ищутся по дереву, потому что адрес у них один, а написан он коротко.
|
|
109
175
|
*/
|
|
110
176
|
function looksLikePath(candidate) {
|
|
111
177
|
if (/[*<>{}$|\s]|\.\.\.|…/.test(candidate)) {
|
|
112
178
|
return false;
|
|
113
179
|
}
|
|
114
|
-
if (/^(https
|
|
180
|
+
if (/^(https?:|@|~|\/|-)/.test(candidate) || candidate.includes(':')) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
// Каталог, который проверка не обходит, она и не судит: там чужое, сборка и служебное
|
|
184
|
+
if (SKIPPED_DIRS.some((dir) => candidate.startsWith(`${dir}/`))) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
// Начинается с точки и стоит без каталога — род файла, а не файл
|
|
188
|
+
if (/^\.[^/]+$/.test(candidate)) {
|
|
115
189
|
return false;
|
|
116
190
|
}
|
|
117
191
|
|
|
118
|
-
return
|
|
192
|
+
return candidate.includes('/') || new RegExp(`\\.(?:${EXTENSIONS})$`).test(candidate);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Есть ли такой адрес в дереве. Укоренённый спрашивается у файловой системы: он назван
|
|
197
|
+
* целиком, и промах в нём — промах. Голое имя ищется по дереву целиком — и среди файлов, и
|
|
198
|
+
* среди каталогов: имя каталога в обзорном документе либы означает каталог рядом, а не
|
|
199
|
+
* каталог в корне.
|
|
200
|
+
*/
|
|
201
|
+
function existsInTree(candidate) {
|
|
202
|
+
const bare = candidate.replace(/\/$/, '');
|
|
203
|
+
|
|
204
|
+
if (ROOTED_IN.has(bare.split('/')[0])) {
|
|
205
|
+
return existsSync(join(ROOT, bare));
|
|
206
|
+
}
|
|
207
|
+
if (TREE.files.has(bare) || TREE.dirs.has(bare)) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
if (bare.includes('/')) {
|
|
211
|
+
return [...TREE.files, ...TREE.dirs].some((path) => path.endsWith(`/${bare}`));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return TREE.byName.has(bare);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Переносимый текст: разложенный пакетом — по шапке, его исходник — по каталогу из настройки.
|
|
219
|
+
* Адреса в нём принадлежат тому дереву, куда правило ложится: `libs/common/util` в дереве,
|
|
220
|
+
* которое зовёт свои корни иначе, — не мёртвая ссылка, а пример. Судить их здесь значит
|
|
221
|
+
* краснеть на полтораста строк, ни одна из которых не чинится правкой этого дерева.
|
|
222
|
+
*/
|
|
223
|
+
function isPortable(doc) {
|
|
224
|
+
if (PORTABLE_DIRS.some((dir) => doc.startsWith(dir))) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return readFileSync(join(ROOT, doc), 'utf8')
|
|
229
|
+
.split('\n', STAMP_LOOKAHEAD)
|
|
230
|
+
.some((line) => STAMP_LINE.test(line));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Из проверки адресов выведены документы, которые по устройству говорят о несуществующем. */
|
|
234
|
+
const isSkipped = (doc) => doc.startsWith(ARCHIVE_DIR) || doc.startsWith(TASKS_DIR) || isPortable(doc);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Полнота указателя каталога: у каждой записи каталога есть строка в таблице, у каждой
|
|
238
|
+
* строки — запись. Записью считается первое имя в обратных кавычках строки таблицы: во
|
|
239
|
+
* второй колонке стоит проза, и брать оттуда было бы нечего. Каталог берётся у системы
|
|
240
|
+
* контроля версий той же выборкой, что и дерево: черновик, закрытый настройкой
|
|
241
|
+
* неотслеживаемого, в репозиторий не едет и указателю не нужен.
|
|
242
|
+
*/
|
|
243
|
+
function checkIndex(dir) {
|
|
244
|
+
const index = `${dir}README.md`;
|
|
245
|
+
if (!existsSync(join(ROOT, index))) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const named = new Set(
|
|
250
|
+
readFileSync(join(ROOT, index), 'utf8')
|
|
251
|
+
.split('\n')
|
|
252
|
+
.filter((line) => line.startsWith('|'))
|
|
253
|
+
.map((line) => line.match(PATH_IN_BACKTICKS)?.[0].replaceAll('`', ''))
|
|
254
|
+
.filter((name) => name?.endsWith('.md'))
|
|
255
|
+
);
|
|
256
|
+
const stored = new Set(
|
|
257
|
+
[...TREE.files]
|
|
258
|
+
.filter((path) => path.startsWith(dir) && path.endsWith('.md') && path !== index)
|
|
259
|
+
.map((path) => path.slice(dir.length))
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
[...stored]
|
|
263
|
+
.filter((name) => !named.has(name))
|
|
264
|
+
.sort()
|
|
265
|
+
.forEach((name) => indexProblems.push(`${index}: запись \`${name}\` лежит в каталоге, но в таблице не названа`));
|
|
266
|
+
[...named]
|
|
267
|
+
.filter((name) => !stored.has(name))
|
|
268
|
+
.sort()
|
|
269
|
+
.forEach((name) => indexProblems.push(`${index}: строка \`${name}\` названа в таблице, но записи в каталоге нет`));
|
|
119
270
|
}
|
|
120
271
|
|
|
121
272
|
function checkDoc(doc, allowed) {
|
|
@@ -136,27 +287,46 @@ function checkDoc(doc, allowed) {
|
|
|
136
287
|
if (!looksLikePath(candidate) || allowed.has(candidate)) {
|
|
137
288
|
continue;
|
|
138
289
|
}
|
|
139
|
-
if (!
|
|
290
|
+
if (!existsInTree(candidate)) {
|
|
140
291
|
report(doc, index + 1, candidate);
|
|
141
292
|
}
|
|
142
293
|
}
|
|
143
294
|
});
|
|
144
295
|
}
|
|
145
296
|
|
|
297
|
+
/** Расхождение указателя печатается своим списком: чинится оно строкой в таблице, а не молчанием. */
|
|
298
|
+
function reportIndex() {
|
|
299
|
+
if (indexProblems.length === 0) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
console.error(`\nуказатель разошёлся с каталогом, расхождений ${indexProblems.length}\n`);
|
|
304
|
+
indexProblems.forEach((problem) => console.error(` ${problem}`));
|
|
305
|
+
console.error(
|
|
306
|
+
'\nЗапись называется в таблице указателя тем же изменением, которым кладётся:\nчитатель ищет по указателю, а не обходом каталога.'
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
146
310
|
const allowlist = readAllowlist();
|
|
147
311
|
const allowedPaths = new Set(allowlist.paths);
|
|
148
|
-
const collected = collectDocs().filter((doc) => !allowlist.files.includes(doc) && !doc
|
|
312
|
+
const collected = collectDocs().filter((doc) => !allowlist.files.includes(doc) && !isSkipped(doc));
|
|
149
313
|
const dropped = droppedByGit(collected);
|
|
150
314
|
const docs = collected.filter((doc) => !dropped.has(doc));
|
|
151
315
|
|
|
152
316
|
docs.forEach((doc) => checkDoc(doc, allowedPaths));
|
|
317
|
+
INDEXED_DIRS.forEach((dir) => checkIndex(dir));
|
|
153
318
|
|
|
154
319
|
if (problems.length > 0) {
|
|
155
320
|
console.error(`check-doc-paths: расхождений ${problems.length}\n`);
|
|
156
321
|
problems.forEach((problem) => console.error(` ${problem}`));
|
|
157
322
|
console.error(
|
|
158
|
-
`\nЛибо
|
|
323
|
+
`\nЛибо адрес устарел и его надо поправить, либо документ описывает ещё не созданное —\nтогда он вносится в ${ALLOWLIST}.`
|
|
159
324
|
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
reportIndex();
|
|
328
|
+
|
|
329
|
+
if (problems.length > 0 || indexProblems.length > 0) {
|
|
160
330
|
process.exit(1);
|
|
161
331
|
}
|
|
162
332
|
|