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
@@ -164,12 +164,18 @@ done
164
164
  # --- 8. храповик: старое пропускает, новое не пускает ------------------------
165
165
  # Главный вопрос к храповику: «может ли новый код добавить нарушение и пройти?»
166
166
  # Может — значит это советчик, а не гейт.
167
+ # ПОЧЕМУ ПРИМАНКА ИМЕННО gate-not-weakened. У записи должен быть ТОЛЬКО переносимый рецепт:
168
+ # иначе на машине, где стоит ruff или eslint, установка возьмёт рецепт под язык, и проверка
169
+ # станет печатать чужой формат вывода. Так и вышло — прогон был зелёным локально и красным в
170
+ # конвейере ровно потому, что в конвейер добавили ruff: четыре проверки искали в выводе наши
171
+ # «почини: …» и «путь:строка», а получали формат ruff. Прогон, чей исход зависит от того, что
172
+ # случайно стоит на машине, не проверяет ничего.
167
173
  R="$WORK/ratchet"; mkdir -p "$R"; cd "$R" || exit 1
168
174
  git init -q .
169
- printf 'def a():\n print("старое")\n' > old.py
175
+ printf 'x = 1 # noqa\n' > old.py
170
176
  node "$CLI" init >/dev/null 2>&1
171
- node "$CLI" add no-print-in-prod >/dev/null 2>&1
172
- node "$CLI" ratchet no-print-in-prod >/dev/null 2>&1
177
+ node "$CLI" add gate-not-weakened >/dev/null 2>&1
178
+ node "$CLI" ratchet gate-not-weakened >/dev/null 2>&1
173
179
 
174
180
  # Судим по вердикту гейта, а не по коду возврата doctor: он ненулевой и по другим
175
181
  # причинам (в свежей папке нет .gitignore), и проверка бы врала о храповике.
@@ -179,7 +185,7 @@ case "$OUT" in
179
185
  *) ok "храповик пропустил старое нарушение" ;;
180
186
  esac
181
187
 
182
- printf 'def b():\n print("новое")\n' > new.py
188
+ printf 'y = 2 # noqa\n' > new.py
183
189
  OUT="$(node "$CLI" doctor --run 2>&1)"
184
190
  case "$OUT" in
185
191
  *"новых нарушений"*) ok "храповик не пустил новое нарушение" ;;
@@ -189,7 +195,7 @@ esac
189
195
  # Второй прогон с тем же новым нарушением обязан краснеть так же. Пока реестр перезаписывался
190
196
  # всем текущим списком, одно исправленное нарушение затягивало в долг ВСЕ новые: один красный
191
197
  # прогон — и дальше зелено навсегда. «Может ли новый код добавить нарушение и пройти?» — мог.
192
- printf 'def c():\n print("ещё одно")\n' > another.py
198
+ printf 'z = 3 # noqa\n' > another.py
193
199
  node "$CLI" doctor --run >/dev/null 2>&1
194
200
  rm old.py
195
201
  OUT="$(node "$CLI" doctor --run 2>&1)"
@@ -199,12 +205,12 @@ case "$OUT2" in
199
205
  *) bad "исправление одного нарушения затянуло новые в долг" "второй прогон зелёный" ;;
200
206
  esac
201
207
  rm -f another.py
202
- printf 'def a():\n print("старое")\n' > old.py
203
- node "$CLI" ratchet no-print-in-prod >/dev/null 2>&1 || true
208
+ printf 'x = 1 # noqa\n' > old.py
209
+ node "$CLI" ratchet gate-not-weakened >/dev/null 2>&1 || true
204
210
 
205
211
  rm -f new.py old.py
206
212
  node "$CLI" doctor --run >/dev/null 2>&1
207
- if grep -q 'old.py' ratchets/no-print-in-prod.txt; then
213
+ if grep -q 'old.py' ratchets/gate-not-weakened.txt; then
208
214
  bad "исправленное осталось в реестре — храповик не затягивается"
209
215
  else
210
216
  ok "исправленное вычеркнуто из реестра"
@@ -250,27 +256,13 @@ case "$OUT" in
250
256
  esac
251
257
  cd "$WORK" || exit 1
252
258
 
253
- # --- 8б. проект называет свои каталоги, где печать — интерфейс ---------------
254
- # Исключение объявляется В МАНИФЕСТЕ и потому видно глазами. Проверяем обе стороны: названный
255
- # каталог пропускается, все остальные — нет. Исключение, которое прячет всё, бесполезно.
256
- P="$WORK/printok"; mkdir -p "$P/cli" "$P/src"; cd "$P" || exit 1
257
- printf 'def a():\n print("вывод программы")\n' > cli/main.py
258
- printf 'def b():\n print("забытая отладка")\n' > src/service.py
259
- OUT="$(AQK_PRINT_OK_DIRS=cli bash "$ROOT/kit/gates/no-print-in-prod/check.sh" . 2>&1)"
260
- case "$OUT" in
261
- *"cli/main.py"*) bad "названный каталог не исключён" "$OUT" ;;
262
- *"src/service.py"*) ok "названный каталог пропущен, остальные проверяются" ;;
263
- *) bad "проверка печати не нашла отладку вне названного каталога" "$OUT" ;;
264
- esac
265
- cd "$WORK" || exit 1
266
-
267
259
  # --- 9. разбор ошибки: три случая различаются прогоном, а не памятью ---------
