agent-quality-kit 0.6.0 → 0.7.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 (111) hide show
  1. package/README.md +18 -2
  2. package/README.ru.md +16 -0
  3. package/kit/docs/ai/agent-harness-playbook.md +1 -1
  4. package/kit/docs/ready-made-rules.md +29 -4
  5. package/kit/gates/README.md +40 -0
  6. package/kit/gates/ci-actually-fails/README.md +12 -0
  7. package/kit/gates/ci-actually-fails/check.sh +26 -3
  8. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +16 -0
  9. package/kit/gates/ci-actually-fails/red/.github/workflows/soft.yml +15 -0
  10. package/kit/gates/color-from-token/check.sh +13 -1
  11. package/kit/gates/commit-explains-itself/README.md +13 -3
  12. package/kit/gates/commit-explains-itself/check.sh +8 -4
  13. package/kit/gates/complexity-limit/README.md +5 -0
  14. package/kit/gates/complexity-limit/check.sh +21 -2
  15. package/kit/gates/complexity-limit/green/test_fixtures.py +14 -0
  16. package/kit/gates/deps-are-pinned/README.md +14 -1
  17. package/kit/gates/deps-are-pinned/check.sh +6 -1
  18. package/kit/gates/deps-are-pinned/green/pyproject-with-requirements/pyproject.toml +12 -0
  19. package/kit/gates/deps-are-pinned/green/pyproject-with-requirements/requirements.txt +3 -0
  20. package/kit/gates/deps-are-pinned/red/pyproject-loose/pyproject.toml +12 -0
  21. package/kit/gates/deps-are-pinned/red/pyproject-loose/requirements.txt +3 -0
  22. package/kit/gates/duplicate-code/README.md +11 -2
  23. package/kit/gates/duplicate-code/check.sh +31 -4
  24. package/kit/gates/duplicate-code/gate.yml +8 -0
  25. package/kit/gates/duplicate-code/green/imports_a.go +20 -0
  26. package/kit/gates/duplicate-code/green/imports_b.go +19 -0
  27. package/kit/gates/entry-links-exist/README.md +5 -0
  28. package/kit/gates/entry-links-exist/check.sh +6 -0
  29. package/kit/gates/entry-links-exist/green/AGENTS.md +3 -0
  30. package/kit/gates/file-size-limit/README.md +9 -2
  31. package/kit/gates/file-size-limit/check.sh +13 -1
  32. package/kit/gates/gate-not-weakened/check.sh +13 -1
  33. package/kit/gates/hook-actually-fires/README.md +74 -0
  34. package/kit/gates/hook-actually-fires/check.sh +183 -0
  35. package/kit/gates/hook-actually-fires/gate.yml +15 -0
  36. package/kit/gates/hook-actually-fires/green/.claude/hooks/hooks.json +3 -0
  37. package/kit/gates/hook-actually-fires/green/.claude/settings.json +74 -0
  38. package/kit/gates/hook-actually-fires/green/.claude/settings.local.json +74 -0
  39. package/kit/gates/hook-actually-fires/red/.claude/hooks/hooks.json +4 -0
  40. package/kit/gates/hook-actually-fires/red/.claude/settings.json +53 -0
  41. package/kit/gates/no-phantom-package/README.md +84 -0
  42. package/kit/gates/no-phantom-package/check.sh +161 -0
  43. package/kit/gates/no-phantom-package/gate.yml +20 -0
  44. package/kit/gates/no-phantom-package/green/AGENTS.md +15 -0
  45. package/kit/gates/no-phantom-package/red/AGENTS.md +15 -0
  46. package/kit/gates/no-print-in-prod/README.md +33 -39
  47. package/kit/gates/no-print-in-prod/gate.yml +14 -6
  48. package/kit/gates/personal-config-not-shared/README.md +66 -0
  49. package/kit/gates/personal-config-not-shared/check.sh +103 -0
  50. package/kit/gates/personal-config-not-shared/gate.yml +16 -0
  51. package/kit/gates/personal-config-not-shared/green/.aqk-tracked +9 -0
  52. package/kit/gates/personal-config-not-shared/red/.aqk-tracked +6 -0
  53. package/kit/gates/secrets-not-in-code/check.sh +13 -1
  54. package/kit/gates/swallowed-error/README.md +36 -18
  55. package/kit/gates/swallowed-error/gate.yml +13 -3
  56. package/kit/gates/test-has-assertion/check.sh +13 -1
  57. package/kit/gates/test-not-adjusted/README.md +79 -0
  58. package/kit/gates/test-not-adjusted/check.sh +136 -0
  59. package/kit/gates/test-not-adjusted/gate.yml +19 -0
  60. package/kit/gates/test-not-adjusted/green/after/calc.py +6 -0
  61. package/kit/gates/test-not-adjusted/green/after/tests/test_calc.py +9 -0
  62. package/kit/gates/test-not-adjusted/green/before/calc.py +2 -0
  63. package/kit/gates/test-not-adjusted/green/before/tests/test_calc.py +5 -0
  64. package/kit/gates/test-not-adjusted/red/after/calc.py +2 -0
  65. package/kit/gates/test-not-adjusted/red/after/tests/test_calc.py +5 -0
  66. package/kit/gates/test-not-adjusted/red/before/calc.py +2 -0
  67. package/kit/gates/test-not-adjusted/red/before/tests/test_calc.py +7 -0
  68. package/kit/gates/todo-without-task/README.md +6 -0
  69. package/kit/gates/todo-without-task/check.sh +13 -1
  70. package/kit/ratchet/ratchet.sh +70 -2
  71. package/kit/rules/general.md +9 -0
  72. package/kit/rules-en/general.md +82 -0
  73. package/kit/rules-en/security.md +33 -0
  74. package/kit/rules-en/testing.md +48 -0
  75. package/llms.txt +1 -1
  76. package/package.json +3 -2
  77. package/tool/commands/badge.mjs +7 -1
  78. package/tool/commands/doctor.mjs +49 -9
  79. package/tool/commands/gates.mjs +10 -4
  80. package/tool/commands/project.mjs +8 -1
  81. package/tool/commands/prove.mjs +67 -0
  82. package/tool/commands/report.mjs +4 -1
  83. package/tool/i18n/en-docs.mjs +70 -0
  84. package/tool/i18n/en.mjs +49 -54
  85. package/tool/i18n/ru-docs.mjs +70 -0
  86. package/tool/i18n/ru.mjs +48 -54
  87. package/tool/i18n/templates-en.mjs +1 -1
  88. package/tool/i18n/templates-ru.mjs +1 -1
  89. package/tool/lib/core.mjs +7 -1
  90. package/tool/lib/manifest.mjs +36 -5
  91. package/tool/lib/prove.mjs +160 -0
  92. package/tool/lib/repo.mjs +31 -2
  93. package/tool/lib/scope.mjs +37 -2
  94. package/tool/lib/templates.mjs +2 -0
  95. package/tool/program.mjs +5 -0
  96. package/tool/selfcheck/gates.sh +66 -0
  97. package/tool/selfcheck/mutation.sh +21 -1
  98. package/tool/selfcheck/smoke.sh +279 -39
  99. package/tool/selfcheck/units-level.mjs +60 -0
  100. package/tool/selfcheck/units.mjs +113 -2
  101. package/kit/gates/no-print-in-prod/check.sh +0 -38
  102. package/kit/gates/no-print-in-prod/green/docs.ts +0 -15
  103. package/kit/gates/no-print-in-prod/green/main.go +0 -8
  104. package/kit/gates/no-print-in-prod/green/main.rs +0 -4
  105. package/kit/gates/no-print-in-prod/red/main.go +0 -8
  106. package/kit/gates/no-print-in-prod/red/main.rs +0 -4
  107. package/kit/gates/swallowed-error/check.sh +0 -54
  108. package/kit/gates/swallowed-error/green/run.js +0 -8
  109. package/kit/gates/swallowed-error/red/run.js +0 -3
  110. /package/kit/gates/commit-explains-itself/green/{COMMIT_MSG → .aqk-commit-msg} +0 -0
  111. /package/kit/gates/commit-explains-itself/red/{COMMIT_MSG → .aqk-commit-msg} +0 -0
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env sh
2
+ # Личный файл агента, попавший в git: то, что человек писал для себя, становится обязательным
3
+ # для всех и никем не рецензируется.
4
+ #
5
+ # ЗАЧЕМ. Дело не в опрятности. `CLAUDE.local.md` дописывается ПОСЛЕ общего свода — дословно из
6
+ # документации: "Within each directory, `CLAUDE.local.md` is appended after `CLAUDE.md`, so your
7
+ # personal notes are the last thing Claude reads at that level"
8
+ # (code.claude.com/docs/en/memory, сверено 2026-09-07). Значит личные заметки одного человека
9
+ # читаются последними и перекрывают общие правила команды — у всех, молча.
10
+ # `.claude/settings.local.json` — то же самое для прав: разрешения, которые один человек выдал
11
+ # себе, достаются всем, кто склонировал репозиторий.
12
+ #
13
+ # Оба файла по устройству личные, и документация говорит об этом прямо: "For private
14
+ # per-project preferences that shouldn't be checked into version control… Add `CLAUDE.local.md`
15
+ # to your `.gitignore` so it isn't committed" и "Claude Code keeps it out of git when it creates
16
+ # the file; if you create it by hand, add it to `.gitignore` yourself".
17
+ DIR="${1:-.}"
18
+ # Существование файла проверяется ДО `.`, а не запасной веткой после. Прежняя строка
19
+ # `. файл 2>/dev/null || запасной_вариант` выглядела страховкой и ею не была: под `sh` (dash)
20
+ # неудачный `.` завершает скрипт немедленно, и ветка после `||` не выполняется никогда; под
21
+ # `bash`, которым гейты и запускаются из манифеста, она выполняется, но подставляет только
22
+ # ПЕРЕМЕННУЮ — функции обхода остаются неопределёнными, конвейер печатает пустоту, и проверка
23
+ # выходит с НУЛЁМ. Замерено 2026-09-08 на файле с настоящим нарушением: гейт сказал «чисто».
24
+ SKIP_LIB="$(dirname "$0")/../_skip.sh"
25
+ if [ ! -f "$SKIP_LIB" ]; then
26
+ echo "рядом с проверкой нет _skip.sh — обход не собран, проверка не состоялась"
27
+ echo " почини: скопируй гейт вместе с файлом kit/gates/_skip.sh, он общий на весь каталог"
28
+ exit 2
29
+ fi
30
+ . "$SKIP_LIB"
31
+
32
+ # Образцы (gates.sh) читают список отслеживаемых файлов из `.aqk-tracked`: настоящего git внутри
33
+ # каталога комплекта взять неоткуда, а вложенный .git создал бы embedded-репозиторий.
34
+ # В настоящем проекте такого файла не бывает — спрашиваем сам git.
35
+ # Имя с точкой намеренно: файл `TRACKED` в корне проекта — вещь возможная, и он молча
36
+ # подменял бы собой список git. Найдено код-ревью 2026-09-07.
37
+ if [ -f "$DIR/.aqk-tracked" ]; then
38
+ LIST=$(cat "$DIR/.aqk-tracked")
39
+ else
40
+ if ! (cd "$DIR" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1); then
41
+ echo "не git-репозиторий — проверять нечего"
42
+ exit 0
43
+ fi
44
+ # core.quotePath=false ОБЯЗАТЕЛЕН. По умолчанию git заворачивает путь с не-ASCII в кавычки
45
+ # («"\320\264\320\276\320\272/CLAUDE.local.md"»), и якорь «$» в шаблоне ниже не совпадал:
46
+ # личный свод в каталоге с русским именем пропускался молча. Тот же класс, что CRLF ниже.
47
+ # Найдено код-ревью 2026-09-07.
48
+ LIST=$(cd "$DIR" && git -c core.quotePath=false ls-files 2>/dev/null)
49
+ fi
50
+ # Перевод строки Windows. Без снятия «\r» якорь «$» в шаблоне ниже не совпадал, и красный
51
+ # образец с CRLF становился зелёным. Поймано мутационной проверкой до единого прогона в чужом
52
+ # проекте — ровно то, ради чего она написана.
53
+ LIST=$(printf '%s\n' "$LIST" | tr -d '\r')
54
+ [ -z "$LIST" ] && exit 0
55
+
56
+ # Репозиторий, который целиком является заготовкой проекта, проверять нечем: все файлы в нём —
57
+ # не чьи-то личные, а рыба для будущего проекта. Признак — файл настроек генератора в корне.
58
+ # Замер: в `Frojd/Wagtail-Pipit` обе находки были такими, и обе ложные.
59
+ # `.copier-answers.yml` в этот список НЕ входит, хотя выглядит родственником: copier кладёт его
60
+ # в СГЕНЕРИРОВАННЫЙ проект, а не в шаблон. Шаблон несёт `copier.yml`. Найдено код-ревью
61
+ # 2026-09-07: обычный рабочий репозиторий, собранный из copier-шаблона, целиком выпадал из
62
+ # проверки — молчаливый отказ по причине, не имеющей отношения к предмету.
63
+ for GEN in cookiecutter.json copier.yml copier.yaml; do
64
+ if [ -f "$DIR/$GEN" ]; then
65
+ echo "репозиторий — заготовка проекта ($GEN), личных файлов в нём нет по устройству"
66
+ exit 0
67
+ fi
68
+ done
69
+
70
+ # Заготовки — не личные файлы. Замер по выдаче поиска: из двадцати совпадений по имени
71
+ # `CLAUDE.local.md` больше половины оказались `*.example`, `*.template` и файлами внутри
72
+ # `templates/` — их кладут в репозиторий намеренно, чтобы человек скопировал себе.
73
+ # Подстановки генератора (`{{cookiecutter.project_name}}/…`) — оттуда же.
74
+ # `settings.local.json` считается личным ТОЛЬКО внутри `.claude/`: это единственное место,
75
+ # откуда Claude Code его читает. Без привязки к каталогу под проверку попадали
76
+ # `.vscode/settings.local.json` и `node_modules/…/settings.local.json` — чужие файлы с
77
+ # совпавшим именем. Найдено код-ревью 2026-09-07.
78
+ HITS=$(printf '%s\n' "$LIST" \
79
+ | grep -E '(^|/)(CLAUDE\.local\.md|\.claude/settings\.local\.json)$' \
80
+ | grep -vE '(^|/)(templates?|examples?|samples?|dist|fixtures?)/' \
81
+ | grep -vF '{{' \
82
+ || true)
83
+
84
+ # Образцы гейтов — не личные файлы. В красном образце другой записи может лежать настоящий
85
+ # `settings.local.json`, и это её предмет, а не наш. Поймано на собственном репозитории:
86
+ # первой находкой стал зелёный образец `hook-actually-fires`.
87
+ if command -v own_samples_filter >/dev/null 2>&1 || type own_samples_filter >/dev/null 2>&1; then
88
+ HITS=$(printf '%s\n' "$HITS" | own_samples_filter "$DIR" | grep -v '^$' || true)
89
+ fi
90
+ # Каталог самого комплекта: у нас образцы лежат не в `gates/`, а в `kit/gates/`.
91
+ HITS=$(printf '%s\n' "$HITS" | grep -vE '(^|/)kit/gates/[^/]+/(red|green)(/|$)' | grep -v '^$' || true)
92
+
93
+ [ -z "$HITS" ] && exit 0
94
+
95
+ printf '%s\n' "$HITS" | while IFS= read -r F; do
96
+ case "$F" in
97
+ *CLAUDE.local.md) echo "$F: личный свод отслеживается git — он дописывается ПОСЛЕ общего и перекрывает его у всех" ;;
98
+ *) echo "$F: личные права отслеживаются git — разрешения одного человека достались всем" ;;
99
+ esac
100
+ done
101
+ echo " почини: убери файл из индекса — git rm --cached <файл> — и добавь его в .gitignore."
102
+ echo " личное, попавшее в общий репозиторий, никто не рецензирует: его туда никто и не звал."
103
+ exit 1
@@ -0,0 +1,16 @@
1
+ intent: личные файлы агента не раздаются всему проекту через git
2
+ intent_en: an agent's personal files are not shared with the whole project through git
3
+
4
+ # Там, где агента используют: есть свод, который он читает при запуске. Признак шире, чем
5
+ # `has_agent_config`: настройки заводят не все, а свод — почти каждый.
6
+ trigger:
7
+ has_agent_entry: true
8
+
9
+ recipes:
10
+ any: bash {gate}/check.sh {dir}
11
+
12
+ proof: incidents/README.md, 2026-09-07 «личное, попавшее в общий репозиторий» — замер по
13
+ тринадцати чужим репозиториям: шесть настоящих находок в пяти (`shacker/django-todo` раздаёт
14
+ всем `Read(//Users/shacker/**)` и абсолютные пути со своей машины; `modu-ai/moai-adk`,
15
+ `garrynewman/Control4.Jailbreak`, `AmyangXYZ/reze-mipo`, `shuigedeng/taotao-cloud-project`),
16
+ ноль ложных на семи контрольных
@@ -0,0 +1,9 @@
1
+ README.md
2
+ CLAUDE.md
3
+ CLAUDE.local.md.example
4
+ templates/CLAUDE.local.md
5
+ docs/settings.local.json.template
6
+ {{cookiecutter.project_name}}/CLAUDE.local.md
7
+ .vscode/settings.local.json
8
+ node_modules/foo/settings.local.json
9
+ src/app.py
@@ -0,0 +1,6 @@
1
+ README.md
2
+ CLAUDE.md
3
+ CLAUDE.local.md
4
+ .claude/settings.json
5
+ .claude/settings.local.json
6
+ src/app.py
@@ -2,7 +2,19 @@
2
2
  # Секрет, попавший в историю, из неё уже не убрать: ветку перепишут, а копии останутся у всех,
