@rt-tools/agent-kit 0.14.0 → 0.16.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 (90) hide show
  1. package/README.md +17 -0
  2. package/assets/checks/archive-age.mjs +107 -0
  3. package/assets/checks/archive-prune.mjs +46 -0
  4. package/assets/checks/check-archive-age.mjs +42 -0
  5. package/assets/checks/check-board.github.mjs +16 -0
  6. package/assets/checks/check-descriptions.mjs +123 -0
  7. package/assets/checks/check-dupes.mjs +31 -3
  8. package/assets/checks/check-file-size.mjs +47 -2
  9. package/assets/checks/check-turn-map.mjs +20 -3
  10. package/assets/checks/lib-common.mjs +12 -1
  11. package/assets/checks/lib-domains.mjs +1 -1
  12. package/assets/checks/rt-kit-checks.config.mjs +24 -1
  13. package/assets/checks/spec-anchors.mjs +18 -3
  14. package/assets/checks/spec-common.mjs +5 -1
  15. package/assets/defaults/project.sh +22 -17
  16. package/assets/defaults/turn-map.md +15 -19
  17. package/assets/docs/GLOSSARY.md +52 -58
  18. package/assets/hooks/rule-article.sh +12 -0
  19. package/assets/hooks/skill-gate.sh +5 -4
  20. package/assets/hooks/task-flow-guard.sh +20 -0
  21. package/assets/hooks/turn-exit-guard.sh +229 -4
  22. package/assets/laws/delivery.md +92 -104
  23. package/assets/laws/frontend-application.md +4 -0
  24. package/assets/laws/project-documentation.md +64 -68
  25. package/assets/laws/verifiability.md +32 -33
  26. package/assets/laws/work-conduct.md +167 -157
  27. package/assets/patterns/doc-style-sweep.md +1 -1
  28. package/assets/patterns/doc-style-trace.md +1 -1
  29. package/assets/patterns/git-workflow-commit.azure.md +1 -1
  30. package/assets/patterns/git-workflow-commit.github.md +7 -1
  31. package/assets/patterns/git-workflow-commit.gitlab.md +1 -1
  32. package/assets/patterns/git-workflow-docker.md +1 -1
  33. package/assets/patterns/git-workflow-merge.md +14 -3
  34. package/assets/patterns/git-workflow-pr.azure.md +1 -1
  35. package/assets/patterns/git-workflow-pr.github.md +1 -1
  36. package/assets/patterns/git-workflow-pr.gitlab.md +1 -1
  37. package/assets/patterns/git-workflow-restart.md +1 -1
  38. package/assets/patterns/git-workflow-secrets.md +1 -1
  39. package/assets/patterns/git-workflow-stack.md +93 -0
  40. package/assets/patterns/seo-page.md +1 -1
  41. package/assets/patterns/spec-driven-rule.md +55 -0
  42. package/assets/patterns/status-report-table.github.md +88 -0
  43. package/assets/patterns/task-flow-archive.md +3 -4
  44. package/assets/patterns/task-flow-close.md +6 -1
  45. package/assets/patterns/task-flow-start.md +17 -5
  46. package/assets/patterns/ts-procedure.md +1 -1
  47. package/assets/pitfalls/doc-style.md +5 -0
  48. package/assets/pitfalls/git-workflow.github.md +47 -0
  49. package/assets/pitfalls/task-flow.md +28 -0
  50. package/assets/pitfalls/testing.md +14 -0
  51. package/assets/pitfalls/turn-conduct.md +33 -0
  52. package/assets/rules/angular-patterns.md +1 -1
  53. package/assets/rules/api-layer.md +3 -3
  54. package/assets/rules/browser-verification.md +15 -1
  55. package/assets/rules/dependencies.md +1 -1
  56. package/assets/rules/deploy-flow.azure.md +1 -1
  57. package/assets/rules/deploy-flow.github.md +1 -1
  58. package/assets/rules/deploy-flow.gitlab.md +1 -1
  59. package/assets/rules/doc-style.md +18 -0
  60. package/assets/rules/entity-conventions.needs-admin.md +1 -1
  61. package/assets/rules/entity-models.md +1 -1
  62. package/assets/rules/git-workflow.azure.md +1 -1
  63. package/assets/rules/git-workflow.github.md +154 -181
  64. package/assets/rules/git-workflow.gitlab.md +1 -1
  65. package/assets/rules/lib-layers.md +1 -1
  66. package/assets/rules/observability.needs-app.md +1 -1
  67. package/assets/rules/platform-access.md +1 -1
  68. package/assets/rules/reuse-first.md +1 -1
  69. package/assets/rules/seo.md +4 -3
  70. package/assets/rules/shared-code.md +1 -1
  71. package/assets/rules/spec-driven.md +68 -1
  72. package/assets/rules/status-report.md +97 -0
  73. package/assets/rules/styling-bem.md +12 -0
  74. package/assets/rules/task-flow.md +102 -100
  75. package/assets/rules/testing.md +67 -66
  76. package/assets/rules/turn-conduct.md +146 -105
  77. package/assets/rules/turn-entry.md +7 -1
  78. package/assets/rules/typescript-conventions.md +1 -1
  79. package/assets/skills/agent-kit-extend.md +1 -1
  80. package/assets/skills/agent-kit.md +18 -1
  81. package/bin/agent-kit.d.ts.map +1 -1
  82. package/bin/agent-kit.js +25 -0
  83. package/bin/agent-kit.js.map +1 -1
  84. package/lib/cost.d.ts +44 -0
  85. package/lib/cost.d.ts.map +1 -0
  86. package/lib/cost.js +181 -0
  87. package/lib/cost.js.map +1 -0
  88. package/package.json +1 -1
  89. package/rt-tools-agent-kit-0.16.0.tgz +0 -0
  90. package/rt-tools-agent-kit-0.14.0.tgz +0 -0