268
260
  # «Сторожа не было», «сторож был и не сработал», «сторож был и его обошли» — разные починки.
269
261
  # Пока их различает человек по памяти, чинят обычно не то. Здесь их различает прогон.
270
262
  Y="$WORK/why"; mkdir -p "$Y"; cd "$Y" || exit 1
271
263
  git init -q .
272
264
  node "$CLI" init >/dev/null 2>&1
273
- printf 'def a():\n print("отладка")\n' > x.py
265
+ printf 'q = 1 # noqa\n' > x.py
274
266
 
275
267
  OUT="$(node "$CLI" why "миграция базы применена задом наперёд" 2>&1)"
276
268
  case "$OUT" in
@@ -278,21 +270,21 @@ case "$OUT" in
278
270
  *) bad "why не сказал, что сторожа не было" "$OUT" ;;
279
271
  esac
280
272
 
281
- OUT="$(node "$CLI" why "отладочная печать уехала в прод" 2>&1)"
273
+ OUT="$(node "$CLI" why "подавление проверки целиком без причины" 2>&1)"
282
274
  case "$OUT" in
283
275
  *"не поставлен"*) ok "why: запись есть в каталоге, но в проекте не поставлена" ;;
284
276
  *) bad "why не отличил «не поставлен» от «не было»" "$OUT" ;;
285
277
  esac
286
278
 
287
- node "$CLI" add no-print-in-prod >/dev/null 2>&1
288
- OUT="$(node "$CLI" why "отладочная печать уехала в прод" 2>&1)"
279
+ node "$CLI" add gate-not-weakened >/dev/null 2>&1
280
+ OUT="$(node "$CLI" why "подавление проверки целиком без причины" 2>&1)"
289
281
  case "$OUT" in
290
282
  *"его обошли"*) ok "why: сторож стоит и ловит — значит его обошли" ;;
291
283
  *) bad "why не отличил «обошли» от «не сработал»" "$OUT" ;;
292
284
  esac
293
285
 
294
286
  rm x.py
295
- OUT="$(node "$CLI" why "отладочная печать уехала в прод" 2>&1)"
287
+ OUT="$(node "$CLI" why "подавление проверки целиком без причины" 2>&1)"
296
288
  case "$OUT" in
297
289
  *"этой поломки не видит"*) ok "why: сторож стоит, а поломки не видит" ;;
298
290
  *) bad "why не отличил «не сработал» от «обошли»" "$OUT" ;;
@@ -579,11 +571,11 @@ rm -rf "$FBDIR" "$FBPROJ"
579
571
  REPDIR="$(mktemp -d)"
580
572
  (
581
573
  cd "$REPDIR" && git init -q . && mkdir -p src &&
582
- printf 'def f():\n print("debug")\n' > src/a.py &&
574
+ printf 'a = 1 # noqa\n' > src/a.py &&
583
575
  node "$CLI" start > /tmp/aqk-start.log 2>&1
584
576
  )
585
577
  REP_OUT=$( cd "$REPDIR" && node "$CLI" report 2>&1 ); REP_CODE=$?
586
- if [ "$REP_CODE" -ne 0 ] && printf '%s' "$REP_OUT" | grep -q '❌ no-print-in-prod'; then
578
+ if [ "$REP_CODE" -ne 0 ] && printf '%s' "$REP_OUT" | grep -q '❌ gate-not-weakened'; then
587
579
  ok "report краснеет кодом возврата и называет упавший гейт"
588
580
  else
589
581
  # Код возврата отчёта не говорит, ПОЧЕМУ он ноль: гейт не сработал, не установился или
@@ -591,7 +583,7 @@ else
591
583
  # и «проверка не ловит на этой системе».
592
584
  G_LS=$( cd "$REPDIR" && ls gates 2>&1 | tr '\n' ' ' )
593
585
  G_DECL=$( cd "$REPDIR" && sed -n '/^gates:/,$p' .aqk.yml 2>/dev/null | grep -cE '^[[:space:]]+[A-Za-z0-9_-]+:' )
594
- G_OUT=$( cd "$REPDIR" && bash gates/no-print-in-prod/check.sh . 2>&1 | head -2 ); G_CODE=$?
586
+ G_OUT=$( cd "$REPDIR" && bash gates/gate-not-weakened/check.sh . 2>&1 | head -2 ); G_CODE=$?
595
587
  bad "report не отличает красное от зелёного" "код отчёта $REP_CODE; гейт напрямую: код $G_CODE, вывод «$(printf '%s' "$G_OUT" | tr '\n' ' ')»; в gates/: «$G_LS»; объявлено гейтов: $G_DECL; хвост start: «$(tail -4 /tmp/aqk-start.log 2>/dev/null | tr '\n' ' ')»"