3
3
  # кто её тянул. Поэтому проверка стоит до коммита, а не «иногда руками».
4
4
  DIR="${1:-.}"
5
- . "$(dirname "$0")/../_skip.sh" 2>/dev/null || SKIP_NAMES=".git .aqk node_modules .venv"
5
+ # Существование файла проверяется ДО `.`, а не запасной веткой после. Прежняя строка
6
+ # `. файл 2>/dev/null || запасной_вариант` выглядела страховкой и ею не была: под `sh` (dash)
7
+ # неудачный `.` завершает скрипт немедленно, и ветка после `||` не выполняется никогда; под
8
+ # `bash`, которым гейты и запускаются из манифеста, она выполняется, но подставляет только
9
+ # ПЕРЕМЕННУЮ — функции обхода остаются неопределёнными, конвейер печатает пустоту, и проверка
10
+ # выходит с НУЛЁМ. Замерено 2026-09-08 на файле с настоящим нарушением: гейт сказал «чисто».
11
+ SKIP_LIB="$(dirname "$0")/../_skip.sh"
12
+ if [ ! -f "$SKIP_LIB" ]; then
13
+ echo "рядом с проверкой нет _skip.sh — обход не собран, проверка не состоялась"
14
+ echo " почини: скопируй гейт вместе с файлом kit/gates/_skip.sh, он общий на весь каталог"
15
+ exit 2
16
+ fi
17
+ . "$SKIP_LIB"
6
18
  # Красный образец — намеренно сломанный код в репозитории. Сканирующий гейт обязан его
