@rt-tools/agent-kit 0.14.0 → 0.15.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 (87) hide show
  1. package/README.md +17 -0
  2. package/assets/checks/check-board.github.mjs +16 -0
  3. package/assets/checks/check-descriptions.mjs +123 -0
  4. package/assets/checks/check-dupes.mjs +31 -3
  5. package/assets/checks/check-file-size.mjs +47 -2
  6. package/assets/checks/check-turn-map.mjs +20 -3
  7. package/assets/checks/lib-common.mjs +12 -1
  8. package/assets/checks/lib-domains.mjs +1 -1
  9. package/assets/checks/rt-kit-checks.config.mjs +17 -1
  10. package/assets/checks/spec-anchors.mjs +18 -3
  11. package/assets/checks/spec-common.mjs +5 -1
  12. package/assets/defaults/project.sh +21 -16
  13. package/assets/defaults/turn-map.md +15 -19
  14. package/assets/docs/GLOSSARY.md +52 -58
  15. package/assets/hooks/rule-article.sh +12 -0
  16. package/assets/hooks/skill-gate.sh +5 -4
  17. package/assets/hooks/task-flow-guard.sh +20 -0
  18. package/assets/hooks/turn-exit-guard.sh +229 -4
  19. package/assets/laws/delivery.md +92 -104
  20. package/assets/laws/frontend-application.md +4 -0
  21. package/assets/laws/project-documentation.md +64 -68
  22. package/assets/laws/verifiability.md +32 -33
  23. package/assets/laws/work-conduct.md +167 -157
  24. package/assets/patterns/doc-style-sweep.md +1 -1
  25. package/assets/patterns/doc-style-trace.md +1 -1
  26. package/assets/patterns/git-workflow-commit.azure.md +1 -1
  27. package/assets/patterns/git-workflow-commit.github.md +7 -1
  28. package/assets/patterns/git-workflow-commit.gitlab.md +1 -1
  29. package/assets/patterns/git-workflow-docker.md +1 -1
  30. package/assets/patterns/git-workflow-merge.md +14 -3
  31. package/assets/patterns/git-workflow-pr.azure.md +1 -1
  32. package/assets/patterns/git-workflow-pr.github.md +1 -1
  33. package/assets/patterns/git-workflow-pr.gitlab.md +1 -1
  34. package/assets/patterns/git-workflow-restart.md +1 -1
  35. package/assets/patterns/git-workflow-secrets.md +1 -1
  36. package/assets/patterns/git-workflow-stack.md +93 -0
  37. package/assets/patterns/seo-page.md +1 -1
  38. package/assets/patterns/spec-driven-rule.md +55 -0
  39. package/assets/patterns/status-report-table.github.md +88 -0
  40. package/assets/patterns/task-flow-archive.md +3 -4
  41. package/assets/patterns/task-flow-close.md +6 -1
  42. package/assets/patterns/task-flow-start.md +17 -5
  43. package/assets/patterns/ts-procedure.md +1 -1
  44. package/assets/pitfalls/doc-style.md +5 -0
  45. package/assets/pitfalls/git-workflow.github.md +47 -0
  46. package/assets/pitfalls/task-flow.md +28 -0
  47. package/assets/pitfalls/testing.md +14 -0
  48. package/assets/pitfalls/turn-conduct.md +33 -0
  49. package/assets/rules/angular-patterns.md +1 -1
  50. package/assets/rules/api-layer.md +3 -3
  51. package/assets/rules/browser-verification.md +15 -1
  52. package/assets/rules/dependencies.md +1 -1
  53. package/assets/rules/deploy-flow.azure.md +1 -1
  54. package/assets/rules/deploy-flow.github.md +1 -1
  55. package/assets/rules/deploy-flow.gitlab.md +1 -1
  56. package/assets/rules/doc-style.md +7 -0
  57. package/assets/rules/entity-conventions.needs-admin.md +1 -1
  58. package/assets/rules/entity-models.md +1 -1
  59. package/assets/rules/git-workflow.azure.md +1 -1
  60. package/assets/rules/git-workflow.github.md +154 -181
  61. package/assets/rules/git-workflow.gitlab.md +1 -1
  62. package/assets/rules/lib-layers.md +1 -1
  63. package/assets/rules/observability.needs-app.md +1 -1
  64. package/assets/rules/platform-access.md +1 -1
  65. package/assets/rules/reuse-first.md +1 -1
  66. package/assets/rules/seo.md +4 -3
  67. package/assets/rules/shared-code.md +1 -1
  68. package/assets/rules/spec-driven.md +68 -1
  69. package/assets/rules/status-report.md +97 -0
  70. package/assets/rules/styling-bem.md +12 -0
  71. package/assets/rules/task-flow.md +102 -100
  72. package/assets/rules/testing.md +67 -66
  73. package/assets/rules/turn-conduct.md +146 -105
  74. package/assets/rules/turn-entry.md +7 -1
  75. package/assets/rules/typescript-conventions.md +1 -1
  76. package/assets/skills/agent-kit-extend.md +1 -1
  77. package/assets/skills/agent-kit.md +18 -1
  78. package/bin/agent-kit.d.ts.map +1 -1
  79. package/bin/agent-kit.js +25 -0
  80. package/bin/agent-kit.js.map +1 -1
  81. package/lib/cost.d.ts +44 -0
  82. package/lib/cost.d.ts.map +1 -0
  83. package/lib/cost.js +181 -0
  84. package/lib/cost.js.map +1 -0
  85. package/package.json +1 -1
  86. package/rt-tools-agent-kit-0.15.0.tgz +0 -0
  87. 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
  просится в список игнорируемого; вписывает его проект — в чужие файлы дерева пакет не пишет.