596
588
  fi
597
589
  if [ -f "$REPDIR/.aqk/report.md" ] && grep -q '^## ' "$REPDIR/.aqk/report.md"; then
@@ -631,11 +623,11 @@ rm -rf "$NOTEDIR" "$NOTEHOME"
631
623
  # то есть настройка правкой чужого файла, которую затрёт следующий `aqk add`.
632
624
  IGNDIR="$(mktemp -d)"
633
625
  mkdir -p "$IGNDIR/third-party/inner" "$IGNDIR/src"
634
- printf 'def f():\n print("свой")\n' > "$IGNDIR/src/mine.py"
635
- printf 'def f():\n print("чужой")\n' > "$IGNDIR/third-party/inner/theirs.py"
636
- OUT_BEFORE="$(bash "$ROOT/kit/gates/no-print-in-prod/check.sh" "$IGNDIR" 2>&1)"
626
+ printf 'mine = 1 # noqa\n' > "$IGNDIR/src/mine.py"
627
+ printf 'theirs = 1 # noqa\n' > "$IGNDIR/third-party/inner/theirs.py"
628
+ OUT_BEFORE="$(bash "$ROOT/kit/gates/gate-not-weakened/check.sh" "$IGNDIR" 2>&1)"
637
629
  printf '# принесено из другого репозитория\nthird-party/\n' > "$IGNDIR/.aqkignore"
638
- OUT_AFTER="$(bash "$ROOT/kit/gates/no-print-in-prod/check.sh" "$IGNDIR" 2>&1)"
630
+ OUT_AFTER="$(bash "$ROOT/kit/gates/gate-not-weakened/check.sh" "$IGNDIR" 2>&1)"
639
631
  if printf '%s' "$OUT_BEFORE" | grep -q 'theirs.py' &&
640
632
  ! printf '%s' "$OUT_AFTER" | grep -q 'theirs.py' &&
641
633
  printf '%s' "$OUT_AFTER" | grep -q 'mine.py'; then
@@ -893,17 +885,104 @@ rm -rf "$BDIR2"
893
885
  # Воспроизводим без Windows: урезаем PATH до одного node — инструментов не видно так же.
894
886
  NRDIR="$(mktemp -d)"; NRBIN="$(mktemp -d)"
895
887
  ln -sf "$(command -v node)" "$NRBIN/node"
896
- ( cd "$NRDIR" && git init -q . && mkdir -p src && printf 'def f():\n print("debug")\n' > src/a.py )
888
+ ( cd "$NRDIR" && git init -q . && mkdir -p src && printf 'a = 1 # noqa\n' > src/a.py )
897
889
  NR_OUT=$( cd "$NRDIR" && PATH="$NRBIN" node "$CLI" start 2>&1 ); NR_CODE=$?
898
890
  NR_GATES=$( ls "$NRDIR/gates" 2>/dev/null | grep -cv '^_' )
899
891
  if [ "$NR_CODE" -eq 0 ] && [ "$NR_GATES" -ge 5 ] &&
900
- [ -f "$NRDIR/gates/no-print-in-prod/check.sh" ]; then
892
+ [ -f "$NRDIR/gates/gate-not-weakened/check.sh" ]; then
901
893
  ok "start пропускает запись без пригодного инструмента и ставит остальные ($NR_GATES)"
902
894
  else
903
895
  bad "start бросил установку из-за одной записи" "код $NR_CODE, поставлено $NR_GATES, хвост: $(printf '%s' "$NR_OUT" | tail -2 | tr '\n' ' ')"
904
896
  fi
905
897
  rm -rf "$NRDIR" "$NRBIN"
906
898
 