7
19
  # пропускать, иначе будет вечно краснеть на том, что сам же и положил. Исключение снимается,
8
20
  # когда проверяют сам образец: тогда каталог red и есть цель проверки.
@@ -1,26 +1,44 @@
1
1
  # Ошибка не глушится молча
2
2
 
3
- **Намерение.** Ошибка либо обработана и записана в лог, либо проброшена дальше. Третьего нет.
3
+ **Намерение.** Перехваченная и выброшенная ошибка это отказ, о котором никто не узнал.
4
+ Система продолжает работать «как будто всё хорошо», а причина всплывает через недели и в другом
5
+ месте. Ошибка либо обработана и записана, либо проброшена дальше.
4
6
 
5
- **Какой отказ это поймало.** В разборе 1069 коммитов за 90 дней: **1436 блоков перехвата, из
6
- которых лишь около 10% пробрасывают ошибку дальше.** Остальные глушат её в лог или возвращают
7
- пустоту. Цена три недели поиска источника отказов, которые гасились на месте.
8
- Запись в журнале: `incidents/README.md`, 2026-08-25.
7
+ **Своей проверки здесь больше нет так решил замер.** Переносимая версия искала перехват по
8
+ тексту. Прогон по пяти чужим репозиториям (`httpx`, `fastapi`, `zod`, `cobra`, `ripgrep`) дал
9
+ 46 находок, и настоящим оказался ровно один класс пустые `catch (_) {}` в `zod`. Остальное:
9
10
 