package/README.md CHANGED
@@ -27,6 +27,7 @@ npx agent-kit sync # разложить выбранное в docs/constitu
27
27
  npx agent-kit doctor # что разложено, что отстало, чего не хватает
28
28
  npx agent-kit adopt # отдать пакету файлы, лежащие на его путях не от него
29
29
  npx agent-kit stats # чем пользовались, чем ни разу, обо что спотыкались
30
+ npx agent-kit cost # сколько весит вход в работу, одно правило и весь слой
30
31
  ```
31
32
 
32
33
  `init` называет и то, чего пакет ждёт от дерева: значения дырок, которые придётся вписать в
@@ -274,6 +275,22 @@ npx agent-kit stats --json # то же машиночитаемо
274
275
  Самая ценная строка сводки — не «чем пользовались», а **что разложено и не загружено ни разу**:
275
276
  чем пользуются, видно и по работе, а мёртвый ресурс ничем себя не выдаёт.
276
277
 
278
+ ## Цена контекста
279
+
280
+ Сводка говорит, чем пользовались; цена — сколько это стоило. Заход платит за слой правил окном,
281
+ и до этой команды вес того, что он получает целиком, не считало ничто.
282
+
283
+ ```bash
284
+ npx agent-kit cost # вход в работу, самое тяжёлое правило, весь слой
285
+ npx agent-kit cost --rule <имя> # взвесить названное правило
286
+ npx agent-kit cost --json # то же машиночитаемо — этим числа кладут в замысел
287
+ ```
288
+
289
+ Считается не файл, а то, что заход получает: описание правила приходит ему полем, словарь и
290
+ карта хода — выводом хуков. Меряется в символах и байтах — тем, что берётся на месте, без сети.
291
+ Число сравнимо только с числом, снятым той же командой; чем считано, стоит в первой строке
292
+ вывода.
293
+
277
294
  Наблюдения лежат в `.claude/rt-kit/observations/` файлом на день и снимаются через тридцать
278
295
  дней. Запись выключается ключом `"observe": false` в конфиге — целиком, а не частями. Каталог
279
296
  просится в список игнорируемого; вписывает его проект — в чужие файлы дерева пакет не пишет.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Возраст записей описания прошлого.
3
+ *
4
+ * Каталог набирает по записи на каждую закрытую работу и не отдаёт обратно ничего: папку
5
+ * задачи разбирает паттерн закрытия работы, и запись оттуда переживает всё дерево. Срок
6
+ * назначает дерево ключом настройки; пакет умолчания не даёт — установка новой версии не
7
+ * вправе начать сносить чужой архив, а снимается там разбор просьбы, которого нет больше нигде.
8
+ *
9
+ * Отбор живёт здесь один на двоих: команда чистки снимает по нему, проверка по нему же
10
+ * краснеет. Разойдясь, они говорили бы о каталоге разное, а заметить это нечем — чистка молча
11
+ * оставляла бы то, на что проверка молча не смотрит.
12
+ *
13
+ * Возраст меряется датой последнего коммита файла. Время файла на диске не годится: свежий
14
+ * чекаут делает все записи одновременными, и чистка на чужой машине не сняла бы ни одной.
15
+ * Шапка записи не годится тоже — день слияния стоит в ней не всегда и не всюду одинаково.
16
+ *
17
+ * Запись, которой в истории ещё нет, считается сегодняшней: она приехала этой же веткой и
18
+ * перестоять не могла.
19
+ */
20
+ import { execFileSync } from 'node:child_process';
21
+ import { existsSync, readdirSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+
24
+ import { CONFIG } from './rt-kit-checks.config.mjs';
25
+
26
+ /** Каталог описания прошлого — без завершающей косой черты: её несёт настройка. */
27
+ export const ARCHIVE_DIR = CONFIG.archiveDir.replace(/\/$/, '');
28
+
29
+ /**
30
+ * Срок хранения записи в сутках. `null` — срок не назначен, и тогда молчат обе стороны:
31
+ * проверка не краснеет, чистка не снимает. Дерево называет своё число ключом настройки.
32
+ */
33
+ export const RETENTION_DAYS = CONFIG.archiveRetentionDays ?? null;
34
+
35
+ /** Сутки в миллисекундах — считать возраст удобнее в них. */
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+
38
+ /**
39
+ * Дата последнего коммита у каждой записи каталога.
40
+ *
41
+ * Один проход по истории вместо вызова на файл: на трёх сотнях записей это разница между
42
+ * секундой и полуминутой. Лог идёт новыми вперёд, поэтому первая встреченная дата файла и есть
43
+ * последняя.
44
+ */
45
+ function lastCommitDates(root) {
46
+ const log = execFileSync('git', ['log', '--format=%cI', '--name-only', '--', ARCHIVE_DIR], {
47
+ cwd: root,
48
+ encoding: 'utf8',
49
+ maxBuffer: 64 * 1024 * 1024,
50
+ });
51
+
52
+ const dates = new Map();
53
+ let current = null;
54
+
55
+ for (const line of log.split('\n')) {
56
+ if (line === '') {
57
+ continue;
58
+ }
59
+
60
+ if (line.startsWith(`${ARCHIVE_DIR}/`)) {
61
+ if (current !== null && !dates.has(line)) {
62
+ dates.set(line, current);
63
+ }
64
+
65
+ continue;
66
+ }
67
+
68
+ current = line;
69
+ }
70
+
71
+ return dates;
72
+ }
73
+
74
+ /**
75
+ * Записи каталога с их возрастом в сутках.
76
+ *
77
+ * @param root Корень дерева.
78
+ * @param now Момент отсчёта — передаётся, чтобы проверка и чистка судили по одному времени.
79
+ * @returns Записи: путь, дата последнего коммита и возраст в сутках.
80
+ */
81
+ export function archiveRecords(root, now = new Date()) {
82
+ if (!existsSync(join(root, ARCHIVE_DIR))) {
83
+ return [];
84
+ }
85
+
86
+ const dates = lastCommitDates(root);
87
+
88
+ return readdirSync(join(root, ARCHIVE_DIR))
89
+ .filter((name) => name.endsWith('.md'))
90
+ .map((name) => {
91
+ const path = `${ARCHIVE_DIR}/${name}`;
92
+ const committed = dates.get(path);
93
+ const ageDays = committed === undefined ? 0 : (now.getTime() - new Date(committed).getTime()) / DAY_MS;
94
+
95
+ return { path, name, committed, ageDays };
96
+ })
97
+ .sort((one, other) => other.ageDays - one.ageDays);
98
+ }
99
+
100
+ /** Записи, перестоявшие срок. Срок не назначен — перестоявших нет ни одной. */
101
+ export function staleRecords(root, now = new Date()) {
102
+ if (RETENTION_DAYS === null) {
103
+ return [];
104
+ }
105
+
106
+ return archiveRecords(root, now).filter((record) => record.ageDays > RETENTION_DAYS);
107
+ }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Чистка описания прошлого по сроку.
4
+ *
5
+ * Запись живёт назначенный деревом срок, дальше снимается из дерева и остаётся в истории
6
+ * системы контроля версий — достать её оттуда можно по имени файла, оно же единственный
7
+ * указатель каталога.
8
+ *
9
+ * Сухой прогон — умолчание. Команда сносит разбор просьбы — единственную запись слов
10
+ * владельца, — и снос называется явно: `--apply`. Перечень снимаемого печатается в обоих
11
+ * случаях одинаково, чтобы решение принималось по тому же списку, который потом уедет.
12
+ *
13
+ * Срок и отбор берутся у `archive-age.mjs` — того же модуля, по которому краснеет проверка.
14
+ */
15
+ import { execFileSync } from 'node:child_process';
16
+
17
+ import { RETENTION_DAYS, archiveRecords, staleRecords } from './archive-age.mjs';
18
+ import { ROOT } from './rt-kit-checks.config.mjs';
19
+
20
+ if (RETENTION_DAYS === null) {
21
+ console.log('archive-prune: срок хранения описания прошлого деревом не назначен — снимать нечего');
22
+ process.exit(0);
23
+ }
24
+
25
+ const apply = process.argv.includes('--apply');
26
+ const stale = staleRecords(ROOT);
27
+ const total = archiveRecords(ROOT).length;
28
+
29
+ if (stale.length === 0) {
30
+ console.log(`archive-prune: записей ${total}, перестоявших срок в ${RETENTION_DAYS} суток нет`);
31
+ process.exit(0);
32
+ }
33
+
34
+ console.log(`archive-prune: перестояло ${stale.length} из ${total} при сроке в ${RETENTION_DAYS} суток`);
35
+
36
+ for (const record of stale) {
37
+ console.log(` ${record.name} — ${Math.floor(record.ageDays)} суток, последний коммит ${record.committed.slice(0, 10)}`);
38
+ }
39
+
40
+ if (!apply) {
41
+ console.log('\nЭто сухой прогон: не снято ничего. Снос идёт доводом --apply.');
42
+ process.exit(0);
43
+ }
44
+
45
+ execFileSync('git', ['rm', '--quiet', '--', ...stale.map((record) => record.path)], { cwd: ROOT, stdio: 'inherit' });
46
+ console.log(`\nСнято записей: ${stale.length}. Они остаются в истории — найти их можно по имени файла.`);
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Сверка срока хранения описания прошлого.
4
+ *
5
+ * Одной чистки хватает ровно на срок: каталог набирает по записи на каждую закрытую работу, и
6
+ * первая уцелевшая перестаивает снова. Без сверки срок держался бы памятью того, кто помнит
7
+ * про команду чистки, — то есть не держался бы вовсе.
8
+ *
9
+ * Сверка не чистит: снос — решение, а не следствие проверки. Она называет перестоявшие записи
10
+ * и команду, которой их снимают.
11
+ *
12
+ * FAIL-OPEN: срок деревом не назначен — сверять нечего, нулевой код. Умолчания у срока нет
13
+ * намеренно: пакет, назначивший его за дерево, начал бы сносить чужой архив в день установки.
14
+ * Каталога нет — то же самое. Пустой каталог отказом не считается: он означает, что всё снято
15
+ * по сроку.
16
+ *
17
+ * Ненулевой код возврата и перечень перестоявших записей.
18
+ */
19
+ import { ARCHIVE_DIR, RETENTION_DAYS, archiveRecords, staleRecords } from './archive-age.mjs';
20
+ import { ROOT } from './rt-kit-checks.config.mjs';
21
+
22
+ if (RETENTION_DAYS === null) {
23
+ console.log('check-archive-age: срок хранения описания прошлого деревом не назначен — сверять нечего');
24
+ process.exit(0);
25
+ }
26
+
27
+ const total = archiveRecords(ROOT).length;
28
+ const stale = staleRecords(ROOT);
29
+
30
+ if (stale.length > 0) {
31
+ console.error(`check-archive-age: расхождений ${stale.length}`);
32
+
33
+ for (const record of stale) {
34
+ console.error(` ${record.path}: ${Math.floor(record.ageDays)} суток при сроке в ${RETENTION_DAYS}`);
35
+ }
36
+
37
+ console.error(`\nЗапись живёт ${RETENTION_DAYS} суток и снимается: \`node tools/archive-prune.mjs --apply\`.`);
38
+ console.error('Снятая остаётся в истории — найти её можно по имени файла.');
39
+ process.exit(1);
40
+ }
41
+
42
+ console.log(`check-archive-age: записей ${total} в ${ARCHIVE_DIR}, ни одна не перестояла срок в ${RETENTION_DAYS} суток`);
@@ -138,6 +138,12 @@ function folderInBranch(branch, options) {
138
138
  *
139
139
  * Свежая вершина не судится: между пушем и прогоном проходит время, и красная строка на этом
140
140
  * промежутке значила бы «подожди», а не «чини».
141
+ *
142
+ * У конфликтующей заявки прогона не бывает вовсе, и причина не в потерянном событии: конвейер
143
+ * проверяет слияние ветки с базой, а слияния при конфликте нет. Совет вернуть событие
144
+ * выполняется буквально и не помогает — за один заход заявка перезакрывалась дважды подряд, и
145
+ * прогон встал только после вливания главной ветки. Строка поэтому называет ту причину, которая
146
+ * чинится.
141
147
  */
142
148
  function checkHeadRun(pull, options) {
143
149
  if (checkEvicted(pull, options)) {
@@ -154,6 +160,16 @@ function checkHeadRun(pull, options) {
154
160
  return;
155
161
  }
156
162
 
163
+ if (pull.mergeable === 'CONFLICTING') {
164
+ report(
165
+ `PR #${pull.number}: на вершине ${pull.headRefOid.slice(0, 8)} прогона нет и не будет, пока она конфликтует — ` +
166
+ `конвейер проверяет слияние ветки с базой, а слияния при конфликте нет; влей главную ветку и запушь, ` +
167
+ `перезакрытие PR тут не помогает`
168
+ );
169
+
170
+ return;
171
+ }
172
+
157
173
  report(
158
174
  `PR #${pull.number}: на вершине ${pull.headRefOid.slice(0, 8)} прогона нет, а лежит она ${minutes} мин — ` +
159
175
  `конвейер события не получил; верни его новым коммитом либо перезакрытием PR ` +
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Сверка длины описаний правил и паттернов.
4
+ *
5
+ * Описание едет в системный промпт каждого захода — все, сколько их есть в дереве, — и
6
+ * платит их заход, чем бы ни занимался. Тем оно и отличается от тела правила: тело
7
+ * исполнитель читает сам и платит за это ходом, описание приходит даром. Даром — пока
8
+ * оно короткое.
9
+ *
10
+ * Растёт оно само: описание пишут вслед за правилом и пересказывают в нём содержимое.
11
+ * Ни одна проверка длины не считала, и на дереве, где эта сверка заводилась, сорок
12
+ * описаний из семидесяти четырёх переросли предел.
13
+ *
14
+ * Отвечает описание на один вопрос — брать это правило или нет. Всё, что отвечает на
15
+ * вопрос «а что там внутри», приходит вторым разом вместе с самим правилом.
16
+ *
17
+ * FAIL-OPEN: каталога скилов в дереве нет — сверять нечего, нулевой код.
18
+ *
19
+ * Описание длиннее предела, оставленное намеренно, называется в перечне принятого долга
20
+ * рядом — по имени скила, с причиной. Молчаливое превышение и осознанное выглядят
21
+ * одинаково, поэтому второе называется списком.
22
+ *
23
+ * Ненулевой код возврата и перечень превысивших с числами.
24
+ */
25
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ const ROOT = process.cwd();
29
+ const SKILLS = join(ROOT, '.claude/skills');
30
+ const DEBT = join(ROOT, '.claude/rt-kit/description-debt.json');
31
+
32
+ /**
33
+ * Предел длины описания в знаках.
34
+ *
35
+ * Считаются знаки, а не байты: байт о цене окна не говорит, а кириллица делает его в
36
+ * полтора раза больше знака. Триста — число владельца, назначенное от первого замера.
37
+ */
38
+ const LIMIT = 300;
39
+
40
+ /** Описание из шапки: строка `description:` до конца строки. */
41
+ function descriptionOf(text) {
42
+ const match = /^description:\s*(.+)$/m.exec(text);
43
+
44
+ return match === null ? null : match[1].trim();
45
+ }
46
+
47
+ /** Перечень принятого долга: имя скила → причина. Нет файла — долга нет. */
48
+ function debt() {
49
+ if (!existsSync(DEBT)) {
50
+ return {};
51
+ }
52
+
53
+ try {
54
+ return JSON.parse(readFileSync(DEBT, 'utf8'));
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ function main() {
61
+ if (!existsSync(SKILLS)) {
62
+ console.log('check-descriptions: каталога скилов нет — сверять нечего');
63
+
64
+ return 0;
65
+ }
66
+
67
+ const accepted = debt();
68
+ const over = [];
69
+ const owed = [];
70
+ let counted = 0;
71
+
72
+ for (const name of readdirSync(SKILLS)) {
73
+ const file = join(SKILLS, name, 'SKILL.md');
74
+
75
+ if (!existsSync(file)) {
76
+ continue;
77
+ }
78
+
79
+ const description = descriptionOf(readFileSync(file, 'utf8'));
80
+
81
+ if (description === null) {
82
+ continue;
83
+ }
84
+
85
+ counted += 1;
86
+
87
+ if (description.length <= LIMIT) {
88
+ continue;
89
+ }
90
+
91
+ if (Object.hasOwn(accepted, name)) {
92
+ owed.push(`${name}: ${description.length} знаков — ${accepted[name]}`);
93
+ continue;
94
+ }
95
+
96
+ over.push({ name, length: description.length });
97
+ }
98
+
99
+ for (const line of owed) {
100
+ console.log(` долг ${line}`);
101
+ }
102
+
103
+ if (over.length > 0) {
104
+ over.sort((first, second) => second.length - first.length);
105
+ console.log(`check-descriptions: длиннее предела ${over.length} из ${counted}, предел ${LIMIT} знаков\n`);
106
+
107
+ for (const item of over) {
108
+ console.log(` ${item.name}: ${item.length} знаков, лишних ${item.length - LIMIT}`);
109
+ }
110
+
111
+ console.log('\nОписание отвечает на один вопрос — брать это правило или нет. Перечисление разделов');
112
+ console.log('и пересказ статей приходят вторым разом вместе с самим правилом.');
113
+ console.log('Оставленное намеренно называется в .claude/rt-kit/description-debt.json с причиной.');
114
+
115
+ return 1;
116
+ }
117
+
118
+ console.log(`check-descriptions: описаний ${counted}, все в пределе ${LIMIT} знаков` + (owed.length > 0 ? `, принятого долга ${owed.length}` : ''));
119
+
120
+ return 0;
121
+ }
122
+
123
+ process.exit(main());
@@ -163,6 +163,12 @@ const PAIR_RE = /([\w'"[\].]+)\s*:\s*([^,\n]+)/g;
163
163
  const MIN_VALUE_LENGTH = 4;
164
164
  /** Минимум пар, при котором совпадение таблиц о чём-то говорит */
165
165
  const MIN_TABLE_PAIRS = 2;
166
+ /**
167
+ * Доля совпавших пар, начиная с которой таблицы считаются одной. Полное равенство слепо ровно
168
+ * там, где копия разошлась с оригиналом на строку, — а это и есть тот случай, ради которого
169
+ * копии сводят. Порог высокий: таблицы одного домена делят по две-три пары без всякого родства.
170
+ */
171
+ const MIN_TABLE_SHARE = 0.8;
166
172
 
167
173
  /**
168
174
  * Пакеты, чьи наборы считаются наравне с либами. Своё перечисление под уже
@@ -237,7 +243,7 @@ for (const path of SOURCE_ROOTS.flatMap((root) => collectFiles(root))) {
237
243
  ([, key, value]) => `${key.replaceAll(/['"[\]]/g, '')}:${value.trim().replace(/,$/, '')}`
238
244
  );
239
245
  if (pairs.length >= MIN_TABLE_PAIRS) {
240
- tables.push({ name, lib, pairs: [...pairs].sort().join('|') });
246
+ tables.push({ name, lib, pairs: new Set(pairs) });
241
247
  }
242
248
  }
243
249
 
@@ -286,14 +292,36 @@ for (const [value, places] of [...namesByValue.entries()].sort()) {
286
292
  findings.push({ key: `value ${value} @ ${where}`, text: `значение ${value} объявлено в ${libs.size} либах: ${where}` });
287
293
  }
288
294
 
295
+ /**
296
+ * Доля совпавших пар считается от большей таблицы: от меньшей таблица из двух пар, целиком
297
+ * лежащая внутри таблицы из двадцати, читалась бы полной копией.
298
+ */
299
+ const tableOverlap = (first, second) => {
300
+ let same = 0;
301
+ for (const pair of first.pairs) {
302
+ if (second.pairs.has(pair)) {
303
+ same += 1;
304
+ }
305
+ }
306
+ const larger = Math.max(first.pairs.size, second.pairs.size);
307
+
308
+ return { same, larger, share: same / larger };
309
+ };
310
+
289
311
  for (let i = 0; i < tables.length; i++) {
290
312
  for (let j = i + 1; j < tables.length; j++) {
291
313
  const [first, second] = [tables[i], tables[j]];
292
- if (first.lib === second.lib || first.pairs !== second.pairs) {
314
+ if (first.lib === second.lib) {
315
+ continue;
316
+ }
317
+ const { same, larger, share } = tableOverlap(first, second);
318
+ if (share < MIN_TABLE_SHARE) {
293
319
  continue;
294
320
  }
295
321
  const key = `table ${[`${first.name} @ ${first.lib}`, `${second.name} @ ${second.lib}`].sort().join(' ~ ')}`;
296
- findings.push({ key, text: `${first.name} (${first.lib}) и ${second.name} (${second.lib}) — одна таблица соответствий` });
322
+ const apart = larger - same;
323
+ const tail = apart === 0 ? 'одна таблица соответствий' : `одна таблица соответствий, разошедшаяся на ${apart} из ${larger} пар`;
324
+ findings.push({ key, text: `${first.name} (${first.lib}) и ${second.name} (${second.lib}) — ${tail}` });
297
325
  }
298
326
  }
299
327
 
@@ -38,6 +38,14 @@ const ALLOWLIST = allowlistOf('file-size');
38
38
  /** Пределов два: код и текст слоя правил. Какой из них применён, каждая строка отказа называет. */
39
39
  const LIMIT = CONFIG.fileSizeLimit;
40
40
  const PROSE_LIMIT = CONFIG.proseSizeLimit ?? CONFIG.fileSizeLimit;
41
+ /**
42
+ * Второй предел текста — в знаках. Строки меряют, сколько текста помещается на экран, но веса
43
+ * не меряют вовсе: правило о заявках занимает 282 строки при 13 595 знаках, а правило поставки —
44
+ * 272 строки при 21 508. Сжатие слоя срезает знаки, а число переносов оставляет прежним, и
45
+ * строковый предел достигнутого не закрепляет. Дерево, не назвавшее этого числа, судится
46
+ * по-прежнему одними строками.
47
+ */
48
+ const PROSE_CHARS = CONFIG.proseCharLimit ?? 0;
41
49
  /** Корни текста слоя правил; дерево, их не назвавшее, судится одним пределом. */
42
50
  const PROSE_ROOTS = CONFIG.proseRoots ?? [];
43
51
 
@@ -74,6 +82,16 @@ function lineCount(path) {
74
82
  return readFileSync(join(ROOT, path), 'utf8').split('\n').length;
75
83
  }
76
84
 
85
+ /** Спутник — таблица связи, а не проза: компаньон правила и перечень сценариев спека. */
86
+ function companion(path) {
87
+ return path.endsWith('/implementation.md') || path.endsWith('/scenarios.md');
88
+ }
89
+
90
+ /** Знаки, а не байты: кириллица весит по два байта, и байтовый счёт судил бы язык, а не текст. */
91
+ function charCount(path) {
92
+ return readFileSync(join(ROOT, path), 'utf8').length;
93
+ }
94
+
77
95
  /**
78
96
  * Список известного читается отдельно от общего читателя: у этой проверки нет файла — это
79
97
  * не пустой список, а нечитаемая настройка, и молчать о ней нельзя. Пустой список законен
@@ -86,15 +104,32 @@ const known = new Map([...[...accepted.keys()].map((path) => [path, 'приня
86
104
  const tooLong = new Map();
87
105
  const tracked = trackedFiles().filter(judged);
88
106
 
107
+ const overweight = new Map();
108
+
89
109
  for (const path of tracked) {
90
110
  const lines = lineCount(path);
91
111
  if (lines > limitOf(path).limit) {
92
112
  tooLong.set(path, lines);
93
113
  }
114
+
115
+ // Вес судится только у текста слоя правил и только там, где дерево назвало число: у кода
116
+ // длину стережёт ещё и линтер, а у прозы — одни эти два предела.
117
+ //
118
+ // Спутники из счёта веса выведены. Компаньон правила и перечень сценариев — таблицы связи:
119
+ // заголовок привязки дословно повторяет утверждение, потому что связь идёт по его тексту, и
120
+ // резать там нечего, не порвав саму связь. Вес такого файла растёт с числом утверждений, а
121
+ // не с многословием: у правила поставки семьдесят шесть привязок на 24 326 знаков, из них
122
+ // пояснений всего 5 729. Строковый предел на них остаётся — он ловит другое.
123
+ if (PROSE_CHARS > 0 && PROSE_ROOTS.length > 0 && limitOf(path).title === 'предел текста' && !companion(path)) {
124
+ const chars = charCount(path);
125
+ if (chars > PROSE_CHARS) {
126
+ overweight.set(path, chars);
127
+ }
128
+ }
94
129
  }
95
130
 
96
131
  if (process.argv.includes('--baseline')) {
97
- console.log(baselineOf([...tooLong.keys()].sort(), allowlist));
132
+ console.log(baselineOf([...new Set([...tooLong.keys(), ...overweight.keys()])].sort(), allowlist));
98
133
  process.exit(0);
99
134
  }
100
135
 
@@ -104,7 +139,11 @@ const gone = [...known.keys()].filter((path) => !existsSync(join(ROOT, path)));
104
139
  /** Файл поделили, а строку оставили: список перестал бы отвечать за то, что в нём стоит. */
105
140
  const shrunk = [...known.keys()].filter((path) => !tooLong.has(path) && existsSync(join(ROOT, path)));
106
141
 
142
+ /** Тяжёлое по знакам судится тем же списком известного: один долг на файл, а не два. */
143
+ const heavy = [...overweight].filter(([path]) => !known.has(path) && !tooLong.has(path));
144
+
107
145
  const problems = [
146
+ ...heavy.map(([path, chars]) => `${path}: ${chars} знаков, предел веса текста ${PROSE_CHARS} — резать довод, а не дописывать строку в ${ALLOWLIST}`),
108
147
  ...fresh.map(([path, lines]) => {
109
148
  const { limit, title } = limitOf(path);
110
149
  return `${path}: ${lines} строк, ${title} ${limit} — делить, а не дописывать строку в ${ALLOWLIST}`;
@@ -121,8 +160,14 @@ if (problems.length > 0) {
121
160
  }
122
161
 
123
162
  const limits = PROSE_ROOTS.length > 0 ? `предел кода ${LIMIT}, предел текста ${PROSE_LIMIT}` : `предел ${LIMIT}`;
163
+ /**
164
+ * Предел веса называется только там, где дерево задало и число, и корни текста: вес судится у
165
+ * прозы слоя правил, а дерево, её корней не назвавшее, судится одним числом строк — и вторая
166
+ * цифра в сводке говорила бы о проверке, которая там не работает.
167
+ */
168
+ const weight = PROSE_CHARS > 0 && PROSE_ROOTS.length > 0 ? `, предел веса текста ${PROSE_CHARS} знаков` : '';
124
169
 
125
170
  console.log(
126
- `check-file-size: проверено ${tracked.length} файлов, ${limits}, длиннее предела ${tooLong.size}, ` +
171
+ `check-file-size: проверено ${tracked.length} файлов, ${limits}${weight}, длиннее предела ${tooLong.size}, ` +
127
172
  `из них принято ${accepted.size}, долг ${debt.size} — новых нет`
128
173
  );
@@ -41,11 +41,28 @@ const RULE = join(ROOT, '.claude/skills/task-flow/SKILL.md');
41
41
  */
42
42
  const LIMIT_BYTES = 6144;
43
43
 
44
- /** Имена состояний из таблицы: первая ячейка в обратных кавычках и всё, что за ней. */
45
- function statesOf(text) {
44
+ /**
45
+ * Имена состояний: строка таблицы, у которой первая ячейка стоит в обратных кавычках, — а для
46
+ * карты хода ещё и строка списка «- `имя` — действие; ведёт `паттерн`». В правиле список так не
47
+ * читается: тем же видом там записаны паттерны, и они попали бы в состояния.
48
+ * у которой первая ячейка стоит в обратных кавычках.
49
+ *
50
+ * Обе формы читаются намеренно. Список дешевле таблицы на треть — форматтер добивает столбцы
51
+ * пробелами до общей ширины, и эти пробелы едут в контекст каждого захода, ничего не значая;
52
+ * таблица при этом остаётся законной, и дерево, которое её не переписывало, работает как
53
+ * прежде.
54
+ */
55
+ function statesOf(text, { listed: readListed = false } = {}) {
46
56
  const states = [];
47
57
 
48
58
  for (const line of text.split('\n')) {
59
+ const listed = readListed && line.match(/^-\s+`([^`]+)`\s+—\s+(.+)$/);
60
+
61
+ if (listed) {
62
+ states.push({ name: listed[1], rest: listed[2].split(';').map((part) => part.trim()) });
63
+ continue;
64
+ }
65
+
49
66
  if (!line.startsWith('|')) {
50
67
  continue;
51
68
  }
@@ -87,7 +104,7 @@ function main() {
87
104
  faults.push(`карта выросла: ${bytes} байт при пределе ${LIMIT_BYTES}`);
88
105
  }
89
106
 
90
- const inMap = statesOf(text);
107
+ const inMap = statesOf(text, { listed: true });
91
108
 
92
109
  if (inMap.length === 0) {
93
110
  faults.push('в карте нет ни одного состояния — таблица сломана');
@@ -60,7 +60,18 @@ const isLib = (path) => existsSync(join(ROOT, path, 'project.json'));
60
60
  * нет вовсе, и прямое чтение роняло проверку отказом «нет такого файла» — то есть первая же
61
61
  * установка получала поломку вместо отчёта о том, что долгов нет.
62
62
  */
63
- const allowlist = parseAllowlist('lib-layers', ['notDomains', 'legacyDomains', 'legacyLibs', 'singleLayerDomains', 'accepted', 'debt']);
63
+ // `flatLibRoots` стоит в перечне наравне с остальными: сбор плоских либ читает эту сторону, а
64
+ // разбор её не собирал — значение выходило пустым и подставлялось пустым списком молча. Дерево с
65
+ // непустым набором плоских корней получало ноль плоских либ и зелёную проверку.
66
+ const allowlist = parseAllowlist('lib-layers', [
67
+ 'notDomains',
68
+ 'legacyDomains',
69
+ 'legacyLibs',
70
+ 'singleLayerDomains',
71
+ 'flatLibRoots',
72
+ 'accepted',
73
+ 'debt',
74
+ ]);
64
75
  const pathsOf = (key) => [...allowlist[key].keys()];
65
76
 
66
77
  /** Паттерн `<корень>/x/*` покрывает и сам каталог `<корень>/x`: исключение снимается целиком */
@@ -196,7 +196,7 @@ function collectStrayLibs(knownLibs) {
196
196
  * `nx test` по ней молча не гонял ни одной спеки.
197
197
  */
198
198
  function collectFlatLibs() {
199
- return (allowlist.flatLibRoots ?? [])
199
+ return [...(allowlist.flatLibRoots?.keys() ?? [])]
200
200
  .flatMap((root) => dirsIn(root).map((entry) => `${root}/${entry}`))
201
201
  .filter((path) => isLib(path))
202
202
  .sort();