899
+ # --- 48г. подделка не берёт уровень выше первого -------------------------------
900
+ # ЗАЧЕМ. Ступень AQK-2 называлась «гейты доказаны» и проверяла, что существуют две папки.
901
+ # Проект с гейтами `true` — командой, которая всегда отвечает «ок», — проходил порог AQK-3 и
902
+ # получал зелёный значок в README. Проверено прогоном 2026-09-07: три «гейта», ноль защиты,
903
+ # высший уровень. Это `pytest || true` на уровне всего стандарта.
904
+ FAKEDIR="$(mktemp -d)"
905
+ (
906
+ cd "$FAKEDIR" && git init -q .
907
+ mkdir -p gates ratchets incidents && printf '# Журнал\n' > incidents/README.md
908
+ # Манифест пишется целиком, а не правится python-ом: проверка, которая на машине без python3
909
+ # молча собирает ДРУГОЙ проект, доказывает не то, что называет. Найдено код-ревью 2026-09-07.
910
+ {
911
+ echo 'aqk: "0.6.0"'
912
+ echo 'entry:'
913
+ echo ' - AGENTS.md'
914
+ echo 'rules: .aqk/rules'
915
+ echo 'gates:'
916
+ echo ' lint: "true"'
917
+ echo ' test: "true"'
918
+ echo ' security: "true"'
919
+ echo 'samples: gates'
920
+ echo 'ratchets: ratchets'
921
+ echo 'lessons: incidents'
922
+ } > .aqk.yml
923
+ printf '# Свод\n' > AGENTS.md
924
+ mkdir -p .aqk/rules && printf '# правила\n' > .aqk/rules/general.md
925
+ )
926
+ FAKE_OUT=$( cd "$FAKEDIR" && node "$CLI" doctor --run --min 3 2>&1 ); FAKE_CODE=$?
927
+ FAKE_PROVE=$( cd "$FAKEDIR" && node "$CLI" prove 2>&1 ); FAKE_PCODE=$?
928
+ # Проверяем ИМЕННО ту ступень, что назначена: без этого `grep 'AQK-1|AQK-3'` совпадал всегда,
929
+ # потому что doctor печатает все четыре строки в любом исходе. Пустая проверка хуже отсутствующей.
930
+ if [ "$FAKE_CODE" -ne 0 ] && [ "$FAKE_PCODE" -ne 0 ] &&
931
+ printf '%s' "$FAKE_OUT" | grep -qE 'AQK-1\.?$|AQK-1[^0-9]' &&
932
+ printf '%s' "$FAKE_OUT" | grep -qiE '(НЕ пройден|not passed|not reached)'; then
933
+ ok "гейт «true» не берёт уровень выше первого"
934
+ else
935
+ bad "подделка получила уровень" "порог: код $FAKE_CODE, доказательство: код $FAKE_PCODE, хвост: $(printf '%s' "$FAKE_OUT" | tail -2 | tr '\n' ' ')"
936
+ fi
937
+ rm -rf "$FAKEDIR"
938
+
939
+ # --- 48в. отказ установки называет, что поставить ------------------------------
940
+ # ЗАЧЕМ. Запись, у которой остался только рецепт под язык (`no-print-in-prod`, `swallowed-error`,
941
+ # `dead-code`), на машине без нужного инструмента ставиться не может — и это законно. Но отказ
942
+ # обязан быть действием, а не тупиком: «нет команды ни под python, ни общей» не говорит человеку
943
+ # ничего. Правило «находка без действия закрывает окно, а не дефект» — то же самое, что мы
944
+ # требуем от записей каталога, и к собственной программе оно относится так же.
945
+ # Найдено первым прогоном в чужом репозитории (httpx), 2026-09-07.
946
+ NODIR="$(mktemp -d)"; NOBIN="$(mktemp -d)"
947
+ ln -sf "$(command -v node)" "$NOBIN/node"
948
+ ( cd "$NODIR" && git init -q . && mkdir -p src && printf 'x = 1\n' > src/a.py && PATH="$NOBIN" node "$CLI" init >/dev/null 2>&1 )
949
+ NO_OUT=$( cd "$NODIR" && PATH="$NOBIN" node "$CLI" add no-print-in-prod 2>&1 ); NO_CODE=$?
950
+ # Имя инструмента в отказе БЫЛО и до этой проверки — не хватало действия. Требуем оба:
951
+ # «ruff не установлен» — это диагноз, «поставь ruff» — это то, ради чего человек читает.
952
+ if [ "$NO_CODE" -ne 0 ] &&
953
+ printf '%s' "$NO_OUT" | grep -q 'ruff' &&
954
+ printf '%s' "$NO_OUT" | grep -qiE '(почини|поставь|install)'; then
955
+ ok "отказ установки называет инструмент И что с ним делать"
956
+ else
957
+ bad "отказ установки — тупик" "код $NO_CODE, вывод: $(printf '%s' "$NO_OUT" | tr '\n' ' ' | tail -c 200)"
958
+ fi
959
+ rm -rf "$NODIR" "$NOBIN"
960
+
961
+ # --- 48б. строгий режим приёмки: «не проверено» становится ошибкой -------------
962
+ # ЗАЧЕМ. «Записи не проверить, нет инструмента» — законное состояние на чужой машине и
963
+ # недопустимое на нашей: там инструменты ставит отдельный шаг, и жёлтая тильда вместо красного
964
+ # означает, что шаг не сработал. Ровно так и было: `pipx install vulture` стоял в конвейере с
965
+ # самого его появления, ставил vulture в каталог вне PATH, и запись dead-code не проверялась
966
+ # конвейером ни разу. Проверяем сам переключатель: на записи с заведомо отсутствующим
967
+ # инструментом обычный прогон зелёный, строгий — красный.
968
+ STPKG="$(mktemp -d)"
969
+ cp -r "$ROOT/tool" "$ROOT/kit" "$ROOT/package.json" "$STPKG/" 2>/dev/null
970
+ # Ломаем рецепт одной записи: программы с таким именем на машине нет ни у кого.
971
+ sed -i.bak 's|^ python: vulture .*| python: aqk-nesuschestvuyuschiy-instrument {dir}|' \
972
+ "$STPKG/kit/gates/dead-code/gate.yml" 2>/dev/null
973
+ # Переключатель задаётся явно в ОБЕ стороны. Без этого проверка наследовала AQK_GATES_STRICT
974
+ # из окружения — и в конвейере, где он поднят, «обычный» прогон был бы строгим, а проверка
975
+ # переключателя проверяла бы одно и то же дважды.
976
+ ST_SOFT_OUT=$(AQK_GATES_STRICT=0 bash "$STPKG/tool/selfcheck/gates.sh" 2>&1); ST_SOFT=$?
977
+ ST_HARD_OUT=$(AQK_GATES_STRICT=1 bash "$STPKG/tool/selfcheck/gates.sh" 2>&1); ST_HARD=$?
978
+ if [ "$ST_SOFT" -eq 0 ] && [ "$ST_HARD" -ne 0 ] &&
979
+ printf '%s' "$ST_HARD_OUT" | grep -q 'строгий режим'; then
980
+ ok "строгий режим делает «не проверено» ошибкой, обычный — нет"
981
+ else
982
+ bad "строгий режим приёмки не работает" "обычный: $ST_SOFT, строгий: $ST_HARD"
983
+ fi
984
+ rm -rf "$STPKG"
985
+
907
986
  # --- 49. выведенную запись не ставят, а называют преемника --------------------