@@ -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();
@@ -80,6 +80,19 @@ const DEFAULTS = {
80
80
  * только это число.
81
81
  */
82
82
  proseSizeLimit: 300,
83
+ /**
84
+ * Предел веса текста слоя правил — в знаках. Строки меряют, сколько текста помещается на
85
+ * экран; веса они не меряют вовсе: правило о заявках занимает 282 строки при 13 595 знаках,
86
+ * а правило поставки — 272 строки при 21 508. Сжатие слоя срезает знаки и оставляет число
87
+ * переносов прежним, поэтому строковый предел достигнутого не закрепляет, и текст отрастает
88
+ * обратно молча.
89
+ *
90
+ * Число назначено по сжатому слою: самое тяжёлое правило весит 21 508 знаков, и предел стоит
91
+ * чуть выше него. Ниже ставить нельзя — это потребовало бы резать заново то, что уже прошло
92
+ * сжатие; выше незачем — тогда он ничего не закрепляет. Ноль выключает проверку веса вовсе:
93
+ * дерево, не назвавшее числа, судится по-прежнему одними строками.
94
+ */
95
+ proseCharLimit: 22000,
83
96
  /**
84
97
  * Корни, под которыми лежит текст слоя правил, и его источники. Файл отсюда судится
85
98
  * пределом текста, всё остальное — пределом кода. Пусто — предел один на всё дерево.
@@ -258,7 +271,10 @@ export const parseAllowlist = (name, sides = ['accepted', 'debt']) => {
258
271
  return new Map();
259
272
  }
260
273
  if (Array.isArray(entries) || typeof entries !== 'object' || entries === null) {
261
- refuse(`«${side}» записан не объектом — у записи нет места ни для причины, ни для номера задачи`);
274
+ refuse(
275
+ `«${side}» записан не объектом — у записи нет места ни для причины, ни для номера задачи. ` +
276
+ `Форма: {"${side}": {"<ключ>": {"reason": "<почему>", "task": "${key || 'КЛЮЧ'}-<номер>"}}}`,
277
+ );
262
278
  }
263
279
  const parsed = new Map();
264
280
  for (const [entry, value] of Object.entries(entries)) {
@@ -108,7 +108,11 @@ function checkRuleImplementation(specFile, text, mapFile, heading = '## Прав
108
108
 
109
109
  const rows = new Map();
110
110
  for (const line of rowsOfMap(specFile, mapFile, mapHeading)) {
111
- const cells = line.match(/^\|([^|]+)\|([^|]*)\|\s*$/);
111
+ // Привязка записывается двумя формами, и читаются обе. Таблица — прежняя; список — та,
112
+ // ради которой из компаньонов уходят пробелы выравнивания: форматтер добивает столбцы до
113
+ // общей ширины, и в компаньонах дерева это 120 017 знаков из 328 738, то есть 37%.
114
+ // Связь при этом не меняется: она идёт по тексту утверждения, а не по форме строки.
115
+ const cells = line.match(/^\|([^|]+)\|([^|]*)\|\s*$/) ?? line.match(/^-\s+\*\*(.+?)\*\*\s+—\s+(.*)$/);
112
116
  if (!cells) {
113
117
  continue;
114
118
  }
@@ -210,8 +214,14 @@ function symbolOwners() {
210
214
 
211
215
  for (const file of SOURCE_ROOTS.flatMap((root) => walk(root, (name) => name.endsWith('.ts') || name.endsWith('.html')))) {
212
216
  const text = file.endsWith('.ts') ? codeOf(read(file)) : read(file);
213
- for (const [token] of text.matchAll(/[A-Za-z_][\w-]*/g)) {
217
+ // Решётка входит в токен: приватное поле класса объявлено с ней, и якорь на него иначе
218
+ // не попадал бы в перечень владельцев ни разу. Имя без решётки помнится наравне с ним
219
+ // самим — привязки прежней формы остаются зелёными, и переходить разом не приходится.
220
+ for (const [token] of text.matchAll(/#?[A-Za-z_][\w-]*/g)) {
214
221
  remember(token, file);
222
+ if (token.startsWith('#')) {
223
+ remember(token.slice(1), file);
224
+ }
215
225
  if (token.includes('-')) {
216
226
  token.split('-').forEach((part) => part && remember(part, file));
217
227
  }
@@ -242,7 +252,12 @@ function checkTracedAnchors() {
242
252
 
243
253
  const owners = symbolOwners();
244
254
  for (const { mapFile, path, symbol } of declared) {
245
- const here = (codeAt(path).match(new RegExp(`\\b${escapeForRegExp(symbol)}\\b`, 'g')) || []).length;
255
+ // Граница слова ставится только там, где она есть: перед решёткой её нет, и образец с
256
+ // ней давал бы ноль вхождений у всякого приватного имени.
257
+ const bound = symbol.startsWith('#')
258
+ ? `${escapeForRegExp(symbol)}\\b`
259
+ : `\\b${escapeForRegExp(symbol)}\\b`;
260
+ const here = (codeAt(path).match(new RegExp(bound, 'g')) || []).length;
246
261
  const elsewhere = [...(owners.get(symbol) || [])].filter((file) => file !== path).length;
247
262
  if (here + elsewhere < 2) {
248
263
  report(
@@ -66,8 +66,12 @@ const E2E_ROOTS = CONFIG.e2eRoots;
66
66
  * переписан целиком. Алфавит не перечисляется диапазонами: перечисленные молча не покрывают
67
67
  * соседнего, и промах выглядит отсутствием привязки. Путь при этом остаётся латинским — он
68
68
  * адрес в дереве, а не слово текста.
69
+ *
70
+ * Решётка перед именем законна: приватное поле класса объявлено с ней, и записанное без неё имя
71
+ * называет метод не тем именем, каким он объявлен. Проверка при этом остаётся зелёной — граница
72
+ * слова перед решёткой есть, — поэтому промах не краснеет ни разу и виден только чтением.
69
73
  */
70
- const ANCHOR = /`([\w./-]+\.[A-Za-z]{2,10}):(\p{L}[\p{L}\p{N}_-]*|_[\w-]*)`/gu;
74
+ const ANCHOR = /`([\w./-]+\.[A-Za-z]{2,10}):(#?\p{L}[\p{L}\p{N}_-]*|#?_[\w-]*)`/gu;
71
75
  /**
72
76
  * Явный вердикт вместо якоря: статья, которой в дереве исполняться негде. Так бывает
73
77
  * законно — правило говорит о службе, которой дерево не держит, или о движении человека,
@@ -258,24 +258,29 @@ rt_shell_writes_default() {
258
258
  # Судится заголовок команды — именно в нём стоит тот путь, куда команда пишет.
259
259
  #
260
260
  # Исключение — интерпретатор: ему код приходит телом, и путь записи стоит именно там. Признак
261
- # ошибается в сторону лишнего чтения тела: имя интерпретатора, стоящее в команде где угодно,
262
- # возвращает разбор тела целиком.
261
+ # читается у той строки, которая тело открыла, а не у всей команды: тело принадлежит команде
262
+ # своего заголовка. Прежде он читался у всего текста разом, и слово из документа отключало
263
+ # вырезание целиком — строка «**Чем проверяется:** `bash projects/…`» в замысле делала запись
264
+ # `plan.md` правкой кода приложения. Гард отбивал тем самым запись того файла, отсутствием
265
+ # которого он же и отказывает.
263
266
  rt_shell_paths_default() {
264
267
  text="$(printf '%s' "$1" | tr "\"'\`" ' ')"
265
- if ! printf '%s' "$text" \
266
- | grep -Eq '(^|[|;&(]|[[:space:]])(python3?|node|ruby|perl|php|deno|bun|bash|sh|zsh)([[:space:]]|$)'; then
267
- text="$(printf '%s' "$text" | awk '
268
- function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
269
- tag != "" { if (trim($0) == tag) tag = ""; next }
270
- {
271
- print
272
- if (match($0, /<<-?[ \t]*[A-Za-z_][A-Za-z0-9_]*/)) {
273
- t = substr($0, RSTART, RLENGTH)
274
- sub(/^<<-?[ \t]*/, "", t)
275
- tag = t
276
- }
277
- }')"
278
- fi
268
+ text="$(printf '%s' "$text" | awk '
269
+ function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
270
+ tag != "" {
271
+ if (keep) { print }
272
+ if (trim($0) == tag) { tag = ""; keep = 0 }
273
+ next
274
+ }
275
+ {
276
+ print
277
+ if (match($0, /<<-?[ \t]*[A-Za-z_][A-Za-z0-9_]*/)) {
278
+ t = substr($0, RSTART, RLENGTH)
279
+ sub(/^<<-?[ \t]*/, "", t)
280
+ tag = t
281
+ keep = ($0 ~ /(^|[|;&(]|[ \t])(python3?|node|ruby|perl|php|deno|bun|bash|sh|zsh)([ \t]|$)/)
282
+ }
283
+ }')"
279
284
 
280
285
  # Пути берутся только у тех кусков команды, которые пишут. Прежде брались у всей строки
281
286
  # целиком, и команда чтения, сцепленная с записью, отдавала свои пути как цели записи:
@@ -9,19 +9,17 @@
9
9
 
10
10
  ## Состояния и обязательные действия
11
11
 
12
- | Состояние | Обязательное действие | Ведёт паттерн |
13
- | ------------------------- | ------------------------------------------------------ | ------------------ |
14
- | `просьба-не-разобрана` | разведка по дереву, затем вопросы | `task-flow-start` |
15
- | `разбор-закрыт` | договорённость о продукте либо причина её отсутствия | `task-flow-start` |
16
- | `договорённость-записана` | завести задачу, ветку и папку | `task-flow-start` |
17
- | `задача-взята` | написать замысел | `task-flow-start` |
18
- | `замысел-записан` | делать первый этап | `task-flow-start` |
19
- | `этап-идёт` | доделать этап и отметить в ходе работы | `task-flow-resume` |
20
- | `этапы-кончились` | прогнать набор и открыть PR черновиком | `task-flow-close` |
21
- | `работа-отдана` | взять следующую задачу | `task-flow-resume` |
22
- | `разбор-кончился` | влить договорённость, привести тексты, разобрать папку | `task-flow-close` |
23
- | `папка-разобрана` | снять черновик и попросить влить | `task-flow-archive` |
24
- | `влито` | разбор работы правилами и сверка очереди | `task-flow-archive` |
12
+ - `просьба-не-разобрана` разведка по дереву, затем вопросы; ведёт `task-flow-start`
13
+ - `разбор-закрыт` договорённость о продукте либо причина её отсутствия; ведёт `task-flow-start`
14
+ - `договорённость-записана` завести задачу, ветку и папку; ведёт `task-flow-start`
15
+ - `задача-взята` написать замысел; ведёт `task-flow-start`
16
+ - `замысел-записан` делать первый этап; ведёт `task-flow-start`
17
+ - `этап-идёт` доделать этап и отметить в ходе работы; ведёт `task-flow-resume`
18
+ - `этапы-кончились` прогнать набор и открыть PR черновиком; ведёт `task-flow-close`
19
+ - `работа-отдана` взять следующую задачу; ведёт `task-flow-resume`
20
+ - `разбор-кончился` влить договорённость, привести тексты, разобрать папку; ведёт `task-flow-close`
21
+ - `папка-разобрана` снять черновик и попросить влить; ведёт `task-flow-archive`
22
+ - `влито` разбор работы правилами и сверка очереди; ведёт `task-flow-archive`
25
23
 
26
24
  Ни у одного состояния обязательное действие не звучит как «ждать». Прогон, разбор владельцем и
27
25
  слияние идут без исполнителя и от взгляда быстрее не становятся.
@@ -30,12 +28,10 @@
30
28
 
31
29
  Способов четыре, и других нет.
32
30
 
33
- | Выход | Чем подтверждается |
34
- | -------------------------------------------------- | --------------------------------------------------------------- |
35
- | вопрос владельцу, ответа на который в правилах нет | вопрос задан, и за тот же ход правила читались |
36
- | отказ гарда | отказ назван владельцу, обход не искался |
37
- | заполненное окно там, где сжатия нет | ход работы дописан, передача написана |
38
- | работа отдана, и следующая начата | PR открыт, и по следующей задаче сделано действие, а не сказано |
31
+ - **вопрос владельцу, ответа на который в правилах нет** — вопрос задан, и за тот же ход правила читались
32
+ - **отказ гарда** отказ назван владельцу, обход не искался
33
+ - **заполненное окно там, где сжатия нет** ход работы дописан, передача написана
34
+ - **работа отдана, и следующая начата** PR открыт, и по следующей задаче сделано действие, а не сказано
39
35
 
40
36
  Там, где дерево объявило порог сжатия ниже порога остановки, заполненное окно ход не кончает:
41
37
  контекст сжимается, передача приходит входом, и работа идёт дальше тем же заходом. Порог