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
@@ -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 называлась «гейты доказаны» и проверяла существование двух папок.
@@ -2,8 +2,8 @@
2
2
  // «функция работает» от «функция написана».
3
3
  import test from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { isFix, fixHotspots, probeVerdict } from "../lib/history.mjs";
6
- import { scanningGates, isCode } from "../commands/probe.mjs";
5
+ import { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe } from "../lib/history.mjs";
6
+ import { probeableGates, gatesState, extAlternatives, planProbeGates, blindAdvice, isCode } from "../commands/probe.mjs";
7
7
 
8
8
  // Признак починки берётся из ТЕМЫ коммита, а не из тела: тема — единственное, что пишут все,
9
9
  // и единственное, что видно в `git log --oneline`. Три написания, потому что репозитории
@@ -61,33 +61,25 @@ test("пустая история — пустой рейтинг, а не па
61
61
  assert.deepEqual(fixHotspots("", { isCode: () => true }), []);
62
62
  });
63
63
 
64
- // Три состояния, и сливать их нельзя. «Не смогли проверить» — не «прикрыто»: ровно та
65
- // подмена, против которой написан весь комплект.
66
- test("вердикт пробы различает поймано, не поймано и нечем проверить", () => {
67
- assert.equal(probeVerdict([{ code: 1 }, { code: 0 }]), "caught");
68
- assert.equal(probeVerdict([{ code: 0 }, { code: 0 }]), "blind");
69
- assert.equal(probeVerdict([{ code: 2 }, { code: 0 }]), "unknown");
70
- // Поймавший гейт сильнее непроверенного: класс закрыт, даже если рядом чего-то не хватает.
71
- assert.equal(probeVerdict([{ code: 2 }, { code: 1 }]), "caught");
72
- assert.equal(probeVerdict([]), "unknown");
73
- });
74
64
 
