agent-quality-kit 0.10.0 → 0.11.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 (66) hide show
  1. package/README.md +8 -3
  2. package/README.ru.md +8 -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 +2 -2
  10. package/kit/gates/complexity-limit/red/deep.js +15 -0
  11. package/kit/gates/duplicate-code/check.sh +7 -0
  12. package/kit/gates/duplicate-code/gate.yml +5 -1
  13. package/kit/gates/file-size-limit/check.sh +7 -0
  14. package/kit/gates/file-size-limit/red/big.js +600 -0
  15. package/kit/gates/gate-not-weakened/check.sh +7 -0
  16. package/kit/gates/gate-not-weakened/green/suppress.js +4 -0
  17. package/kit/gates/gate-not-weakened/red/suppress.js +5 -0
  18. package/kit/gates/mcp-server-resolves/check.sh +7 -0
  19. package/kit/gates/no-phantom-package/check.sh +7 -0
  20. package/kit/gates/no-print-in-prod/gate.yml +3 -3
  21. package/kit/gates/personal-config-not-shared/check.sh +7 -0
  22. package/kit/gates/protection-not-removed/check.sh +22 -13
  23. package/kit/gates/secrets-not-in-code/check.sh +7 -0
  24. package/kit/gates/secrets-not-in-code/green/config.js +4 -0
  25. package/kit/gates/secrets-not-in-code/red/leak.js +6 -0
  26. package/kit/gates/swallowed-error/gate.yml +3 -3
  27. package/kit/gates/test-has-assertion/check.sh +7 -0
  28. package/kit/gates/test-has-assertion/green/checkout.test.js +5 -0
  29. package/kit/gates/test-has-assertion/red/checkout.test.js +8 -0
  30. package/kit/gates/test-not-adjusted/check.sh +8 -2
  31. package/kit/gates/todo-without-task/check.sh +7 -0
  32. package/kit/gates/todo-without-task/gate.yml +2 -2
  33. package/kit/gates/todo-without-task/green/app.js +2 -0
  34. package/kit/gates/todo-without-task/red/later.js +4 -0
  35. package/llms.txt +4 -2
  36. package/package.json +1 -1
  37. package/tool/commands/badge.mjs +1 -1
  38. package/tool/commands/context.mjs +8 -0
  39. package/tool/commands/doctor.mjs +18 -148
  40. package/tool/commands/gates.mjs +2 -0
  41. package/tool/commands/probe.mjs +197 -41
  42. package/tool/commands/report.mjs +1 -1
  43. package/tool/i18n/en-docs.mjs +2 -0
  44. package/tool/i18n/en-gates.mjs +19 -5
  45. package/tool/i18n/en.mjs +3 -0
  46. package/tool/i18n/ru-docs.mjs +2 -0
  47. package/tool/i18n/ru-gates.mjs +20 -6
  48. package/tool/i18n/ru.mjs +3 -0
  49. package/tool/lib/evidence.mjs +15 -2
  50. package/tool/lib/execution.mjs +67 -0
  51. package/tool/lib/history.mjs +79 -8
  52. package/tool/lib/protection.mjs +52 -0
  53. package/tool/lib/prove.mjs +40 -13
  54. package/tool/lib/run.mjs +165 -0
  55. package/tool/selfcheck/gates.sh +32 -4
  56. package/tool/selfcheck/smoke/_fixture.mjs +24 -2
  57. package/tool/selfcheck/smoke/fail-closed.test.mjs +112 -0
  58. package/tool/selfcheck/smoke/own-samples.test.mjs +131 -0
  59. package/tool/selfcheck/smoke/protection.test.mjs +101 -0
  60. package/tool/selfcheck/smoke/release-tools.test.mjs +55 -0
  61. package/tool/selfcheck/smoke.sh +15 -1
  62. package/tool/selfcheck/units-context.mjs +24 -0
  63. package/tool/selfcheck/units-evidence.mjs +34 -0
  64. package/tool/selfcheck/units-execution.mjs +88 -0
  65. package/tool/selfcheck/units-level.mjs +32 -1
  66. package/tool/selfcheck/units-probe.mjs +282 -22
@@ -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,83 @@ 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
+ function probeVerdictPaired(before, after) {
115
+ const byName = new Map(after.map((r) => [r.name, r]));
116
+ let usable = 0, alreadyRed = 0, failed = 0, caught = 0;
117
+ for (const b of before) {
118
+ const a = byName.get(b.name);
119
+ const broke = (r) => !r || (r.code !== 0 && r.code !== 1);
120
+ if (broke(b) || broke(a)) { failed++; continue; }
121
+ if (b.code === 1) { alreadyRed++; continue; }
122
+ usable++;
123
+ if (a.code === 1) caught++;
124
+ }
125
+ const verdict = !usable ? "unknown" : caught ? "caught" : "blind";
126
+ return { verdict, usable, alreadyRed, failed, caught };
127
+ }
128
+
129
+ // Счёт непокрытого КЛАССАМИ, а не пробами. Замер на десяти живых репозиториях 2026-09-10:
130
+ // у requests, click, flask и httpx проба сказала «непокрытых классов: 18», а различных классов
131
+ // там ШЕСТЬ — повторены по трём горячим файлам. Втрое завышенное число, и завышали его мы сами
132
+ // тем самым приёмом, который ловим у других: считали события, а называли их сущностями.
133
+ //
134
+ // Число проб остаётся отдельно: «шесть классов на трёх файлах» и «шесть на одном» — разные
135
+ // факты. Класс, слепой ХОТЬ ГДЕ-ТО, считается непокрытым: «где-то ловится» не защищает то
136
+ // место, где не ловится.
137
+ function countProbe(records) {
138
+ const bins = { blind: new Set(), caught: new Set(), unknown: new Set() };
139
+ let probes = 0;
140
+ for (const { entry, verdict } of records) {
141
+ if (!bins[verdict]) continue;
142
+ bins[verdict].add(entry);
143
+ probes++;
144
+ }
145
+ return {
146
+ blindClasses: bins.blind.size,
147
+ caughtClasses: bins.caught.size,
148
+ unknownClasses: bins.unknown.size,
149
+ probes,
150
+ };
80
151
  }