908
987
  # ЗАЧЕМ. Зрелость записи считается по доказательству, и объявить её нельзя — кроме одного
909
988
  # состояния: `deprecated`. Оно объявляется, и весь его смысл в отказе: запись, которую всё ещё
@@ -933,11 +1012,11 @@ rm -rf "$DEPKG" "$DEPRJ"
933
1012
  SCDIR="$(mktemp -d)"
934
1013
  (
935
1014
  cd "$SCDIR" && git init -q . && git config user.email t@t && git config user.name t
936
- mkdir -p src && printf 'def old():\n print("старый долг")\n' > src/old.py
1015
+ mkdir -p src && printf 'old = 1 # noqa\n' > src/old.py
937
1016
  node "$CLI" init >/dev/null 2>&1
938
- node "$CLI" add no-print-in-prod >/dev/null 2>&1
1017
+ node "$CLI" add gate-not-weakened >/dev/null 2>&1
939
1018
  git add -A && git commit -qm "база" >/dev/null 2>&1
940
- printf 'def fresh():\n print("новый долг")\n' > src/fresh.py
1019
+ printf 'fresh = 1 # noqa\n' > src/fresh.py
941
1020
  )
942
1021
  SC_WIDE=$( cd "$SCDIR" && node "$CLI" doctor --run 2>&1 )
943
1022
  SC_NARROW=$( cd "$SCDIR" && node "$CLI" doctor --run --since HEAD 2>&1 )
@@ -957,6 +1036,167 @@ else
957
1036
  fi
958
1037
  rm -rf "$SCDIR"
959
1038
 
