agent-quality-kit 0.10.1 → 0.12.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 (67) hide show
  1. package/README.md +10 -3
  2. package/README.ru.md +10 -3
  3. package/kit/gates/_native.sh +4 -0
  4. package/kit/gates/_skip.sh +7 -0
  5. package/kit/gates/api-contract-has-arbiter/check.sh +7 -0
  6. package/kit/gates/color-from-token/check.sh +7 -0
  7. package/kit/gates/commit-explains-itself/check.sh +8 -2
  8. package/kit/gates/complexity-limit/check.sh +22 -4
  9. package/kit/gates/complexity-limit/gate.yml +7 -2
  10. package/kit/gates/complexity-limit/red/deep.js +15 -0
  11. package/kit/gates/dead-code/gate.yml +5 -0
  12. package/kit/gates/duplicate-code/check.sh +7 -0
  13. package/kit/gates/duplicate-code/gate.yml +10 -1
  14. package/kit/gates/file-size-limit/check.sh +7 -0
  15. package/kit/gates/file-size-limit/red/big.js +600 -0
  16. package/kit/gates/gate-not-weakened/check.sh +7 -0
  17. package/kit/gates/gate-not-weakened/green/suppress.js +4 -0
  18. package/kit/gates/gate-not-weakened/red/suppress.js +5 -0
  19. package/kit/gates/mcp-server-resolves/check.sh +7 -0
  20. package/kit/gates/no-phantom-package/check.sh +7 -0
  21. package/kit/gates/no-print-in-prod/gate.yml +8 -3
  22. package/kit/gates/personal-config-not-shared/check.sh +7 -0
  23. package/kit/gates/secrets-not-in-code/check.sh +7 -0
  24. package/kit/gates/secrets-not-in-code/gate.yml +20 -0
  25. package/kit/gates/secrets-not-in-code/green/config.js +4 -0
  26. package/kit/gates/secrets-not-in-code/red/leak.js +6 -0
  27. package/kit/gates/swallowed-error/gate.yml +8 -3
  28. package/kit/gates/test-has-assertion/check.sh +7 -0
  29. package/kit/gates/test-has-assertion/green/checkout.test.js +5 -0
  30. package/kit/gates/test-has-assertion/red/checkout.test.js +8 -0
  31. package/kit/gates/test-not-adjusted/check.sh +8 -2
  32. package/kit/gates/todo-without-task/check.sh +7 -0
  33. package/kit/gates/todo-without-task/gate.yml +7 -2
  34. package/kit/gates/todo-without-task/green/app.js +2 -0
  35. package/kit/gates/todo-without-task/red/later.js +4 -0
  36. package/llms.txt +4 -2
  37. package/package.json +1 -1
  38. package/tool/commands/badge.mjs +1 -1
  39. package/tool/commands/context.mjs +3 -1
  40. package/tool/commands/doctor.mjs +94 -152
  41. package/tool/commands/gates.mjs +3 -2
  42. package/tool/commands/probe.mjs +284 -44
  43. package/tool/commands/report.mjs +1 -1
  44. package/tool/commands/vitals.mjs +6 -1
  45. package/tool/i18n/en-docs.mjs +2 -1
  46. package/tool/i18n/en-gates.mjs +23 -5
  47. package/tool/i18n/en.mjs +16 -0
  48. package/tool/i18n/ru-docs.mjs +2 -1
  49. package/tool/i18n/ru-gates.mjs +24 -6
  50. package/tool/i18n/ru.mjs +16 -0
  51. package/tool/lib/adopt.mjs +98 -0
  52. package/tool/lib/cadence.mjs +36 -1
  53. package/tool/lib/execution.mjs +50 -1
  54. package/tool/lib/history.mjs +89 -8
  55. package/tool/lib/prove.mjs +2 -2
  56. package/tool/lib/repo.mjs +47 -0
  57. package/tool/lib/run.mjs +194 -0
  58. package/tool/selfcheck/gates.sh +42 -4
  59. package/tool/selfcheck/smoke/_fixture.mjs +24 -2
  60. package/tool/selfcheck/smoke/fail-closed.test.mjs +112 -0
  61. package/tool/selfcheck/smoke/own-samples.test.mjs +131 -0
  62. package/tool/selfcheck/units-cadence.mjs +37 -1
  63. package/tool/selfcheck/units-execution.mjs +78 -1
  64. package/tool/selfcheck/units-level.mjs +24 -0
  65. package/tool/selfcheck/units-probe.mjs +385 -22
  66. package/tool/selfcheck/units-repo.mjs +123 -1
  67. package/tool/selfcheck/units.mjs +34 -1
@@ -21,16 +21,17 @@
21
21
  // удаляется. Не меняет манифест. Не роняет прогон: код возврата всегда 0 — это осмотр, а
22
22
  // не порог. Порог — у `doctor --run --min`.
23
23
  import { spawnSync } from "node:child_process";
24
- import { mkdtemp, mkdir, copyFile, rm, readdir, writeFile, readFile } from "node:fs/promises";
24
+ import { mkdtemp, mkdir, copyFile, rm, readdir, writeFile, readFile, symlink } from "node:fs/promises";
25
+ import { readFileSync } from "node:fs";
25
26
  import { tmpdir } from "node:os";
26
27
  import { join, dirname, extname } from "node:path";
27
28
  import { readManifest } from "../lib/manifest.mjs";
28
- import { commandFor } from "../lib/prove.mjs";
29
- import { fixHotspots, probeVerdict } from "../lib/history.mjs";
29
+ import { fixHotspots, probeSummary, probeVerdictPaired, countProbe } from "../lib/history.mjs";
30
30
  import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
31
31
  import { CWD, GATES_SRC, TARGET_DIR, c, SELF, exists } from "../lib/core.mjs";
32
- import { probeState, probeEvery, PROBE_EVERY } from "../lib/cadence.mjs";
32
+ import { probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan } from "../lib/cadence.mjs";
33
33
  import { L } from "../i18n/index.mjs";
34
+ import { gateCommand } from "../lib/execution.mjs";
34
35
 
35
36
  // Тот же набор расширений, что у привязки доказательства к дифу. Список один на программу:
36
37
  // второй через месяц разошёлся бы с первым.
