@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.
Files changed (52) hide show
  1. package/README.md +5 -0
  2. package/assets/checks/board.github.mjs +45 -2
  3. package/assets/checks/check-board.github.mjs +7 -14
  4. package/assets/checks/check-doc-paths.mjs +200 -30
  5. package/assets/checks/check-specs.mjs +131 -17
  6. package/assets/checks/rt-kit-checks.config.mjs +12 -0
  7. package/assets/commands/next-session.md +122 -0
  8. package/assets/defaults/gate-map.sh +29 -3
  9. package/assets/defaults/project.sh +50 -0
  10. package/assets/docs/GLOSSARY.md +74 -0
  11. package/assets/hooks/git-guard-delivery.sh +87 -4
  12. package/assets/hooks/grill-gate.sh +96 -0
  13. package/assets/hooks/task-flow-guard.sh +14 -3
  14. package/assets/hooks/window-fill-guard.sh +150 -0
  15. package/assets/laws/code-structure.md +10 -0
  16. package/assets/laws/delivery.md +8 -1
  17. package/assets/laws/project-documentation.md +14 -0
  18. package/assets/laws/verifiability.md +13 -0
  19. package/assets/laws/work-conduct.md +19 -0
  20. package/assets/patterns/git-workflow-commit.github.md +4 -0
  21. package/assets/patterns/spec-driven-domain.md +35 -0
  22. package/assets/patterns/task-flow-close.md +72 -8
  23. package/assets/patterns/task-flow-handoff.md +115 -0
  24. package/assets/patterns/task-flow-resume.md +2 -2
  25. package/assets/patterns/task-flow-start.md +18 -2
  26. package/assets/rules/angular-patterns.md +4 -0
  27. package/assets/rules/browser-verification.md +4 -3
  28. package/assets/rules/doc-style.md +53 -1
  29. package/assets/rules/git-workflow.azure.md +24 -0
  30. package/assets/rules/git-workflow.github.md +23 -0
  31. package/assets/rules/git-workflow.gitlab.md +23 -0
  32. package/assets/rules/spec-driven.md +19 -1
  33. package/assets/rules/task-flow.md +88 -2
  34. package/assets/skills/agent-kit.md +4 -0
  35. package/assets/templates/rule.md +1 -1
  36. package/lib/commands.d.ts.map +1 -1
  37. package/lib/commands.js +2 -1
  38. package/lib/commands.js.map +1 -1
  39. package/lib/config.d.ts +3 -1
  40. package/lib/config.d.ts.map +1 -1
  41. package/lib/config.js +2 -0
  42. package/lib/config.js.map +1 -1
  43. package/lib/hooks-map.d.ts +20 -5
  44. package/lib/hooks-map.d.ts.map +1 -1
  45. package/lib/hooks-map.js +56 -15
  46. package/lib/hooks-map.js.map +1 -1
  47. package/lib/sync.d.ts.map +1 -1
  48. package/lib/sync.js +2 -5
  49. package/lib/sync.js.map +1 -1
  50. package/package.json +1 -1
  51. package/rt-tools-agent-kit-0.5.1.tgz +0 -0
  52. 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 { existsSync, readdirSync, statSync } from 'node:fs';
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(`docs/tasks/${name}/: разбор брошен ${age} дн. назад — заведи задачу или удали папку`);
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 = Number(new RegExp(`^${TASK_KEY}-(\\d+)-`).exec(name)?.[1]);
157
- if (!Number.isInteger(number) || openNumbers.has(number)) {
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(`docs/tasks/${name}/: задача #${number} закрыта, а папка лежит среди текущих — разбери её`);
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
- * собирает. К моменту заведения проверки из 159 путей, названных в `docs/`,
9
- * не существовало 80.
8
+ * собирает.
10
9
  *
11
- * Считаются только пути в обратных кавычках и вне блоков кода: в блоках лежат
12
- * команды и вывод, где `dist/apps/api/main.js` — результат сборки, а не файл
13
- * репозитория. Шаблоны (`*`, `<…>`, `{…}`) пропускаются: это форма пути, а не путь.
10
+ * Считаются только адреса в обратных кавычках и вне блоков кода: в блоках лежат
11
+ * команды и вывод, где путь до собранного — результат сборки, а не файл
12
+ * репозитория. Шаблоны (`*`, `<…>`, `{…}`) пропускаются: это форма адреса, а не адрес.
14
13
  *
15
- * Документы, которые по устройству говорят о несуществующем планы будущего и
16
- * архив, перечислены в tools/doc-paths-allowlist.json.
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` — копии репозитория под `.claude/`: их документы описывают раскладку своей
28
- // ветки, а проверка ищет пути в дереве текущей. Одна брошенная копия дала 73 расхождения и
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
- const PATH_IN_BACKTICKS = new RegExp(`\`([^\`\\n]+?\\.(?:${EXTENSIONS}))\``, 'g');
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
- * закрытый `.gitignore` или `.git/info/exclude`. Проверка судит репозиторий, а не рабочий
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
- * URL и пакеты npm.
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?:|@|~|\/)/.test(candidate)) {
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 ROOTED_IN.has(candidate.split('/')[0]);
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 (!existsSync(join(ROOT, candidate))) {
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.startsWith(ARCHIVE_DIR));
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Либо путь устарел и его надо поправить, либо документ описывает ещё не созданное —\nтогда он вносится в ${ALLOWLIST}.`
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