1039
+ # --- 52. совет по починке не теряется в обрезке --------------------------------
1040
+ # ЗАЧЕМ. Все записи каталога печатают «почини: …» последней строкой, а прогон показывал три
1041
+ # первые и обрезал остальное — то есть ровно ту строку, ради которой человек смотрит на красное,
1042
+ # он не видел никогда. Находка без действия закрывает окно, а не дефект.
1043
+ ADIR="$(mktemp -d)"
1044
+ (
1045
+ cd "$ADIR" && git init -q . && mkdir -p src
1046
+ for n in a b c d e; do printf '%s = 1 # noqa\n' "$n" > "src/$n.py"; done
1047
+ node "$CLI" init >/dev/null 2>&1
1048
+ node "$CLI" add gate-not-weakened >/dev/null 2>&1
1049
+ )
1050
+ A_OUT=$( cd "$ADIR" && node "$CLI" doctor --run 2>&1 )
1051
+ if printf '%s' "$A_OUT" | grep -qiE '(почини|fix)[[:space:]]*:' &&
1052
+ printf '%s' "$A_OUT" | grep -qE 'py:[0-9]+'; then
1053
+ ok "совет по починке виден при обрезанных находках"
1054
+ else
1055
+ bad "совет по починке потерялся" "находок в выводе: $(printf '%s' "$A_OUT" | grep -c 'py:')"
1056
+ fi
1057
+ rm -rf "$ADIR"
1058
+
1059
+ # --- 53. у долга есть цель и срок, и оба с последствием ------------------------
1060
+ # ЗАЧЕМ. Реестр, который может только сокращаться, всё равно не знает, когда кончится, — и
1061
+ # потому не кончается. Цель и срок без машинного последствия были бы украшением, поэтому
1062
+ # проверяем ровно последствия: срок вышел — красное; цель достигнута — сказано вслух.
1063
+ RDIR="$(mktemp -d)"
1064
+ (
1065
+ cd "$RDIR" && git init -q . && mkdir -p src && printf 'a = 1 # noqa\n' > src/a.py
1066
+ node "$CLI" init >/dev/null 2>&1
1067
+ node "$CLI" add gate-not-weakened >/dev/null 2>&1
1068
+ node "$CLI" ratchet gate-not-weakened >/dev/null 2>&1
1069
+ )
1070
+ R_REG="$RDIR/ratchets/gate-not-weakened.txt"
1071
+ if [ -f "$R_REG" ] && grep -q 'aqk-goal' "$R_REG"; then
1072
+ # Долг снят, новых нарушений нет — зелено.
1073
+ R_BASE=$( cd "$RDIR" && node "$CLI" doctor --run 2>&1 ); R_BASE_CODE=$?
1074
+ # Срок в прошлом — обязано покраснеть без единого нового нарушения.
1075
+ sed -i.bak 's/^# aqk-deadline:.*/# aqk-deadline: 2020-01-01/' "$R_REG"
1076
+ R_LATE=$( cd "$RDIR" && node "$CLI" doctor --run 2>&1 )
1077
+ if printf '%s' "$R_LATE" | grep -qi 'срок\|deadline'; then
1078
+ ok "срок долга вышел — храповик краснеет без новых нарушений"
1079
+ else
1080
+ bad "просроченный долг прошёл молча" "код базового прогона $R_BASE_CODE"
1081
+ fi
1082
+ # Цель заведомо достигнута — храповик обязан сказать, что обёртку пора убрать.
1083
+ sed -i.bak 's/^# aqk-deadline:.*/# aqk-deadline:/; s/^# aqk-goal:.*/# aqk-goal: 99/' "$R_REG"
1084
+ R_DONE=$( cd "$RDIR" && node "$CLI" doctor --run 2>&1 )
1085
+ if printf '%s' "$R_DONE" | grep -qi 'погашен\|paid off'; then
1086
+ ok "цель достигнута — храповик говорит убрать обёртку"
1087
+ else
1088
+ bad "погашенный долг не назван" "$(printf '%s' "$R_DONE" | grep -i ratchet | head -1)"
1089
+ fi
1090
+ else
1091
+ bad "реестр долга не создан или без цели" "$R_REG"
1092
+ fi
1093
+ rm -rf "$RDIR"
1094
+
1095
+ # --- 54. упавший гейт не стирает реестр долга ----------------------------------
1096
+ # ЗАЧЕМ. Провал без единой разобранной находки — это отказ инструмента, а не чистый прогон.
1097
+ # Храповик вычёркивал ВЕСЬ реестр как исправленный, возвращал ноль и — после появления цели —
1098
+ # предлагал снять защиту: «долг погашен, убери обёртку». Снятие защиты по итогам прогона,
1099
+ # которого не было. Найдено ревью 2026-09-06.
1100
+ WDIR="$(mktemp -d)"
1101
+ mkdir -p "$WDIR/ratchets"
1102
+ printf '# Реестр долга: проба\n# aqk-goal: 0\nsrc/a.py: печать\n' > "$WDIR/ratchets/t.txt"
1103
+ W_OUT=$( cd "$WDIR" && bash "$ROOT/kit/ratchet/ratchet.sh" ratchets/t.txt sh -c 'exit 3' 2>&1 ); W_CODE=$?
1104
+ W_LEFT=$(grep -c 'src/a.py' "$WDIR/ratchets/t.txt" || true)
1105
+ if [ "$W_CODE" -ne 0 ] && [ "$W_LEFT" -eq 1 ] &&
1106
+ ! printf '%s' "$W_OUT" | grep -qi 'погашен'; then
1107
+ ok "упавший гейт не стирает реестр и не предлагает снять защиту"
1108
+ else
1109
+ bad "упавший гейт съел реестр" "код $W_CODE, строк долга осталось $W_LEFT"
1110
+ fi
1111
+ # Директива с опечаткой обязана быть слышной: молчаливо отключённая цель — та же тишина.
1112
+ printf '# Реестр\n# aqk-goal: скоро\nsrc/a.py: печать\n' > "$WDIR/ratchets/t.txt"
1113
+ W_BAD=$( cd "$WDIR" && bash "$ROOT/kit/ratchet/ratchet.sh" ratchets/t.txt sh -c 'echo "src/a.py: печать"' 2>&1 )
1114
+ if printf '%s' "$W_BAD" | grep -qi 'не число'; then
1115
+ ok "опечатка в директиве храповика названа, а не проглочена"
1116
+ else
1117
+ bad "нечисловая цель отключилась молча" "$(printf '%s' "$W_BAD" | head -1)"
1118
+ fi
1119
+ rm -rf "$WDIR"
1120
+
1121
+ # --- 55. просроченный долг краснеет и при сужении по дифу ----------------------
1122
+ # ЗАЧЕМ. Сообщение храповика про срок называет путь к реестру, а реестра в дифе нет: фильтр по
1123
+ # путям отбрасывал единственную строку, находок не оставалось, и гейт печатался зелёным с
1124
+ # пометкой «находки вне дифа». То есть `--since` отменял правило SPEC.md §7.6 ровно в том
1125
+ # режиме, в котором его и запускают. Найдено ревью 2026-09-06.
1126
+ DDIR="$(mktemp -d)"
1127
+ (
1128
+ cd "$DDIR" && git init -q . && git config user.email t@t && git config user.name t
1129
+ mkdir -p src && printf 'a = 1 # noqa\n' > src/a.py
1130
+ node "$CLI" init >/dev/null 2>&1
1131
+ node "$CLI" add gate-not-weakened >/dev/null 2>&1
1132
+ node "$CLI" ratchet gate-not-weakened >/dev/null 2>&1
1133
+ sed -i.bak 's/^# aqk-deadline:.*/# aqk-deadline: 2020-01-01/' ratchets/gate-not-weakened.txt
1134
+ git add -A >/dev/null 2>&1 && git commit -qm base >/dev/null 2>&1
1135
+ )
1136
+ D_WIDE=$( cd "$DDIR" && node "$CLI" doctor --run 2>&1 )
1137
+ D_NARROW=$( cd "$DDIR" && node "$CLI" doctor --run --since HEAD 2>&1 )
1138
+ if printf '%s' "$D_WIDE" | grep -q 'gate-not-weakened' &&
1139
+ printf '%s' "$D_NARROW" | grep -qE 'gate-not-weakened.*(код|exit)' ; then
1140
+ ok "просроченный долг краснеет и при --since"
1141
+ else
1142
+ bad "--since отменил срок долга" "узкий прогон: $(printf '%s' "$D_NARROW" | grep no-print | head -1 | cut -c1-90)"
1143
+ fi
1144
+ rm -rf "$DDIR"
1145
+
1146
+ # --- 56. совещательный гейт показан, но прогон не уронен -----------------------
1147
+ # ЗАЧЕМ. Правило вводят в проект, где старый код ему не соответствует. Без третьего пути выбор
1148
+ # из двух крайностей: включить и сломать сборку либо не включать вовсе. Проверяем обе стороны:
1149
+ # без списка — роняет; со списком — показано и НАЗВАНО, а прогон зелёный. Молчание о
1150
+ # совещательном гейте было бы выключенной проверкой, притворяющейся отсутствующей.
1151
+ VDIR="$(mktemp -d)"
1152
+ (
1153
+ cd "$VDIR" && git init -q . && mkdir -p src && printf 'a = 1 # noqa\n' > src/a.py
1154
+ node "$CLI" init >/dev/null 2>&1
1155
+ node "$CLI" add gate-not-weakened >/dev/null 2>&1
1156
+ )
1157
+ ( cd "$VDIR" && node "$CLI" doctor --run --min 1 >/dev/null 2>&1 ); V_HARD=$?
1158
+ printf '\nadvisory:\n - gate-not-weakened\n' >> "$VDIR/.aqk.yml"
1159
+ V_OUT=$( cd "$VDIR" && node "$CLI" doctor --run --min 1 2>&1 ); V_SOFT=$?
1160
+ if [ "$V_HARD" -ne 0 ] && [ "$V_SOFT" -eq 0 ] &&
1161
+ printf '%s' "$V_OUT" | grep -qE 'advisory|совещательн' &&
1162
+ printf '%s' "$V_OUT" | grep -q 'src/a.py'; then
1163
+ ok "совещательный гейт показывает находки, называется и не роняет прогон"
1164
+ else
1165
+ bad "совещательный режим работает не так" "обычный код $V_HARD, совещательный $V_SOFT"
1166
+ fi
1167
+ # Опечатка в имени поля обязана быть названа: «advisery:» молча означало бы «совещательных нет»,
1168
+ # и правило, которое человек считал введённым, роняло бы сборку.
1169
+ sed -i.bak 's/^advisory:/advisery:/' "$VDIR/.aqk.yml"
1170
+ V_TYPO=$( cd "$VDIR" && node "$CLI" doctor 2>&1 )
1171
+ if printf '%s' "$V_TYPO" | grep -qi 'advisery'; then
1172
+ ok "опечатка в имени поля манифеста названа"
1173
+ else
1174
+ bad "опечатка в advisory проглочена" "$(printf '%s' "$V_TYPO" | tail -2 | head -1)"
1175
+ fi
1176
+ rm -rf "$VDIR"
1177
+
1178
+ # --- 81. версия в README и llms.txt та же, что в package.json ---------------
1179
+ # Обе строки — инструкция по установке для постороннего: `rev:` для pre-commit и `@vX.Y.Z` для
1180
+ # GitHub Action. Устаревшая ставит человеку не тот комплект и молчит об этом: команда проходит,
1181
+ # ставится прошлогодняя версия. AGENTS.md требует, чтобы README и llms.txt не расходились, —
1182
+ # и без прибора требование не работало: на 2026-09-08 в README стояло v0.6.0, а в llms.txt
1183
+ # v0.4.2, отставание на два выпуска. Проверка сравнивает обе с package.json, а не друг с другом:
1184
+ # совпасть друг с другом они могут и будучи одинаково устаревшими.
1185
+ VERS_BAD=""
1186
+ for VF in README.md llms.txt; do
1187
+ [ -f "$ROOT/$VF" ] || continue
1188
+ # Берём только версии AQK — «v1.2.3» в примерах чужих действий (actions/checkout@v4) не наши.
1189
+ for V in $(grep -oE '(rev:[[:space:]]*|Agent_Quality_Kit@)v[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/$VF" \
1190
+ | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | sort -u); do
1191
+ [ "$V" = "v$PKGVER" ] || VERS_BAD="${VERS_BAD:+$VERS_BAD, }$VF: $V"
1192
+ done
1193
+ done
1194
+ if [ -z "$VERS_BAD" ]; then
1195
+ ok "версия в README и llms.txt совпадает с package.json (v$PKGVER)"
1196
+ else
1197
+ bad "версия в документах разошлась с package.json" "package.json: v$PKGVER; найдено — $VERS_BAD"
1198
+ fi
1199
+
960
1200
  # --- итог -------------------------------------------------------------------