@@ -99,14 +100,107 @@ async function writeMark(now, blind, lines) {
99
100
  await writeFile(MARK(), body + "\n", "utf8");
100
101
  }
101
102
 
102
- // Гейты, которым можно подставить каталог. Команда записи каталога кончается каталогом
103
- // проверки; написанная руками чем угодно, и подставлять там некуда. Ровно то же правило,
104
- // по которому `prove` объявляет запись недоказуемой, а не сломанной.
105
- function scanningGates(man) {
103
+
104
+ // Отчего проба не состоялась. Раньше здесь было три состояния: гейты, чья команда не кончается
105
+ // каталогом, объявлялись непригодными подставить образец было некуда. С песочницей подставлять
106
+ // в команду больше не нужно: образец кладётся в КОПИЮ ПРОЕКТА, а гейт запускается в ней как есть.
107
+ // Поэтому пригодна любая непустая команда, и состояний осталось два.
108
+ function gatesState(man) {
109
+ const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
110
+ const declared = Object.entries(gates)
111
+ .map(([name, raw]) => [name, String(raw || "").trim()])
112
+ .filter(([, cmd]) => cmd);
113
+ if (!declared.length) return { state: "none", declared: 0, probeable: 0 };
114
+ return { state: "ok", declared: declared.length, probeable: declared.length };
115
+ }
116
+
117
+ // Гейты, пригодные для пробы: все объявленные с непустой командой.
118
+ function probeableGates(man) {
106
119
  const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
107
120
  return Object.entries(gates)
108
121
  .map(([name, raw]) => [name, String(raw || "").trim()])
109
- .filter(([, cmd]) => cmd && /(\.|\.\/)$/.test(cmd));
122
+ .filter(([, cmd]) => cmd);
123
+ }
124
+
125
+ // Семьи расширений. Образец подбирается по ТОЧНОМУ расширению, и правило верное: питоновский
126
+ // образец в проекте на TypeScript не проверит ничего, а покажет «не прикрыто» — ложная тревога
127
+ // того же класса, что молчащий гейт, только наоборот.
128
+ //
129
+ // Но `.js` и `.mjs` — одно и то же содержимое, а не два языка. Прогон на самом комплекте
130
+ // 2026-09-10: два горячих файла из пяти — `.mjs`, и обоим ответили «нет образца под .mjs»;
131
+ // комплект целиком написан в этом расширении, то есть проба была слепа к собственному коду.
132
+ // Заводить второй набор файлов ради той же строчки — дублирование, которое разойдётся.
133
+ //
134
+ // Семьи узкие намеренно: `.jsx`/`.tsx` сюда не входят, у них своя разметка.
135
+ const EXT_FAMILIES = [[".js", ".mjs", ".cjs"], [".ts", ".mts", ".cts"]];
136
+
137
+ function extAlternatives(ext) {
138
+ const fam = EXT_FAMILIES.find((f) => f.includes(ext));
139
+ return fam ? [ext, ...fam.filter((e) => e !== ext)] : [ext];
140
+ }
141
+
142
+ // Показать САМ ОБРАЗЕЦ, а не пересказ. «Класс не прикрыт» остаётся словами, пока человек не
143
+ // увидел, что именно мы подсадили в его файл.
144
+ //
145
+ // Первая версия печатала одну «показательную» строку — и угадывала плохо: у мёртвого кода дефект
146
+ // во ВТОРОЙ функции, у отладочной печати во второй строке тела. Угадывать не надо: образцы
147
+ // каталога маленькие по норме, и четырёх строк хватает, чтобы стало видно. Комментарии
148
+ // выброшены: в наших образцах они объясняют замысел коллеге, а не показывают дефект.
149
+ function sampleLines(path, max = 4) {
150
+ let text = "";
151
+ try { text = readFileSync(path, "utf8"); } catch { return []; }
152
+ return text.split("\n")
153
+ .map((l) => l.replace(/\s+$/, ""))
154
+ .filter((l) => l.trim() && !/^\s*(#|\/\/|\/\*|\*|--|<!--)/.test(l))
155
+ .slice(0, max)
156
+ .map((l) => l.slice(0, 88));
157
+ }
158
+
159
+ // Совет по НЕПОКРЫТОМУ классу: команда, которую можно вставить прямо сейчас.
160
+ //
161
+ // Проба находит настоящие дыры и печатала про них «close it: aqk add <имя>» — то есть «поставь
162
+ // нашу штуку». Человек, впервые увидевший комплект, закрывает окно. А готовая однострочная
163
+ // команда под его стек У НАС УЖЕ ЛЕЖИТ в `recipes` записи каталога; мы её не показывали.
164
+ //
165
+ // Замер руками на `requests` (самый скачиваемый python-пакет) 2026-09-10: в
166
+ // `src/requests/utils.py` — 75 коммитов-починок; дописана функция с `except Exception: pass`;
167
+ // их собственные `ruff` и `pytest` дали 0 и на чистой копии, и на подсаженной. Строка, которая
168
+ // поймала бы это, лежала в нашем каталоге всё это время.
169
+ //
170
+ // Переносимый рецепт (`any`) в совет НЕ идёт: он зовёт файл из комплекта, и человеку без
171
+ // комплекта вставить его некуда. Нет родного рецепта под стек — команды нет, и это честнее
172
+ // выдуманной.
173
+ function blindAdvice(entry, facts, hot = {}) {
174
+ const recipes = entry?.recipes && typeof entry.recipes === "object" ? entry.recipes : {};
175
+ // `langs` приходит МНОЖЕСТВОМ, а не массивом — `Array.isArray` тихо давал пустой список, и
176
+ // совет не печатался вовсе. Поймано на живом `requests`: langs = Set(1) { python }.
177
+ const langs = facts?.langs ? [...facts.langs] : [];
178
+ // Тот же порядок, что у `pickRecipe`: свой язык → безъязыковой родной → ничего. Переносимый
179
+ // (`any`) сюда не идёт никогда: он зовёт файл из комплекта, и человеку без комплекта вставить
180
+ // его некуда.
181
+ let cmd = null;
182
+ for (const key of [...langs, "native"]) {
183
+ const r = recipes[key];
184
+ if (!r || /\{gate\}/.test(r)) continue;
185
+ cmd = String(r).replace(/\{dir\}/g, ".")
186
+ // Вычистить то, что относится к НАМ, а не к его проекту. Исключение наших красных
187
+ // образцов нужно УСТАНОВЛЕННОМУ гейту — рядом с ним лежат образцы. Человеку, который
188
+ // команду только копирует, этих каталогов не существует, и флаги про них подрывают
189
+ // доверие: инструмент говорит про чужое хозяйство вместо его кода.
190
+ .replace(/\s--ignore-pattern\s+'[^']*gates\/[^']*'/g, "")
191
+ .replace(/\s--ignore-paths=?\s*'[^']*gates\/[^']*'/g, "")
192
+ .replace(/\s+/g, " ")
193
+ .trim();
194
+ break;
195
+ }
196
+ // Адрес — того инструмента, которым команда начинается: поле `tool` общее на все языки, и
197
+ // python-проекту показывалась ссылка на eslint. Первые три слова, а не одно: `npx knip`,
198
+ // `python -m vulture`. Не совпало — весь список: лишняя ссылка лучше, чем ни одной.
199
+ const urls = entry?.tool ? String(entry.tool).split(/\s+·\s+/) : [];
200
+ const head = cmd ? cmd.split(" ").slice(0, 3) : [];
201
+ const own = urls.find((u) => head.includes(u.replace(/\/+$/, "").split("/").pop()));
202
+ const tool = own ?? (entry?.tool ? String(entry.tool) : null);
203
+ return { command: cmd, tool, file: hot.file ?? null, fixes: hot.fixes ?? null, slug: entry?.slug ?? null };
110
204
  }
111
205
 
112
206
  // Красный образец записи, подходящий по расширению горячего файла. Расширение обязано
@@ -117,39 +211,114 @@ async function redSampleFor(entry, ext) {
117
211
  if (!(await exists(dir))) return null;
118
212
  let names = [];
119
213
  try { names = await readdir(dir); } catch { return null; }
120
- const hit = names.find((n) => extname(n).toLowerCase() === ext);
121
- return hit ? join(dir, hit) : null;
214
+ for (const want of extAlternatives(ext)) {
215
+ const hit = names.find((n) => extname(n).toLowerCase() === want);
216
+ if (hit) return join(dir, hit);
217
+ }
218
+ return null;
122
219
  }
123
220
 
124
- // Проба: временный каталог, в нём образец по пути горячего файла. Путь сохраняется целиком —
125
- // правила, привязанные к путям (`.aqkignore`, исключения гейтов), обязаны действовать так же,
126
- // как в настоящем репозитории. Без этого проба отвечала бы про несуществующее место.
127
- async function buildProbe(relPath, sample) {
128
- const root = await mkdtemp(join(tmpdir(), "aqk-probe-"));
221
+ // Песочница: КОПИЯ ПРОЕКТА, в которую подсаживается образец. Раньше здесь был временный каталог
222
+ // с одним файлом, а путь к нему подставлялся в команду гейта отчего пробовать можно было
223
+ // только команды, кончающиеся каталогом. Замер 2026-09-10 на семи чужих репозиториях: у шести
224
+ // команды такие (`xo`, `eslint lib/**/*.js`, `mocha --require…`, `pytest`), и проба не
225
+ // запускалась вовсе.
226
+ //
227
+ // Способ взят из мутационного тестирования, где та же задача решена двадцать лет назад: Stryker
228
+ // копирует проект во временный каталог, СИМЛИНКУЕТ `node_modules` и гоняет там родную команду.
229
+ // Копируются только ОТСЛЕЖИВАЕМЫЕ файлы (`git archive HEAD`) — рабочее дерево не трогается, а
230
+ // мусор сборки не тащится; тяжёлые каталоги зависимостей симлинкуются, иначе `npm test` в
231
+ // песочнице падал бы с «модуль не найден», и это читалось бы как сбой инструмента.
232
+ const DEP_DIRS = ["node_modules", ".venv", "venv", "vendor", "target", ".tox", ".bundle"];
233
+
234
+ async function buildSandbox() {
235
+ // Копируется РАБОЧЕЕ ДЕРЕВО, а не HEAD. Первая версия брала `git archive HEAD`, и это было
236
+ // неверно: комплект зовут из хука ДО коммита, и пользователь пробует то, что у него сейчас,
237
+ // а не то, что уже записано. На свежем `init` + `add` без коммита проба вообще ничего не
238
+ // видела — гейты в песочнице отсутствовали и «не запускались».
239
+ //
240
+ // Список — `git ls-files --cached --others --exclude-standard`: отслеживаемые плюс новые, но
241
+ // БЕЗ игнорируемых. Игнорируемое — это сборка и зависимости; первое пробе не нужно, второе
242
+ // приходит симлинком.
243
+ //
244
+ // Копирование средствами node, а не `tar`: у конвейера есть windows-задание, и полагаться на
245
+ // ключи GNU tar там нельзя.
246
+ const r = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
247
+ cwd: CWD, encoding: "utf8", timeout: 60000, maxBuffer: 64 * 1024 * 1024,
248
+ });
249
+ if (r.status !== 0) return null;
250
+ const files = String(r.stdout || "").split("\0").filter(Boolean);
251
+ if (!files.length) return null;
252
+
253
+ const root = await mkdtemp(join(tmpdir(), "aqk-sandbox-"));
254
+ const made = new Set();
255
+ for (const rel of files) {
256
+ const dest = join(root, rel);
257
+ const dir = dirname(dest);
258
+ if (!made.has(dir)) { await mkdir(dir, { recursive: true }); made.add(dir); }
259
+ // Файл мог исчезнуть между списком и копией, а каталог — оказаться подмодулем.
260
+ try { await copyFile(join(CWD, rel), dest); } catch { /* пропускаем, не роняя пробу */ }
261
+ }
262
+ for (const dep of DEP_DIRS) {
263
+ const from = join(CWD, dep);
264
+ if (await exists(from)) { try { await symlink(from, join(root, dep), "junction"); } catch { /* уже есть */ } }
265
+ }
266
+ return root;
267
+ }
268
+
269
+ // Подсадка образца на место горячего файла и возврат как было. Файл СНАЧАЛА удаляется:
270
+ // в песочнице он может быть жёсткой ссылкой, и запись поверх задела бы оригинал.
271
+ async function plant(root, relPath, sample) {
129
272
  const dest = join(root, relPath);
130
273
  await mkdir(dirname(dest), { recursive: true });
274
+ let backup = null;
275
+ try { backup = await readFile(dest); } catch { /* файла может не быть */ }
276
+ await rm(dest, { force: true });
131
277
  await copyFile(sample, dest);
132
- // Переносится ТОЛЬКО .aqkignore: правила, привязанные к путям, обязаны действовать так же,
133
- // как в настоящем репозитории. Манифест НЕ переносится намеренно — иначе записи, читающие
134
- // `.aqk.yml` (`gates-are-runnable`, `gate-has-samples`, `protection-not-removed`), краснеют
135
- // на том, что в пробе нет объявленных ими файлов, и проба объявляет класс прикрытым, хотя
136
- // на подсаженный брак не отреагировал никто. Ошибка в сторону «прикрыто» — это тишина,
137
- // а тишина здесь и есть предмет спора. Поймано первым же прогоном на своём репозитории.
138
- if (await exists(join(CWD, ".aqkignore"))) {
139
- await copyFile(join(CWD, ".aqkignore"), join(root, ".aqkignore"));
140
- }
141
- return root;
278
+ return async () => {
279
+ await rm(dest, { force: true });
280
+ if (backup !== null) await writeFile(dest, backup);
281
+ };
142
282
  }
143
283
 
144
- function runGates(gates, dir) {
284
+ // Гейт запускается В ПЕСОЧНИЦЕ и командой КАК ЕСТЬ — ничего в неё не подставляется. Именно это
285
+ // и делает пробу независимой от формы команды.
286
+ //
287
+ // `stopOnRed` — ранний выход: как только гейт покраснел, вердикт «поймано» уже получен, и гонять
288
+ // остальные незачем. На сухом прогоне выхода нет: там нужны ВСЕ длительности и все коды.
289
+ function runGates(gates, sandbox, { stopOnRed = false } = {}) {
145
290
  const out = [];
146
291
  for (const [name, cmd] of gates) {
147
- const r = spawnSync(commandFor(cmd, dir), { shell: true, cwd: CWD, encoding: "utf8", timeout: 120000 });
148
- out.push({ name, code: r.status === null ? 2 : r.status });
292
+ const t0 = Date.now();
293
+ const r = spawnSync(gateCommand(cmd), { shell: true, cwd: sandbox, encoding: "utf8", timeout: 120000 });
294
+ const code = r.status === null ? 2 : r.status;
295
+ out.push({ name, code, ms: Date.now() - t0 });
296
+ if (stopOnRed && code === 1) break;
149
297
  }
150
298
  return out;
151
299
  }
152
300
 
301
+ // Каким гейтом пробовать и в каком порядке.
302
+ //
303
+ // Цена пробы = (файлы × записи) × сумма длительностей гейтов. На самом комплекте после перехода
304
+ // на песочницу это стало больше десяти минут и упёрлось в таймаут: среди тридцати гейтов есть
305
+ // `smoke` на 58 секунд, и он гонялся заново на каждую подсадку. Команда, идущая четверть часа,
306
+ // не запускается никем.
307
+ //
308
+ // Длительности берутся из сухого прогона, который и так обязателен. Порядок — от быстрых к
309
+ // медленным, чтобы ранний выход срабатывал раньше. Слишком медленные исключаются, но НЕ молча:
310
+ // их имена обязаны попасть в вывод, иначе «никто не ловит» будет означать «никто из тех, кого
311
+ // мы решили спросить».
312
+ const SLOW_MS = 20000;
313
+
314
+ function planProbeGates(before, { slowMs = SLOW_MS } = {}) {
315
+ const usable = before.filter((r) => r.code === 0).sort((a, b) => (a.ms || 0) - (b.ms || 0));
316
+ return {
317
+ use: usable.filter((r) => (r.ms || 0) <= slowMs),
318
+ tooSlow: usable.filter((r) => (r.ms || 0) > slowMs),
319
+ };
320
+ }
321
+
153
322
  // `auto` — проба запущена САМА, по каденции, из `doctor --run`. Тогда она короче и говорит
154
323
  // вслух, почему случилась: команда, возникшая без спроса, обязана объяснить себя, иначе её
155
324
  // читают как сбой.
@@ -161,8 +330,9 @@ async function cmdProbe(args, { auto = false } = {}) {
161
330
  console.log(c.bold(`\n${P.title}\n`));
162
331
 
163
332
  const man = await readManifest();
164
- const gates = scanningGates(man);
165
- if (!gates.length) { console.log(c.yellow(` ${P.noGates(`${SELF} add <имя>`)}\n`)); return; }
333
+ const gates = probeableGates(man);
334
+ const gs = gatesState(man);
335
+ if (gs.state === "none") { console.log(c.yellow(` ${P.noGates(`${SELF} add <имя>`)}\n`)); return; }
166
336
 
167
337
  const raw = gitLog(2000);
168
338
  if (raw === null) { console.log(c.yellow(` ${P.noGit}\n`)); return; }
@@ -175,9 +345,40 @@ async function cmdProbe(args, { auto = false } = {}) {
175
345
  const catalog = await readCatalog();
176
346
  const entries = catalog.filter((e) => triggerVerdict(e, facts).applies);
177
347
 
178
- console.log(c.dim(` ${P.method(hot.length, entries.length)}\n`));
348
+ // ПЕСОЧНИЦА строится ОДИН раз на прогон, а не на каждую пробу: копия отслеживаемых файлов
349
+ // стоит доли секунды, но умножать её на файлы × записи незачем — между пробами меняется
350
+ // ровно один файл.
351
+ const sandbox = await buildSandbox();
352
+ if (!sandbox) { console.log(c.yellow(` ${P.noSandbox}\n`)); return; }
353
+
354
+ try {
355
+ // СУХОЙ ПРОГОН по чистой песочнице. Без него «покраснел от подсадки» неотличимо от «был
356
+ // красным и до неё»: у чужого проекта гейты краснеют на своём накопленном долге, и
357
+ // засчитывать эту красноту за поимку значит выдавать чужой долг за свою заслугу.
358
+ // В мутационном тестировании этот прогон обязателен по той же причине.
359
+ const before = runGates(gates, sandbox);
360
+ const plan = planProbeGates(before);
361
+ if (!plan.use.length) {
362
+ const red = before.filter((r) => r.code === 1).map((r) => r.name);
363
+ const broke = before.filter((r) => r.code !== 0 && r.code !== 1).map((r) => r.name);
364
+ if (plan.tooSlow.length) console.log(c.yellow(` ${P.allSlow(plan.tooSlow.map((g) => g.name))}\n`));
365
+ else console.log(c.yellow(` ${P.noBaseline(red, broke)}\n`));
366
+ return;
367
+ }
368
+ // Пробуем только запланированными, в порядке плана.
369
+ const byName = new Map(gates);
370
+ const probeGates = plan.use.map((g) => [g.name, byName.get(g.name)]);
371
+ const baseline = plan.use.map((g) => ({ name: g.name, code: g.code }));
179
372
 
180
- let blind = 0;
373
+ console.log(c.dim(` ${P.method(hot.length, entries.length, plan.use.length)}\n`));
374
+ if (plan.tooSlow.length) {
375
+ console.log(c.dim(` ${P.tooSlow(plan.tooSlow.map((g) => `${g.name} (${Math.round(g.ms / 1000)}s)`))}\n`));
376
+ }
377
+
378
+ // Записи проб: по ним считаются КЛАССЫ, а не события. Счётчики на месте были
379
+ // событиями и втрое завышали итог — см. countProbe.
380
+ const records = [];
381
+ let unprobedN = 0;
181
382
  for (const { path: rel, fixes } of hot) {
182
383
  console.log(` ${c.bold(rel)} ${c.dim(P.fixes(fixes))}`);
183
384
  const ext = extname(rel).toLowerCase();
@@ -187,29 +388,67 @@ async function cmdProbe(args, { auto = false } = {}) {
187
388
  const sample = await redSampleFor(e.slug, ext);
188
389
  if (!sample) continue;
189
390
  probed++;
190
- const dir = await buildProbe(rel, sample);
191
- let results;
192
- try { results = runGates(gates, dir); } finally { await rm(dir, { recursive: true, force: true }); }
193
- const verdict = probeVerdict(results);
194
- const caught = results.filter((r) => r.code === 1).map((r) => r.name);
391
+ const restore = await plant(sandbox, rel, sample);
392
+ let after;
393
+ try { after = runGates(probeGates, sandbox, { stopOnRed: true }); } finally { await restore(); }
394
+ // Ранний выход обрывает список: гейты, до которых не дошли, считаются такими же, как на
395
+ // сухом прогоне. Иначе их отсутствие прочиталось бы как сбой запуска.
396
+ const seen = new Set(after.map((a) => a.name));
397
+ const full = after.concat(baseline.filter((b) => !seen.has(b.name)));
398
+ const r = probeVerdictPaired(baseline, full);
399
+ const verdict = r.verdict;
400
+ const caught = full.filter((a) => a.code === 1 && baseline.find((b) => b.name === a.name)?.code === 0)
401
+ .map((a) => a.name);
402
+ records.push({ entry: e.slug, file: rel, verdict });
195
403
  if (verdict === "caught") {
196
404
  console.log(` ${c.green("✔")} ${e.intent.padEnd(48)} ${c.dim(P.caught(caught.join(", ")))}`);
197
405
  } else if (verdict === "blind") {
198
- blind++;
199
406
  console.log(` ${c.red("✘")} ${e.intent.padEnd(48)} ${c.red(P.blind)}`);
407
+ // Объяснить, а не назвать. Три строки, каждая отвечает на свой вопрос человека:
408
+ // «почему именно здесь», «что вы вообще подсадили» и «что мне сделать ПРЯМО СЕЙЧАС».
409
+ // Последняя обязана работать БЕЗ комплекта: польза до установки — единственный
410
+ // способ заслужить установку.
411
+ const adv = blindAdvice(e, facts, { file: rel, fixes });
412
+ console.log(c.dim(` ${P.blindWhere(rel, fixes)}`));
413
+ const lines = sampleLines(sample);
414
+ if (lines.length) {
415
+ console.log(c.dim(` ${P.blindWhat}`));
416
+ for (const l of lines) console.log(c.dim(` ${l}`));
417
+ }
418
+ if (adv.command) console.log(` ${c.yellow(P.blindFix(adv.command))}`);
419
+ if (adv.tool) console.log(c.dim(` ${P.blindTool(adv.tool)}`));
200
420
  console.log(c.dim(` ${P.install(`${SELF} add ${e.slug}`)}`));
201
421
  } else {
202
422
  console.log(` ${c.dim("~")} ${c.dim(e.intent.padEnd(48))} ${c.dim(P.unknown)}`);
203
423
  }
204
424
  }
205
- if (!probed) console.log(c.dim(` ${P.noSampleFor(ext || "—")}`));
425
+ if (!probed) { unprobedN++; console.log(c.dim(` ${P.noSampleFor(ext || "—")}`)); }
206
426
  }
207
427
 
208
- console.log(blind ? c.yellow(`\n ${P.summaryBlind(blind)}\n`) : c.green(`\n ${P.summaryClean}\n`));
428
+ const n = countProbe(records);
429
+ const blind = n.blindClasses;
430
+ const state = probeSummary({
431
+ caught: n.caughtClasses, blind, unknown: n.unknownClasses, unprobed: unprobedN,
432
+ });
433
+ const say = {
434
+ blind: () => c.yellow(P.summaryBlind(blind, n.probes)),
435
+ partial: () => c.yellow(P.summaryPartial(n.caughtClasses, n.unknownClasses, unprobedN)),
436
+ clean: () => c.green(P.summaryClean),
437
+ "nothing-ran": () => c.yellow(P.summaryNothingRan(n.unknownClasses)),
438
+ "nothing-probed": () => c.yellow(P.summaryNothingProbed(unprobedN)),
439
+ };
440
+ console.log(`\n ${say[state]()}\n`);
209
441
 
210
442
  // Отметка нужна не для отчёта, а для КАДЕНЦИИ: по ней следующий прогон поймёт, что пора.
211
443
  // Без неё команда снова становится тем, о чём надо вспомнить.
212
- await writeMark(commitCount(), blind, hot.map(({ path: p2, fixes }) => `- ${p2} (${P.fixes(fixes)})`));
444
+ await writeMark(commitCount(), blind, [
445
+ `ran: ${probeGates.map(([name]) => name).join(" ")}`, "",
446
+ ...blindLines(records), "",
447
+ ...hot.map(({ path: p2, fixes }) => `- ${p2} (${P.fixes(fixes)})`),
448
+ ]);
449
+ } finally {
450
+ await rm(sandbox, { recursive: true, force: true });
451
+ }
213
452
  }
214
453
 
215
454
  // Состояние пробы для тех, кто только ПОКАЗЫВАЕТ его: прогон и блок для агента.
@@ -222,7 +461,8 @@ async function probeStatus() {
222
461
  const every = probeEvery(man);
223
462
  if (every === null) return { state: "unknown", behind: null, badEvery: String(man?.probe) };
224
463
  if (every === 0) return { state: "off", behind: null };
225
- return probeState(await readMark(), commitCount(), every);
464
+ const mark = await readMark();
465
+ return { ...probeState(mark, commitCount(), every), classes: parseBlind(mark?.text), ran: parseRan(mark?.text) };
226
466
  }
227
467
 
228
- export { cmdProbe, probeStatus, scanningGates, isCode };
468
+ export { cmdProbe, probeStatus, probeableGates, gatesState, extAlternatives, planProbeGates, blindAdvice, isCode };
@@ -21,7 +21,7 @@ import { readManifest, assessLevel } from "../lib/manifest.mjs";
21
21
  import { proveGates } from "../lib/prove.mjs";
22
22
  import { detectFacts, readCatalog, triggerVerdict, whichSync } from "../lib/repo.mjs";
23
23
  import { changedCode, coverage, evidenceHash, readForHash } from "../lib/evidence.mjs";
24
- import { runGates, declaredGates, sinceRef } from "./doctor.mjs";
24
+ import { runGates, declaredGates, sinceRef } from "../lib/run.mjs";
25
25
  import { L } from "../i18n/index.mjs";
26
26
 
27
27
  // Каким рецептом стоит гейт: родным инструментом или переносимой проверкой. Именно это
@@ -21,6 +21,7 @@ import { join } from "node:path";
21
21
  import { CWD, MANIFEST, SELF, c, exists } from "../lib/core.mjs";
22
22
  import { readManifest, unparsedLines, gateRequires } from "../lib/manifest.mjs";
23
23
  import { whichSync } from "../lib/repo.mjs";
24
+ import { gitBash } from "../lib/execution.mjs";
24
25
  import { updateWanted } from "../lib/brief.mjs";
25
26
  import { L } from "../i18n/index.mjs";
26
27
 
@@ -94,7 +95,11 @@ async function cmdVitals() {
94
95
  for (const [gate, cmd] of Object.entries(gates)) {
95
96
  const prog = progOf(cmd);
96
97
  if (!prog || seen.has(prog)) continue;
97
- seen.set(prog, { gate, prog, found: Boolean(whichSync(prog)) });
98
+ // На Windows слово `bash` в PATH — часто заглушка WSL, и «найден» было бы неправдой: гейт
99
+ // запустится через Git Bash (gateCommand) или не запустится вовсе. Спрашиваем того же, кого
100
+ // спросит прогон, — иначе vitals и doctor --run снова разойдутся.
101
+ const found = prog === "bash" && process.platform === "win32" ? Boolean(gitBash()) : Boolean(whichSync(prog));
102
+ seen.set(prog, { gate, prog, found });
98
103
  }
99
104
 
100
105
  // Первого слова мало. Запись каталога бывает обёрткой: команда начинается с `bash`, который
@@ -70,8 +70,9 @@ const enDocs = {
70
70
  probeNever: "No coverage probe has run — what is covered by nothing here is UNKNOWN. That is not \"covered\": `aqk probe`.",
71
71
  probeOff: "The coverage probe is switched off in the manifest (`probe: 0`) — nobody counts what is covered by nothing here.",
72
72
  probeUnknown: "The coverage probe could not run — what is covered by nothing here is UNKNOWN. That is not \"covered\".",
73
- probeBlind: (n, behind) =>
73
+ probeBlind: (n, behind, names = "") =>
74
74
  `Covered by nothing: ${n} defect classes in the places people most often come back to fix` +
75
+ (names ? ` — ${names}` : "") +
75
76
  (behind ? ` (the probe is ${behind} commits behind)` : "") + ". Details: `aqk probe`.",
76
77
  probeClean: (behind) =>
77
78
  "Coverage probe: in the places probed, every applicable class is caught by something" +
@@ -256,23 +256,41 @@ export const enGates = {
256
256
  autoFirst: "no coverage probe has ever run here — running it myself. Turn off: AQK_PROBE=0",
257
257
  auto: (n) => `${n} commits since the last probe — running it myself. Turn off: AQK_PROBE=0`,
258
258
  title: "aqk probe — what the declared checks cannot see",
259
- method: (files, entries) =>
260
- `method: a red sample from a catalogue entry is planted into a copy of each file, ` +
261
- `then the DECLARED gates are run against it. Files: ${files}, applicable entries: ${entries}. ` +
259
+ method: (files, entries, gates) =>
260
+ `method: a red sample from a catalogue entry is planted into a COPY of the project, then the ` +
261
+ `DECLARED gates are run there the command is used as written, nothing is substituted into it. ` +
262
+ `Files: ${files}, applicable entries: ${entries}, gates green on a clean checkout: ${gates}. ` +
262
263
  `The working tree is not touched.`,
263
264
  fixes: (n) => `fixes in history: ${n}`,
264
265
  caught: (names) => `caught by: ${names}`,
265
266
  blind: "NOTHING CATCHES IT",
266
267
  unknown: "nothing to check with — the gate did not run (delegated tool missing)",
268
+ blindWhere: (file, fixes) => `where: ${file} — ${fixes} fix commits in its history`,
269
+ blindWhat: "what we planted into your file:",
270
+ blindFix: (cmd) => `catch it right now, no kit needed: ${cmd}`,
271
+ blindTool: (url) => `the tool: ${url}`,
267
272
  install: (cmd) => `close it: ${cmd}`,
268
273
  noSampleFor: (ext) => `the catalogue has no red sample for "${ext}" — nothing to check with`,
269
274
  noGates: (cmd) => `no gates declared — nothing to probe with. First: ${cmd}`,
275
+ noSandbox: "could not build a sandbox: `git archive HEAD` failed. The probe needs a copy of the tracked files to plant a sample into — it never touches the working tree.",
276
+ noBaseline: (red, broke) =>
277
+ `nothing to judge by: on a CLEAN checkout ${red.length ? `these gates are ALREADY red (${red.join(", ")})` : ""}${red.length && broke.length ? " and " : ""}${broke.length ? `these failed to run (${broke.join(", ")})` : ""}. A gate that is red before the sample is planted says nothing about the sample. Get the pipeline green first, then repeat.`,
278
+ tooSlow: (names) => `not probed with (too slow to run on every planting): ${names.join(", ")}. If a class below is caught by nobody, one of these may still catch it — run them by hand.`,
279
+ allSlow: (names) => `every gate that is green on a clean checkout is too slow to probe with: ${names.join(", ")}. Probing would re-run them for every planting. Declare a fast gate, or run these by hand.`,
270
280
  noGit: "not a git repository — there is no fix history to read",
271
281
  noFixes: "no fix commits found: the subject starts with fix / bugfix / hotfix",
272
- summaryBlind: (n) =>
273
- `classes left uncovered: ${n}. This is not a judgement of the code: these are the places ` +
282
+ summaryBlind: (n, probes) =>
283
+ `classes caught by nobody: ${n}` +
284
+ (probes ? ` (over ${probes} plantings — one class repeated across several hot files is still ONE class)` : "") +
285
+ `. This is not a judgement of the code: these are the places ` +
274
286
  `people come back to with a fix, and defects none of your checks would see there.`,
275
287
  summaryClean: "in the places probed, every applicable class is caught by something.",
288
+ summaryPartial: (caught, unknown, unprobed) =>
289
+ `caught: ${caught}. Could NOT be checked: ${unknown} (the tool is missing, not the protection). Not probed at all: ${unprobed} file(s) — the catalogue has no red sample for their type. "Checked and clean", "not checked" and "not looked at" are three different facts and are not merged here.`,
290
+ summaryNothingRan: (n) =>
291
+ `NOTHING was checked: all ${n} probe(s) failed to run — the delegated tools are missing. This is not a clean result, it is the absence of a result. Install the tools, then repeat.`,
292
+ summaryNothingProbed: (unprobed) =>
293
+ `not a single probe was made${unprobed ? ` — ${unprobed} hot file(s) have no red sample for their type in the catalogue` : ""}. Nothing is known about coverage: this is the absence of a measurement, not a clean result.`,
276
294
  },
277
295
  prove: {
278
296
  title: "aqk prove — proving the gates",
package/tool/i18n/en.mjs CHANGED
@@ -65,6 +65,9 @@ export const en = {
65
65
  gitignore: "repository hygiene",
66
66
  git: "project under version control",
67
67
 
68
+ rulesByHuman: (total, machine, human) =>
69
+ `${human} of ${total} rules in the entry point are guarded by a HUMAN, ${machine} by a machine.`,
70
+ rulesByHumanWhy: "A rule guarded by a human is guarded by nobody the day the human is busy. That is the hole this kit exists to close — and the reminder belongs to the human, not only to the agent. Counted in the ENTRY POINT only: a promise kept in any other file is checked by nothing at all, and this kit will not tell you it exists.",
68
71
  emptyCommands: (n) => `AGENTS.md has ${n} unfilled commands.`,
69
72
  emptyCommandsWhy: "An agent cannot execute an empty line.",
70
73
 
@@ -101,6 +104,17 @@ export const en = {
101
104
  `claim unverified: "${e}" is declared held by gate "${g}", but neither its command nor the\n linter config names rules ${codes} — the entry may be held by nothing`,
102
105
  coversUnprovenHow: (cmd) => `settle it: add those rules to the linter, or install the entry — ${cmd}`,
103
106
  totalCovered: (n) => `held by another arbiter ${n}`,
107
+ blindHeading: (behind) => `The probe${behind ? ` (${behind} commits ago)` : ""} planted defects in your files — your checks did NOT catch them:`,
108
+ blindRan: (g) => `gate ${g} is declared and was run — and still missed the defect in this file`,
109
+ blindInstalled: "declared, but the probe did not run this gate (added later or too slow) — the next probe will show whether it catches",
110
+ blindMore: (cmd) => `what was planted and where — ${cmd}`,
111
+ probeNever: (cmd) => `Whether your checks catch a real defect has not been tested yet: ${cmd} plants one in a copy of the project and shows (a minute or two, your files are not touched).`,
112
+ startWith: "Start with these three — born from a real failure, and each closes with one ready command:",
113
+ startCmd: (cmd) => `one line, no kit needed: ${cmd}`,
114
+ startTool: (url) => `the tool: ${url}`,
115
+ startHook: "Running these by hand is a one-off. To have them run before every push: pre-commit (repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit, hooks aqk / aqk-doctor), or a plain .git/hooks/pre-push.",
116
+ haveAlready: (n) => `Checks you ALREADY have (${n}) — found in your own files, not invented:`,
117
+ haveAlreadyHow: (line) => `declare them and a machine holds them, not your attention. In .aqk.yml, under gates: ${line}`,
104
118
  total: "Total:",
105
119
  totalHeld: (n) => `held by a machine ${n}`,
106
120
  totalTodo: (n) => `applicable but not installed ${n}`,
@@ -117,6 +131,8 @@ export const en = {
117
131
  `either fix them and drop them from advisory, or admit the rule does not exist.`,
118
132
  runHeading: "Running the declared gates",
119
133
  timeout: "did not finish within 5 minutes",
134
+ running: (i, n) => `[${i}/${n}] running…`,
135
+ proving: "checking that the gates catch defects on their own samples…",
120
136
  exitCode: (code) => `exit ${code}`,
121
137
  moreLines: (n) => `… and ${n} more lines`,
122
138
  declaredNotRun: (n) => `${n} gates declared, but never run.`,
@@ -72,8 +72,9 @@ const ruDocs = {
72
72
  probeNever: "Проба покрытия не делалась — что здесь не прикрыто ничем, НЕИЗВЕСТНО. Это не «прикрыто»: `aqk probe`.",
73
73
  probeOff: "Проба покрытия выключена в манифесте (`probe: 0`) — что здесь не прикрыто ничем, никто не считает.",
74
74
  probeUnknown: "Пробу покрытия провести не удалось — что здесь не прикрыто ничем, НЕИЗВЕСТНО. Это не «прикрыто».",
75
- probeBlind: (n, behind) =>
75
+ probeBlind: (n, behind, names = "") =>
76
76
  `Не прикрыто ничем: ${n} классов брака в местах, куда чаще всего возвращаются с починкой` +
77
+ (names ? ` — ${names}` : "") +
77
78
  (behind ? ` (проба отстала на ${behind} коммитов)` : "") + ". Подробно: `aqk probe`.",
78
79
  probeClean: (behind) =>
79
80
  "Проба покрытия: в проверенных местах каждый применимый класс кто-то ловит" +
@@ -258,23 +258,41 @@ export const ruGates = {
258
258
  autoFirst: "пробы покрытия здесь ещё не делали — делаю её сам. Выключить: AQK_PROBE=0",
259
259
  auto: (n) => `прошло ${n} коммитов с прошлой пробы — делаю её сам. Выключить: AQK_PROBE=0`,
260
260
  title: "aqk probe — чего объявленные проверки не видят",
261
- method: (files, entries) =>
262
- `метод: в копию каждого файла подсаживается красный образец записи каталога, ` +
263
- `по нему прогоняются ОБЪЯВЛЕННЫЕ гейты. Файлов: ${files}, применимых записей: ${entries}. ` +
261
+ method: (files, entries, gates) =>
262
+ `способ: красный образец записи каталога подсаживается в КОПИЮ проекта, и там гоняются ` +
263
+ `ОБЪЯВЛЕННЫЕ гейты команда берётся как написана, в неё ничего не подставляется. ` +
264
+ `Файлов: ${files}, применимых записей: ${entries}, гейтов зелёных на чистом дереве: ${gates}. ` +
264
265
  `Рабочее дерево не трогается.`,
265
266
  fixes: (n) => `починок в истории: ${n}`,
266
267
  caught: (names) => `ловит: ${names}`,
267
268
  blind: "НЕ ЛОВИТ НИКТО",
268
269
  unknown: "проверить нечем — гейт не состоялся (нет делегированной программы)",
270
+ blindWhere: (file, fixes) => `где: ${file} — починок в истории: ${fixes}`,
271
+ blindWhat: "что подсадили в ваш файл:",
272
+ blindFix: (cmd) => `поймать прямо сейчас, без комплекта: ${cmd}`,
273
+ blindTool: (url) => `инструмент: ${url}`,
269
274
  install: (cmd) => `закрыть: ${cmd}`,
270
275
  noSampleFor: (ext) => `в каталоге нет красного образца под «${ext}» — проверить нечем`,
271
276
  noGates: (cmd) => `гейтов не объявлено — пробовать нечем. Сначала: ${cmd}`,
277
+ noSandbox: "не удалось построить песочницу: `git archive HEAD` не отработал. Пробе нужна копия отслеживаемых файлов, чтобы подсадить в неё образец, — рабочее дерево она не трогает никогда.",
278
+ noBaseline: (red, broke) =>
279
+ `судить не по чему: на ЧИСТОМ дереве ${red.length ? `уже красные (${red.join(", ")})` : ""}${red.length && broke.length ? ", а " : ""}${broke.length ? `не запустились (${broke.join(", ")})` : ""}. Гейт, красный ДО подсадки, о подсадке не говорит ничего. Сначала позеленить конвейер, потом повторить.`,
280
+ tooSlow: (names) => `не пробовали ими (слишком долго гонять на каждую подсадку): ${names.join(", ")}. Если класс ниже не ловит никто — может ловить кто-то из них, проверьте руками.`,
281
+ allSlow: (names) => `все гейты, зелёные на чистом дереве, слишком медленны для пробы: ${names.join(", ")}. Проба гоняла бы их заново на каждую подсадку. Объявите быстрый гейт или прогоните эти руками.`,
272
282
  noGit: "это не репозиторий git — истории починок взять неоткуда",
273
283
  noFixes: "в истории не нашлось коммитов-починок: тема начинается с fix / исправ / почин",
274
- summaryBlind: (n) =>
275
- `не прикрыто классов: ${n}. Это не оценка кода: это места, куда возвращаются с починкой, ` +
276
- брак, которого там не увидит ни одна ваша проверка.`,
284
+ summaryBlind: (n, probes) =>
285
+ `классов не ловит никто: ${n}` +
286
+ (probes ? ` (на ${probes} подсадках один класс, повторённый по нескольким горячим файлам, остаётся ОДНИМ)` : "") +
287
+ `. Это не приговор коду: это места, куда возвращаются с починкой, и дефекты, ` +
288
+ `которых там не увидела бы ни одна ваша проверка.`,
277
289
  summaryClean: "в проверенных местах каждый применимый класс кто-то ловит.",
290
+ summaryPartial: (caught, unknown, unprobed) =>
291
+ `поймано: ${caught}. Проверить НЕ смогли: ${unknown} (не хватает инструмента, а не защиты). Не пробовали вовсе: файлов ${unprobed} — в каталоге нет красного образца их типа. «Проверено и чисто», «не проверено» и «не смотрели» — три разных факта, и здесь они не сливаются.`,
292
+ summaryNothingRan: (n) =>
293
+ `НЕ проверено ничего: все ${n} пробы не смогли запуститься — делегированные инструменты не установлены. Это не чистый результат, это отсутствие результата. Поставьте инструменты и повторите.`,
294
+ summaryNothingProbed: (unprobed) =>
295
+ `не сделано ни одной пробы${unprobed ? `: у ${unprobed} горячих файлов нет в каталоге красного образца их типа` : ""}. О прикрытии не известно ничего — это отсутствие замера, а не чистый результат.`,
278
296
  },
279
297
  prove: {
280
298
  title: "aqk prove — доказательство гейтов",