10
- **Почему машина, а не внимательность.** Пустой перехват выглядит безобидно и пишется быстрее
11
- правильного. В ревью на 400 строк он не читается как дефект — он читается как аккуратность.
11
+ ```python
12
+ try:
13
+ if sniffio.current_async_library() == "trio":
14
+ return True
15
+ except ImportError: # библиотеки нет — значит не trio. Это не глушение, это ветвление.
16
+ pass
17
+ ```
12
18
 
13
- **Что именно ищется.** Перехват с пустым телом: `except …: pass` и `…: ...` в Python, пустой
14
- `catch () { }`, пустой `.catch(() => {})`.
19
+ ```rust
20
+ let _ = self.wtr.write(b"\n")?; // «?» пробрасывает ошибку. Отброшено только число байт.
21
+ ```
15
22
 
16
- **Чего НЕ ловит.** Перехват, который что-то делает, но не то: записал в лог на уровне `debug` и
17
- вернул пустоту формально не пустой, по сути тот же тихий отказ. Это остаётся человеку.
23
+ Питоновский `except <узкое исключение>: pass` законный приём: ловят не ошибку, а её отсутствие.
24
+ Растовый `let _ = …?` вообще ничего не глушит. Отличить это от настоящего глушения можно только
25
+ разбором кода — а разбор уже написан.
18
26
 
19
- **Готовый аналог есть, и он подробнее.** В Python три правила `ruff`
20
- покрывают разные оттенки: `BLE` — ловля голого исключения, `TRY400` — запись в лог без трейса,
21
- `SIM105` — перехват ради тишины. В TypeScript — `no-empty` с запретом пустого перехвата.
22
- Измерено на живом проекте: `TRY400` нашёл 47 мест, которые самописный разбор искал час.
23
- Рецепты под эти стеки берут готовое; своя проверка — запасная.
27
+ **Готовый аналог это и есть он сам.**
24
28
 
