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
package/tool/i18n/ru.mjs CHANGED
@@ -66,6 +66,9 @@ export const ru = {
66
66
  gitignore: "гигиена репозитория",
67
67
  git: "проект под контролем версий",
68
68
 
69
+ rulesByHuman: (total, machine, human) =>
70
+ `правил в точке входа: ${total}. Сторож — ЧЕЛОВЕК у ${human}, машина у ${machine}.`,
71
+ rulesByHumanWhy: "Правило со сторожем-человеком не сторожит никто в тот день, когда человек занят. Это та самая дыра, ради которой комплект и написан, — и напоминание про неё нужно человеку, а не только агенту. Считаны правила ТОЧКИ ВХОДА: обещание, живущее в любом другом файле, не проверяет вообще ничто, и комплект даже не скажет вам, что оно есть.",
69
72
  emptyCommands: (n) => `В AGENTS.md ${n} незаполненных команд.`,
70
73
  emptyCommandsWhy: "Агент не может выполнить пустую строку.",
71
74
 
@@ -102,6 +105,17 @@ export const ru = {
102
105
  `заявка не подтверждена: «${e}» объявлена закрытой гейтом «${g}», но ни его команда, ни\n конфиг линтера не называют правил ${codes} — запись может быть не закрыта ничем`,
103
106
  coversUnprovenHow: (cmd) => `подтверди: добавь эти правила в линтер, либо поставь запись — ${cmd}`,
104
107
  totalCovered: (n) => `закрыто другим арбитром ${n}`,
108
+ blindHeading: (behind) => `Проба${behind ? ` (коммитов с тех пор: ${behind})` : ""} подсадила брак в ваши файлы — ваши проверки его НЕ ПОЙМАЛИ:`,
109
+ blindRan: (g) => `гейт ${g} стоит и прогонялся — а брак в этом файле пропустил`,
110
+ blindInstalled: "объявлено, но проба этот гейт не гоняла (поставлен позже или медленный) — поймает ли, покажет следующая",
111
+ blindMore: (cmd) => `что именно подсадили и куда — ${cmd}`,
112
+ probeNever: (cmd) => `Ловят ли ваши проверки настоящий брак, ещё не проверялось: ${cmd} подсадит его в копию проекта и покажет (минута-две, рабочие файлы не трогает).`,
113
+ startWith: "Начните с этих трёх — они родились из настоящего отказа и закрываются одной готовой командой:",
114
+ startCmd: (cmd) => `одной строкой, без комплекта: ${cmd}`,
115
+ startTool: (url) => `инструмент: ${url}`,
116
+ startHook: "Прогнать руками — разовый героизм. Чтобы это случалось перед каждым пушем: pre-commit (репозиторий https://github.com/arsen-ask-lx/Agent_Quality_Kit, хуки aqk / aqk-doctor) либо обычный .git/hooks/pre-push.",
117
+ haveAlready: (n) => `Проверки, которые у вас УЖЕ ЕСТЬ (${n}) — прочитаны в ваших файлах, не выдуманы:`,
118
+ haveAlreadyHow: (line) => `объявите их — и держать будет машина, а не ваше внимание. В .aqk.yml, в gates: ${line}`,
105
119
  total: "Итого:",
106
120
  totalHeld: (n) => `держит машина ${n}`,
107
121
  totalTodo: (n) => `применимо но не поставлено ${n}`,
@@ -118,6 +132,8 @@ export const ru = {
118
132
  `либо почини и убери из advisory, либо признай, что правила нет.`,
119
133
  runHeading: "Прогон объявленных гейтов",
120
134
  timeout: "не уложился в 5 минут",
135
+ running: (i, n) => `[${i}/${n}] идёт…`,
136
+ proving: "проверяю, что гейты ловят брак на своих образцах…",
121
137
  exitCode: (code) => `код ${code}`,
122
138
  moreLines: (n) => `… и ещё ${n} строк`,
123
139
  declaredNotRun: (n) => `${n} гейтов объявлено, но не запускалось.`,
@@ -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 };
@@ -9,7 +9,7 @@
9
9
  // брак возвращается; там и стоит спрашивать, смотрит ли на них хоть одна проверка.
10
10
  //
11
11
  // ЧТО ЭТО НЕ ЗНАЧИТ. Часто чинят и то, что часто меняют: рейтинг говорит «сюда возвращаются»,
12
- // а не «здесь плохо». Ответ на «прикрыто ли» даёт не он, а проба — см. probeVerdict.
12
+ // а не «здесь плохо». Ответ на «прикрыто ли» даёт не он, а проба — см. probeVerdictPaired.
13
13
 
14
14
  // Признак починки берётся из ТЕМЫ коммита. Тема — единственное, что пишут все, и единственное,
15
15
  // что видно в `git log --oneline`.
@@ -71,12 +71,93 @@ function fixHotspots(raw, { isCode }) {
71
71
  // самый отказ, против которого написан весь комплект. Засчитать как «не прикрыто» — тоже
72
72
  // неправда: мы не знаем.
73
73
  //
74
- // Поймавший гейт сильнее непроверенного: класс закрыт, даже если рядом чего-то не хватает.
75
- function probeVerdict(results) {
76
- if (results.some((r) => r.code === 1)) return "caught";
77
- if (results.length === 0) return "unknown";
78
- if (results.some((r) => r.code !== 0)) return "unknown";
79
- return "blind";
74
+
75
+ // Итог пробы по всем её исходам. Отдельная функция, а не тернарник на месте: исходов у
76
+ // у пробы три, и пока веток было две, `unknown` молча падал в «поймано всё».
77
+ //
78
+ // Замер 2026-09-10 на проекте с чужими командами: единственная запись вернула «нечем
79
+ // проверить — инструмент делегирован и не установлен», а итог сказал «каждый применимый
80
+ // класс кем-то ловится». Ошибка запуска, выданная за чистоту, — то самое, ради чего
81
+ // написан стандарт, в его собственной главной команде.
82
+ //
83
+ // Сочетания названы исчерпывающе. `partial` существует потому, что «поймано три, проверить
84
+ // две не смогли» и «поймано три» — разные факты, и сливать их значит округлять в свою пользу.
85
+ // `unprobed` — горячие файлы, которым проба не делалась ВОВСЕ: красного образца их расширения
86
+ // в каталоге нет. Прогон на самом комплекте 2026-09-10: два `.mjs` из пяти горячих файлов не
87
+ // пробовались никак, а итог говорил «каждый применимый класс кем-то ловится». Оговорка «в
88
+ // пробованных местах» верна буквально и обманывает по смыслу — она молча сужает утверждение до
89
+ // мест, где проба удалась. Поэтому «чисто» отвечается только при нуле пропущенных.
90
+ function probeSummary({ caught = 0, blind = 0, unknown = 0, unprobed = 0 } = {}) {
91
+ if (blind) return "blind";
92
+ if (caught && (unknown || unprobed)) return "partial";
93
+ if (caught) return "clean";
94
+ if (unknown) return "nothing-ran";
95
+ return "nothing-probed";
96
+ }
97
+
98
+ // Парный вердикт: гейт прогнан ДВАЖДЫ — по чистой песочнице и по ней же с подсаженным
99
+ // образцом. Так проба перестаёт зависеть от формы команды: `npm test`, `pytest`, `xo`,
100
+ // `eslint lib/**/*.js` работают наравне с `bash check.sh .`.
101
+ //
102
+ // Замер 2026-09-10: у шести чужих репозиториев из семи команды каталогом не кончаются, и
103
+ // старая проба на них не запускалась вовсе. Способ не выдуман — так устроено мутационное
104
+ // тестирование: Stryker копирует проект во временный каталог, симлинкует `node_modules` и
105
+ // гоняет РОДНУЮ команду; прогон по чистой копии там обязателен.
106
+ //
107
+ // Что исключается из суждения и почему:
108
+ // · сбой ЗАПУСКА (код не 0 и не 1) с любой стороны — про подсадку не сказано ничего;
109
+ // · гейт, красный ЕЩЁ ДО подсадки, — его краснота объясняется состоянием проекта, и
110
+ // засчитывать её за поимку значит выдавать чужой долг за свою заслугу;
111
+ // · гейт, ПОЗЕЛЕНЕВШИЙ от подсадки, — он смотрит не туда, и это тоже не поимка.
112
+ // Исключены все — вердикт `unknown`, а не `blind`: «не по чему судить» и «никто не ловит»
113
+ // разные факты, и молчание здесь и есть предмет спора.
114
+ //
115
+ // И ещё одно: гейт, СЛОМАННЫЙ подсадкой (работал до, не смог после), мог быть тем самым
116
+ // ловцом. Пока никто другой не поймал, «слеп» про такой класс — неправда, и вердикт `unknown`.
117
+ // Найдено пробой на самом комплекте 2026-09-11: образец лёг на место общей библиотеки
118
+ // kit/gates/_skip.sh, двенадцать гейтов вышли с кодом 2 — включая тот, что этот образец в
119
+ // отдельной папке ловит. Остальные молчали, и проба назвала класс слепым.
120
+ function probeVerdictPaired(before, after) {
121
+ const byName = new Map(after.map((r) => [r.name, r]));
122
+ let usable = 0, alreadyRed = 0, failed = 0, caught = 0, brokenByPlant = 0;
123
+ for (const b of before) {
124
+ const a = byName.get(b.name);
125
+ const broke = (r) => !r || (r.code !== 0 && r.code !== 1);
126
+ if (broke(b) || broke(a)) {
127
+ failed++;
128
+ if (!broke(b)) brokenByPlant++;
129
+ continue;
130
+ }
131
+ if (b.code === 1) { alreadyRed++; continue; }
132
+ usable++;
133
+ if (a.code === 1) caught++;
134
+ }
135
+ const verdict = caught ? "caught" : !usable || brokenByPlant ? "unknown" : "blind";
136
+ return { verdict, usable, alreadyRed, failed, caught, brokenByPlant };
137
+ }
138
+
139
+ // Счёт непокрытого КЛАССАМИ, а не пробами. Замер на десяти живых репозиториях 2026-09-10:
140
+ // у requests, click, flask и httpx проба сказала «непокрытых классов: 18», а различных классов
141
+ // там ШЕСТЬ — повторены по трём горячим файлам. Втрое завышенное число, и завышали его мы сами
142
+ // тем самым приёмом, который ловим у других: считали события, а называли их сущностями.
143
+ //
144
+ // Число проб остаётся отдельно: «шесть классов на трёх файлах» и «шесть на одном» — разные
145
+ // факты. Класс, слепой ХОТЬ ГДЕ-ТО, считается непокрытым: «где-то ловится» не защищает то
146
+ // место, где не ловится.
147
+ function countProbe(records) {
148
+ const bins = { blind: new Set(), caught: new Set(), unknown: new Set() };
149
+ let probes = 0;
150
+ for (const { entry, verdict } of records) {
151
+ if (!bins[verdict]) continue;
152
+ bins[verdict].add(entry);
153
+ probes++;
154
+ }
155
+ return {
156
+ blindClasses: bins.blind.size,
157
+ caughtClasses: bins.caught.size,
158
+ unknownClasses: bins.unknown.size,
159
+ probes,
160
+ };
80
161
  }
81
162
 
82
- export { isFix, fixHotspots, probeVerdict };
163
+ export { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe };
@@ -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 };