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