961
1201
  printf '\n'
962
1202
  if [ "$FAIL" -eq 0 ]; then
@@ -0,0 +1,60 @@
1
+ // tool/selfcheck/units-level.mjs — проверки уровня и доказательства гейтов.
2
+ //
3
+ // ОТДЕЛЬНЫМ ФАЙЛОМ, а не в units.mjs: тот перерос собственный предел в 500 строк, и поймал
4
+ // это наш же гейт `file-size-limit` на прогоне. Шов по смыслу: здесь всё про то, чем ступень
5
+ // отличается от заявления, — остальное осталось на месте.
6
+ //
7
+ // node --test tool/selfcheck/units-level.mjs
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { commandFor } from "../lib/prove.mjs";
11
+ import { assessLevel } from "../lib/manifest.mjs";
12
+
13
+ // --- доказательство гейтов ------------------------------------------------------------
14
+ // ЗАЧЕМ. Ступень AQK-2 называлась «гейты доказаны» и проверяла существование двух папок.
15
+ // Проект с тремя гейтами `true` проходил порог AQK-3 — проверено прогоном на пустой папке.
16
+ test("обёртка храповика снимается перед подстановкой каталога", () => {
17
+ // Иначе доказательство гоняет гейт вместе с реестром долга, и реестр перезаписывается
18
+ // находками из ОБРАЗЦА. На живом проекте это стёрло бы долг целиком.
19
+ const cmd = "bash ratchets/_ratchet.sh ratchets/x.txt bash gates/x/check.sh .";
20
+ assert.equal(commandFor(cmd, "gates/x/red"), "bash gates/x/check.sh gates/x/red");
21
+ });
22
+
23
+ test("каталог подставляется вместо последнего слова команды", () => {
24
+ assert.equal(commandFor("bash gates/x/check.sh .", "gates/x/green"), "bash gates/x/check.sh gates/x/green");
25
+ assert.equal(commandFor("ruff check --select T20 .", "gates/x/red"), "ruff check --select T20 gates/x/red");
26
+ });
27
+
28
+ test("ступень выше первой не берётся без доказательства", async () => {
29
+ const man = {
30
+ aqk: "0.6.0", entry: [], rules: ".", samples: ".", ratchets: ".", lessons: ".",
31
+ gates: { lint: "true" },
32
+ };
33
+ const withoutProof = await assessLevel(man, null);
34
+ const withFailedProof = await assessLevel(man, { ok: false });
35
+ const withProof = await assessLevel(man, { ok: true });
36
+ // Без доказательства ступень не выполнена, но помечена «не проверяли» — это разные состояния.
37
+ assert.equal(withoutProof.steps[2].ok, false);
38
+ assert.equal(withoutProof.steps[2].needsProof, true);
39
+ assert.equal(withFailedProof.steps[2].ok, false);
40
+ assert.equal(withFailedProof.steps[2].needsProof, false);
41
+ assert.equal(withProof.steps[2].ok, true);
42
+ });
43
+
44
+ test("обёртка родного инструмента едет вместе с каталогом образца", () => {
45
+ // `_native.sh <каталог> <команда…>` прячет пути gates/*/red|green. Оставленный «.» спрятал бы
46
+ // ровно то, что образец обязан показать, — красный прошёл бы зелёным.
47
+ const cmd = "bash gates/_native.sh . ruff check --select T20 .";
48
+ assert.equal(
49
+ commandFor(cmd, "gates/x/red"),
50
+ "bash gates/_native.sh gates/x/red ruff check --select T20 gates/x/red"
51
+ );
52
+ });
53
+
54
+ test("обе обёртки снимаются вместе", () => {
55
+ const cmd = "bash ratchets/_ratchet.sh ratchets/x.txt bash gates/_native.sh . ruff check .";
56
+ assert.equal(
57
+ commandFor(cmd, "gates/x/green"),
58
+ "bash gates/_native.sh gates/x/green ruff check gates/x/green"
59
+ );
60
+ });