agent-quality-kit 0.2.4 → 0.3.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 (37) hide show
  1. package/README.md +118 -94
  2. package/README.ru.md +178 -0
  3. package/kit/gates/_skip.sh +31 -3
  4. package/kit/gates/commit-explains-itself/gate.yml +1 -0
  5. package/kit/gates/complexity-limit/gate.yml +1 -0
  6. package/kit/gates/dead-code/gate.yml +1 -0
  7. package/kit/gates/deps-are-pinned/gate.yml +1 -0
  8. package/kit/gates/duplicate-code/gate.yml +1 -0
  9. package/kit/gates/entry-links-exist/gate.yml +1 -0
  10. package/kit/gates/file-size-limit/gate.yml +1 -0
  11. package/kit/gates/gate-has-samples/gate.yml +1 -0
  12. package/kit/gates/gates-are-runnable/gate.yml +1 -0
  13. package/kit/gates/gates-run-in-ci/gate.yml +1 -0
  14. package/kit/gates/lesson-has-outcome/gate.yml +1 -0
  15. package/kit/gates/no-print-in-prod/check.sh +3 -1
  16. package/kit/gates/no-print-in-prod/gate.yml +1 -0
  17. package/kit/gates/secrets-not-in-code/gate.yml +1 -0
  18. package/kit/gates/swallowed-error/gate.yml +1 -0
  19. package/kit/gates/todo-without-task/gate.yml +1 -0
  20. package/package.json +2 -1
  21. package/tool/commands/doctor.mjs +38 -41
  22. package/tool/commands/gates.mjs +103 -109
  23. package/tool/commands/project.mjs +84 -85
  24. package/tool/commands/report.mjs +194 -0
  25. package/tool/i18n/en.mjs +423 -0
  26. package/tool/i18n/index.mjs +33 -0
  27. package/tool/i18n/ru.mjs +424 -0
  28. package/tool/i18n/templates-en.mjs +164 -0
  29. package/tool/i18n/templates-ru.mjs +170 -0
  30. package/tool/lib/core.mjs +5 -1
  31. package/tool/lib/manifest.mjs +12 -31
  32. package/tool/lib/repo.mjs +45 -21
  33. package/tool/lib/templates.mjs +38 -182
  34. package/tool/program.mjs +31 -9
  35. package/tool/selfcheck/gates.sh +7 -1
  36. package/tool/selfcheck/smoke.sh +97 -0
  37. package/tool/selfcheck/units.mjs +80 -5
@@ -13,6 +13,7 @@ import {
13
13
  detectFacts, readCatalog, pickRecipe, triggerVerdict, stems, overlap, matchCatalog,
14
14
  } from "../lib/repo.mjs";
15
15
  import { GATE_YML_TEMPLATE, CHECK_SH_TEMPLATE, README_TEMPLATE } from "../lib/templates.mjs";
16
+ import { L } from "../i18n/index.mjs";
16
17
 
17
18
  // Ставит гейт из каталога в проект. Проверка КОПИРУЕТСЯ в репозиторий, а не остаётся
18
19
  // ссылкой в пакет: при установке через npx пакет временный, и завтра команда в манифесте
@@ -24,7 +25,7 @@ import { GATE_YML_TEMPLATE, CHECK_SH_TEMPLATE, README_TEMPLATE } from "../lib/te
24
25
  // читает окружение и выдаёт тысячу чужих нарушений.