25
- **Образцы.** `red/` Python и JavaScript с пустым перехватом. `green/` — тот же код: запись в
26
- лог и проброс.
29
+ | Стек | Чем |
30
+ |---|---|
31
+ | Python | `ruff --select BLE,TRY400,SIM105` |
32
+ | JavaScript, TypeScript | `eslint no-empty` с `allowEmptyCatch: false` |
33
+ | Go | [`errcheck`](https://github.com/kisielk/errcheck) — канонический инструмент под этот предмет |
34
+
35
+ **Чего НЕ ловит.** Перехват, который что-то делает, но делает не то: `except: return None`,
36
+ `catch { return [] }`. Формально обработка есть, по сути ошибка потеряна так же. Это видно
37
+ только глазами и на ревью. Для Rust рецепта нет: у `clippy` есть близкие правила
38
+ (`let_underscore_must_use`), но мы их не замеряли, а рецепт без замера — та же догадка,
39
+ из-за которой эта запись и лишилась своей проверки.
40
+
41
+ **Чем это оплачено.** Проект на Rust, Java, Ruby, PHP по этому пункту не получает ничего.
42
+
43
+ **Образцы.** `red/loader.py` — `except Exception: pass`. `green/loader.py` — узкое исключение,
44
+ запись в журнал и проброс. Проверяются рецептом `python` (`samples_for`).
@@ -1,13 +1,23 @@
1
1
  intent: ошибка не глушится молча — она обработана и записана либо проброшена
2
2
  intent_en: an error is not silently swallowed — it is handled and logged, or re-raised
3
3
 
4
+ # Только языки, под которые есть готовый разбор кода. Переносимой проверки здесь больше нет:
5
+ # отличить «перехват ради тишины» от законного `except ImportError: pass` можно разбором,
6
+ # а не поиском по тексту.
4
7
  trigger:
5
- always: true
8
+ langs: python, javascript, typescript, go
6
9
 
7
10
  recipes:
8
- any: bash {gate}/check.sh {dir}
9
11
  # BLE — ловля голого исключения, TRY400 — запись без трейса, SIM105 — перехват ради тишины.
10
12
  python: ruff check --select BLE,TRY400,SIM105 {dir}
11
13
  typescript: eslint --rule '{"no-empty":["error",{"allowEmptyCatch":false}]}' {dir}
14
+ javascript: eslint --rule '{"no-empty":["error",{"allowEmptyCatch":false}]}' {dir}
15
+ # errcheck — канонический инструмент Go под этот же предмет: ошибка присвоена и не проверена.
16
+ go: errcheck -blank {dir}/...
12
17
 
13
- proof: incidents/README.md — «2026-08-25 разбор 1069 коммитов»: 1436 блоков перехвата, лишь 10% пробрасывают ошибку
18
+ samples_for: python
19
+
20
+ proof: incidents/README.md, 2026-09-07 «замер по пяти стекам вынес приговор пяти записям» —
21
+ переносимая версия дала 46 находок на пяти чужих репозиториях, настоящими оказались только
22
+ пустые `catch {}` в zod; весь питоновский класс `except ImportError: pass` и растовый
23
+ `let _ = write(..)?` были ложными
@@ -12,7 +12,19 @@
12
12
  # исключения. Гейт, спорящий о стиле, выключают целиком, поэтому здесь остались только
13
13
  # безусловные случаи — те, где тест не может провалиться ни при каких данных.
14
14
  DIR="${1:-.}"
15
- . "$(dirname "$0")/../_skip.sh" 2>/dev/null || SKIP_NAMES=".git .aqk node_modules .venv"
15
+ # Существование файла проверяется ДО `.`, а не запасной веткой после. Прежняя строка
16
+ # `. файл 2>/dev/null || запасной_вариант` выглядела страховкой и ею не была: под `sh` (dash)
17
+ # неудачный `.` завершает скрипт немедленно, и ветка после `||` не выполняется никогда; под
18
+ # `bash`, которым гейты и запускаются из манифеста, она выполняется, но подставляет только
19
+ # ПЕРЕМЕННУЮ — функции обхода остаются неопределёнными, конвейер печатает пустоту, и проверка
20
+ # выходит с НУЛЁМ. Замерено 2026-09-08 на файле с настоящим нарушением: гейт сказал «чисто».
21
+ SKIP_LIB="$(dirname "$0")/../_skip.sh"
22
+ if [ ! -f "$SKIP_LIB" ]; then
23
+ echo "рядом с проверкой нет _skip.sh — обход не собран, проверка не состоялась"
24
+ echo " почини: скопируй гейт вместе с файлом kit/gates/_skip.sh, он общий на весь каталог"
25
+ exit 2
26
+ fi
27
+ . "$SKIP_LIB"
16
28
 
17
29
  # Только файлы тестов. Слово «assert» в обычном коде — не тест, а проверка входа.
18
30
  FILES=$(find "$DIR" $(skip_find) -type f \( \
@@ -0,0 +1,79 @@
1
+ # Арбитра не правит тот, кто чинит код
2
+
3
+ **Намерение.** Когда проверка краснеет, у пишущего два выхода: починить код или подогнать
4
+ проверку. Второй дешевле и с виду неотличим от первого — прогон зелёный, диф маленький.
5
+ Для агента, которому поставлена задача «сделай, чтобы прошло», это выход по умолчанию.
6
+
7
+ **Откуда взято.** Практики называют этот приём поимённо. В разборе 1154 обсуждений с
8
+ r/programming, r/learnprogramming, **r/ExperiencedDevs** и Hacker News (Baltes, Cheong, Treude,
9
+ [«An Endless Stream of AI Slop»](https://arxiv.org/html/2603.27249v3), январь–сентябрь 2025)
10
+ он идёт в списке конкретных технических приёмов рядом с «casting to `any` to silence type
11
+ errors» и «deleting methods instead of fixing them»:
12
+
13
+ > «Test subversion»: changing tests to pass broken code rather than fixing underlying issues
14
+
15
+ Наше собственное правило `kit/rules/testing.md` требует того же с самого начала — и сторожа не
16
+ имело: «Тот, кто чинит код, не правит тест, который этот код проверяет».
17
+
18
+ **Готовый аналог есть, и он лучше того, что написали бы мы.**
19
+ [`checkwash`](https://github.com/taipei49314/checkwash) (Apache-2.0, чистый stdlib Python, без
20
+ зависимостей, 21 детектор) разбирает диф и находит ослабленные утверждения, снятые проверки,
21
+ подменённые ожидания, изменённые эталонные файлы. Своей проверки здесь нет и не будет —
22
+ это обвязка вокруг него.
23
+
24
+ Инструмент вышел 1 сентября 2026, последняя версия 5 сентября; его собственная документация
25
+ говорит ровно то, что говорим мы: *«checkwash cannot block when it does not run. A change that
26
+ deletes or disables the checkwash job disarms it in the same diff»* — это как раз то, что
27
+ стерегут наши `gates-run-in-ci`, `ci-actually-fails` и `gate-not-weakened`. Мы дополняем друг
28
+ друга, а не соперничаем.
29
+
30
+ **Что добавляет запись — и почему это не мелочь.** Замерено 2026-09-07 на образце: код изменён
31
+ на неверный, три точных утверждения заменены на `is not None`. `checkwash` находит все три —
32
+ и **завершается кодом 0**: он понижает их до `warn` по признаку `REPAIR_EVIDENCE` («в дифе есть
33
+ и правка кода, значит похоже на настоящую починку»). То есть ровно тот случай, ради которого
34
+ запись нужна, по умолчанию проходит зелёным.
35
+
36
+ Поднять порог целиком нельзя — замерено на живой истории `httpx`, последние 40 коммитов:
37
+
38
+ | Порог | Заблокировано | Чем |
39
+ |---|---|---|
40
+ | по умолчанию (`high`) | 2.5% | одна настоящая находка, но предмет записи пропускается |
41
+ | `--fail-on warn` | 15% | восемь находок из одиннадцати — `CI_WORKFLOW_TOUCHED`, то есть «диф трогает конвейер» |
42
+ | **отбор по правилу (наш)** | **2.5%** | та же настоящая находка **плюс** подгонка арбитра уровня `warn` |
43
+
44
+ Отбор идёт по правилу: всё уровня `high` и выше, плюс семейство `ASSERT_*` и подмена предмета
45
+ уровня `warn`. Конвейер не наш предмет здесь — его стерегут другие записи.
46
+
47
+ **Чего НЕ ловит.**
48
+
49
+ - **`TEST_DISABLED` уровня `warn` — намеренно.** Инструмент сам различает: тест исчез вместе с
50
+ правкой кода — `warn`, тест исчез без неё — `high` («NO_PROD_CHANGE_IN_DIFF»). Первое обычно
51
+ законная уборка при снятии возможности: в `httpx` коммит «Graceful upgrade path for 0.28»
52
+ удалил два теста вместе с самой возможностью. Поднимать это целиком значит красить каждую
53
+ депрекацию — замер давал 5% вместо 2.5%, и лишняя половина была именно такой. Второе и так
54
+ `high` и потому попадает под общее правило.
55
+ - **Тест, который был слабым с самого начала.** Запись смотрит на изменение, а не на
56
+ качество: тест, родившийся тавтологией, — предмет `test-has-assertion`.
57
+ - **Подгонку через данные.** Правку фикстуры или эталонного файла `checkwash` умеет, но наша
58
+ обвязка её не поднимает выше порога инструмента.
59
+ - **Правку свода правил — намеренно.** `checkwash` поднимает `GUARDRAIL_TOUCHED` до `critical`
60
+ на любое изменение `AGENTS.md`, `CLAUDE.md`, `.claude/**`. Для нас это худшее из возможных
61
+ ложных срабатываний: свод — ровно тот файл, ради правки которого комплект существует, его
62
+ пишет `aqk init`. Отбор идёт **по имени правила**, а не по уровню, и это правило в список не
63
+ входит. Найдено код-ревью, а не замером: в `httpx` файлов инструкций агенту нет вовсе.
64
+ - **Файл, который инструмент не смог разобрать.** `TEST_FILE_UNPARSEABLE` остаётся `warn` и в
65
+ наш список не входит: часть дифа окажется неразобранной, а гейт промолчит. Ошибки настройки
66
+ самого инструмента мы, наоборот, поднимаем в отказ (код 2).
67
+ - **Разрешённое исключение.** Находку, записанную командой `checkwash allow` и попавшую в
68
+ реестр на базовой стороне дифа, мы не красим. Иначе у команды, разобравшей случай глазами,
69
+ не остаётся выхода, кроме как выключить проверку целиком. Проверено сквозным прогоном:
70
+ из трёх находок красного образца после записи исключения остаётся одна.
71
+ - **Проекты без Python.** `checkwash` ставится через `pip`. Запись объявляет это полем
72
+ `requires`, и `add` отказывает с названной причиной, а не ставит гейт, который встанет
73
+ с «not found».
74
+
75
+ **Образцы.** Настоящей истории git внутри каталога комплекта взять неоткуда, поэтому образец —
76
+ это две папки, `before/` и `after/`; проверка собирает из них одноразовый репозиторий с двумя
77
+ коммитами. `red/` — код изменён на неверный, три утверждения заменены на `is not None`.
78
+ `green/` — код и тест выросли вместе: добавлена функция и тест к ней, ни одно утверждение не
79
+ убрано и не ослаблено.
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env sh
2
+ # Арбитра правит не тот, кто чинит код. Проверку выполняет `checkwash` — готовый инструмент,
3
+ # который это умеет лучше, чем сумели бы мы. Здесь только обвязка: выбор диапазона, режим
4
+ # образцов и строка совета.
5
+ #
6
+ # ЗАЧЕМ ЭТО ВООБЩЕ. «Test subversion» — правка теста, чтобы прошёл сломанный код, — один из
7
+ # приёмов, которые практики называют поимённо в разборе 1154 обсуждений с Reddit и Hacker News
8
+ # (Baltes, Cheong, Treude, «An Endless Stream of AI Slop», arxiv 2603.27249). Наше собственное
9
+ # правило `kit/rules/testing.md` требует того же и сторожа не имело.
10
+ #
11
+ # ПОЧЕМУ НЕ `--fail-on warn` И НЕ ПОРОГ ПО УМОЛЧАНИЮ. Измерено 2026-09-07 на живой истории
12
+ # `httpx`, последние 40 коммитов:
13
+ # порог по умолчанию (high) — блокирует 2.5% коммитов, и та единственная блокировка настоящая;
14
+ # `--fail-on warn` — блокирует 15%, и восемь из одиннадцати находок уровня warn это
15
+ # `CI_WORKFLOW_TOUCHED`, то есть «диф трогает конвейер» вообще.
16
+ # Поднять порог целиком значит красить любую правку конвейера. Поэтому отбор идёт ПО ПРАВИЛУ:
17
+ # всё, что high и выше, плюс подгонка арбитра уровня warn. Именно её инструмент понижает по
18
+ # признаку REPAIR_EVIDENCE — «в дифе есть и правка кода, значит похоже на настоящую починку», —
19
+ # а это ровно тот случай, ради которого запись и заведена: код сломали, тест подогнали.
20
+ # Конвейер здесь не наш предмет: его стерегут `ci-actually-fails` и `gates-run-in-ci`.
21
+ #
22
+ # ОТБОР ИДЁТ ПО ИМЕНИ ПРАВИЛА, А НЕ ПО УРОВНЮ. Первая версия блокировала всё уровня high и выше
23
+ # «на всякий случай» — и ловила `GUARDRAIL_TOUCHED`, который инструмент поднимает до critical на
24
+ # ЛЮБУЮ правку `AGENTS.md`, `CLAUDE.md`, `.claude/**`. Для нас это худшее из возможных ложных
25
+ # срабатываний: свод правил — ровно тот файл, ради правки которого комплект и существует, его
26
+ # пишет `aqk init`. Найдено код-ревью 2026-09-07; замер по `httpx` этого показать не мог —
27
+ # там нет файлов инструкций агенту вовсе.
28
+ #
29
+ # ALWAYS — подгонка арбитра: красим на любом уровне. Именно её инструмент понижает до `warn`
30
+ # по признаку REPAIR_EVIDENCE, и именно она предмет этой записи.
31
+ ALWAYS='ASSERT_REMOVED|ASSERT_WEAKENED|ASSERT_SUBSTITUTED|TEST_PATCHES_SUBJECT|SUBJECT_NORMALIZED|CONFTEST_PATCHES_PROD'
32
+ #
33
+ # HIGH_ONLY — тест исчез. Инструмент сам различает: вместе с правкой кода — `warn` (обычно
34
+ # законная уборка при снятии возможности, так было в коммите `httpx` «Graceful upgrade path for
35
+ # 0.28»); без правки кода — `high` («NO_PROD_CHANGE_IN_DIFF»), и это чистое снятие сигнала.
36
+ # Поднимать первое значит красить каждую депрекацию: замер давал 5% заблокированных коммитов
37
+ # вместо 2.5%, и лишняя половина была именно такой.
38
+ HIGH_ONLY='TEST_DISABLED'
39
+ #
40
+ # Всё остальное — не наш предмет. `CI_WORKFLOW_TOUCHED` стерегут `ci-actually-fails` и
41
+ # `gates-run-in-ci`; `GUARDRAIL_TOUCHED` не стережёт никто, и это правильно: правка свода
42
+ # правил — обычная работа, а не подгонка арбитра.
43
+
44
+ DIR="${1:-.}"
45
+
46
+ # Режим образца: рядом лежат before/ и after/. Настоящей истории git внутри каталога комплекта
47
+ # взять неоткуда, а вложенный .git создал бы embedded-репозиторий. Собираем одноразовый.
48
+ if [ -d "$DIR/before" ] && [ -d "$DIR/after" ]; then
49
+ T=$(mktemp -d) || exit 2
50
+ cp -R "$DIR/before/." "$T/" 2>/dev/null
51
+ ( cd "$T" && git init -q . && git config user.email aqk@example && git config user.name aqk &&
52
+ git add -A && git commit -qm "before" ) >/dev/null 2>&1
53
+ find "$T" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + 2>/dev/null
54
+ cp -R "$DIR/after/." "$T/" 2>/dev/null
55
+ ( cd "$T" && git add -A && git commit -qm "after" ) >/dev/null 2>&1
56
+ REPO="$T"; RANGE="HEAD~1..HEAD"
57
+ else
58
+ if ! (cd "$DIR" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1); then
59
+ echo "не git-репозиторий — проверять нечего"
60
+ exit 0
61
+ fi
62
+ # Диапазон: по умолчанию последний коммит. Проект может назвать свой — так же, как это
63
+ # делает `doctor --since`.
64
+ RANGE="${AQK_TEST_RANGE:-HEAD~1..HEAD}"
65
+ # Проверяем ТОТ диапазон, который сейчас используется, а не всегда `HEAD~1`. Проект, назвавший
66
+ # `AQK_TEST_RANGE=origin/main..HEAD`, получал бы «меньше двух коммитов» на клоне без `HEAD~1`,
67
+ # хотя его диапазон полностью разрешим. Найдено код-ревью 2026-09-07.
68
+ BASE="${RANGE%%..*}"
69
+ if ! (cd "$DIR" && git rev-parse -q --verify "$BASE" >/dev/null 2>&1); then
70
+ echo "$BASE не разрешается — сравнивать не с чем, проверка пропущена"
71
+ echo " почини: дай конвейеру историю глубже одного коммита —"
72
+ echo " actions/checkout@v4 с fetch-depth: 2, либо назови свой диапазон в AQK_TEST_RANGE."
73
+ exit 0
74
+ fi
75
+ REPO="$DIR"
76
+ fi
77
+
78
+ if ! command -v checkwash >/dev/null 2>&1; then
79
+ [ -n "${T:-}" ] && rm -rf "$T"
80
+ echo "checkwash не установлен — проверять нечем"
81
+ echo " почини: pip install checkwash — запись делегирует ему целиком, своей проверки у неё нет."
82
+ exit 2
83
+ fi
84
+
85
+ # `--fail-on info` — чтобы инструмент отдал ВСЁ, что нашёл; решение принимаем сами, ниже.
86
+ JSON=$(checkwash check "$RANGE" --repo "$REPO" --format json --fail-on info 2>/dev/null)
87
+ [ -n "${T:-}" ] && rm -rf "$T"
88
+
89
+ if [ -z "$JSON" ]; then
90
+ echo "checkwash не дал разбора для $RANGE"
91
+ echo " почини: запусти команду руками и посмотри, почему она молчит."
92
+ echo " «не смогли проверить» и «нарушений нет» дают одинаково пустой список и разные выводы."
93
+ exit 2
94
+ fi
95
+
96
+ # Разбор вывода: json печатается по полю на строку, поэтому пары «rule/severity» собираются
97
+ # построчно. Своего разбора json мы не пишем — нужны два поля, а не структура.
98
+ # Ошибки разбора самого инструмента — это «не смогли проверить», а не «нарушений нет».
99
+ if printf '%s\n' "$JSON" | grep -q '"config_errors": \[$' &&
100
+ printf '%s\n' "$JSON" | sed -n '/"config_errors": \[/,/\]/p' | grep -q '"'; then
101
+ printf '%s\n' "$JSON" | sed -n '/"config_errors": \[/,/\]/p'
102
+ echo " почини: у checkwash ошибка в настройке — запусти его руками и прочти вывод."
103
+ exit 2
104
+ fi
105
+
106
+ # Разбор вывода: json печатается по полю на строку, поэтому поля находки собираются построчно.
107
+ # Своего разбора json мы не пишем — нужны пять полей, а не структура.
108
+ FOUND=$(printf '%s\n' "$JSON" | awk -v always="$ALWAYS" -v highonly="$HIGH_ONLY" '
109
+ /"allowlisted":/ { al = ($0 ~ /true/) }
110
+ /"rule":/ { r = $0; sub(/.*"rule":[[:space:]]*"/, "", r); sub(/".*/, "", r) }
111
+ /"severity":/ { s = $0; sub(/.*"severity":[[:space:]]*"/, "", s); sub(/".*/, "", s) }
112
+ /"path":/ { p = $0; sub(/.*"path":[[:space:]]*"/, "", p); sub(/".*/, "", p) }
113
+ /"message":/ { m = $0; sub(/.*"message":[[:space:]]*"/, "", m); sub(/".*/, "", m) }
114
+ # Печатаем на `unit` — ПОСЛЕДНЕМ поле находки. Поля идут по алфавиту, и `message` стоит
115
+ # РАНЬШЕ `rule` и `severity`: печать по сообщению подписывала правило от предыдущей находки.
116
+ /"unit":/ {
117
+ u = $0
118
+ sub(/.*"unit":[[:space:]]*/, "", u) # значение бывает и `null` без кавычек
119
+ gsub(/^[",[:space:]]+|[",[:space:]]+$/, "", u)
120
+ if (u == "null") u = ""
121
+ if (r != "") {
122
+ # Разрешённое исключение уже разобрано человеком и записано в реестр инструмента со
123
+ # сроком. Красить его значит не оставить выхода, кроме как выключить проверку целиком.
124
+ blocking = !al && (r ~ ("^(" always ")$") ||
125
+ ((s == "high" || s == "critical") && r ~ ("^(" highonly ")$")))
126
+ if (blocking) printf "%s:%s: %s — %s [%s]\n", p, (u == "" ? "0" : u), r, m, s
127
+ }
128
+ r = ""; s = ""; p = ""; u = ""; m = ""; al = 0
129
+ }
130
+ ')
131
+
132
+ [ -z "$FOUND" ] && exit 0
133
+ printf '%s\n' "$FOUND"
134
+ echo " почини: верни утверждение на место, а код приведи в соответствие с ним."
135
+ echo " тест, подогнанный под сломанный код, — это зелёный прогон без основания."
136
+ exit 1
@@ -0,0 +1,19 @@
1
+ intent: арбитра не правит тот, кто чинит код — утверждение не ослабляют, чтобы прошло
2
+ intent_en: whoever fixes the code does not adjust the arbiter — assertions are not weakened to pass
3
+
4
+ # Там, где есть тесты. Без них подгонять нечего.
5
+ trigger:
6
+ has_tests: true
7
+
8
+ recipes:
9
+ any: bash {gate}/check.sh {dir}
10
+
11
+ # Программа, без которой запись не работает. По первому слову команды этого не видно: обёртка
12
+ # начинается с `bash`, который есть всегда, и гейт ставился бы, а вставал при первом запуске.
13
+ requires: checkwash
14
+
15
+ proof: incidents/README.md, 2026-09-07 «арбитра правит тот, кто чинит код» — приём назван
16
+ практиками в разборе 1154 обсуждений с Reddit и Hacker News (Baltes, Cheong, Treude,
17
+ arxiv 2603.27249, «test subversion»); замер обвязки по 40 коммитам `httpx` — одна блокировка,
18
+ настоящая, при 15% на пороге `--fail-on warn` и 2.5% на пороге по умолчанию, который пропускает
19
+ сам предмет записи
@@ -0,0 +1,6 @@
1
+ def add(a, b):
2
+ return a + b
3
+
4
+
5
+ def mul(a, b):
6
+ return a * b
@@ -0,0 +1,9 @@
1
+ from calc import add, mul
2
+
3
+
4
+ def test_add():
5
+ assert add(2, 2) == 4
6
+
7
+
8
+ def test_mul():
9
+ assert mul(3, 4) == 12
@@ -0,0 +1,2 @@
1
+ def add(a, b):
2
+ return a + b
@@ -0,0 +1,5 @@
1
+ from calc import add
2
+
3
+
4
+ def test_add():
5
+ assert add(2, 2) == 4
@@ -0,0 +1,2 @@
1
+ def add(a, b):
2
+ return a + b + 1
@@ -0,0 +1,5 @@
1
+ from calc import add
2
+
3
+
4
+ def test_add():
5
+ assert add(2, 2) is not None
@@ -0,0 +1,2 @@
1
+ def add(a, b):
2
+ return a + b
@@ -0,0 +1,7 @@
1
+ from calc import add
2
+
3
+
4
+ def test_add():
5
+ assert add(2, 2) == 4
6
+ assert add(0, 0) == 0
7
+ assert add(-1, 1) == 0
@@ -23,4 +23,10 @@
23
23
  поэтому рецепт под эти стеки берёт их. Своя проверка остаётся как запасная — для языков, где
24
24
  готового правила нет.
25
25
 
26
+ **Замер оправдал переносимую проверку.** Пять чужих репозиториев (`httpx`, `fastapi`, `zod`,
27
+ `cobra`, `ripgrep`) — 51 находка, ложных ноль: маркер `TODO` либо стоит в коде, либо нет,
28
+ двусмысленности здесь нет. На той же ревизии 2026-09-07 две соседние записи лишились своих
29
+ переносимых проверок именно из-за замера — эта его прошла, и потому осталась. В `cobra` и
30
+ `ripgrep`, где `ruff` и eslint неприменимы, она единственная, кто эти маркеры видит.
31
+
26
32
  **Образцы.** `red/` — код с двумя маркерами. `green/` — тот же код, задача заведена, маркера нет.
@@ -2,7 +2,19 @@
2
2
  # Маркер «доделать потом» — это задача, спрятанная от очереди работ. Её не видно при
3
3
  # планировании, о ней не знает никто, кроме того, кто её оставил, и она переживает автора.
4
4
  DIR="${1:-.}"
5
- . "$(dirname "$0")/../_skip.sh" 2>/dev/null || SKIP_NAMES=".git .aqk node_modules .venv"
5
+ # Существование файла проверяется ДО `.`, а не запасной веткой после. Прежняя строка
6
+ # `. файл 2>/dev/null || запасной_вариант` выглядела страховкой и ею не была: под `sh` (dash)
7
+ # неудачный `.` завершает скрипт немедленно, и ветка после `||` не выполняется никогда; под
8
+ # `bash`, которым гейты и запускаются из манифеста, она выполняется, но подставляет только
9
+ # ПЕРЕМЕННУЮ — функции обхода остаются неопределёнными, конвейер печатает пустоту, и проверка
10
+ # выходит с НУЛЁМ. Замерено 2026-09-08 на файле с настоящим нарушением: гейт сказал «чисто».
11
+ SKIP_LIB="$(dirname "$0")/../_skip.sh"
12
+ if [ ! -f "$SKIP_LIB" ]; then
13
+ echo "рядом с проверкой нет _skip.sh — обход не собран, проверка не состоялась"
14
+ echo " почини: скопируй гейт вместе с файлом kit/gates/_skip.sh, он общий на весь каталог"
15
+ exit 2
16
+ fi
17
+ . "$SKIP_LIB"
6
18
 
7
19
  # shellcheck disable=SC2086
8
20
  # Маркер обязан стоять В КОММЕНТАРИИ. Иначе гейт краснеет на имени переменной с таким же