agent-quality-kit 0.11.0 → 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.
@@ -0,0 +1,98 @@
1
+ // tool/lib/adopt.mjs — проверки, которые у проекта УЖЕ ЕСТЬ. Читаются из его собственных
2
+ // файлов, а не выдумываются.
3
+ //
4
+ // ЗАЧЕМ. Комплект, поставленный в `express` — проект с eslint, mocha и конвейером, — показывал
5
+ // двадцать крестов подряд и «держит машина 0». С точки зрения владельца это неправда: его
6
+ // проверки держат, просто мы считали только СВОИ записи. Мы видели, что конвейер ЕСТЬ
7
+ // (`has_ci`), но не читали, что в нём, и человек должен был переписать в манифест руками то,
8
+ // что мы могли прочитать сами. Отсюда и «не понимает, что у него хорошо»: хорошее уже есть, а
9
+ // мы о нём молчим.
10
+ //
11
+ // ОТДЕЛЬНЫМ ФАЙЛОМ, а не в repo.mjs: там осмотр репозитория ради НАШЕГО каталога, здесь —
12
+ // чтение ЧУЖИХ конфигов, и у каждого источника свой формат, который меняется по своим
13
+ // причинам. Шов вскрыл наш же `file-size-limit`: с python-источниками repo.mjs перевалил бы
14
+ // за 500 строк.
15
+ //
16
+ // ПРЕДЛАГАЕМ, А НЕ ОБЪЯВЛЯЕМ. Гейт, вписанный в чужой манифест без спроса, — это наше решение
17
+ // в чужом файле; и человек обязан видеть ИСТОЧНИК, иначе предложение неотличимо от догадки.
18
+ //
19
+ // Сборка и запуск проверками не считаются: `build` собирает, `start` запускает, судит — ни тот
20
+ // ни другой. Берём только то, что отвечает «прошло или нет».
21
+ const CHECK_NAMES = new Set(["test", "tests", "lint", "typecheck", "type-check", "types", "check"]);
22
+
23
+ // Окружения tox, которые судят, а не гоняют тесты под матрицей версий. Голый `tox` не
24
+ // предлагаем: он проходит все интерпретаторы из envlist, и у человека без пяти питонов это
25
+ // красный прогон на пустом месте. Имена сняты с живых tox-файлов (rich, click, flask).
26
+ const TOX_CHECKS = new Set(["lint", "style", "typing", "types", "type", "mypy", "check", "typecheck"]);
27
+
28
+ // Источники в порядке доверия: при совпадении имени берётся первый. Замер 2026-09-11 на
29
+ // восьми python-проектах: package.json нет ни у кого, Makefile у трёх, `.pre-commit-config.yaml`
30
+ // у семи, tox у пяти, scripts/ у httpx. Шаги конвейера НЕ читаются: там `${{ matrix.x }}`,
31
+ // `PYTHONPATH=…` и обёртки `uv run --locked --group …` — строка, которая работает только в
32
+ // том конвейере, предложенная как локальный гейт, покраснела бы у человека в первую же минуту.
33
+ function proposeGates(files = {}) {
34
+ const out = [];
35
+ const seen = new Set();
36
+ const push = (name, cmd, source) => {
37
+ const key = name === "tests" ? "test" : name.replace(/^type-?check$|^types$/, "typecheck");
38
+ if (seen.has(key)) return;
39
+ seen.add(key);
40
+ out.push({ name: key, cmd, source });
41
+ };
42
+
43
+ const pkg = files["package.json"];
44
+ if (pkg) {
45
+ let scripts = null;
46
+ try { scripts = JSON.parse(pkg)?.scripts; } catch { scripts = null; }
47
+ if (scripts && typeof scripts === "object") {
48
+ for (const name of Object.keys(scripts)) {
49
+ if (!CHECK_NAMES.has(name)) continue;
50
+ // `npm test` — каноничное написание для теста, остальное через `run`.
51
+ push(name, name === "test" || name === "tests" ? "npm test" : `npm run ${name}`, "package.json");
52
+ }
53
+ }
54
+ }
55
+
56
+ const mk = files.Makefile;
57
+ if (typeof mk === "string") {
58
+ for (const m of mk.matchAll(/^([A-Za-z][\w-]*):/gm)) {
59
+ if (CHECK_NAMES.has(m[1])) push(m[1], `make ${m[1]}`, "Makefile");
60
+ }
61
+ }
62
+
63
+ // pre-commit — одна команда на всё, что в нём стоит. Хуки называются поимённо: «у вас есть
64
+ // .pre-commit-config.yaml» ничего не говорит, «у вас есть ruff-check и check-yaml» — говорит.
65
+ const pc = files[".pre-commit-config.yaml"];
66
+ if (typeof pc === "string") {
67
+ const ids = [...pc.matchAll(/^\s*-\s*id:\s*["']?([\w.-]+)/gm)].map((m) => m[1]);
68
+ if (ids.length) {
69
+ const shown = ids.slice(0, 4).join(", ") + (ids.length > 4 ? ` +${ids.length - 4}` : "");
70
+ push("pre-commit", "pre-commit run --all-files", `.pre-commit-config.yaml: ${shown}`);
71
+ }
72
+ }
73
+
74
+ // tox живёт в двух местах и двух синтаксисах: `[testenv:lint]` в tox.ini (и внутри
75
+ // legacy_tox_ini в pyproject), `[tool.tox.env.lint]` в pyproject.toml нового образца.
76
+ for (const src of ["tox.ini", "pyproject.toml"]) {
77
+ const t = files[src];
78
+ if (typeof t !== "string") continue;
79
+ for (const m of t.matchAll(/^\s*\[(?:testenv:|tool\.tox\.env\.)([\w-]+)\]/gm)) {
80
+ if (TOX_CHECKS.has(m[1])) push(m[1], `tox -e ${m[1]}`, src);
81
+ }
82
+ }
83
+
84
+ // Каталог scripts/ с исполняемыми test, check, lint — соглашение encode (httpx, starlette):
85
+ // конвейер зовёт ровно их. Содержимое не читаем, важно только, что файл есть.
86
+ for (const name of ["test", "check", "lint"]) {
87
+ if (`scripts/${name}` in files) push(name, `scripts/${name}`, "scripts/");
88
+ }
89
+
90
+ return out;
91
+ }
92
+
93
+ // Какие файлы читать — знает тот, кто их разбирает. Список в команде разошёлся бы с разбором
94
+ // в первый же день, когда появится новый источник.
95
+ const ADOPT_FILES = ["package.json", "Makefile", ".pre-commit-config.yaml", "tox.ini", "pyproject.toml"];
96
+ const ADOPT_SCRIPTS = ["scripts/test", "scripts/check", "scripts/lint"];
97
+
98
+ export { proposeGates, ADOPT_FILES, ADOPT_SCRIPTS };
@@ -54,4 +54,39 @@ function probeEvery(man) {
54
54
  return n;
55
55
  }
56
56
 
57
- export { probeDue, probeState, probeEvery, PROBE_EVERY };
57
+ // ИМЕНА непойманных классов в отметке. Хранилось одно число, `blind: 1`: агент в `context`
58
+ // видел «один класс», человек в `doctor` не видел ничего, и чтобы узнать КАКОЙ, надо было снова
59
+ // запускать пробу — ту самую команду, о которой не вспоминают. Находка, которую не показали,
60
+ // ни от чего не предостерегает.
61
+ //
62
+ // Правило то же, что у `countProbe`: класс, слепой хоть где-то, — непокрыт, а «не смогли
63
+ // проверить» слепотой не считается. Файл — первый, где слеп: пробы идут от самого горячего.
64
+ function blindLines(records) {
65
+ const seen = new Set();
66
+ const out = [];
67
+ for (const { entry, file, verdict } of records || []) {
68
+ if (verdict !== "blind" || seen.has(entry)) continue;
69
+ seen.add(entry);
70
+ out.push(`blind-class: ${entry} ${file}`);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // Отметка старого формата строк `blind-class:` не несёт — тогда список пуст, и это «имён не
76
+ // знаем», а не «слепых нет»: число `blind:` читается отдельно и остаётся правдой.
77
+ function parseBlind(text) {
78
+ return [...String(text || "").matchAll(/^blind-class:\s*(\S+)\s+(.+?)\s*$/gm)]
79
+ .map((m) => ({ slug: m[1], file: m[2] }));
80
+ }
81
+
82
+ // Какие гейты проба ПРОГОНЯЛА. Без этого «объявлен сейчас» читалось как «поставлен после
83
+ // пробы» — а проба как раз и гоняет объявленные: гейт, стоявший ДО неё и брак пропустивший,
84
+ // получал утешительное «поймает ли, покажет следующая» вместо «здесь не ловит». Именно
85
+ // прогонявшиеся, а не объявленные: медленные проба пропускает, и про них «не поймал» — ложь.
86
+ // Старая отметка строки не несёт — null: «не знаем», а не «ничего не прогонялось».
87
+ function parseRan(text) {
88
+ const m = /^ran:[ \t]*(.*)$/m.exec(String(text || ""));
89
+ return m ? new Set(m[1].split(/\s+/).filter(Boolean)) : null;
90
+ }
91
+
92
+ export { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan };
@@ -27,6 +27,10 @@
27
27
 
28
28
  // Умолчание для незнакомой программы: ноль — чисто, единица — находка, остальное — сбой.
29
29
  // Это честнее догадки: неизвестный код становится «не знаем», а не «поймал».
30
+ import { existsSync } from "node:fs";
31
+ import { win32 } from "node:path";
32
+ import { whichSync } from "./repo.mjs";
33
+
30
34
  const DEFAULT = (code) => code === 1;
31
35
 
32
36
  const ADAPTERS = {
@@ -64,4 +68,49 @@ function classify(r, isFinding = DEFAULT) {
64
68
  return { state: "infra_error", reason: "unexpected_exit", code };
65
69
  }
66
70
 
67
- export { classify, findingCodes };
71
+ // КАКОЙ BASH ЗАПУСКАТЬ НА WINDOWS. В System32 лежит bash.exe — заглушка WSL, и в PATH она стоит
72
+ // раньше Git Bash: установщик Git по умолчанию кладёт туда только Git\cmd. Команда гейта
73
+ // `bash kit/gates/x/check.sh .` уезжала в Linux-подсистему — другие программы, другой git,
74
+ // другие пути — и прогон краснел «не из-за кода». Отчёт с живого проекта 2026-09-11: «гейты
75
+ // под Windows берут bash из WSL. Через Git Bash все проходят».
76
+ //
77
+ // Приём не выдуман: на ту же ловушку наступали Claude Code (anthropics/claude-code#23556) и
78
+ // Archon (coleam00/Archon#1326), и общий ответ — не верить слову `bash` из PATH, а брать его
79
+ // рядом с git. Git комплекту нужен всё равно. Явный путь — AQK_BASH: у всего, что мы решаем
80
+ // сами, обязан быть способ решить иначе.
81
+ //
82
+ // Не нашли — null, и команда остаётся как есть. Подставлять заглушку WSL «за неимением» нельзя:
83
+ // это ровно то, от чего мы уходим; `vitals` назовёт bash ненайденным.
84
+ function gitBash({ platform = process.platform, env = process.env, which = whichSync, exists = existsSync } = {}) {
85
+ if (platform !== "win32") return null;
86
+ if (env.AQK_BASH) return env.AQK_BASH;
87
+ const cands = [];
88
+ const git = which("git", env);
89
+ if (git) {
90
+ // Git\cmd\git.exe и Git\mingw64\bin\git.exe — корень на два и на три уровня выше.
91
+ for (const root of [win32.dirname(win32.dirname(git)), win32.dirname(win32.dirname(win32.dirname(git)))]) {
92
+ cands.push(win32.join(root, "bin", "bash.exe"), win32.join(root, "usr", "bin", "bash.exe"));
93
+ }
94
+ }
95
+ for (const base of [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs")]) {
96
+ if (base) cands.push(win32.join(base, "Git", "bin", "bash.exe"));
97
+ }
98
+ return cands.find((p) => exists(p)) || null;
99
+ }
100
+
101
+ // Подменяется только ПЕРВОЕ слово: внутренний `bash` у обёртки храповика запускает уже Git Bash,
102
+ // а у него свой PATH. Кавычки — путь почти всегда с пробелом («Program Files»).
103
+ function launchable(cmd, bash) {
104
+ const s = String(cmd || "");
105
+ return bash && /^bash(\s|$)/.test(s) ? `"${bash}"${s.slice(4)}` : s;
106
+ }
107
+
108
+ // Одна точка для всех пяти мест, где запускается команда гейта: прогон, доказательство, проба,
109
+ // храповик, `why`. Поиск делается раз на процесс — он смотрит на диск, а гейтов бывает тридцать.
110
+ let cachedBash;
111
+ function gateCommand(cmd) {
112
+ if (cachedBash === undefined) cachedBash = gitBash();
113
+ return launchable(cmd, cachedBash);
114
+ }
115
+
116
+ export { classify, findingCodes, gitBash, launchable, gateCommand };
@@ -111,19 +111,29 @@ function probeSummary({ caught = 0, blind = 0, unknown = 0, unprobed = 0 } = {})
111
111
  // · гейт, ПОЗЕЛЕНЕВШИЙ от подсадки, — он смотрит не туда, и это тоже не поимка.
112
112
  // Исключены все — вердикт `unknown`, а не `blind`: «не по чему судить» и «никто не ловит»
113
113
  // разные факты, и молчание здесь и есть предмет спора.
114
+ //
115
+ // И ещё одно: гейт, СЛОМАННЫЙ подсадкой (работал до, не смог после), мог быть тем самым
116
+ // ловцом. Пока никто другой не поймал, «слеп» про такой класс — неправда, и вердикт `unknown`.
117
+ // Найдено пробой на самом комплекте 2026-09-11: образец лёг на место общей библиотеки
118
+ // kit/gates/_skip.sh, двенадцать гейтов вышли с кодом 2 — включая тот, что этот образец в
119
+ // отдельной папке ловит. Остальные молчали, и проба назвала класс слепым.
114
120
  function probeVerdictPaired(before, after) {
115
121
  const byName = new Map(after.map((r) => [r.name, r]));
116
- let usable = 0, alreadyRed = 0, failed = 0, caught = 0;
122
+ let usable = 0, alreadyRed = 0, failed = 0, caught = 0, brokenByPlant = 0;
117
123
  for (const b of before) {
118
124
  const a = byName.get(b.name);
119
125
  const broke = (r) => !r || (r.code !== 0 && r.code !== 1);
120
- if (broke(b) || broke(a)) { failed++; continue; }
126
+ if (broke(b) || broke(a)) {
127
+ failed++;
128
+ if (!broke(b)) brokenByPlant++;
129
+ continue;
130
+ }
121
131
  if (b.code === 1) { alreadyRed++; continue; }
122
132
  usable++;
123
133
  if (a.code === 1) caught++;
124
134
  }
125
- const verdict = !usable ? "unknown" : caught ? "caught" : "blind";
126
- return { verdict, usable, alreadyRed, failed, caught };
135
+ const verdict = caught ? "caught" : !usable || brokenByPlant ? "unknown" : "blind";
136
+ return { verdict, usable, alreadyRed, failed, caught, brokenByPlant };
127
137
  }
128
138
 
129
139
  // Счёт непокрытого КЛАССАМИ, а не пробами. Замер на десяти живых репозиториях 2026-09-10:
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { CWD, exists } from "./core.mjs";
16
16
  import { parseManifest, gateRequires } from "./manifest.mjs";
17
17
  import { whichSync } from "./repo.mjs";
18
- import { classify, findingCodes } from "./execution.mjs";
18
+ import { classify, findingCodes, gateCommand } from "./execution.mjs";
19
19
 
20
20
  // Гейт можно доказать, если у него есть оба образца. Признак по образцам, а не по тексту
21
21
  // команды: запись, делегирующая готовому инструменту (`npx knip --directory .`), каталог
@@ -108,7 +108,7 @@ async function samplesForRecipe(samplesDir, name) {
108
108
  // «не знаем» — а вызывающий тут же считал его находкой. Опыт 2026-09-09: проверка, виснущая
109
109
  // на красном образце, получала вердикт «доказана».
110
110
  function run(cmd, timeoutMs, prog) {
111
- const r = spawnSync(cmd, { shell: true, encoding: "utf8", cwd: CWD, timeout: timeoutMs });
111
+ const r = spawnSync(gateCommand(cmd), { shell: true, encoding: "utf8", cwd: CWD, timeout: timeoutMs });
112
112
  const out = `${r.stdout || ""}${r.stderr || ""}`.trim();
113
113
  return { ...classify(r, findingCodes(prog)), out };
114
114
  }
package/tool/lib/repo.mjs CHANGED
@@ -265,6 +265,22 @@ function pickRecipe(rec, facts, missing) {
265
265
  console.log(c.dim(` ${c.yellow("!")} ${L.recipe.skipped(lang, prog)}`));
266
266
  }
267
267
 
268
+ // Безъязыковой родной рецепт. Ключи рецептов — языки, и это верно для линтеров, но неверно
269
+ // для инструментов, которым язык безразличен: `gitleaks` ищет ключи в любом файле, и
270
+ // `secrets-not-in-code` объявлен `always: true`. Без этого ключа выбор был из двух плохих —
271
+ // продублировать команду под восемь языков (тот самый повтор, против которого у нас гейт)
272
+ // или оставить запись без родного рецепта. Второе и было: замер на десяти чужих проектах
273
+ // 2026-09-10 дал секреты в НЕПОКРЫТЫХ у axios и у всех четырёх python-проектов, то есть там,
274
+ // где польза наибольшая, команды для вставки не существовало.
275
+ //
276
+ // Стоит ПОСЛЕ языков и ПЕРЕД переносимым: свой язык точнее, а родной сильнее переносимого —
277
+ // но только если его есть чем выполнить.
278
+ if (recipes.native) {
279
+ const prog = String(recipes.native).trim().split(/\s+/)[0];
280
+ if (runnable(recipes.native)) return recipes.native;
281
+ if (Array.isArray(missing) && !missing.includes(prog)) missing.push(prog);
282
+ }
283
+
268
284
  return recipes.any || null;
269
285
  }
270
286
 
@@ -362,9 +378,40 @@ async function matchCatalog(query) {
362
378
  return out;
363
379
  }
364
380
 
381
+ // С ЧЕГО НАЧАТЬ: три записи вместо двадцати равнозначных крестов.
382
+ //
383
+ // Двадцать одинаковых требований — это ноль требований: закрывают первое попавшееся или не
384
+ // закрывают ничего. Порядок НЕ по нашему вкусу; два признака, оба — факты, которые у нас уже
385
+ // есть:
386
+ // · запись родилась из настоящего отказа (`lifecycle: stable` — `proof` ссылается на журнал
387
+ // шишек), то есть она про боль, которая СЛУЧАЛАСЬ, а не про «хорошую практику»;
388
+ // · её можно закрыть одной готовой командой — цена входа минутная.
389
+ // Сначала то, что и больно, и дёшево.
390
+ //
391
+ // При равенстве признаков — по имени: одинаковый ввод обязан давать одинаковый ответ, иначе
392
+ // человек видит разный совет на двух прогонах подряд и перестаёт верить обоим.
393
+ function startWith(entries, facts, n = 3) {
394
+ const langs = facts?.langs ? [...facts.langs] : [];
395
+ const cheap = (e) => {
396
+ const r = e?.recipes && typeof e.recipes === "object" ? e.recipes : {};
397
+ return [...langs, "native"].some((k) => r[k] && !/\{gate\}/.test(r[k])) ? 1 : 0;
398
+ };
399
+ // Зрелость НЕ поле записи, а вычисляемый признак: `proof` ссылается на журнал шишек. Тот же
400
+ // признак, которым каталог отделяет условную запись с первого дня (`entryLifecycle`).
401
+ // Заводить второй счёт нельзя: разъехавшись, они дали бы разные ответы про одну запись.
402
+ const hurt = (e) => (/incidents\//.test(String(e?.proof || "")) ? 1 : 0);
403
+ return [...entries]
404
+ .sort((a, b) =>
405
+ (hurt(b) + cheap(b)) - (hurt(a) + cheap(a)) ||
406
+ cheap(b) - cheap(a) ||
407
+ String(a.slug).localeCompare(String(b.slug)))
408
+ .slice(0, n);
409
+ }
410
+
365
411
  // Наружу — то, что действительно импортируют другие файлы и модульные проверки. Экспорт,
366
412
  // который никто не берёт, читается как часть договора и мешает менять внутренности.
367
413
  export {
368
414
  whichSync,
369
415
  EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor, browserServerAdvice, MARKS,
416
+ startWith,
370
417
  stems, overlap, matchCatalog, isApiSpec };
package/tool/lib/run.mjs CHANGED
@@ -13,6 +13,7 @@ import { scopeOutput, splitAdvice, changedFiles } from "./scope.mjs";
13
13
  import { CWD, c, die } from "./core.mjs";
14
14
  import { advisorySet } from "./manifest.mjs";
15
15
  import { L } from "../i18n/index.mjs";
16
+ import { gateCommand } from "./execution.mjs";
16
17
 
17
18
 
18
19
  // «Гейт объявлен» и «гейт работает» — разные утверждения. Первое читается из манифеста,
@@ -39,10 +40,36 @@ function sinceRef(argv = process.argv) {
39
40
  return v && !v.startsWith("-") ? v : null;
40
41
  }
41
42
 
43
+ // СТРОКА «ИДЁТ». Гейт идёт через spawnSync, и строка про него печаталась только по завершении:
44
+ // минута `smoke` — минута пустого экрана, «работает» неотличимо от «повисло», и человек пишет
45
+ // сам, не дождавшись. Просьба владельца 2026-09-10: «я запустил и должен видеть, как идёт».
46
+ //
47
+ // Только в терминал. В пайп и в конвейер — ни байта: лог там читают глазами и разбирают
48
+ // машиной, и строка, переписанная возвратом каретки, в файле превращается в мусор.
49
+ // Секунды не тикают: для этого нужен асинхронный запуск и убийство группы процессов на
50
+ // таймауте, а у нас windows в конвейере. Имя и номер гейта отвечают на вопрос и без них.
51
+ function progress({ tty = process.stdout.isTTY, write = (s) => process.stdout.write(s) } = {}) {
52
+ let shown = false;
53
+ return {
54
+ show(text) {
55
+ if (!tty) return;
56
+ if (shown) write("\r\x1b[K");
57
+ write(text);
58
+ shown = true;
59
+ },
60
+ clear() {
61
+ if (!tty || !shown) return;
62
+ write("\r\x1b[K");
63
+ shown = false;
64
+ },
65
+ };
66
+ }
67
+
42
68
  function runGates(man, opts = {}) {
43
69
  const gates = declaredGates(man);
44
70
  if (!gates.length) return { failed: 0, ran: 0, results: [] };
45
71
  const advisory = advisorySet(man);
72
+ const bar = progress();
46
73
 
47
74
  // Сужение по дифу — договор с человеком, и он должен видеть, ЧТО именно сужено. Пустой диф
48
75
  // называется вслух: иначе «все гейты зелёные» означало бы «сравнили не с тем» и читалось бы
@@ -55,9 +82,11 @@ function runGates(man, opts = {}) {
55
82
  let failed = 0;
56
83
  const results = [];
57
84
 
58
- for (const [name, cmd] of gates) {
85
+ for (const [i, [name, cmd]] of gates.entries()) {
86
+ bar.show(` ${c.dim("⋯")} ${name.padEnd(14)} ${c.dim(L.doctor.running(i + 1, gates.length))}`);
59
87
  const t0 = Date.now();
60
- const r = spawnSync(cmd, { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
88
+ const r = spawnSync(gateCommand(cmd), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
89
+ bar.clear();
61
90
  const secs = (Math.max(0, Date.now() - t0) / 1000).toFixed(1);
62
91
  // Вывод гейта запоминается целиком (с потолком, чтобы болтливый инструмент не съел память):
63
92
  // по нему считается покрытие дифа — какой файл вообще был назван хоть одной проверкой.
@@ -162,4 +191,4 @@ function runGates(man, opts = {}) {
162
191
  return { failed, ran: gates.length, results, advisoryFailed };
163
192
  }
164
193
 
165
- export { declaredGates, sinceRef, runGates };
194
+ export { declaredGates, sinceRef, runGates, progress };
@@ -118,14 +118,24 @@ for GATE in "$CAT"/*/; do
118
118
  # наблюдался трижды под тяжёлой параллельной нагрузкой, каждый раз на РАЗНОЙ записи, README
119
119
  # которой на месте и раздел содержит; воспроизвести в покое не удалось за сорок прогонов.
120
120
  # Причина не доказана — но обвинять запись в том, чего не проверяли, нельзя в любом случае.
121
- printf '%s\n' "$README_TEXT" | grep -qE '[Гг]отовы(й аналог|й инструмент|е правил)|[Гг]отового аналога'
121
+ # ПОЧЕМУ ЗДЕСЬ НЕТ КОНВЕЙЕРА. Было `printf | grep -qE …`, и это давало ПЛАВАЮЩИЙ отказ,
122
+ # который мы ловили трижды и не могли объяснить: запись обвинялась в отсутствии раздела,
123
+ # который у неё есть. Причина найдена 2026-09-10 тем самым сообщением «grep вышел с кодом
124
+ # 141», ради которого исход и был разложен на три. 141 = 128 + 13 = SIGPIPE: `grep -q`
125
+ # выходит на ПЕРВОМ совпадении и закрывает трубу, а `printf` в этот момент ещё пишет и
126
+ # умирает от сигнала. При `set -o pipefail` (строка 9) статусом конвейера становится 141.
127
+ # Воспроизведено 100 из 100, когда совпадение в НАЧАЛЕ текста, — оттого и «случайная»
128
+ # запись: у одних раздел в начале README, у других в конце.
129
+ # Здесь-строка конвейера не создаёт, и гонки не существует.
130
+ grep -qE '[Гг]отовы(й аналог|й инструмент|е правил)|[Гг]отового аналога' <<< "$README_TEXT"
122
131
  case $? in
123
132
  0) : ;;
124
133
  1) bad "$SLUG: в README нет раздела про готовый аналог — «не искал» и «нет» разные утверждения" ;;
125
134
  *) bad "$SLUG: поиск раздела про готовый аналог НЕ СОСТОЯЛСЯ (grep вышел с кодом $?) — это не приговор записи" ;;
126
135
  esac
127
136
  # Те же три исхода, что и у поиска выше, и по той же причине.
128
- printf '%s\n' "$README_TEXT" | grep -qE 'чего НЕ ловит|Чего НЕ ловит|чего не ловит|Чего не ловит'
137
+ # Тот же довод, что у поиска выше: конвейер + `grep -q` = гонка с SIGPIPE.
138
+ grep -qE 'чего НЕ ловит|Чего НЕ ловит|чего не ловит|Чего не ловит' <<< "$README_TEXT"
129
139
  case $? in
130
140
  0) : ;;
131
141
  1) bad "$SLUG: в README нет раздела «чего НЕ ловит» — граница записи обязана быть названа" ;;
@@ -7,7 +7,7 @@
7
7
  // незачем, а сто коммитов за день перепроверить надо.
8
8
  import test from "node:test";
9
9
  import assert from "node:assert/strict";
10
- import { probeDue, probeState, probeEvery, PROBE_EVERY } from "../lib/cadence.mjs";
10
+ import { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan } from "../lib/cadence.mjs";
11
11
 
12
12
  test("порог по умолчанию — сто коммитов, и он назван числом, а не спрятан", () => {
13
13
  assert.equal(PROBE_EVERY, 100);
@@ -67,3 +67,39 @@ test("непонятое значение порога — null, а не тих
67
67
  assert.equal(probeEvery({ probe: "часто" }), null);
68
68
  assert.equal(probeEvery({ probe: -5 }), null);
69
69
  });
70
+
71
+ // --- отметка несёт ИМЕНА непойманных классов ------------------------------------------
72
+ // ЗАЧЕМ. Отметка хранила одно число — `blind: 1`. Агент в `context` видел «один класс», человек
73
+ // в `doctor` не видел ничего, и чтобы узнать КАКОЙ, надо было снова запускать пробу — ту самую
74
+ // команду, о которой никто не вспоминает. Находка, которую не показали, не предостерегает.
75
+ test("отметка пробы: по строке на слепой класс, в самом горячем файле, где он слеп", () => {
76
+ const recs = [
77
+ { entry: "swallowed-error", file: "src/a.py", verdict: "blind" },
78
+ { entry: "swallowed-error", file: "src/b.py", verdict: "blind" },
79
+ { entry: "no-print-in-prod", file: "src/a.py", verdict: "caught" },
80
+ { entry: "dead-code", file: "src/b.py", verdict: "unknown" },
81
+ ];
82
+ const lines = blindLines(recs);
83
+ // То же правило, что у countProbe: класс, слепой хоть где-то, — непокрыт; «не смогли» — не слеп.
84
+ assert.deepEqual(lines, ["blind-class: swallowed-error src/a.py"]);
85
+ const text = ["at: 5", "blind: 1", "", ...lines, "- src/a.py (fixes: 3)"].join("\n");
86
+ assert.deepEqual(parseBlind(text), [{ slug: "swallowed-error", file: "src/a.py" }]);
87
+ });
88
+
89
+ test("отметка старого формата и пустая — пустой список, а не падение", () => {
90
+ assert.deepEqual(parseBlind("at: 5\nblind: 1\n"), []);
91
+ assert.deepEqual(parseBlind(""), []);
92
+ assert.deepEqual(parseBlind(null), []);
93
+ });
94
+
95
+ // «Объявлен сейчас» не значит «поставлен после пробы»: проба и гоняет объявленные гейты.
96
+ // Первая версия так и решила — и на самом комплекте написала «поставлено после пробы» про
97
+ // gate-not-weakened, который стоял ДО неё и в kit/gates/_skip.sh брак пропустил. Самое ценное
98
+ // сообщение («объявлен, но здесь не ловит») подменилось утешительным. Поймано живым прогоном.
99
+ test("отметка помнит, какие гейты проба ПРОГОНЯЛА", () => {
100
+ const text = "at: 5\nblind: 1\nran: smoke gate-not-weakened units\n\nblind-class: x a.sh\n";
101
+ assert.deepEqual([...parseRan(text)].sort(), ["gate-not-weakened", "smoke", "units"]);
102
+ // Старая отметка этой строки не несёт: «не знаем», а не «ничего не было объявлено».
103
+ assert.equal(parseRan("at: 5\nblind: 1\n"), null);
104
+ assert.deepEqual([...parseRan("ran:\n")], []);
105
+ });
@@ -17,7 +17,7 @@
17
17
  // рядом с инструментом — нормализующим адаптером, а протокол остаётся простым.
18
18
  import test from "node:test";
19
19
  import assert from "node:assert/strict";
20
- import { classify, findingCodes } from "../lib/execution.mjs";
20
+ import { classify, findingCodes, gitBash, launchable, gateCommand } from "../lib/execution.mjs";
21
21
 
22
22
  // Вход — то, что отдаёт spawnSync: { status, signal, error }.
23
23
  const R = (over = {}) => ({ status: 0, signal: null, error: undefined, ...over });
@@ -86,3 +86,80 @@ test("адаптеры знают свои коды: vulture 3 — находк
86
86
  test("наши собственные обёртки: 2 — «нет инструмента», а не находка", () => {
87
87
  assert.equal(classify(R({ status: 2 }), findingCodes("bash")).state, "infra_error");
88
88
  });
89
+
90
+ // --- какой bash запускать на Windows ---------------------------------------------------
91
+ // ЗАЧЕМ. На Windows 10/11 в System32 лежит bash.exe — заглушка WSL, и в PATH она стоит раньше
92
+ // Git Bash (установщик Git по умолчанию кладёт в PATH только Git\cmd). Команда гейта
93
+ // `bash kit/gates/x/check.sh .` уезжала в Linux-подсистему: другие программы, другой git,
94
+ // другие пути — и прогон краснел «не из-за кода». Отчёт с живого проекта 2026-09-11: «гейты
95
+ // под Windows берут bash из WSL. Через Git Bash все проходят». Известная ловушка, на неё
96
+ // наступали Claude Code (issue #23556) и Archon (#1326); общий приём — брать bash рядом с git.
97
+ const winFs = (files) => (p) => files.includes(p);
98
+ test("Windows: bash берётся рядом с git.exe, а не из PATH", () => {
99
+ const git = "C:\\Program Files\\Git\\cmd\\git.exe";
100
+ const bash = "C:\\Program Files\\Git\\bin\\bash.exe";
101
+ const got = gitBash({ platform: "win32", env: {}, which: () => git, exists: winFs([bash]) });
102
+ assert.equal(got, bash);
103
+ });
104
+
105
+ test("Windows: git из mingw64 и запасной путь в Program Files тоже находятся", () => {
106
+ const bash = "C:\\Program Files\\Git\\bin\\bash.exe";
107
+ const deep = gitBash({ platform: "win32", env: {},
108
+ which: () => "C:\\Program Files\\Git\\mingw64\\bin\\git.exe", exists: winFs([bash]) });
109
+ assert.equal(deep, bash);
110
+ const pf = gitBash({ platform: "win32", env: { ProgramFiles: "C:\\Program Files" },
111
+ which: () => null, exists: winFs([bash]) });
112
+ assert.equal(pf, bash);
113
+ });
114
+
115
+ test("Windows: явный AQK_BASH главнее поиска; не нашли — null, а не заглушка WSL", () => {
116
+ assert.equal(gitBash({ platform: "win32", env: { AQK_BASH: "D:\\x\\bash.exe" }, which: () => null, exists: () => false }),
117
+ "D:\\x\\bash.exe");
118
+ assert.equal(gitBash({ platform: "win32", env: {}, which: () => null, exists: () => false }), null);
119
+ });
120
+
121
+ test("не Windows — bash из PATH как был, команда не трогается", () => {
122
+ assert.equal(gitBash({ platform: "linux", env: {}, which: () => "/usr/bin/git", exists: () => true }), null);
123
+ assert.equal(launchable("bash x.sh .", null), "bash x.sh .");
124
+ });
125
+
126
+ test("подменяется только первое слово bash, в кавычках — путь с пробелом", () => {
127
+ const b = "C:\\Program Files\\Git\\bin\\bash.exe";
128
+ assert.equal(launchable("bash kit/gates/x/check.sh .", b), `"${b}" kit/gates/x/check.sh .`);
129
+ // Внутренний bash обёртки храповика запускает уже Git Bash — у него свой PATH, трогать незачем.
130
+ assert.equal(launchable("bash r/_ratchet.sh r/x.txt bash g/check.sh .", b), `"${b}" r/_ratchet.sh r/x.txt bash g/check.sh .`);
131
+ assert.equal(launchable("bashate lint.sh", b), "bashate lint.sh");
132
+ assert.equal(launchable("npm test", b), "npm test");
133
+ });
134
+
135
+ // Сторож одной точки: команда гейта, запущенная оболочкой В ОБХОД gateCommand, на Windows снова
136
+ // уедет в WSL. Пять мест запуска починены разом; шестое, добавленное через месяц, забудет.
137
+ test("каждый запуск команды через оболочку идёт через gateCommand", async () => {
138
+ const { readdir, readFile } = await import("node:fs/promises");
139
+ const { join, dirname } = await import("node:path");
140
+ const { fileURLToPath } = await import("node:url");
141
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
142
+ const bad = [];
143
+ for (const dir of ["lib", "commands"]) {
144
+ for (const f of (await readdir(join(root, dir))).filter((n) => n.endsWith(".mjs"))) {
145
+ const text = await readFile(join(root, dir, f), "utf8");
146
+ for (const m of text.matchAll(/spawnSync\(([^,]+),\s*\{[^}]*shell:\s*true/g)) {
147
+ if (!m[1].trim().startsWith("gateCommand(")) bad.push(`${dir}/${f}: spawnSync(${m[1].trim()}, …)`);
148
+ }
149
+ }
150
+ }
151
+ assert.deepEqual(bad, [], "запуск в обход gateCommand");
152
+ });
153
+
154
+ // Живьём, на настоящем Windows: модульные проверки выше подставляют пути, а не запускают. Без
155
+ // этой строки «windows: pass» в конвейере ничего не говорил о подмене — задание не гоняло ни
156
+ // этот файл, ни гейты через комплект. Кавычки в команде двойные: cmd.exe одинарные не снимает.
157
+ test("Windows: команда гейта со словом bash отвечает из Git Bash", { skip: process.platform !== "win32" && "только на Windows" }, async () => {
158
+ const { spawnSync } = await import("node:child_process");
159
+ const bash = gitBash();
160
+ assert.ok(bash, "Git Bash не найден рядом с git.exe");
161
+ assert.doesNotMatch(bash, /\\(system32|windowsapps)\\/i, "найдена заглушка WSL, а не Git Bash");
162
+ const r = spawnSync(gateCommand('bash -c "uname -s"'), { shell: true, encoding: "utf8" });
163
+ assert.equal(r.status, 0, r.stderr);
164
+ assert.match(r.stdout, /MINGW|MSYS/, `ответил не Git Bash: ${r.stdout}`);
165
+ });
@@ -12,6 +12,30 @@ import assert from "node:assert/strict";
12
12
  import { commandFor, verdict } from "../lib/prove.mjs";
13
13
  import { assessLevel, layoutChecks, unknownKeys, KNOWN_KEYS, parseManifest, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
14
14
  import { pickLang, langFromText } from "../i18n/index.mjs";
15
+ import { progress } from "../lib/run.mjs";
16
+
17
+ // --- строка «идёт» во время прогона ---------------------------------------------------
18
+ // ЗАЧЕМ. Гейт идёт через spawnSync, и строка про него печаталась только по завершении: минута
19
+ // `smoke` — минута пустого экрана. Человек не отличает «работает» от «повисло» и пишет сам.
20
+ test("строка «идёт» пишется только в терминал и стирается без следа", () => {
21
+ const out = [];
22
+ const pipe = progress({ tty: false, write: (s) => out.push(s) });
23
+ pipe.show("⋯ smoke [2/10]");
24
+ pipe.clear();
25
+ assert.deepEqual(out, [], "в пайп и в конвейер — ни байта: лог читается глазами и разбирается машиной");
26
+
27
+ const term = [];
28
+ const p = progress({ tty: true, write: (s) => term.push(s) });
29
+ p.clear();
30
+ assert.deepEqual(term, [], "стирать нечего — управляющих кодов нет");
31
+ p.show("⋯ smoke [2/10]");
32
+ assert.ok(term.join("").includes("smoke [2/10]"));
33
+ assert.ok(!term.join("").includes("\n"), "без перевода строки: её перепишет итог гейта");
34
+ p.clear();
35
+ assert.equal(term.at(-1), "\r\x1b[K", "итог гейта ложится на чистую строку");
36
+ p.clear();
37
+ assert.equal(term.filter((s) => s === "\r\x1b[K").length, 1, "повторное стирание ничего не пишет");
38
+ });
15
39
 
16
40
  // --- доказательство гейтов ------------------------------------------------------------
17
41
  // ЗАЧЕМ. Ступень AQK-2 называлась «гейты доказаны» и проверяла существование двух папок.