25
26
  async function installGate(slug, man, facts) {
26
27
  const src = join(GATES_SRC, slug);
27
- if (!(await exists(src))) die(`Нет такого гейта: ${slug}\nСписок применимых — ${SELF} doctor`);
28
+ if (!(await exists(src))) die(L.add.noSuchGate(slug, `${SELF} doctor`));
28
29
 
29
30
  const rec = { slug, ...parseManifest(await readFile(join(src, "gate.yml"), "utf8")) };
30
31
  const dst = join(CWD, PROJECT_GATES, slug);
@@ -40,7 +41,7 @@ async function installGate(slug, man, facts) {
40
41
  const cmd = String(pickRecipe(rec, facts) || "")
41
42
  .replace(/\{gate\}/g, `${PROJECT_GATES}/${slug}`)
42
43
  .replace(/\{dir\}/g, ".");
43
- if (!cmd) die(`У записи ${slug} нет команды ни под ${[...facts.langs].join("/") || "этот стек"}, ни общей.`);
44
+ if (!cmd) die(L.add.noRecipe(slug, [...facts.langs].join("/") || L.add.thisStack));
44
45
 
45
46
  const manPath = join(CWD, MANIFEST);
46
47
  const { text, why } = manifestWithGate(await readFile(manPath, "utf8"), slug, cmd);
@@ -50,39 +51,39 @@ async function installGate(slug, man, facts) {
50
51
 
51
52
  async function cmdAdd(args) {
52
53
  const slug = args.find((a) => !a.startsWith("-"));
53
- if (!slug) die(`Укажи имя гейта: ${SELF} add <имя>. Список — ${SELF} doctor`);
54
+ if (!slug) die(L.add.needName(`${SELF} add ${L.help.name}`, `${SELF} doctor`));
54
55
 
55
56
  const man = await readManifest();
56
- if (!man) die(`Нет .aqk.yml — сначала ${SELF} init`);
57
+ if (!man) die(L.add.noManifest(`${SELF} init`));
57
58
  const facts = await detectFacts(man);
58
59
 
59
60
  const src = join(GATES_SRC, slug);
60
- if (!(await exists(src))) die(`Нет такого гейта: ${slug}\nСписок применимых — ${SELF} doctor`);
61
+ if (!(await exists(src))) die(L.add.noSuchGate(slug, `${SELF} doctor`));
61
62
  const probe = { slug, ...parseManifest(await readFile(join(src, "gate.yml"), "utf8")) };
62
63
  const verdict = triggerVerdict(probe, facts);
63
64
  if (!verdict.applies) {
64
- console.log(c.yellow(`\n Этот гейт к репозиторию не применим: ${verdict.why}`));
65
- console.log(c.dim(" Ставлю всё равно — решение твоё, но сторожить ему нечего.\n"));
65
+ console.log(c.yellow(`\n ${L.add.notApplicable(verdict.why)}`));
66
+ console.log(c.dim(` ${L.add.installAnyway}\n`));
66
67
  }
67
68
 
68
69
  const { cmd, copied, declared, why } = await installGate(slug, man, facts);
69
70
 
70
71
  console.log(c.bold(`\naqk add ${slug}\n`));
71
- console.log(` ${c.green("✔")} ${PROJECT_GATES}/${slug}/ ${c.dim(`${copied.length} файлов: проверка и образцы`)}`);
72
+ console.log(` ${c.green("✔")} ${PROJECT_GATES}/${slug}/ ${c.dim(L.add.copied(copied.length))}`);
72
73
  if (declared) {
73
- console.log(` ${c.green("✔")} .aqk.yml ${c.dim(`гейт объявлен: ${cmd}`)}`);
74
+ console.log(` ${c.green("✔")} .aqk.yml ${c.dim(L.add.declared(cmd))}`);
74
75
  } else {
75
- console.log(` ${c.yellow("!")} .aqk.yml ${c.dim(`не тронут (${why}). Впиши сам: ${slug}: "${cmd}"`)}`);
76
+ console.log(` ${c.yellow("!")} .aqk.yml ${c.dim(L.add.notDeclared(why, slug, cmd))}`);
76
77
  }
77
78
 
78
79
  console.log(`
79
- ${c.bold("Дальше:")}
80
+ ${c.bold(L.add.nextTitle)}
80
81
 
81
- 1. Проверь, что он краснеет и молчит там, где должен:
82
- ${c.bold(`${cmd.replace(/ \.$/, ` ${PROJECT_GATES}/${slug}/red`)}`)} ${c.dim("→ ожидается отказ")}
83
- ${c.bold(`${cmd.replace(/ \.$/, ` ${PROJECT_GATES}/${slug}/green`)}`)} ${c.dim("→ ожидается тишина")}
84
- 2. Впиши команду в хук коммита и в конвейер. ${c.dim("Гейт, который никто не запускает, — не гейт.")}
85
- 3. Прогон всех объявленных: ${c.bold(`${SELF} doctor --run`)}
82
+ 1. ${L.add.next1}
83
+ ${c.bold(`${cmd.replace(/ \.$/, ` ${PROJECT_GATES}/${slug}/red`)}`)} ${c.dim(L.add.expectFail)}
84
+ ${c.bold(`${cmd.replace(/ \.$/, ` ${PROJECT_GATES}/${slug}/green`)}`)} ${c.dim(L.add.expectSilence)}
85
+ 2. ${L.add.next2} ${c.dim(L.add.next2Why)}
86
+ 3. ${L.add.next3(c.bold(`${SELF} doctor --run`))}
86
87
  `);
87
88
  }
88
89
 
@@ -92,9 +93,9 @@ ${c.bold("Дальше:")}
92
93
 
93
94
  async function cmdNew(args) {
94
95
  const slug = args.find((a) => !a.startsWith("-"));
95
- if (!slug) die(`Укажи имя: ${SELF} new no-print-in-prod`);
96
+ if (!slug) die(L.gnew.needName(`${SELF} new no-print-in-prod`));
96
97
  if (!/^[a-z][a-z0-9-]{2,}$/.test(slug)) {
97
- die(`Имя «${slug}» не годится: латиница через дефис, например secrets-not-in-code.\nИмя читают в чужих проектах — оно часть словаря.`);
98
+ die(L.gnew.badName(slug));
98
99
  }
99
100
 
100
101
  // Сначала сверка: новая запись нужна реже, чем кажется. Порог берётся по совпадению с
@@ -102,10 +103,10 @@ async function cmdNew(args) {
102
103
  const words = slug.replace(/-/g, " ") + " " + args.filter((a) => !a.startsWith("-")).slice(1).join(" ");
103
104
  for (const { rec, hits, headScore } of await matchCatalog(words)) {
104
105
  if (hits >= 2 && headScore >= 0.5 && !args.includes("--force")) {
105
- console.log(c.yellow(`\n Похоже, такое уже есть: ${c.bold(rec.slug)}`));
106
+ console.log(c.yellow(`\n ${L.gnew.looksExisting(c.bold(rec.slug))}`));
106
107
  console.log(` ${rec.intent || ""}\n`);
107
- console.log(c.dim(" Рецепт под другой стек — это строка в recipes существующей записи."));
108
- console.log(c.dim(` Всё равно завести новую: ${SELF} new ${slug} --force\n`));
108
+ console.log(c.dim(` ${L.gnew.recipeNotGate}`));
109
+ console.log(c.dim(` ${L.gnew.forceHint(`${SELF} new ${slug} --force`)}\n`));
109
110
  process.exit(1);
110
111
  }
111
112
  }
@@ -116,7 +117,7 @@ async function cmdNew(args) {
116
117
  // результата нет. Признак один — работаем ли мы над самим комплектом.
117
118
  const inKit = resolve(CWD) === resolve(PKG_ROOT);
118
119
  const dst = inKit ? join(GATES_SRC, slug) : join(CWD, PROJECT_GATES, slug);
119
- if (await exists(dst)) die(`${relative(CWD, dst)} уже существует.`);
120
+ if (await exists(dst)) die(L.gnew.exists(relative(CWD, dst)));
120
121
 
121
122
  await mkdir(join(dst, "red"), { recursive: true });
122
123
  await mkdir(join(dst, "green"), { recursive: true });
@@ -137,17 +138,17 @@ async function cmdNew(args) {
137
138
  console.log(c.bold(`\naqk new ${slug}\n`));
138
139
  console.log(` ${c.green("✔")} ${relative(CWD, dst)}/ ${c.dim("gate.yml · check.sh · red/ · green/ · README.md")}`);
139
140
  console.log(`
140
- ${c.bold("Дальше — по порядку:")}
141
-
142
- 1. ${c.bold("Проверь, нет ли готового правила")} в ruff, eslint, semgrep.
143
- ${c.dim("Готовое точнее, подробнее и его поддерживают без тебя. Своя проверка — запасная.")}
144
- 2. ${c.bold("Положи образцы.")} В ${c.bold("red/")} — код, на котором проверка обязана сработать.
145
- В ${c.bold("green/")} — тот же код, но правильный.
146
- ${c.dim("Зелёный важнее: он ловит проверку, которая краснеет на исправном коде.")}
147
- 3. ${c.bold("Напиши проверку")} в check.sh. В тексте отказа — что именно сделать.
148
- 4. ${c.bold("Заполни gate.yml:")} намерение, триггер, доказательство отказом.
149
- 5. ${c.bold("Прогони:")} bash tool/selfcheck/gates.sh
150
- ${c.dim("Арбитр обязан покраснеть на red/ и промолчать на green/. Не прошло — не запись.")}
141
+ ${c.bold(L.gnew.nextTitle)}
142
+
143
+ 1. ${c.bold(L.gnew.n1)} ${L.gnew.n1Where}
144
+ ${c.dim(L.gnew.n1Why)}
145
+ 2. ${c.bold(L.gnew.n2)} ${c.bold("red/")} ${L.gnew.n2Red}
146
+ ${c.bold("green/")} ${L.gnew.n2Green}
147
+ ${c.dim(L.gnew.n2Why)}
148
+ 3. ${c.bold(L.gnew.n3)} ${L.gnew.n3Where}
149
+ 4. ${c.bold(L.gnew.n4)} ${L.gnew.n4What}
150
+ 5. ${c.bold(L.gnew.n5)} bash tool/selfcheck/gates.sh
151
+ ${c.dim(L.gnew.n5Why)}
151
152
  `);
152
153
  }
153
154
 
@@ -159,14 +160,14 @@ ${c.bold("Дальше — по порядку:")}
159
160
 
160
161
  async function cmdRatchet(args) {
161
162
  const slug = args.find((a) => !a.startsWith("-"));
162
- if (!slug) die(`Укажи гейт: ${SELF} ratchet <имя>. Он должен быть уже объявлен в .aqk.yml`);
163
+ if (!slug) die(L.ratchet.needName(`${SELF} ratchet ${L.help.name}`));
163
164
 
164
165
  const manPath = join(CWD, MANIFEST);
165
- if (!(await exists(manPath))) die(`Нет .aqk.yml — сначала ${SELF} init`);
166
+ if (!(await exists(manPath))) die(L.ratchet.noManifest(`${SELF} init`));
166
167
  let text = await readFile(manPath, "utf8");
167
168
 
168
169
  const line = text.split("\n").find((l) => new RegExp(`^\\s+${slug}:`).test(l));
169
- if (!line) die(`Гейт «${slug}» не объявлен в .aqk.yml. Сначала: ${SELF} add ${slug}`);
170
+ if (!line) die(L.ratchet.notDeclared(slug, `${SELF} add ${slug}`));
170
171
 
171
172
  const cmd = line.replace(/^\s*[^:]+:\s*/, "").replace(/^"|"$/g, "");
172
173
  const inKit = resolve(CWD) === resolve(PKG_ROOT);
@@ -177,7 +178,7 @@ async function cmdRatchet(args) {
177
178
  // Тогда снимаем снимок заново по внутренней команде, а строку манифеста не трогаем.
178
179
  const reg = join(CWD, RATCHET_DIR, `${slug}.txt`);
179
180
  const wrapped0 = cmd.includes("ratchet.sh");
180
- if (wrapped0 && (await exists(reg))) die(`На гейте «${slug}» храповик уже стоит.`);
181
+ if (wrapped0 && (await exists(reg))) die(L.ratchet.already(slug));
181
182
  const prefix = `bash ${lib} ${RATCHET_DIR}/${slug}.txt `;
182
183
  const inner = wrapped0 && cmd.startsWith(prefix) ? cmd.slice(prefix.length) : cmd;
183
184
 
@@ -193,11 +194,7 @@ async function cmdRatchet(args) {
193
194
  // строки не должна читаться как новое нарушение.
194
195
  const r = spawnSync(inner, { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
195
196
  if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {
196
- die(
197
- `Гейт «${slug}» не запускается: ${inner}\n` +
198
- `Снимать долг с несуществующего сторожа нельзя — в реестр попадут его же сообщения\n` +
199
- `об ошибке, и он станет разрешением. Сначала почини команду.`
200
- );
197
+ die(L.ratchet.notRunnable(slug, inner));
201
198
  }
202
199
  const keys = [...new Set(
203
200
  `${r.stdout || ""}${r.stderr || ""}`
@@ -210,10 +207,7 @@ async function cmdRatchet(args) {
210
207
  const stamp = new Date().toISOString().slice(0, 10);
211
208
  await writeFile(
212
209
  reg,
213
- `# Реестр долга: ${slug}\n` +
214
- `# Снят ${stamp}. Список разрешается ТОЛЬКО укорачивать.\n` +
215
- `# Новое нарушение красит гейт; исправленное вычёркивается автоматически.\n` +
216
- keys.join("\n") + (keys.length ? "\n" : ""),
210
+ L.ratchet.registryHead(slug, stamp) + keys.join("\n") + (keys.length ? "\n" : ""),
217
211
  "utf8"
218
212
  );
219
213
 
@@ -224,19 +218,19 @@ async function cmdRatchet(args) {
224
218
  }
225
219
 
226
220
  console.log(c.bold(`\naqk ratchet ${slug}\n`));
227
- console.log(` ${c.green("✔")} ${RATCHET_DIR}/${slug}.txt ${c.dim(`${keys.length} нарушений записано долгом`)}`);
228
- if (!inKit) console.log(` ${c.green("✔")} ${lib} ${c.dim("обёртка скопирована в проект")}`);
229
- console.log(` ${c.green("✔")} .aqk.yml ${c.dim("команда завёрнута в храповик")}`);
221
+ console.log(` ${c.green("✔")} ${RATCHET_DIR}/${slug}.txt ${c.dim(L.ratchet.recorded(keys.length))}`);
222
+ if (!inKit) console.log(` ${c.green("✔")} ${lib} ${c.dim(L.ratchet.libCopied)}`);
223
+ console.log(` ${c.green("✔")} .aqk.yml ${c.dim(L.ratchet.wrapped)}`);
230
224
  console.log(`
231
- ${c.bold("Что это меняет:")}
225
+ ${c.bold(L.ratchet.changesTitle)}
232
226
 
233
- Правило действует ${c.bold("со дня установки")}. Старый код трогать не надо, но новое
234
- нарушение того же класса гейт не пропустит.
227
+ ${L.ratchet.changes1(c.bold(L.ratchet.fromToday))}
228
+ ${L.ratchet.changes2}
235
229
 
236
- ${c.dim("Проверка, что это храповик, а не советчик: «может ли новый код добавить нарушение")}
237
- ${c.dim("и пройти?» Может — значит гейта нет.")}
230
+ ${c.dim(L.ratchet.test1)}
231
+ ${c.dim(L.ratchet.test2)}
238
232
 
239
- Прогнать: ${c.bold(`${SELF} doctor --run`)}
233
+ ${L.ratchet.run(c.bold(`${SELF} doctor --run`))}
240
234
  `);
241
235
  }
242
236
 
@@ -250,7 +244,7 @@ ${c.bold("Что это меняет:")}
250
244
 
251
245
  async function cmdFind(args) {
252
246
  const query = args.filter((a) => !a.startsWith("-")).join(" ").trim();
253
- if (!query) die(`Опиши намерение словами: ${SELF} find "отладочная печать не доезжает до прода"`);
247
+ if (!query) die(L.find.needQuery(`${SELF} ${L.find.example}`));
254
248
 
255
249
  const q = stems(query);
256
250
  const scored = (await matchCatalog(query)).map((m) => [m.score, m.rec]);
@@ -273,41 +267,41 @@ async function cmdFind(args) {
273
267
  const near = scored.filter(([sc]) => sc >= 0.3 && sc < 0.6);
274
268
 
275
269
  if (same.length) {
276
- console.log(c.green(" Такое уже есть — новую запись заводить не надо:\n"));
270
+ console.log(c.green(` ${L.find.exists}\n`));
277
271
  for (const [sc, rec] of same.slice(0, 3)) {
278
- console.log(` ${c.bold(rec.slug)} ${c.dim(`совпадение ${Math.round(sc * 100)}%`)}`);
272
+ console.log(` ${c.bold(rec.slug)} ${c.dim(L.find.match(Math.round(sc * 100)))}`);
279
273
  console.log(` ${rec.intent || ""}`);
280
274
  const langs = Object.keys(rec.recipes || {}).filter((k) => k !== "any");
281
- console.log(c.dim(` рецепты: ${langs.length ? langs.join(", ") + ", " : ""}общий`));
275
+ console.log(c.dim(` ${L.find.recipes(langs.length ? langs.join(", ") + ", " : "")}`));
282
276
  }
283
- console.log(c.dim("\n Если у тебя рецепт под другой стек — это строка в recipes существующей"));
284
- console.log(c.dim(" записи, а не новый гейт. Намерение одно, исполнителей может быть много.\n"));
277
+ console.log(c.dim(`\n ${L.find.existsWhy1}`));
278
+ console.log(c.dim(` ${L.find.existsWhy2}\n`));
285
279
  } else if (near.length) {
286
- console.log(c.yellow(" Точного совпадения нет, но рядом лежит:\n"));
280
+ console.log(c.yellow(` ${L.find.near}\n`));
287
281
  for (const [sc, rec] of near.slice(0, 4)) {
288
282
  console.log(` ${c.bold(rec.slug)} ${c.dim(`${Math.round(sc * 100)}%`)} ${rec.intent || ""}`);
289
283
  }
290
- console.log(c.dim("\n Прочитай их README. Если намерение то же — дополняй, а не заводи новое.\n"));
284
+ console.log(c.dim(`\n ${L.find.nearWhy}\n`));
291
285
  } else {
292
- console.log(c.yellow(" Такого намерения в каталоге нет.\n"));
286
+ console.log(c.yellow(` ${L.find.none}\n`));
293
287
  }
294
288
 
295
289
  if (journal.length) {
296
- console.log(c.bold(" В журнале есть шишка на эту тему:\n"));
290
+ console.log(c.bold(` ${L.find.journal}\n`));
297
291
  for (const [, date, title] of journal.slice(0, 3)) console.log(` ${c.dim(date)} ${title}`);
298
- console.log(c.dim("\n Шишка записана — значит доказательство для новой записи уже есть.\n"));
292
+ console.log(c.dim(`\n ${L.find.journalWhy}\n`));
299
293
  }
300
294
 
301
295
  if (!same.length) {
302
- console.log(`${c.bold("Как добавить свой гейт:")}
303
-
304
- 1. ${c.bold("Назови отказ.")} Какой конкретный брак он поймал в живом проекте, чего это стоило.
305
- ${c.dim("«Это хорошая практика» не принимается: так каталог набирает сотни пунктов и умирает.")}
306
- 2. ${c.bold("Заведи папку")} kit/gates/<имя>/ — gate.yml, red/, green/, README.md.
307
- ${c.dim("Норма записи со всеми полями — kit/gates/README.md")}
308
- 3. ${c.bold("Проверь машиной:")} bash tool/selfcheck/gates.sh
309
- ${c.dim("Арбитр обязан покраснеть на red/ и промолчать на green/. Не прошло — не запись.")}
310
- 4. ${c.bold("Пришли изменением")} в репозиторий комплекта.
296
+ console.log(`${c.bold(L.find.howTitle)}
297
+
298
+ 1. ${c.bold(L.find.how1)} ${L.find.how1What}
299
+ ${c.dim(L.find.how1Why)}
300
+ 2. ${c.bold(L.find.how2)} kit/gates/${L.help.name}/ ${L.find.how2What}
301
+ ${c.dim(L.find.how2Why)}
302
+ 3. ${c.bold(L.find.how3)} bash tool/selfcheck/gates.sh
303
+ ${c.dim(L.find.how3Why)}
304
+ 4. ${c.bold(L.find.how4)} ${L.find.how4What}
311
305
  `);
312
306
  }
313
307
  }
@@ -340,15 +334,15 @@ async function runsInCi(slug, cmd) {
340
334
  const script = (cmd.match(/[\w./-]+\.(?:sh|mjs|js|py)/) || [])[0];
341
335
  for (const f of files) {
342
336
  const text = await readFile(f, "utf8");
343
- if (/doctor\s+--run|--run\s+.*doctor/.test(text)) return { ci: true, runs: true, how: "разом: doctor --run" };
344
- if (text.includes(slug) || (script && text.includes(script))) return { ci: true, runs: true, how: "отдельным шагом" };
337
+ if (/doctor\s+--run|--run\s+.*doctor/.test(text)) return { ci: true, runs: true, how: L.why.ciAtOnce };
338
+ if (text.includes(slug) || (script && text.includes(script))) return { ci: true, runs: true, how: L.why.ciOwnStep };
345
339
  }
346
340
  return { ci: true, runs: false };
347
341
  }
348
342
 
349
343
  async function cmdWhy(args) {
350
344
  const query = args.filter((a) => !a.startsWith("-")).join(" ").trim();
351
- if (!query) die(`Опиши, что пропустили: ${SELF} why "файл вырос до девяти тысяч строк"`);
345
+ if (!query) die(L.why.needQuery(`${SELF} ${L.why.example}`));
352
346
 
353
347
  const man = await readManifest();
354
348
  const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
@@ -365,80 +359,80 @@ async function cmdWhy(args) {
365
359
  // случай отправляет чинить не то, а это дороже, чем лишний вопрос.
366
360
  const near = matches.filter((x) => x.rawScore >= 0.18).sort((a, b) => b.rawScore - a.rawScore);
367
361
  if (!byName && (!best || best.score < 0.5) && near.length) {
368
- console.log(c.yellow(" Уверенного совпадения нет. Похоже на эти записи:\n"));
362
+ console.log(c.yellow(` ${L.why.unsure}\n`));
369
363
  for (const m of near.slice(0, 3)) {
370
364
  console.log(` ${c.bold(m.rec.slug)} ${c.dim(`${Math.round(m.rawScore * 100)}%`)} ${m.rec.intent || ""}`);
371
365
  }
372
- console.log(c.dim(`\n Назови запись именем: ${SELF} why <имя>`));
373
- console.log(c.dim(` Ни одна не подходит — значит сторожа не было: ${SELF} new <имя>\n`));
366
+ console.log(c.dim(`\n ${L.why.unsureByName(`${SELF} why ${L.help.name}`)}`));
367
+ console.log(c.dim(` ${L.why.unsureNone(`${SELF} new ${L.help.name}`)}\n`));
374
368
  return;
375
369
  }
376
370
 
377
371
  const decide = () => {
378
- console.log(`${c.bold("Дальше решаешь ты:")} это твоя частность или общий случай?`);
379
- console.log(c.dim(" Общий — идёт в каталог и достаётся всем. Частный — остаётся у тебя."));
380
- console.log(c.dim(` Урок в общий журнал в любом случае: ${SELF} note "что случилось"\n`));
372
+ console.log(`${c.bold(L.why.decideTitle)} ${L.why.decideQ}`);
373
+ console.log(c.dim(` ${L.why.decideWhy}`));
374
+ console.log(c.dim(` ${L.why.decideNote(`${SELF} note ""`)}\n`));
381
375
  };
382
376
 
383
377
  // --- 1. сторожа не было ----------------------------------------------------
384
378
  if (!best || best.score < 0.25) {
385
- console.log(c.yellow(" Сторожа не было.") + c.dim(" В каталоге нет записи с таким намерением.\n"));
386
- console.log(` ${c.bold("Почини так:")} заведи запись — ${c.bold(`${SELF} new <имя>`)}`);
387
- console.log(c.dim(" Красный образец бери прямо из этой поломки: она уже случилась, выдумывать нечего.\n"));
379
+ console.log(c.yellow(` ${L.why.noGuard}`) + c.dim(` ${L.why.noGuardWhy}\n`));
380
+ console.log(` ${c.bold(L.why.fix)} ${L.why.noGuardFix(c.bold(`${SELF} new ${L.help.name}`))}`);
381
+ console.log(c.dim(` ${L.why.noGuardHint}\n`));
388
382
  decide();
389
383
  return;
390
384
  }
391
385
 
392
386
  const slug = best.rec.slug;
393
- console.log(` Ближайшая запись каталога: ${c.bold(slug)} ${c.dim(`совпадение ${Math.round(best.score * 100)}%`)}`);
387
+ console.log(` ${L.why.closest(c.bold(slug), Math.round(best.score * 100))}`);
394
388
  console.log(` ${c.dim(best.rec.intent || "")}\n`);
395
389
 
396
390
  // --- 2. запись есть, но в проекте не объявлена -----------------------------
397
391
  const cmd = gates[slug];
398
392
  if (!cmd || !String(cmd).trim()) {
399
- console.log(c.yellow(" Сторож есть в каталоге, но в этом проекте не поставлен.\n"));
400
- console.log(` ${c.bold("Почини так:")} ${c.bold(`${SELF} add ${slug}`)}`);
401
- console.log(c.dim(" Он покраснеет на старом коде — это нормально: старое закрывается храповиком,"));
402
- console.log(c.dim(` новое ловится со дня установки. ${SELF} ratchet ${slug}\n`));
393
+ console.log(c.yellow(` ${L.why.notInstalled}\n`));
394
+ console.log(` ${c.bold(L.why.fix)} ${c.bold(`${SELF} add ${slug}`)}`);
395
+ console.log(c.dim(` ${L.why.notInstalledHint1}`));
396
+ console.log(c.dim(` ${L.why.notInstalledHint2(`${SELF} ratchet ${slug}`)}\n`));
403
397
  decide();
404
398
  return;
405
399
  }
406
400
 
407
401
  // --- 3. объявлен: спрашиваем у него самого ---------------------------------
408
- console.log(c.dim(` Объявлен: ${cmd}`));
402
+ console.log(c.dim(` ${L.why.declaredAs(cmd)}`));
409
403
  const r = spawnSync(String(cmd), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
410
404
  const ci = await runsInCi(slug, String(cmd));
411
405
 
412
406
  if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {
413
- console.log(c.yellow("\n Сторож объявлен, но не запускается.") + c.dim(" Худший случай: тишина читается как успех.\n"));
414
- console.log(` ${c.bold("Почини так:")} путь или программа из команды не существуют — проверь их.`);
415
- console.log(c.dim(" Отсутствие сигнала неотличимо от успеха, поэтому это не «мелочь в конфиге».\n"));
407
+ console.log(c.yellow(`\n ${L.why.notRunning}`) + c.dim(` ${L.why.notRunningWhy}\n`));
408
+ console.log(` ${c.bold(L.why.fix)} ${L.why.notRunningFix}`);
409
+ console.log(c.dim(` ${L.why.notRunningHint}\n`));
416
410
  decide();
417
411
  return;
418
412
  }
419
413
 
420
414
  if (r.status !== 0) {
421
- console.log(c.yellow("\n Сторож есть и эту поломку ловит — значит его обошли.\n"));
415
+ console.log(c.yellow(`\n ${L.why.bypassed}\n`));
422
416
  if (!ci.ci) {
423
- console.log(` ${c.bold("Почини так:")} конвейера нет. Проверка, которую гоняет только человек,`);
424
- console.log(c.dim(" работает ровно до первого «забыл».\n"));
417
+ console.log(` ${c.bold(L.why.fix)} ${L.why.noCiFix}`);
418
+ console.log(c.dim(` ${L.why.noCiHint}\n`));
425
419
  } else if (!ci.runs) {
426
- console.log(` ${c.bold("Почини так:")} конвейер есть, но этот гейт в нём не запускается.`);
427
- console.log(c.dim(` Дешевле всего одним шагом: ${SELF} doctor --run — он гоняет всё объявленное.\n`));
420
+ console.log(` ${c.bold(L.why.fix)} ${L.why.notInCiFix}`);
421
+ console.log(c.dim(` ${L.why.notInCiHint(`${SELF} doctor --run`)}\n`));
428
422
  } else {
429
- console.log(` ${c.bold("Почини так:")} конвейер его гоняет (${ci.how}) — значит красный прогон`);
430
- console.log(c.dim(" кто-то пропустил или обошёл. Перенеси правило из текста в механику:"));
431
- console.log(c.dim(" блокирующий шаг, а не необязательный; запрет слияния при красном.\n"));
423
+ console.log(` ${c.bold(L.why.fix)} ${L.why.inCiFix(ci.how)}`);
424
+ console.log(c.dim(` ${L.why.inCiHint1}`));
425
+ console.log(c.dim(` ${L.why.inCiHint2}\n`));
432
426
  }
433
427
  decide();
434
428
  return;
435
429
  }
436
430
 
437
- console.log(c.yellow("\n Сторож есть, стоит и запускается — но этой поломки не видит.\n"));
438
- console.log(` ${c.bold("Почини так:")} положи в ${c.bold(`${slug}/red/`)} кусок кода из этой поломки`);
439
- console.log(c.dim(" и доведи проверку до красного на нём. Порядок обратный привычному: сначала"));
440
- console.log(c.dim(" образец, потом правка — иначе непонятно, что именно починено.\n"));
441
- console.log(c.dim(` Проверить после правки: bash tool/selfcheck/gates.sh\n`));
431
+ console.log(c.yellow(`\n ${L.why.blind}\n`));
432
+ console.log(` ${c.bold(L.why.fix)} ${L.why.blindFix(c.bold(`${slug}/red/`))}`);
433
+ console.log(c.dim(` ${L.why.blindHint1}`));
434
+ console.log(c.dim(` ${L.why.blindHint2}\n`));
435
+ console.log(c.dim(` ${L.why.blindCheck}\n`));
442
436
  decide();
443
437
  }
444
438