81
152
 
82
- export { isFix, fixHotspots, probeVerdict };
153
+ export { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe };
@@ -0,0 +1,52 @@
1
+ // tool/lib/protection.mjs — снимок объявленной защиты: кто его пишет и в каком виде.
2
+ //
3
+ // ОТДЕЛЬНЫМ МОДУЛЕМ, а не строкой в `add`: у формата снимка два читателя — эта запись и
4
+ // проверка `kit/gates/protection-not-removed/check.sh`. Знание об одном файле, размазанное по
5
+ // двум местам, однажды разъедется; здесь оно собрано с той стороны, где на JS.
6
+ // Вторая копия неизбежна: проверка написана на POSIX sh и разделить с ней код нельзя —
7
+ // поэтому шапка ниже и текст в её сообщении «почини» обязаны меняться вместе.
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { CWD, RATCHET_DIR, MANIFEST } from "./core.mjs";
11
+
12
+ const HEADER =
13
+ "# Снимок объявленной защиты. Набор может только РАСТИ.\n" +
14
+ "# Убрал гейт — напиши причину после # в его строке, иначе проверка краснеет.\n";
15
+
16
+ // СНИМОК ОБЪЯВЛЕННОЙ ЗАЩИТЫ ПИШЕТ УСТАНОВКА, А НЕ ПРОВЕРКА.
17
+ //
18
+ // Раньше его вёл сам гейт `protection-not-removed`: при отсутствии создавал, при появлении новых
19
+ // имён дописывал. То есть проверка меняла то, о чём судит. Опыт 2026-09-09: в мелком клоне, где
20
+ // удаление снимка лежит глубже выкладки, свидетель (история git) слеп — и проверка записала уже
21
+ // ОСЛАБЛЕННЫЙ набор новым эталоном, вернув ноль. Храповик крутился назад.
22
+ //
23
+ // Здесь этому место по смыслу: `add` уже меняет манифест, и запись в снимок — часть того же
24
+ // действия. Проверка теперь только сравнивает.
25
+ async function recordProtection(man, slug) {
26
+ // `init` кладёт `ratchets: ""` намеренно: пустое поле честнее заглушки. Заполняем его при
27
+ // первой записи — ровно так же поступает `ratchet`. Иначе источников истины два: пустой
28
+ // манифест и умолчание внутри проверки, — и они однажды разойдутся.
29
+ let rdir = typeof man?.ratchets === "string" ? man.ratchets.trim() : "";
30
+ const manPath = join(CWD, MANIFEST);
31
+ if (!rdir) {
32
+ rdir = RATCHET_DIR;
33
+ try {
34
+ const t = await readFile(manPath, "utf8");
35
+ if (/^ratchets:\s*""\s*$/m.test(t)) {
36
+ await writeFile(manPath, t.replace(/^ratchets:\s*""\s*$/m, `ratchets: ${RATCHET_DIR}`), "utf8");
37
+ }
38
+ } catch { /* манифест не прочитан — снимок всё равно заведём в умолчательном каталоге */ }
39
+ }
40
+ const file = join(CWD, rdir, "gates-declared.txt");
41
+ let body = "";
42
+ try { body = await readFile(file, "utf8"); } catch { /* снимка ещё нет — заведём */ }
43
+ const names = body.split("\n").map((l) => l.replace(/\s*#.*$/, "").trim()).filter(Boolean);
44
+ if (names.includes(slug)) return;
45
+ const head = body ? body.replace(/\n?$/, "\n") : HEADER;
46
+ await mkdir(join(CWD, rdir), { recursive: true });
47
+ await writeFile(file, `${head}${slug}\n`, "utf8");
48
+ }
49
+
50
+ // Наружу — только запись. `HEADER` остаётся внутри: экспорт, который никто не берёт, читается
51
+ // как часть договора и мешает менять внутренности. Поймал наш же dead-code.
52
+ export { recordProtection };
@@ -15,6 +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
19
 
19
20
  // Гейт можно доказать, если у него есть оба образца. Признак по образцам, а не по тексту
20
21
  // команды: запись, делегирующая готовому инструменту (`npx knip --directory .`), каталог
@@ -102,12 +103,14 @@ async function samplesForRecipe(samplesDir, name) {
102
103
  }
103
104
  }
104
105
 
105
- function run(cmd, timeoutMs) {
106
+ // Запуск возвращает РАЗОБРАННЫЙ исход, а не сырой код. Прежде здесь стояло
107
+ // `code = r.status === null ? 124 : r.status`, и комментарий рядом честно называл 124
108
+ // «не знаем» — а вызывающий тут же считал его находкой. Опыт 2026-09-09: проверка, виснущая
109
+ // на красном образце, получала вердикт «доказана».
110
+ function run(cmd, timeoutMs, prog) {
106
111
  const r = spawnSync(cmd, { shell: true, encoding: "utf8", cwd: CWD, timeout: timeoutMs });
107
112
  const out = `${r.stdout || ""}${r.stderr || ""}`.trim();
108
- // Убитый по таймауту процесс возвращает null — это не «ноль», а «не знаем».
109
- const code = r.status === null ? 124 : r.status;
110
- return { code, out };
113
+ return { ...classify(r, findingCodes(prog)), out };
111
114
  }
112
115
 
113
116
  // Возвращает { proven, broken, unprovable, results } — числами и списком, чтобы вызывающий
@@ -159,24 +162,48 @@ async function proveGates(man, { timeoutMs = 300000 } = {}) {
159
162
  continue;
160
163
  }
161
164
 
162
- const red = run(commandFor(cmd, s.red), timeoutMs);
163
- const green = run(commandFor(cmd, s.green), timeoutMs);
164
- if (red.code === 0) {
165
+ // Программа, чьи коды разбираем: первое слово команды БЕЗ обёрток. У обёрнутой записи
166
+ // это ruff/vulture, а не bash, — иначе адаптер брался бы для оболочки.
167
+ const prog = effective[0];
168
+ const red = run(commandFor(cmd, s.red), timeoutMs, prog);
169
+ const green = run(commandFor(cmd, s.green), timeoutMs, prog);
170
+
171
+ // СБОЙ АРБИТРА — НЕ ВЕРДИКТ О ЗАПИСИ, ни в ту сторону, ни в другую. Раньше сбой на красном
172
+ // читался как «поймал», а сбой на зелёном — как «ругается на исправный код»: инструмент
173
+ // ломался, а обвиняли запись каталога. Оба случая теперь «доказать не смогли», и причина
174
+ // названа.
175
+ if (red.state === "infra_error" || green.state === "infra_error") {
176
+ const side = red.state === "infra_error" ? "red" : "green";
177
+ const bad = side === "red" ? red : green;
178
+ results.push({ name, state: "unprovable", why: "infra", side, reason: bad.reason, red, green });
179
+ } else if (red.state === "clean") {
165
180
  results.push({ name, state: "broken", why: "red-passed", red, green });
166
- } else if (green.code !== 0) {
181
+ } else if (green.state === "finding") {
167
182
  results.push({ name, state: "broken", why: "green-failed", red, green });
168
183
  } else {
169
184
  results.push({ name, state: "proven", red, green });
170
185
  }
171
186
  }
172
187
 
188
+ return { ...verdict(results), results };
189
+ }
190
+
191
+ // ВЕРДИКТ ОТДЕЛЁН ОТ ПРОГОНА — чтобы правило можно было проверить без запуска процессов.
192
+ //
193
+ // Прежнее правило было `broken === 0 && proven > 0`, и оно позволяло ОДНОМУ доказанному гейту
194
+ // компенсировать сколько угодно недоказанных: проект с пятью объявленными проверками, из
195
+ // которых четыре не смогли отработать, получал AQK-2 за счёт пятой.
196
+ //
197
+ // Различие тонкое и обязательное. «Нечем доказывать» бывает ЗАКОННЫМ: нет образцов, нет
198
+ // программы на этой машине, стоит рецепт под другой язык — ступень за это не отнимают, иначе
199
+ // уровень стал бы зависеть от того, что установлено. А «запускали и не смогло отработать» —
200
+ // сбой, и он ступень отнимает: иначе таймаут арбитра снова становится способом получить зелёное.
201
+ function verdict(results) {
173
202
  const proven = results.filter((r) => r.state === "proven").length;
174
203
  const broken = results.filter((r) => r.state === "broken").length;
175
204
  const unprovable = results.filter((r) => r.state === "unprovable").length;
176
- // Доказательство состоялось, если ни один доказуемый гейт не сломан И хоть один доказан.
177
- // Второе условие обязательно: проект, у которого все гейты недоказуемы, ничего не доказал
178
- // именно так выглядит подделка с тремя `true`.
179
- return { proven, broken, unprovable, ok: broken === 0 && proven > 0, results };
205
+ const infra = results.filter((r) => r.state === "unprovable" && r.why === "infra").length;
206
+ return { proven, broken, unprovable, infra, ok: broken === 0 && infra === 0 && proven > 0 };
180
207
  }
181
208
 
182
- export { proveGates, commandFor };
209
+ export { proveGates, commandFor, verdict };
@@ -0,0 +1,165 @@
1
+ // tool/lib/run.mjs — ПРОГОН объявленных гейтов, отдельно от команды, которая его показывает.
2
+ //
3
+ // ЗАЧЕМ ОТДЕЛЬНЫЙ ФАЙЛ. Прогон жил внутри `doctor.mjs`, и `report.mjs` с `badge.mjs` лезли за
4
+ // ним ИЗ КОМАНДЫ В КОМАНДУ. Это не деталь: команда — это то, что человек набирает, а прогон —
5
+ // то, что делает машина; у них разные причины меняться, и импорт команды из команды прятал
6
+ // этот шов.
7
+ //
8
+ // Вскрылось 2026-09-10 нашим же гейтом: `doctor.mjs` дорос до 508 строк при пределе 500 —
9
+ // файл был полон, и любая следующая правка ложилась туда просто потому, что «так ближе по
10
+ // контексту». Ровно то, о чём предупреждает совет самого гейта.
11
+ import { spawnSync } from "node:child_process";
12
+ import { scopeOutput, splitAdvice, changedFiles } from "./scope.mjs";
13
+ import { CWD, c, die } from "./core.mjs";
14
+ import { advisorySet } from "./manifest.mjs";
15
+ import { L } from "../i18n/index.mjs";
16
+
17
+
18
+ // «Гейт объявлен» и «гейт работает» — разные утверждения. Первое читается из манифеста,
19
+ // второе узнаётся только запуском. Пока doctor верил манифесту на слово, уровень означал
20
+ // добросовестность автора, а не факт — ровно то, от чего мы защищаемся.
21
+ //
22
+ // Запуск чужих команд — по явной просьбе (--run), а не втихую: гейт бывает долгим и с
23
+ // побочными действиями. Без флага doctor честно говорит, что не проверял.
24
+
25
+ function declaredGates(man) {
26
+ const g = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
27
+ return Object.entries(g)
28
+ .map(([name, cmd]) => [name, String(cmd || "").trim()])
29
+ .filter(([, cmd]) => cmd);
30
+ }
31
+
32
+ // Ссылка, относительно которой сужается вывод: `--since main`, `--since HEAD~5`.
33
+ // Без значения флаг бессмыслен — молча взять умолчание нельзя: «сужено не тем» неотличимо
34
+ // от «не сужено».
35
+ function sinceRef(argv = process.argv) {
36
+ const i = argv.indexOf("--since");
37
+ if (i === -1) return null;
38
+ const v = argv[i + 1];
39
+ return v && !v.startsWith("-") ? v : null;
40
+ }
41
+
42
+ function runGates(man, opts = {}) {
43
+ const gates = declaredGates(man);
44
+ if (!gates.length) return { failed: 0, ran: 0, results: [] };
45
+ const advisory = advisorySet(man);
46
+
47
+ // Сужение по дифу — договор с человеком, и он должен видеть, ЧТО именно сужено. Пустой диф
48
+ // называется вслух: иначе «все гейты зелёные» означало бы «сравнили не с тем» и читалось бы
49
+ // как успех. Это тот же класс, что и весь стандарт, только внутри нашего флага.
50
+ const scoped = opts.since ? changedFiles(opts.since, CWD) : null;
51
+ if (opts.since && scoped === null) die(L.doctor.sinceBadRef(opts.since));
52
+ if (scoped) console.log(c.dim(`\n ${L.doctor.sinceHeading(opts.since, scoped.size)}`));
53
+
54
+ console.log(c.bold(`\n ${L.doctor.runHeading}\n`));
55
+ let failed = 0;
56
+ const results = [];
57
+
58
+ for (const [name, cmd] of gates) {
59
+ const t0 = Date.now();
60
+ const r = spawnSync(cmd, { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
61
+ const secs = (Math.max(0, Date.now() - t0) / 1000).toFixed(1);
62
+ // Вывод гейта запоминается целиком (с потолком, чтобы болтливый инструмент не съел память):
63
+ // по нему считается покрытие дифа — какой файл вообще был назван хоть одной проверкой.
64
+ // Без этого «готово = доказано» остаётся правилом, за которым следит только человек.
65
+ const outAll = `${r.stdout || ""}${r.stderr || ""}`.slice(0, 200000);
66
+
67
+ if (r.error && r.error.code === "ETIMEDOUT") {
68
+ console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.timeout)}`);
69
+ failed++;
70
+ results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout, out: outAll });
71
+ continue;
72
+ }
73
+ const code = r.status;
74
+ if (code === 0) {
75
+ // Совещательный называется и когда он зелёный. Иначе гейт, который уронить прогон НЕ
76
+ // МОЖЕТ, по выводу неотличим от того, который может, — и список `advisory:` в манифесте
77
+ // виден только в тот день, когда он покраснел. Измерено 2026-09-09: зелёный
78
+ // совещательный печатался обычной галочкой, а README обещал, что список назван каждый
79
+ // прогон. Тот же класс, что молчащий гейт, только про сам прибор.
80
+ const quiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
81
+ console.log(` ${c.green("✔")} ${name.padEnd(14)}${quiet} ${c.dim(`${secs}s · ${cmd}`)}`);
82
+ // Зелёный гейт иногда всё-таки говорит человеку что-то важное: храповик, дошедший до цели,
83
+ // просит убрать обёртку. Вывод успешного гейта не показывался вовсе, и это сообщение
84
+ // уходило в никуда — тот же класс, что обрезанный совет у красного, только тише.
85
+ // Показываем ровно строки с меткой совета: остальной вывод успешной проверки — шум.
86
+ const okAdvice = splitAdvice(`${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean)).advice;
87
+ for (const line of okAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
88
+ results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), out: outAll });
89
+ } else {
90
+ const raw = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
91
+ // Совет отделяется ДО сужения. Иначе он сам попадает под фильтр по путям: сообщение
92
+ // храповика про вышедший срок называет путь к реестру, реестра в дифе нет, и гейт,
93
+ // обязанный краснеть по сроку, печатался зелёным с пометкой «находки вне дифа».
94
+ // Ровно то, что стандарт запрещает: срок без последствия. Найдено ревью 2026-09-06.
95
+ const parted = splitAdvice(raw);
96
+ let out = parted.findings;
97
+ const alwaysAdvice = parted.advice;
98
+
99
+ // Сужение до дифа. Три исхода, и все три называются вслух.
100
+ if (scoped) {
101
+ const s = scopeOutput(out, scoped);
102
+ // Гейт, у которого находок нет вовсе, а есть только совет, сузить нечем: его вердикт
103
+ // не про файлы. Признать такой успешным — вернуть ту же тишину другим путём.
104
+ if (!s.scopable || out.length === 0) {
105
+ // Гейт печатает вердикт без путей — сузить нечем. Признать его успешным значило бы
106
+ // выдать провал за тишину; остаётся красным, и причина названа.
107
+ // Совещательный не роняет прогон НИКОГДА — в том числе здесь. Раньше failed++ стоял
108
+ // безусловно, и гейт, объявленный совещательным, валил сборку с `--since` только
109
+ // потому, что в его выводе нет путей. Измерено 2026-09-09.
110
+ const nsAdv = advisory.has(name);
111
+ const nsMark = nsAdv ? c.yellow("!") : c.red("✘");
112
+ const nsVerdict = nsAdv ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
113
+ console.log(` ${nsMark} ${name.padEnd(14)} ${nsVerdict} ${c.dim(`· ${L.doctor.notScopable}`)}`);
114
+ if (!nsAdv) failed++;
115
+ results.push({ name, cmd, ok: false, secs, code, advisory: nsAdv, note: L.doctor.notScopable, out: outAll });
116
+ continue;
117
+ }
118
+ if (s.findings === 0) {
119
+ // Долг есть, но не в том, что внёс диф. Зелёный — но с числом спрятанного: молчаливое
120
+ // «всё хорошо» здесь было бы неправдой.
121
+ const sQuiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
122
+ console.log(` ${c.green("✔")} ${name.padEnd(14)}${sQuiet} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
123
+ results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), scopedAway: out.length, out: outAll });
124
+ continue;
125
+ }
126
+ out = s.kept;
127
+ }
128
+
129
+ failed++;
130
+ // Находки обрезаются, совет — никогда. Все записи каталога печатают «почини: …» последней
131
+ // строкой, и при обрезке до трёх строк человек не видел именно её: находка без действия
132
+ // закрывает окно, а не дефект.
133
+ // Совещательный гейт показывает находки и не роняет прогон. Знак другой, чтобы «показано»
134
+ // и «провалено» не читались одинаково; в сводке ниже он назван поимённо.
135
+ const isAdvisory = advisory.has(name);
136
+ if (isAdvisory) failed--;
137
+ const mark = isAdvisory ? c.yellow("!") : c.red("✘");
138
+ const verdict = isAdvisory ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
139
+ console.log(` ${mark} ${name.padEnd(14)} ${verdict} ${c.dim(`· ${secs}s · ${cmd}`)}`);
140
+ // ГОЛОВА И ХВОСТ, А НЕ ТОЛЬКО ГОЛОВА. Гейт, который сам является прогоном (наш `smoke`),
141
+ // печатает сотни строк, и вердикт у него в конце — при обрезке до первых трёх человек
142
+ // видел «программа разбирается» и ни слова о том, что упало. Час поисков в конвейере
143
+ // 2026-09-09 стоил ровно этого. Голова нужна тоже: у сканирующих записей находки идут
144
+ // с первой строки.
145
+ const HEAD = 3, TAIL = 2;
146
+ for (const line of out.slice(0, HEAD)) console.log(c.dim(` ${line.slice(0, 100)}`));
147
+ if (out.length > HEAD + TAIL) {
148
+ console.log(c.dim(` ${L.doctor.moreLines(out.length - HEAD - TAIL)}`));
149
+ for (const line of out.slice(-TAIL)) console.log(c.dim(` ${line.slice(0, 100)}`));
150
+ } else {
151
+ for (const line of out.slice(HEAD)) console.log(c.dim(` ${line.slice(0, 100)}`));
152
+ }
153
+ // Совет тоже не бесконечен: гейт, зовущий помощник шесть раз, печатает его шесть раз.
154
+ for (const line of alwaysAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
155
+ results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll });
156
+ }
157
+ }
158
+ // Совещательные, которые покраснели, называются вслух ВСЕГДА. Молчание о них — ровно та
159
+ // тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
160
+ const advisoryFailed = results.filter((x) => x.advisory && !x.ok).map((x) => x.name);
161
+ if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
162
+ return { failed, ran: gates.length, results, advisoryFailed };
163
+ }
164
+
165
+ export { declaredGates, sinceRef, runGates };
@@ -98,11 +98,39 @@ for GATE in "$CAT"/*/; do
98
98
  # приезжает туда с CRLF (`.gitattributes` держит LF только для `*.sh`), а `grep -i` с
99
99
  # кириллицей в сборке MSYS ведёт себя не так, как GNU grep. Регистр первой буквы назван
100
100
  # явно — это дешевле, чем полагаться на сворачивание регистра в чужой сборке grep.
101
+ # «Файла нет» и «раздела нет» — РАЗНЫЕ приговоры, а до 2026-09-10 оба звучали одинаково:
102
+ # `tr` из недоступного файла даёт пустую строку, grep по ней молчит, и запись обвинялась в
103
+ # том, чего не делала. Дважды наблюдался плавающий отказ этой самой проверки на записи,
104
+ # README которой на месте и раздел содержит; воспроизвести за сорок прогонов не удалось.
105
+ # Пока причина неизвестна, сообщение обязано называть, что именно случилось, — иначе
106
+ # следующий раз снова будет ложным обвинением вместо улики.
107
+ if [ ! -s "$GATE/README.md" ]; then
108
+ bad "$SLUG: README.md записи отсутствует или пуст — проверить разделы НЕЧЕМ, а не «их нет»"
109
+ continue
110
+ fi
101
111
  README_TEXT="$(tr -d '\r' < "$GATE/README.md" 2>/dev/null)"
102
- printf '%s\n' "$README_TEXT" | grep -qE '[Гг]отовы(й аналог|й инструмент|е правил)|[Гг]отового аналога' \
103
- || bad "$SLUG: в README нет раздела про готовый аналог«не искал» и «нет» разные утверждения"
104
- printf '%s\n' "$README_TEXT" | grep -qE 'чего НЕ ловит|Чего НЕ ловит|чего не ловит|Чего не ловит' \
105
- || bad "$SLUG: в README нет раздела «чего НЕ ловит» — граница записи обязана быть названа"
112
+ if [ -z "$README_TEXT" ]; then
113
+ bad "$SLUG: README.md есть, но прочитать не удалосьпроверка НЕ СОСТОЯЛАСЬ"
114
+ continue
115
+ fi
116
+ # Три исхода, а не два. `grep` отвечает 0 «нашёл», 1 «не нашёл» и 2+ «не смог»: при нехватке
117
+ # памяти или процессов он не запускается вовсе, и его код неотличим от «раздела нет». Отказ
118
+ # наблюдался трижды под тяжёлой параллельной нагрузкой, каждый раз на РАЗНОЙ записи, README
119
+ # которой на месте и раздел содержит; воспроизвести в покое не удалось за сорок прогонов.
120
+ # Причина не доказана — но обвинять запись в том, чего не проверяли, нельзя в любом случае.
121
+ printf '%s\n' "$README_TEXT" | grep -qE '[Гг]отовы(й аналог|й инструмент|е правил)|[Гг]отового аналога'
122
+ case $? in
123
+ 0) : ;;
124
+ 1) bad "$SLUG: в README нет раздела про готовый аналог — «не искал» и «нет» разные утверждения" ;;
125
+ *) bad "$SLUG: поиск раздела про готовый аналог НЕ СОСТОЯЛСЯ (grep вышел с кодом $?) — это не приговор записи" ;;
126
+ esac
127
+ # Те же три исхода, что и у поиска выше, и по той же причине.
128
+ printf '%s\n' "$README_TEXT" | grep -qE 'чего НЕ ловит|Чего НЕ ловит|чего не ловит|Чего не ловит'
129
+ case $? in
130
+ 0) : ;;
131
+ 1) bad "$SLUG: в README нет раздела «чего НЕ ловит» — граница записи обязана быть названа" ;;
132
+ *) bad "$SLUG: поиск раздела «чего НЕ ловит» НЕ СОСТОЯЛСЯ (grep вышел с кодом $?) — это не приговор записи" ;;
133
+ esac
106
134
 
107
135
  # Зрелость не объявляют — её считают. Попытка написать `lifecycle: stable` руками отклоняется:
108
136
  # поле, которое заполняет автор, означает доверие к автору, а не факт. Это ровно тот способ,
@@ -23,12 +23,34 @@ const CLI = join(ROOT, "tool", "program.mjs");
23
23
  // а ради того, чтобы висящий процесс называл себя, а не съедал бюджет задания молча.
24
24
  const TIMEOUT_MS = 30000;
25
25
 
26
- function env(home) {
26
+ // Настоящий дом СНИМАЕТСЯ ОДИН РАЗ, до всякой подмены: из него берётся PYTHONUSERBASE.
27
+ const REAL_HOME = process.env.HOME || process.env.USERPROFILE || "";
28
+
29
+ function env(home, dir) {
27
30
  return {
28
31
  ...process.env,
32
+ // PYTHONUSERBASE — ИЗ-ЗА подмены HOME и до неё. Инструменты, поставленные `pip --user`
33
+ // (`vulture`, `pylint`), это python-скрипты, которые ищут свои модули в
34
+ // $HOME/.local/lib. С песочницей вместо HOME импорт падает, инструмент выходит ненулевым,
35
+ // и обёртка родного рецепта читает это как НАХОДКУ. То есть проверка краснела не на
36
+ // дефекте, а на собственной оснастке. Тот же довод дословно записан в шапке `smoke.sh`;
37
+ // здесь он был потерян при переезде на встроенный раннер.
38
+ PYTHONUSERBASE: process.env.PYTHONUSERBASE || `${REAL_HOME}/.local`,
29
39
  HOME: home,
30
40
  USERPROFILE: home,
31
41
  TMPDIR: join(home, "tmp"),
42
+ // ПОТОЛОК ПОИСКА РЕПОЗИТОРИЯ. Без него `git: false` не означает «вне репозитория»: git
43
+ // идёт вверх по дереву, и если ЛЮБОЙ предок временного каталога окажется репозиторием,
44
+ // подопытный проект молча получит чужую историю. Поймано 2026-09-10: проверка «гейт без
45
+ // репозитория обязан сказать "не смогли"» проходила поодиночке и падала внутри `smoke.sh`,
46
+ // потому что там над TMPDIR оказывался git. Один и тот же тест давал разные ответы в
47
+ // зависимости от того, чем занят каталог этажом выше, — то есть герметичность, ради
48
+ // которой этот файл написан, держалась на случайности.
49
+ //
50
+ // Указывается РОДИТЕЛЬ, а не сам каталог: git не поднимается ВЫШЕ потолка, но от стартового
51
+ // каталога до него доходит. С потолком, равным проекту, репозиторий этажом выше находился
52
+ // по-прежнему — проверено прямым опытом, а не выведено из документации.
53
+ GIT_CEILING_DIRECTORIES: dirname(dir),
32
54
  AQK_LANG: "ru",
33
55
  // Сеть в тестах — отдельный класс флейков, и у нас она включалась ТОЛЬКО вне конвейера:
34
56
  // локально проверки были сетевыми, в конвейере нет. Две разные среды по построению.
@@ -62,7 +84,7 @@ function project(t, files = {}, { git = true } = {}) {
62
84
  // что команда не роняет прогон.
63
85
  function run({ dir, home }, cmd, args = []) {
64
86
  const r = spawnSync(cmd, args, {
65
- cwd: dir, encoding: "utf8", timeout: TIMEOUT_MS, env: env(home),
87
+ cwd: dir, encoding: "utf8", timeout: TIMEOUT_MS, env: env(home, dir),
66
88
  });
67
89
  return { code: r.status, out: `${r.stdout || ""}${r.stderr || ""}` };
68
90
  }
@@ -0,0 +1,112 @@
1
+ // tool/selfcheck/smoke/fail-closed.test.mjs — проверка, которая НЕ МОЖЕТ работать, обязана
2
+ // сказать это, а не позеленеть.
3
+ //
4
+ // ЗАЧЕМ. Весь стандарт написан против одного: тишина читается как «чисто». Три раза подряд
5
+ // он нарушен в нём самом — найдено 2026-09-10, когда проба впервые начала гонять гейты по
6
+ // КОПИИ проекта и увидела их так же, как видит пользователь:
7
+ //
8
+ // 1. Подмена общей библиотеки `kit/gates/_skip.sh` даёт код 0. Гейт печатает
9
+ // «command not found» и объявляет проект чистым. Библиотеку подключает КАЖДАЯ запись
10
+ // каталога, то есть одним испорченным файлом гасится весь набор защит.
11
+ // 2. Гейт, читающий историю, в каталоге без git возвращает 0 за ноль секунд. Он не прошёл —
12
+ // он не смог посмотреть, и разницы в выводе нет никакой.
13
+ //
14
+ // Обе — отказ В ОТКРЫТУЮ сторону: чем сильнее сломано, тем зеленее ответ. Договор один и тот
15
+ // же, он уже записан в комплекте: 0 — проверено и чисто, 1 — находка, ВСЁ ОСТАЛЬНОЕ — «не
16
+ // смогли проверить». Именно так `prove` и `execution.mjs` разбирают исход, и гейты обязаны
17
+ // говорить на том же языке.
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { cpSync, writeFileSync, mkdirSync } from "node:fs";
21
+ import { join, dirname } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { project, run, aqk } from "./_fixture.mjs";
24
+
25
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
26
+ const posix = (p) => String(p).replace(/\\/g, "/");
27
+
28
+ // Ставит запись каталога В ПРОЕКТ вместе с общей библиотекой — так же, как это делает `aqk add`.
29
+ // Библиотеку кладём отдельно, чтобы проверка могла её испортить: в исходном каталоге её портить
30
+ // нельзя, там она общая на весь набор.
31
+ function installGate(p, name, { skipLib } = {}) {
32
+ const dest = join(p.dir, "gates", name);
33
+ mkdirSync(dirname(dest), { recursive: true });
34
+ cpSync(join(ROOT, "kit", "gates", name), dest, { recursive: true });
35
+ const lib = join(p.dir, "gates", "_skip.sh");
36
+ if (skipLib === undefined) cpSync(join(ROOT, "kit", "gates", "_skip.sh"), lib);
37
+ else writeFileSync(lib, skipLib, "utf8");
38
+ return posix(join("gates", name, "check.sh"));
39
+ }
40
+
41
+ test("гейт с ИСПОРЧЕННОЙ общей библиотекой не смеет отвечать «чисто»", (t) => {
42
+ const p = project(t, { "src/a.py": "x = 1\n" });
43
+ const check = installGate(p, "complexity-limit", { skipLib: "мусор вместо библиотеки\n" });
44
+ const r = run(p, "bash", [check, "."]);
45
+ assert.notEqual(r.code, 0,
46
+ `гейт вернул 0 при нерабочей библиотеке обхода. Вывод:\n${r.out}`);
47
+ });
48
+
49
+ test("та же запись с ЦЕЛОЙ библиотекой на том же проекте молчит — значит дело в поломке", (t) => {
50
+ const p = project(t, { "src/a.py": "x = 1\n" });
51
+ const check = installGate(p, "complexity-limit");
52
+ const r = run(p, "bash", [check, "."]);
53
+ assert.equal(r.code, 0, `гейт покраснел на исправном проекте. Вывод:\n${r.out}`);
54
+ });
55
+
56
+ // Второй отказ: истории нет — смотреть не на что. «Не смогли» и «чисто» обязаны различаться,
57
+ // иначе проект без git получает зелёный прогон по построению.
58
+ for (const name of ["commit-explains-itself", "test-not-adjusted"]) {
59
+ test(`«${name}» без репозитория говорит «не смогли», а не «чисто»`, (t) => {
60
+ const p = project(t, { "src/a.py": "x = 1\n" }, { git: false });
61
+ const check = installGate(p, name);
62
+ const r = run(p, "bash", [check, "."]);
63
+ assert.notEqual(r.code, 0,
64
+ `гейт вернул 0 там, где истории нет и посмотреть было не на что. Вывод:\n${r.out}`);
65
+ });
66
+ }
67
+
68
+ // У `protection-not-removed` свидетель — тоже история, но выходит он раньше: на отсутствии
69
+ // манифеста. Поэтому проект здесь С манифестом и объявленными гейтами: сторожить есть что,
70
+ // а посмотреть нечем.
71
+ test("«protection-not-removed» без репозитория говорит «не смогли», а не «чисто»", (t) => {
72
+ const p = project(t, {
73
+ "src/a.py": "x = 1\n",
74
+ ".aqk.yml": "aqk: 1\nentry:\n - AGENTS.md\ngates:\n lint: \"true\"\n",
75
+ "AGENTS.md": "# проект\n",
76
+ }, { git: false });
77
+ const check = installGate(p, "protection-not-removed");
78
+ const r = run(p, "bash", [check, "."]);
79
+ assert.notEqual(r.code, 0,
80
+ `гейт вернул 0 там, где снимок сторожить нечем. Вывод:\n${r.out}`);
81
+ });
82
+
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+ // Правило со сторожем-человеком обязано быть названо ЧЕЛОВЕКУ. Написано ДО кода 2026-09-10.
85
+ //
86
+ // ЗАЧЕМ. Отчёт живого проекта: «всё, что касается масштаба, помечено `aqk: человек`. AQK
87
+ // отработал честно: потребовал назвать сторожа, мы назвали — и сторож не проверил». Пометка
88
+ // `человек` — не сторож, а расписка в том, что сторожа нет: гейт `promise-has-gate` видит её
89
+ // и идёт дальше (`if (tag == "человек") next`).
90
+ //
91
+ // Числа уже считаются и уже печатаются — но только в блоке `context`, который читает АГЕНТ.
92
+ // Человеку, который и назначен сторожем, `doctor` про это не говорит ни слова. То есть
93
+ // единственный, кто обязан помнить о непроверяемом обещании, — единственный, кому о нём не
94
+ // сообщают. В нашем собственном своде так помечено 12 правил из 14.
95
+ test("doctor называет человеку, сколько правил не сторожит машина", (t) => {
96
+ const p = project(t, {
97
+ "AGENTS.md": [
98
+ "# проект",
99
+ "## Правила",
100
+ "- **Секреты не в коде.** <!-- aqk: secrets-not-in-code -->",
101
+ "- **План до кода.** <!-- aqk: человек -->",
102
+ "- **Три попытки.** <!-- aqk: человек -->",
103
+ "",
104
+ ].join("\n"),
105
+ ".aqk.yml": 'aqk: 1\nentry:\n - AGENTS.md\ngates:\n lint: "true"\n',
106
+ });
107
+ const r = aqk(p, "doctor");
108
+ assert.match(r.out, /2/,
109
+ `doctor не назвал число правил со сторожем-человеком. Вывод:\n${r.out}`);
110
+ assert.ok(/человек/i.test(r.out),
111
+ `doctor не сказал про сторожа-человека ни слова, хотя таких правил здесь два.\n${r.out}`);
112
+ });