75
- // Пробовать можно только те гейты, которым есть куда подставить каталог: рецепт каталога
76
- // кончается каталогом проверки, команда, написанная руками, чем угодно. То же правило, по
77
- // которому `prove` объявляет запись недоказуемой, а не сломанной.
78
- test("пробуются только гейты, кончающиеся каталогом проверки", () => {
65
+ // Пробуется ЛЮБАЯ непустая команда: образец подсаживается в копию проекта, а не в аргумент
66
+ // команды, поэтому форма команды больше ничего не решает. До 2026-09-10 здесь стояло обратное
67
+ // правило «кончается каталогом», и из-за него проба не запускалась у шести чужих
68
+ // репозиториев из семи.
69
+ test("пробуется любая непустая команда, форма больше не решает", () => {
79
70
  const man = { gates: {
80
- ok: "bash gates/x/check.sh .",
81
- slash: "bash gates/y/check.sh ./",
82
- handmade: "eslint . --max-warnings 0",
71
+ dir: "bash gates/x/check.sh .",
72
+ npm: "npm test",
73
+ glob: "eslint lib/**/*.js",
83
74
  empty: "",
75
+ blank: " ",
84
76
  } };
85
- assert.deepEqual(scanningGates(man).map(([n]) => n), ["ok", "slash"]);
77
+ assert.deepEqual(probeableGates(man).map(([n]) => n), ["dir", "npm", "glob"]);
86
78
  });
87
79
 
88
80
  test("манифест без гейтов не роняет разбор", () => {
89
- assert.deepEqual(scanningGates(null), []);
90
- assert.deepEqual(scanningGates({ gates: [] }), []);
81
+ assert.deepEqual(probeableGates(null), []);
82
+ assert.deepEqual(probeableGates({ gates: [] }), []);
91
83
  });
92
84
 
93
85
  // Образцы каталога исключены по той же причине, по какой их исключает каждая сканирующая
@@ -98,3 +90,374 @@ test("горячим считается код, но не документ и н
98
90
  assert.equal(isCode("README.md"), false);
99
91
  assert.equal(isCode("gates/secrets-not-in-code/red/a.py"), false);
100
92
  });
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+ // Итог пробы. Написано ДО кода 2026-09-10, после замера на чужеподобном проекте.
96
+ //
97
+ // ЗАЧЕМ. Итог считался одним числом: `blind ? «есть дыры» : «поймано всё»`. Исходов у
98
+ // пробы три (`probeVerdict`: caught · blind · unknown), а ветки две — и `unknown`
99
+ // молча падал в «поймано всё». Замер: проба, где ЕДИНСТВЕННАЯ запись вернула
100
+ // «нечем проверить — инструмент не установлен», напечатала «в пробованных местах
101
+ // каждый применимый класс кем-то ловится». Ошибка запуска выдана за чистоту — ровно
102
+ // тот класс, ради которого написан весь стандарт, в нашей же главной команде.
103
+ test("итог пробы: ничего не запустилось — это НЕ чистота", () => {
104
+ assert.equal(probeSummary({ caught: 0, blind: 0, unknown: 4 }), "nothing-ran");
105
+ });
106
+
107
+ test("итог пробы: поймано и при этом что-то не проверили — это НЕ чистота", () => {
108
+ assert.equal(probeSummary({ caught: 3, blind: 0, unknown: 2 }), "partial");
109
+ });
110
+
111
+ // Исходы перечислены исчерпывающе: у каждого сочетания есть имя, и ни одно не
112
+ // сваливается в соседнее по умолчанию.
113
+ test("итог пробы: остальные сочетания названы каждое своим именем", () => {
114
+ assert.equal(probeSummary({ caught: 3, blind: 0, unknown: 0 }), "clean");
115
+ assert.equal(probeSummary({ caught: 0, blind: 2, unknown: 0 }), "blind");
116
+ assert.equal(probeSummary({ caught: 3, blind: 2, unknown: 1 }), "blind");
117
+ assert.equal(probeSummary({ caught: 0, blind: 0, unknown: 0 }), "nothing-probed");
118
+ });
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // Отчего проба не состоялась. Написано ДО кода 2026-09-10.
122
+ //
123
+ // ЗАЧЕМ. `scanningGates` намеренно берёт только гейты, чья команда кончается каталогом:
124
+ // подставить образец больше некуда. Фильтр верен, сломан ОТВЕТ. При двух объявленных
125
+ // гейтах проба говорила «гейтов не объявлено — нечем пробовать, сначала: aqk add» и
126
+ // отправляла человека заводить то, что у него уже есть.
127
+ //
128
+
129
+ test("гейтов нет вовсе — и это другое состояние", () => {
130
+ assert.deepEqual(gatesState({ gates: {} }), { state: "none", declared: 0, probeable: 0 });
131
+ assert.deepEqual(gatesState(null), { state: "none", declared: 0, probeable: 0 });
132
+ });
133
+
134
+ // Пустая команда не считается объявленным гейтом: объявление без команды ничего не
135
+ // защищает, и его же отклоняет `gates-are-runnable`.
136
+ test("пустая команда гейтом не считается", () => {
137
+ assert.deepEqual(gatesState({ gates: { a: "", b: " " } }), { state: "none", declared: 0, probeable: 0 });
138
+ });
139
+
140
+ test("объявленные гейты пригодны все — состояний осталось два", () => {
141
+ const man = { gates: { scan: "bash gates/x/check.sh .", test: "npm test" } };
142
+ assert.deepEqual(gatesState(man), { state: "ok", declared: 2, probeable: 2 });
143
+ });
144
+
145
+ // Файлы, которым проба НЕ ДЕЛАЛАСЬ ВОВСЕ, тоже обязаны попадать в итог. Написано ДО кода
146
+ // 2026-09-10, после прогона на самом комплекте: из пяти горячих файлов два (`.mjs`) не
147
+ // пробовались никак — красного образца такого расширения в каталоге нет ни одного, — а итог
148
+ // сказал «каждый применимый класс кем-то ловится». Оговорка «в пробованных местах» верна
149
+ // буквально и обманывает по смыслу: она молча сужает утверждение до мест, где проба удалась,
150
+ // и никогда не говорит, сколько мест пропущено. Ровно та тишина, которая читается как чисто.
151
+ test("итог пробы: непробованные файлы не дают права говорить «чисто»", () => {
152
+ assert.equal(probeSummary({ caught: 3, blind: 0, unknown: 0, unprobed: 2 }), "partial");
153
+ assert.equal(probeSummary({ caught: 0, blind: 0, unknown: 0, unprobed: 2 }), "nothing-probed");
154
+ });
155
+
156
+ test("итог пробы: «чисто» — только когда пропущенных нет", () => {
157
+ assert.equal(probeSummary({ caught: 3, blind: 0, unknown: 0, unprobed: 0 }), "clean");
158
+ });
159
+
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+ // Парный вердикт пробы: гейт прогоняется ДВАЖДЫ — по чистой песочнице и по ней же с
162
+ // подсаженным образцом. Написано ДО кода 2026-09-10.
163
+ //
164
+ // ЗАЧЕМ. Проба умела подставлять образец только гейтам, чья команда кончается каталогом.
165
+ // Замер на семи склонированных чужих репозиториях: у шести из семи (chalk, execa, axios,
166
+ // requests, click, dependency-cruiser) команды — `xo`, `eslint lib/**/*.js`, `mocha --require…`,
167
+ // `pytest`, — и проба не запускалась вовсе. То есть на настоящих проектах она давала ноль
168
+ // сведений.
169
+ //
170
+ // Способ взят не из головы: так работает мутационное тестирование двадцать лет. Stryker
171
+ // копирует проект во временный каталог, симлинкует `node_modules` и гоняет там РОДНУЮ команду
172
+ // тестов; PIT и mutmut делают то же. Прогон по чистой копии («dry run») там обязателен —
173
+ // без него нельзя отличить «поймал подсадку» от «был красным и до неё».
174
+ function pairs(before, after) {
175
+ return { before: before.map(([name, code]) => ({ name, code })),
176
+ after: after.map(([name, code]) => ({ name, code })) };
177
+ }
178
+
179
+ test("парный вердикт: был зелёным, с подсадкой покраснел — поймано", () => {
180
+ const { before, after } = pairs([["lint", 0], ["test", 0]], [["lint", 0], ["test", 1]]);
181
+ assert.equal(probeVerdictPaired(before, after).verdict, "caught");
182
+ });
183
+
184
+ test("парный вердикт: был зелёным и остался — не поймано никем", () => {
185
+ const { before, after } = pairs([["lint", 0], ["test", 0]], [["lint", 0], ["test", 0]]);
186
+ assert.equal(probeVerdictPaired(before, after).verdict, "blind");
187
+ });
188
+
189
+ // Гейт, красный ЕЩЁ ДО подсадки, о подсадке не говорит ничего: его краснота объясняется
190
+ // состоянием проекта. Считать её поимкой значит выдавать чужой долг за свою заслугу.
191
+ test("парный вердикт: гейт был красным до подсадки — судить по нему нельзя", () => {
192
+ const { before, after } = pairs([["lint", 1]], [["lint", 1]]);
193
+ const r = probeVerdictPaired(before, after);
194
+ assert.equal(r.verdict, "unknown");
195
+ assert.equal(r.alreadyRed, 1);
196
+ assert.equal(r.usable, 0);
197
+ });
198
+
199
+ // Сбой ЗАПУСКА (код не 0 и не 1) исключает ОДИН гейт, а не весь вердикт: если сосед
200
+ // отработал и поймал, знание получено.
201
+ test("парный вердикт: сбой одного гейта не отменяет поимку другим", () => {
202
+ const { before, after } = pairs([["broken", 127], ["test", 0]], [["broken", 127], ["test", 1]]);
203
+ const r = probeVerdictPaired(before, after);
204
+ assert.equal(r.verdict, "caught");
205
+ assert.equal(r.failed, 1);
206
+ assert.equal(r.usable, 1);
207
+ });
208
+
209
+ test("парный вердикт: судить не по чему — все гейты либо сломаны, либо уже красные", () => {
210
+ const { before, after } = pairs([["a", 127], ["b", 1]], [["a", 127], ["b", 1]]);
211
+ const r = probeVerdictPaired(before, after);
212
+ assert.equal(r.verdict, "unknown");
213
+ assert.equal(r.failed, 1);
214
+ assert.equal(r.alreadyRed, 1);
215
+ });
216
+
217
+ // Позеленение от подсадки — тоже не поимка, а признак, что гейт смотрит не туда.
218
+ test("парный вердикт: гейт позеленел от подсадки — это не поимка", () => {
219
+ const { before, after } = pairs([["odd", 1]], [["odd", 0]]);
220
+ assert.equal(probeVerdictPaired(before, after).verdict, "unknown");
221
+ });
222
+
223
+ // Гейт работал ДО подсадки и сломался ОТ неё. Он мог быть тем самым ловцом — «никто не ловит»
224
+ // о нём сказать нельзя. Найдено пробой на самом комплекте 2026-09-11: образец лёг на место
225
+ // kit/gates/_skip.sh, общей библиотеки двенадцати гейтов, все двенадцать вышли с кодом 2 —
226
+ // включая gate-not-weakened, который в отдельной папке этот образец ловит. Остальные молчали,
227
+ // и проба назвала класс слепым; doctor повторил это человеку жирным.
228
+ test("парный вердикт: подсадка сломала работавший гейт — «не смогли», а не «слеп»", () => {
229
+ const { before, after } = pairs([["lint", 0], ["units", 0]], [["lint", 2], ["units", 0]]);
230
+ const r = probeVerdictPaired(before, after);
231
+ assert.equal(r.verdict, "unknown");
232
+ assert.equal(r.brokenByPlant, 1);
233
+ // Поимка соседом остаётся поимкой: знание получено, и сломанный гейт его не отменяет.
234
+ const other = pairs([["lint", 0], ["units", 0]], [["lint", 2], ["units", 1]]);
235
+ assert.equal(probeVerdictPaired(other.before, other.after).verdict, "caught");
236
+ });
237
+
238
+ test("парный вердикт: гейтов нет вовсе", () => {
239
+ assert.equal(probeVerdictPaired([], []).verdict, "unknown");
240
+ });
241
+
242
+ // ─────────────────────────────────────────────────────────────────────────────
243
+ // Семьи расширений. Написано ДО кода 2026-09-10.
244
+ //
245
+ // ЗАЧЕМ. Образец подбирается по ТОЧНОМУ совпадению расширения, и это правило верное: питоновский
246
+ // образец в проекте на TypeScript не проверит ничего, а покажет «не прикрыто» — ложная тревога
247
+ // того же класса, что молчащий гейт, только наоборот.
248
+ //
249
+ // Но `.js` и `.mjs` — это один язык и одно содержимое, а не два. Прогон пробы на САМОМ комплекте
250
+ // 2026-09-10: два горячих файла из пяти — `tool/i18n/en.mjs` и `ru.mjs`, — и обоим ответили «в
251
+ // каталоге нет красного образца под .mjs». Комплект целиком написан в этом расширении, то есть
252
+ // проба слепа к собственному коду. Заводить второй набор файлов ради той же строчки кода —
253
+ // дублирование, которое разойдётся через месяц.
254
+ //
255
+ // Семьи узкие намеренно. `.jsx`/`.tsx` сюда не входят: у них своя разметка, и образец без неё
256
+ // проверит не то, ради чего запись существует.
257
+ test("семья расширений: .mjs и .cjs берут образец .js, и наоборот", () => {
258
+ assert.deepEqual(extAlternatives(".mjs"), [".mjs", ".js", ".cjs"]);
259
+ assert.deepEqual(extAlternatives(".js"), [".js", ".mjs", ".cjs"]);
260
+ });
261
+
262
+ test("семья расширений: .ts со своими, и точное совпадение всегда первое", () => {
263
+ assert.equal(extAlternatives(".mts")[0], ".mts");
264
+ assert.ok(extAlternatives(".mts").includes(".ts"));
265
+ assert.ok(!extAlternatives(".ts").includes(".js"));
266
+ });
267
+
268
+ // Расширение вне семьи остаётся один на один с собой: это и есть запрет подсовывать
269
+ // питоновский образец в чужой язык.
270
+ test("семья расширений: одиночка не получает чужих родственников", () => {
271
+ assert.deepEqual(extAlternatives(".py"), [".py"]);
272
+ assert.deepEqual(extAlternatives(".rs"), [".rs"]);
273
+ assert.deepEqual(extAlternatives(".tsx"), [".tsx"]);
274
+ });
275
+
276
+ // ─────────────────────────────────────────────────────────────────────────────
277
+ // План пробы: каким гейтом пробовать, в каком порядке и каким — не пробовать. Написано ДО кода.
278
+ //
279
+ // ЗАЧЕМ. Цена пробы = (файлы × записи) × сумма длительностей ВСЕХ гейтов. На самом комплекте
280
+ // после перехода на песочницу это стало больше десяти минут и упёрлось в таймаут: среди 30
281
+ // гейтов есть `smoke` на 58 секунд, и он гонялся заново на каждую подсадку. Команда, которая
282
+ // идёт четверть часа, не запускается никем — то есть чинить надо здесь, а не «когда-нибудь».
283
+ //
284
+ // Длительности берутся из СУХОГО ПРОГОНА, который и так обязателен, — лишних запусков нет.
285
+ // Порядок: от быстрых к медленным, чтобы ранний выход на первом красном срабатывал чаще.
286
+ // Слишком медленные исключаются, но НЕ молча: их имена обязаны попасть в вывод, иначе
287
+ // «никто не ловит» будет означать «никто из тех, кого мы решили спросить».
288
+ test("план пробы: гейты идут от быстрых к медленным", () => {
289
+ const before = [
290
+ { name: "smoke", code: 0, ms: 58000 },
291
+ { name: "lint", code: 0, ms: 300 },
292
+ { name: "types", code: 0, ms: 4000 },
293
+ ];
294
+ const plan = planProbeGates(before, { slowMs: 20000 });
295
+ assert.deepEqual(plan.use.map((g) => g.name), ["lint", "types"]);
296
+ });
297
+
298
+ test("план пробы: слишком медленный назван поимённо, а не выброшен молча", () => {
299
+ const before = [{ name: "smoke", code: 0, ms: 58000 }, { name: "lint", code: 0, ms: 300 }];
300
+ const plan = planProbeGates(before, { slowMs: 20000 });
301
+ assert.deepEqual(plan.tooSlow.map((g) => g.name), ["smoke"]);
302
+ });
303
+
304
+ // Уже красный и сломанный не попадают в план вообще: судить по ним нельзя, а гонять их на
305
+ // каждую подсадку — платить за то, что всё равно не будет засчитано.
306
+ test("план пробы: уже красные и несработавшие в план не берутся", () => {
307
+ const before = [
308
+ { name: "red", code: 1, ms: 100 },
309
+ { name: "broken", code: 127, ms: 50 },
310
+ { name: "ok", code: 0, ms: 100 },
311
+ ];
312
+ const plan = planProbeGates(before, { slowMs: 20000 });
313
+ assert.deepEqual(plan.use.map((g) => g.name), ["ok"]);
314
+ assert.deepEqual(plan.tooSlow, []);
315
+ });
316
+
317
+ // Все годные оказались медленными — пробовать нечем, и это НЕ «никто не ловит».
318
+ test("план пробы: все годные медленные — пробовать нечем", () => {
319
+ const plan = planProbeGates([{ name: "smoke", code: 0, ms: 58000 }], { slowMs: 20000 });
320
+ assert.deepEqual(plan.use, []);
321
+ assert.deepEqual(plan.tooSlow.map((g) => g.name), ["smoke"]);
322
+ });
323
+
324
+ // ─────────────────────────────────────────────────────────────────────────────
325
+ // Счёт непокрытого: КЛАССЫ, а не проба×файл. Написано ДО кода 2026-09-10.
326
+ //
327
+ // ЗАЧЕМ. Замер на десяти живых репозиториях: у requests, click, flask и httpx проба сказала
328
+ // «непокрытых классов: 18». Различных классов там ШЕСТЬ — они просто повторены по трём горячим
329
+ // файлам. Втрое завышенное число, и завышаем его мы сами, ровно тем приёмом, который ловим у
330
+ // других: считаем события, а называем их сущностями.
331
+ //
332
+ // Списки у всех четырёх проектов совпали побайтово — то есть ответ говорит не про репозиторий,
333
+ // а про связку `ruff + pytest`. Это законный ответ («ваш инструментарий не покрывает вот эти
334
+ // шесть классов»), но продавать его как восемнадцать находок ИМЕННО У ВАС нельзя.
335
+ //
336
+ // Число проб остаётся видно отдельно: «шесть классов на трёх файлах» и «шесть классов на
337
+ // одном» — разные факты, и сливать их тоже нельзя.
338
+ test("счёт непокрытого: шесть классов на трёх файлах — это шесть, а не восемнадцать", () => {
339
+ const recs = [];
340
+ for (const f of ["a.py", "b.py", "c.py"]) {
341
+ for (const e of ["secrets", "dead-code", "print", "swallowed", "todo", "suppress"]) {
342
+ recs.push({ entry: e, file: f, verdict: "blind" });
343
+ }
344
+ }
345
+ const r = countProbe(recs);
346
+ assert.equal(r.blindClasses, 6);
347
+ assert.equal(r.probes, 18);
348
+ });
349
+
350
+ // Класс, слепой ХОТЬ ГДЕ-ТО, — дыра. Пойманный в одном файле и слепой в другом остаётся дырой:
351
+ // «где-то ловится» не защищает то место, где не ловится.
352
+ test("счёт непокрытого: слепой хоть где-то считается непокрытым", () => {
353
+ const r = countProbe([
354
+ { entry: "secrets", file: "a.py", verdict: "caught" },
355
+ { entry: "secrets", file: "b.py", verdict: "blind" },
356
+ { entry: "todo", file: "a.py", verdict: "caught" },
357
+ ]);
358
+ assert.equal(r.blindClasses, 1);
359
+ assert.equal(r.caughtClasses, 2);
360
+ });
361
+
362
+ test("счёт непокрытого: исходы разложены по своим корзинам", () => {
363
+ const r = countProbe([
364
+ { entry: "a", file: "f", verdict: "caught" },
365
+ { entry: "b", file: "f", verdict: "unknown" },
366
+ { entry: "c", file: "f", verdict: "blind" },
367
+ ]);
368
+ assert.deepEqual(
369
+ { b: r.blindClasses, c: r.caughtClasses, u: r.unknownClasses, p: r.probes },
370
+ { b: 1, c: 1, u: 1, p: 3 });
371
+ });
372
+
373
+ test("счёт непокрытого: пусто не роняет", () => {
374
+ assert.deepEqual(countProbe([]), { blindClasses: 0, caughtClasses: 0, unknownClasses: 0, probes: 0 });
375
+ });
376
+
377
+ // ─────────────────────────────────────────────────────────────────────────────
378
+ // Непокрытый класс объясняется, а не называется. Написано ДО кода 2026-09-10.
379
+ //
380
+ // ЗАЧЕМ. Проба находит настоящие дыры — и печатает про них «close it: aqk add <имя>», то есть
381
+ // «поставь нашу штуку». Человек, который видит комплект впервые, закрывает окно.
382
+ //
383
+ // А готовая однострочная команда под его стек У НАС УЖЕ ЛЕЖИТ, в `recipes` записи каталога. Мы
384
+ // её просто не показываем. Замер руками на `requests` (самый скачиваемый python-пакет): в
385
+ // `src/requests/utils.py` — 75 коммитов-починок; дописана функция с `except Exception: pass`;
386
+ // их собственные `ruff` и `pytest` дали 0 и на чистой копии, и на подсаженной. Строка, которая
387
+ // бы это поймала, — `ruff check --select BLE,TRY400,SIM105 .` — лежала в нашем каталоге всё это
388
+ // время.
389
+ //
390
+ // Польза обязана быть видна ДО установки комплекта: скопировал строку, увидел находки у себя —
391
+ // и только тогда думаешь, ставить ли нас. Отчёт, который учит, а не отчитывается.
392
+ test("совет по непокрытому классу даёт команду под стек, а не «поставь нас»", () => {
393
+ const entry = {
394
+ slug: "swallowed-error",
395
+ recipes: {
396
+ any: "bash {gate}/check.sh {dir}",
397
+ python: "ruff check --select BLE,TRY400,SIM105 {dir}",
398
+ },
399
+ };
400
+ const a = blindAdvice(entry, { langs: new Set(["python"]) }, { file: "src/requests/utils.py", fixes: 75 });
401
+ assert.equal(a.command, "ruff check --select BLE,TRY400,SIM105 .",
402
+ "команда обязана быть готовой к вставке: {dir} подставлен, {gate} не годится");
403
+ assert.equal(a.file, "src/requests/utils.py");
404
+ assert.equal(a.fixes, 75);
405
+ });
406
+
407
+ // Переносимый рецепт зовёт наш файл — вставить его человеку, у которого комплекта нет, нельзя.
408
+ // Тогда команды нет, и совет остаётся один: поставить запись.
409
+ test("совет: переносимый рецепт вставить некуда — команда не выдумывается", () => {
410
+ const entry = { slug: "x", recipes: { any: "bash {gate}/check.sh {dir}" } };
411
+ assert.equal(blindAdvice(entry, { langs: new Set(["python"]) }, { file: "a.py", fixes: 1 }).command, null);
412
+ });
413
+
414
+ test("совет: у стека нет своего рецепта — команды нет", () => {
415
+ const entry = { slug: "x", recipes: { python: "ruff check {dir}" } };
416
+ assert.equal(blindAdvice(entry, { langs: new Set(["go"]) }, { file: "a.go", fixes: 2 }).command, null);
417
+ });
418
+
419
+ // Python-проекту показывалась ссылка на eslint и knip: поле `tool` у записи общее на все языки.
420
+ // Совет, в котором половина не про тебя, читается весь как «не про тебя».
421
+ test("совет: адрес инструмента — того, которым начинается команда", () => {
422
+ const entry = {
423
+ slug: "dead-code",
424
+ tool: "https://github.com/jendrikseipp/vulture · https://github.com/webpro-nl/knip",
425
+ recipes: { python: "vulture --min-confidence 60 {dir}", javascript: "npx knip" },
426
+ };
427
+ assert.equal(blindAdvice(entry, { langs: new Set(["python"]) }).tool, "https://github.com/jendrikseipp/vulture");
428
+ // Команды нет или имя не совпало — весь список: лучше лишняя ссылка, чем ни одной.
429
+ assert.equal(blindAdvice(entry, { langs: new Set(["go"]) }).tool, entry.tool);
430
+ assert.equal(blindAdvice({ slug: "x" }, { langs: new Set() }).tool, null);
431
+ });
432
+
433
+ test("совет: пустая запись не роняет разбор", () => {
434
+ assert.equal(blindAdvice({}, { langs: new Set() }, {}).command, null);
435
+ assert.equal(blindAdvice({}, {}, {}).command, null);
436
+ });
437
+
438
+ // Безъязыковой родной рецепт годится в совет так же, как языковой: секреты ищутся в любом
439
+ // файле, и именно они чаще всего оказывались в непокрытых на чужих проектах.
440
+ test("совет: безъязыковой родной рецепт тоже даёт команду", () => {
441
+ const entry = { slug: "secrets-not-in-code",
442
+ recipes: { native: "gitleaks dir --no-banner {dir}", any: "bash {gate}/check.sh {dir}" } };
443
+ const a = blindAdvice(entry, { langs: new Set(["python"]) }, { file: "a.py", fixes: 9 });
444
+ assert.equal(a.command, "gitleaks dir --no-banner .");
445
+ });
446
+
447
+ // Из совета вычищается то, что относится к НАМ, а не к его проекту. Исключение наших красных
448
+ // образцов (`gates/*/red`) нужно установленному гейту — рядом с ним лежат образцы. Человеку,
449
+ // который команду только копирует, эти флаги бессмысленны и подрывают доверие: он видит, что
450
+ // инструмент говорит про какие-то чужие каталоги, которых у него нет.
451
+ test("совет: исключения наших образцов в команду не попадают", () => {
452
+ const entry = { slug: "no-print-in-prod", recipes: { javascript:
453
+ "eslint --no-config-lookup --ignore-pattern 'gates/*/red/**' --ignore-pattern 'gates/*/green/**' --rule '{\"no-console\":\"error\"}' {dir}" } };
454
+ const a = blindAdvice(entry, { langs: new Set(["javascript"]) }, {});
455
+ assert.ok(!a.command.includes("gates/"), `в совете остались наши каталоги: ${a.command}`);
456
+ assert.ok(a.command.includes("no-console"), "правило потерялось вместе с исключениями");
457
+ assert.ok(a.command.startsWith("eslint "), a.command);
458
+ });
459
+
460
+ test("совет: команда без наших исключений не портится", () => {
461
+ const entry = { slug: "x", recipes: { python: "ruff check --select T20 {dir}" } };
462
+ assert.equal(blindAdvice(entry, { langs: new Set(["python"]) }, {}).command, "ruff check --select T20 .");
463
+ });