agent-quality-kit 0.4.2 → 0.6.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 (76) hide show
  1. package/README.md +136 -12
  2. package/README.ru.md +119 -11
  3. package/kit/docs/ready-made-rules.md +40 -0
  4. package/kit/gates/README.md +35 -0
  5. package/kit/gates/_native.sh +18 -2
  6. package/kit/gates/_skip.sh +18 -0
  7. package/kit/gates/ci-actually-fails/README.md +42 -0
  8. package/kit/gates/ci-actually-fails/check.sh +93 -0
  9. package/kit/gates/ci-actually-fails/gate.yml +14 -0
  10. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +14 -0
  11. package/kit/gates/ci-actually-fails/red/.github/workflows/ci.yml +12 -0
  12. package/kit/gates/color-from-token/README.md +52 -0
  13. package/kit/gates/color-from-token/check.sh +69 -0
  14. package/kit/gates/color-from-token/gate.yml +15 -0
  15. package/kit/gates/color-from-token/green/Button.tsx +4 -0
  16. package/kit/gates/color-from-token/green/Panel.vue +4 -0
  17. package/kit/gates/color-from-token/green/card.css +5 -0
  18. package/kit/gates/color-from-token/green/notes.md +2 -0
  19. package/kit/gates/color-from-token/green/tokens.css +7 -0
  20. package/kit/gates/color-from-token/red/Button.tsx +4 -0
  21. package/kit/gates/color-from-token/red/Panel.vue +4 -0
  22. package/kit/gates/color-from-token/red/card.css +5 -0
  23. package/kit/gates/commit-explains-itself/check.sh +25 -2
  24. package/kit/gates/duplicate-code/check.sh +13 -4
  25. package/kit/gates/duplicate-code/gate.yml +11 -2
  26. package/kit/gates/gate-has-samples/check.sh +9 -3
  27. package/kit/gates/gate-not-weakened/README.md +54 -0
  28. package/kit/gates/gate-not-weakened/check.sh +72 -0
  29. package/kit/gates/gate-not-weakened/gate.yml +15 -0
  30. package/kit/gates/gate-not-weakened/green/checkout.ts +8 -0
  31. package/kit/gates/gate-not-weakened/green/payments.py +6 -0
  32. package/kit/gates/gate-not-weakened/green/release.sh +2 -0
  33. package/kit/gates/gate-not-weakened/red/checkout.ts +9 -0
  34. package/kit/gates/gate-not-weakened/red/payments.py +6 -0
  35. package/kit/gates/gate-not-weakened/red/release.sh +2 -0
  36. package/kit/gates/gates-are-runnable/check.sh +7 -1
  37. package/kit/gates/gates-run-in-ci/check.sh +7 -1
  38. package/kit/gates/lesson-has-outcome/check.sh +6 -1
  39. package/kit/gates/promise-has-gate/README.md +50 -0
  40. package/kit/gates/promise-has-gate/check.sh +88 -0
  41. package/kit/gates/promise-has-gate/gate.yml +14 -0
  42. package/kit/gates/promise-has-gate/green/.aqk.yml +6 -0
  43. package/kit/gates/promise-has-gate/green/AGENTS.md +7 -0
  44. package/kit/gates/promise-has-gate/red/.aqk.yml +6 -0
  45. package/kit/gates/promise-has-gate/red/AGENTS.md +7 -0
  46. package/kit/gates/test-has-assertion/README.md +47 -0
  47. package/kit/gates/test-has-assertion/check.sh +194 -0
  48. package/kit/gates/test-has-assertion/gate.yml +15 -0
  49. package/kit/gates/test-has-assertion/green/checkout.test.ts +9 -0
  50. package/kit/gates/test-has-assertion/green/test_billing.py +17 -0
  51. package/kit/gates/test-has-assertion/red/checkout.test.ts +8 -0
  52. package/kit/gates/test-has-assertion/red/test_billing.py +14 -0
  53. package/kit/ratchet/ratchet.sh +9 -2
  54. package/kit/rules/general.md +14 -0
  55. package/llms.txt +58 -0
  56. package/package.json +4 -2
  57. package/tool/commands/doctor.mjs +106 -11
  58. package/tool/commands/gates.mjs +18 -3
  59. package/tool/commands/project.mjs +13 -4
  60. package/tool/commands/report.mjs +2 -2
  61. package/tool/i18n/en.mjs +55 -0
  62. package/tool/i18n/ru.mjs +61 -0
  63. package/tool/i18n/templates-en.mjs +9 -9
  64. package/tool/i18n/templates-ru.mjs +9 -9
  65. package/tool/lib/baseline.mjs +87 -0
  66. package/tool/lib/core.mjs +10 -2
  67. package/tool/lib/manifest.mjs +69 -2
  68. package/tool/lib/repo.mjs +7 -0
  69. package/tool/lib/scope.mjs +96 -0
  70. package/tool/program.mjs +1 -0
  71. package/tool/selfcheck/gates.sh +29 -5
  72. package/tool/selfcheck/lifecycle.mjs +29 -0
  73. package/tool/selfcheck/mutation.sh +95 -0
  74. package/tool/selfcheck/smoke.sh +269 -8
  75. package/tool/selfcheck/syntax.sh +9 -1
  76. package/tool/selfcheck/units.mjs +160 -1
@@ -384,18 +384,22 @@ rm -rf "$GODIR"
384
384
 
385
385
  # --- 24. просьба про звезду и обратную связь — один раз на машину -----------
386
386
  # Печатается один раз на установку (не на проект): второй init на этой же HOME её не повторяет.
387
+ # USERPROFILE задаётся рядом с HOME: `os.homedir()` на Windows читает именно его, и без этого
388
+ # отметка уезжала в настоящий домашний каталог раннера — к этой проверке она там уже лежала от
389
+ # предыдущих прогонов init, и просьба не печаталась. Изоляция, которая не изолирует, хуже её
390
+ # отсутствия: проверка краснела не на дефекте.
387
391
  # Отметка живёт вне репозитория — внутри .aqk/ она либо закоммитится в чужой проект, либо
388
392
  # потеряется при повторном init --force.
389
393
  FAKEHOME="$(mktemp -d)"
390
394
  D1="$(mktemp -d)"
391
- OUT1=$( cd "$D1" && HOME="$FAKEHOME" node "$CLI" init 2>&1 )
395
+ OUT1=$( cd "$D1" && HOME="$FAKEHOME" USERPROFILE="$FAKEHOME" node "$CLI" init 2>&1 )
392
396
  if printf '%s' "$OUT1" | grep -qi 'звезд'; then
393
397
  ok "первый init на новой машине зовёт поставить звезду"
394
398
  else
395
399
  bad "первый init не упомянул звезду/обратную связь" "$OUT1"
396
400
  fi
397
401
  D2="$(mktemp -d)"
398
- OUT2=$( cd "$D2" && HOME="$FAKEHOME" node "$CLI" init 2>&1 )
402
+ OUT2=$( cd "$D2" && HOME="$FAKEHOME" USERPROFILE="$FAKEHOME" node "$CLI" init 2>&1 )
399
403
  if printf '%s' "$OUT2" | grep -qi 'звезд'; then
400
404
  bad "init повторил просьбу про звезду на той же машине" "второй проект, та же HOME"
401
405
  else
@@ -428,7 +432,9 @@ rm -f /tmp/aqk-broken-doc-links.$$
428
432
  # --- 26. doctor печатает версию комплекта ------------------------------------
429
433
  # Баг-репорт без версии нечем привязать к коммиту — заметили, заполняя .github/ISSUE_TEMPLATE/,
430
434
  # где просили версию из шапки doctor, а шапка её не печатала вовсе.
431
- PKGVER=$(node -e "console.log(require('$ROOT/package.json').version)")
435
+ # Путь отдаётся оболочкой, а Node на Windows не понимает «/d/a/…» из Git Bash. Читаем из
436
+ # текущего каталога, а не подставляем абсолютный путь в код.
437
+ PKGVER=$( cd "$ROOT" && node -p "require('./package.json').version" )
432
438
  DOCVER=$( cd "$ROOT" && node "$CLI" doctor 2>&1 | head -3)
433
439
  if printf '%s' "$DOCVER" | grep -qF "$PKGVER"; then
434
440
  ok "doctor печатает версию комплекта ($PKGVER)"
@@ -532,16 +538,25 @@ fi
532
538
  rm -rf "$SHDIR" "$CEDIR"
533
539
 
534
540
  # --- 32. вывод действительно на двух языках -----------------------------------
541
+ # Утечку русского ищем СЛОВАМИ, а не диапазоном [а-яА-ЯёЁ]. Диапазон непереносим: в сборке grep
542
+ # из MSYS он сравнивает байты, и «—», «·», ««»» из обычной типографики попадают в него — на
543
+ # Windows проверка насчитывала пять «кириллических» строк в чисто английском выводе. Литеральные
544
+ # слова совпадают одинаково везде, что тот же прогон и подтвердил.
535
545
  # Сверка ключей каталогов (units.mjs) доказывает, что строки не разошлись, но не доказывает,
536
546
  # что выбор языка вообще доехал до вывода. Это проверяется только запуском.
537
547
  EN_OUT=$(AQK_LANG=en node "$CLI" 2>&1)
538
548
  RU_OUT=$(AQK_LANG=ru node "$CLI" 2>&1)
539
549
  if printf '%s' "$EN_OUT" | grep -q "install a gate from the catalogue" &&
540
- ! printf '%s' "$EN_OUT" | grep -q '[а-яА-ЯёЁ]' &&
550
+ ! printf '%s' "$EN_OUT" | grep -qE 'гейт|каталог|проверк|уровен|репозитор' &&
541
551
  printf '%s' "$RU_OUT" | grep -q "поставить гейт из каталога"; then
542
552
  ok "справка печатается на двух языках, в английской нет кириллицы"
543
553
  else
544
- bad "выбор языка не доехал до вывода" "$(printf '%s' "$EN_OUT" | head -4)"
554
+ # Диагностика по каждому условию отдельно. Прежняя печатала первые строки вывода по ним
555
+ # видно, что вывод английский, и совершенно не видно, какая из трёх сверок не сошлась.
556
+ EN_HAS=$(printf '%s' "$EN_OUT" | grep -c "install a gate from the catalogue")
557
+ EN_CYR=$(printf '%s' "$EN_OUT" | grep -cE 'гейт|каталог|проверк|уровен|репозитор')
558
+ RU_HAS=$(printf '%s' "$RU_OUT" | grep -c "поставить гейт из каталога")
559
+ bad "выбор языка не доехал до вывода" "англ.фраза=$EN_HAS кириллица_в_англ=$EN_CYR рус.фраза=$RU_HAS"
545
560
  fi
546
561
 
547
562
  # --- 33. ссылка на репозиторий ведёт в репозиторий -----------------------------
@@ -549,7 +564,7 @@ fi
549
564
  # «github:владелец/репозиторий»), просьба про звезду поехала на github.com/agent-quality-kit —
550
565
  # несуществующую страницу. Единственное место, где мы просим человека о чём-то, вело в никуда.
551
566
  FBDIR="$(mktemp -d)"; FBPROJ="$(mktemp -d)"
552
- FB_OUT=$( cd "$FBPROJ" && git init -q . && HOME="$FBDIR" node "$CLI" init 2>&1 )
567
+ FB_OUT=$( cd "$FBPROJ" && git init -q . && HOME="$FBDIR" USERPROFILE="$FBDIR" node "$CLI" init 2>&1 )
553
568
  if printf '%s' "$FB_OUT" | grep -qE 'https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'; then
554
569
  ok "просьба про звезду ведёт на репозиторий, а не на github.com/<имя пакета>"
555
570
  else
@@ -565,13 +580,19 @@ REPDIR="$(mktemp -d)"
565
580
  (
566
581
  cd "$REPDIR" && git init -q . && mkdir -p src &&
567
582
  printf 'def f():\n print("debug")\n' > src/a.py &&
568
- node "$CLI" start >/dev/null 2>&1
583
+ node "$CLI" start > /tmp/aqk-start.log 2>&1
569
584
  )
570
585
  REP_OUT=$( cd "$REPDIR" && node "$CLI" report 2>&1 ); REP_CODE=$?
571
586
  if [ "$REP_CODE" -ne 0 ] && printf '%s' "$REP_OUT" | grep -q '❌ no-print-in-prod'; then
572
587
  ok "report краснеет кодом возврата и называет упавший гейт"
573
588
  else
574
- bad "report не отличает красное от зелёного" "код $REP_CODE"
589
+ # Код возврата отчёта не говорит, ПОЧЕМУ он ноль: гейт не сработал, не установился или
590
+ # установился не тот. Спрашиваем сам гейт напрямую — это и есть разница между «отчёт врёт»
591
+ # и «проверка не ловит на этой системе».
592
+ G_LS=$( cd "$REPDIR" && ls gates 2>&1 | tr '\n' ' ' )
593
+ 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=$?
595
+ 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' ' ')»"
575
596
  fi
576
597
  if [ -f "$REPDIR/.aqk/report.md" ] && grep -q '^## ' "$REPDIR/.aqk/report.md"; then
577
598
  ok "report сохраняет .aqk/report.md"
@@ -696,6 +717,246 @@ else
696
717
  fi
697
718
  rm -rf "$BDIR"
698
719
 
720
+ # --- 41. порог различает «ступень ниже» и «упал гейт» -------------------------
721
+ # ЗАЧЕМ. При упавшем гейте печаталось «Порог AQK-1 НЕ пройден: сейчас AQK-1» — утверждение,
722
+ # противоречащее само себе. Человек шёл чинить манифест, а падал гейт. Это две разные
723
+ # развилки, и сообщение обязано их различать, иначе оно отправляет чинить не то.
724
+ TDIR="$(mktemp -d)"
725
+ ( cd "$TDIR" && git init -q . && node "$CLI" init >/dev/null 2>&1 )
726
+ printf 'gates:\n always-fails: "false"\n' >> "$TDIR/.aqk.yml"
727
+ OUT_GATE="$( cd "$TDIR" && node "$CLI" doctor --run --min 1 2>&1 )"; RC_GATE=$?
728
+ # Ступень ниже порога: пустой манифест без входа и правил.
729
+ EDIR="$(mktemp -d)"; ( cd "$EDIR" && git init -q . && printf 'aqk: 1\n' > .aqk.yml )
730
+ OUT_LVL="$( cd "$EDIR" && node "$CLI" doctor --min 3 2>&1 )"; RC_LVL=$?
731
+ if [ "$RC_GATE" -ne 0 ] && [ "$RC_LVL" -ne 0 ] &&
732
+ printf '%s' "$OUT_GATE" | grep -q 'always-fails' &&
733
+ ! printf '%s' "$OUT_GATE" | grep -qE '(НЕ пройден|NOT passed): (сейчас|currently) AQK-1' &&
734
+ printf '%s' "$OUT_LVL" | grep -qE '(НЕ пройден|NOT passed)'; then
735
+ ok "порог различает упавший гейт и недобранную ступень"
736
+ else
737
+ bad "сообщение о пороге не различает две развилки" "гейт: $(printf '%s' "$OUT_GATE" | tail -2 | tr '\n' ' ')"
738
+ fi
739
+ rm -rf "$TDIR" "$EDIR"
740
+
741
+ # --- 42. duplicate-code: пара упорядочена, а не как отдал обход ---------------
742
+ # ЗАЧЕМ. Ключ пары складывался в порядке, в котором файлы отдал find, а он разный на разных
743
+ # системах (здесь — по хешу имени, не по алфавиту и не по времени создания). Реестр, снятый
744
+ # на одной машине, краснел в конвейере целиком: те же дубли читались как новые. Хуже: храповик
745
+ # объявлял их исправленными и вычёркивал — реестр портился. Инвариант: пара внутри строки
746
+ # всегда лексикографическая, тогда ключ одинаков на любой системе.
747
+ DD="$(mktemp -d)"
748
+ BLOCK='def f():\n a = 1\n b = 2\n c = 3\n d = 4\n e = 5\n g = 6\n h = 7\n return a\n'
749
+ printf "$BLOCK" > "$DD/zz.py"; printf "$BLOCK" > "$DD/aa.py"
750
+ LINE="$(bash "$ROOT/kit/gates/duplicate-code/check.sh" "$DD" 2>&1 | sed "s#$DD/##g" | grep 'одинаков' | head -1)"
751
+ FIRST="${LINE%% и *}"; REST="${LINE#* и }"; SECOND="${REST%%:*}"
752
+ if [ -n "$LINE" ] && [ "${FIRST%%:*}" \< "$SECOND" ]; then
753
+ ok "duplicate-code упорядочивает пару лексикографически"
754
+ else
755
+ bad "пара идёт в порядке обхода — реестр разъедется между машинами" "$LINE"
756
+ fi
757
+ rm -rf "$DD"
758
+
759
+ # --- 43. храповик: ключ переживает сдвиг строки в ПЕРВОМ файле пары -----------
760
+ # ЗАЧЕМ. keys() убирал номер строки шаблоном ':<число>:', а у первого файла пары за номером
761
+ # идёт ' и '. Номер оставался в ключе, и сдвиг кода в первом файле читался как новое
762
+ # нарушение — ровно то, от чего храповик защищает.
763
+ RDIR="$(mktemp -d)"
764
+ printf '# реестр\na.tsx: и b.tsx: одинаковый кусок\n' > "$RDIR/reg.txt"
765
+ bash "$ROOT/kit/ratchet/ratchet.sh" "$RDIR/reg.txt" \
766
+ printf 'a.tsx:171 и b.tsx:188: одинаковый кусок\n' >/dev/null 2>&1; RC_SHIFT=$?
767
+ if [ "$RC_SHIFT" -eq 0 ]; then
768
+ ok "храповик: номер строки убран у обоих файлов пары"
769
+ else
770
+ bad "сдвиг строки в первом файле пары читается как новое нарушение" "код $RC_SHIFT"
771
+ fi
772
+ rm -rf "$RDIR"
773
+
774
+ # --- 44. манифест называет опечатку в имени поля -------------------------------
775
+ # ЗАЧЕМ. Разбор принимает любое имя поля. `gate:` вместо `gates:` молча означало «гейтов не
776
+ # объявлено»: вердикт выходил неверный, а причина не называлась. Человек шёл искать ошибку в
777
+ # проекте, а она была в одной букве манифеста. Тишина неотличима от успеха — тот самый класс,
778
+ # против которого построен стандарт, только внутри самой программы.
779
+ MDIR="$(mktemp -d)"
780
+ ( cd "$MDIR" && git init -q . && node "$CLI" init >/dev/null 2>&1 )
781
+ sed -i 's/^gates:/gate:/' "$MDIR/.aqk.yml"
782
+ OUT_TYPO="$( cd "$MDIR" && node "$CLI" doctor 2>&1 )"
783
+ OUT_OK="$( cd "$MDIR" && sed -i 's/^gate:/gates:/' .aqk.yml && node "$CLI" doctor 2>&1 )"
784
+ if printf '%s' "$OUT_TYPO" | grep -qE '(does not know|не знает).*gate' &&
785
+ ! printf '%s' "$OUT_OK" | grep -qE '(does not know|не знает)'; then
786
+ ok "манифест называет неизвестное поле и молчит на верном"
787
+ else
788
+ bad "опечатка в поле манифеста проходит молча" "$(printf '%s' "$OUT_TYPO" | grep -i 'know\|знает' | head -1)"
789
+ fi
790
+ rm -rf "$MDIR"
791
+
792
+ # --- 45. родной рецепт не читает то, что не читают переносимые -----------------
793
+ # ЗАЧЕМ. own_samples_filter знал про образцы гейтов и .aqkignore, но не про SKIP_NAMES:
794
+ # их применяли только переносимые проверки при обходе, а родному инструменту список не
795
+ # доставался вовсе. На живом проекте (Django + React, 2750 файлов кода) первой находкой
796
+ # duplicate-code оказались методички САМОГО комплекта в .aqk/docs — родной jscpd прошёлся
797
+ # по каталогу, который положил init. Вывод — 5597 строк. Такой гейт выключают целиком,
798
+ # ровно как сказано в шапке _skip.sh про 94% чужих находок.
799
+ NDIR="$(mktemp -d)"
800
+ mkdir -p "$NDIR/.aqk/docs" "$NDIR/node_modules/pkg" "$NDIR/src"
801
+ printf 'нарушение\n' > "$NDIR/.aqk/docs/guide.md"
802
+ printf 'нарушение\n' > "$NDIR/node_modules/pkg/index.js"
803
+ printf 'нарушение\n' > "$NDIR/src/mine.py"
804
+ # «Инструмент» печатает пути и возвращает отказ — как настоящий родной линтер.
805
+ # Пути в цветовых кодах — как их печатает jscpd: имя каталога идёт не после «/» и не с начала
806
+ # строки, а сразу за escape-последовательностью. Фильтр по границе пути их не видел, и на живом
807
+ # проекте вывод сократился с 5597 строк до 5505 — то есть не сократился.
808
+ cat > "$NDIR/fake-tool.sh" <<'EOT'
809
+ printf ' - \033[1m\033[32m.aqk/docs/guide.md:markdown\033[39m\033[22m [8:1 - 20:5]\n'
810
+ printf ' - \033[1m\033[32mnode_modules/pkg/index.js:javascript\033[39m\033[22m [1:1 - 9:2]\n'
811
+ printf ' - \033[1m\033[32msrc/mine.py:python\033[39m\033[22m [1:1 - 9:2]\n'
812
+ exit 1
813
+ EOT
814
+ OUT_N="$(cd "$NDIR" && sh "$ROOT/kit/gates/_native.sh" . sh ./fake-tool.sh 2>&1)"
815
+ if printf '%s' "$OUT_N" | grep -q 'src/mine.py' &&
816
+ ! printf '%s' "$OUT_N" | grep -q '\.aqk/docs' &&
817
+ ! printf '%s' "$OUT_N" | grep -q 'node_modules'; then
818
+ ok "родной рецепт молчит про .aqk и node_modules, но видит свой код"
819
+ else
820
+ bad "родной инструмент выдаёт то, что переносимые не читают" "$(printf '%s' "$OUT_N" | tr '\n' ' ')"
821
+ fi
822
+ rm -rf "$NDIR"
823
+
824
+ # --- 46. commit-explains-itself и синтетический merge-коммит -------------------
825
+ # ЗАЧЕМ. При разборе предложения изменений GitHub выкладывает не коммит автора, а синтетический
826
+ # merge-коммит с сообщением «Merge <sha> into <sha>». Гейт читал именно его, не находил разделов
827
+ # отчёта и краснел — на КАЖДОМ предложении изменений в КАЖДОМ проекте, куда его поставили.
828
+ # Поймано настоящим прогоном конвейера на этой же ветке, а не рассуждением.
829
+ CDIR="$(mktemp -d)"
830
+ (
831
+ cd "$CDIR" && git init -q . && git config user.email a@b.c && git config user.name a
832
+ printf 'один\n' > f.txt && git add -A
833
+ git commit -q -m "feat: первый" -m "Сделано: завёл файл" -m "Не уверен: ни в чём"
834
+ git checkout -q -b feature
835
+ printf 'два\n' >> f.txt && git add -A
836
+ git commit -q -m "feat: второй" -m "Сделано: дописал строку" -m "Не уверен: ни в чём"
837
+ git checkout -q master 2>/dev/null || git checkout -q main
838
+ # Ровно та форма сообщения, которую делает GitHub для ветки предложения изменений.
839
+ git merge -q --no-ff feature -m "Merge $(git rev-parse --short feature) into $(git rev-parse --short HEAD)"
840
+ ) >/dev/null 2>&1
841
+ OUT_M="$(bash "$ROOT/kit/gates/commit-explains-itself/check.sh" "$CDIR" 2>&1)"; RC_M=$?
842
+ # Вторая форма — та, что делает кнопка Merge на сайте. Слова другие, случай тот же: сообщение
843
+ # сочинил не автор. Шаблон «Merge … into …» её не ловил, и main покраснел бы после первого же
844
+ # вливания через кнопку.
845
+ CDIR2="$(mktemp -d)"
846
+ (
847
+ cd "$CDIR2" && git init -q . && git config user.email a@b.c && git config user.name a
848
+ printf 'один\n' > f.txt && git add -A
849
+ git commit -q -m "feat: первый" -m "Сделано: завёл файл" -m "Не уверен: ни в чём"
850
+ git checkout -q -b feature
851
+ printf 'два\n' >> f.txt && git add -A
852
+ git commit -q -m "feat: второй" -m "Сделано: дописал строку" -m "Не уверен: ни в чём"
853
+ git checkout -q master 2>/dev/null || git checkout -q main
854
+ git merge -q --no-ff feature -m "Merge pull request #15 from owner/feature"
855
+ ) >/dev/null 2>&1
856
+ bash "$ROOT/kit/gates/commit-explains-itself/check.sh" "$CDIR2" >/dev/null 2>&1; RC_PR=$?
857
+ rm -rf "$CDIR2"
858
+ if [ "$RC_M" -eq 0 ] && [ "$RC_PR" -eq 0 ]; then
859
+ ok "commit-explains-itself смотрит на коммит автора, а не на merge-коммит конвейера"
860
+ else
861
+ bad "гейт краснеет на слитом предложении изменений" "checkout-форма: $RC_M, кнопка Merge: $RC_PR"
862
+ fi
863
+ rm -rf "$CDIR"
864
+
865
+ # --- 47. doctor --baseline ставит галочки прогоном, а не по памяти -------------
866
+ # ЗАЧЕМ. Методичка про обязательный минимум — 50 пунктов — была единственным местом, где
867
+ # комплект просил верить на слово, что человек её прочитал и сверился. Ручной проход по живому
868
+ # проекту нашёл настоящее (логирование не задано, задачи конвейера не запускались ни разу),
869
+ # но дисциплина не масштабируется. Проверяем главное: значок ставит признак, а не автор.
870
+ BDIR2="$(mktemp -d)"
871
+ ( cd "$BDIR2" && git init -q . && node "$CLI" init >/dev/null 2>&1 )
872
+ OUT_EMPTY="$( cd "$BDIR2" && node "$CLI" doctor --baseline 2>&1 )"
873
+ # Кладём общепринятые признаки трёх РАЗНЫХ экосистем: нейтральность к стеку — условие, а не
874
+ # пожелание. Проверка, знающая только про npm, объявила бы половину мира несоответствующей.
875
+ printf 'x\n' > "$BDIR2/Cargo.lock"; printf 'x\n' > "$BDIR2/ruff.toml"; printf 'x\n' > "$BDIR2/Dockerfile"
876
+ OUT_FULL="$( cd "$BDIR2" && node "$CLI" doctor --baseline 2>&1 )"
877
+ BEFORE=$(printf '%s' "$OUT_EMPTY" | grep -c '✔' || true)
878
+ AFTER=$(printf '%s' "$OUT_FULL" | grep -c '✔' || true)
879
+ if printf '%s' "$OUT_FULL" | grep -qE 'cargo.lock' &&
880
+ printf '%s' "$OUT_FULL" | grep -qE 'ruff.toml' &&
881
+ printf '%s' "$OUT_FULL" | grep -qE 'dockerfile' &&
882
+ [ "$AFTER" -gt "$BEFORE" ]; then
883
+ ok "doctor --baseline засчитывает признаки разных экосистем и называет, чем подтверждено"
884
+ else
885
+ bad "baseline не видит признаков или не называет доказательство" "было ✔ $BEFORE, стало $AFTER"
886
+ fi
887
+ rm -rf "$BDIR2"
888
+
889
+ # --- 48. start не бросает установку из-за одной записи ------------------------
890
+ # ЗАЧЕМ. Записи вроде dead-code нужен настоящий инструмент; переносимого рецепта у неё нет.
891
+ # На машине без него установка ПАДАЛА целиком: человек получал три сторожа вместо двенадцати и
892
+ # ни слова про остальные девять. Найдено прогоном на Windows, где нет ни ruff, ни vulture.
893
+ # Воспроизводим без Windows: урезаем PATH до одного node — инструментов не видно так же.
894
+ NRDIR="$(mktemp -d)"; NRBIN="$(mktemp -d)"
895
+ 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 )
897
+ NR_OUT=$( cd "$NRDIR" && PATH="$NRBIN" node "$CLI" start 2>&1 ); NR_CODE=$?
898
+ NR_GATES=$( ls "$NRDIR/gates" 2>/dev/null | grep -cv '^_' )
899
+ if [ "$NR_CODE" -eq 0 ] && [ "$NR_GATES" -ge 5 ] &&
900
+ [ -f "$NRDIR/gates/no-print-in-prod/check.sh" ]; then
901
+ ok "start пропускает запись без пригодного инструмента и ставит остальные ($NR_GATES)"
902
+ else
903
+ bad "start бросил установку из-за одной записи" "код $NR_CODE, поставлено $NR_GATES, хвост: $(printf '%s' "$NR_OUT" | tail -2 | tr '\n' ' ')"
904
+ fi
905
+ rm -rf "$NRDIR" "$NRBIN"
906
+
907
+ # --- 49. выведенную запись не ставят, а называют преемника --------------------
908
+ # ЗАЧЕМ. Зрелость записи считается по доказательству, и объявить её нельзя — кроме одного
909
+ # состояния: `deprecated`. Оно объявляется, и весь его смысл в отказе: запись, которую всё ещё
910
+ # можно поставить одной командой, не выведена, а просто помечена. Проверяем сам отказ и то, что
911
+ # папка гейта в проекте НЕ появилась: половина установки хуже, чем её отсутствие.
912
+ #
913
+ # Каталог мутируем в КОПИИ пакета, а не в этом репозитории: проверка, которая правит собственные
914
+ # исходники, однажды упадёт посередине и оставит дерево грязным.
915
+ DEPKG="$(mktemp -d)"; DEPRJ="$(mktemp -d)"
916
+ cp -r "$ROOT/tool" "$ROOT/kit" "$ROOT/package.json" "$DEPKG/" 2>/dev/null
917
+ printf 'lifecycle: deprecated\nsuperseded_by: no-print-in-prod\n' >> "$DEPKG/kit/gates/todo-without-task/gate.yml"
918
+ ( cd "$DEPRJ" && git init -q . && printf 'x = 1\n' > a.py && node "$DEPKG/tool/program.mjs" init >/dev/null 2>&1 )
919
+ DE_OUT=$( cd "$DEPRJ" && node "$DEPKG/tool/program.mjs" add todo-without-task 2>&1 ); DE_CODE=$?
920
+ if [ "$DE_CODE" -ne 0 ] &&
921
+ printf '%s' "$DE_OUT" | grep -q 'no-print-in-prod' &&
922
+ [ ! -d "$DEPRJ/gates/todo-without-task" ]; then
923
+ ok "add отказывает в выведенной записи и называет ту, что её заменяет"
924
+ else
925
+ bad "выведенная запись установилась или преемник не назван" "код $DE_CODE, папка: $([ -d "$DEPRJ/gates/todo-without-task" ] && echo есть || echo нет)"
926
+ fi
927
+ rm -rf "$DEPKG" "$DEPRJ"
928
+
929
+ # --- 50. --since показывает только то, что внёс диф ---------------------------
930
+ # ЗАЧЕМ. Первый прогон в живом проекте показывает долг за все годы. Стену красного не разбирают
931
+ # — проверку выключают целиком. Проверяем три исхода разом: старый долг молчит, новый краснеет,
932
+ # а гейт, который печатает вердикт без путей, НЕ становится зелёным от того, что его нечем сузить.
933
+ SCDIR="$(mktemp -d)"
934
+ (
935
+ 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
937
+ node "$CLI" init >/dev/null 2>&1
938
+ node "$CLI" add no-print-in-prod >/dev/null 2>&1
939
+ git add -A && git commit -qm "база" >/dev/null 2>&1
940
+ printf 'def fresh():\n print("новый долг")\n' > src/fresh.py
941
+ )
942
+ SC_WIDE=$( cd "$SCDIR" && node "$CLI" doctor --run 2>&1 )
943
+ SC_NARROW=$( cd "$SCDIR" && node "$CLI" doctor --run --since HEAD 2>&1 )
944
+ if printf '%s' "$SC_WIDE" | grep -q 'old.py' &&
945
+ printf '%s' "$SC_NARROW" | grep -q 'fresh.py' &&
946
+ ! printf '%s' "$SC_NARROW" | grep -q 'old.py'; then
947
+ ok "--since прячет старый долг и показывает внесённый дифом"
948
+ else
949
+ bad "--since сузил не то" "широкий: $(printf '%s' "$SC_WIDE" | grep -c 'py:'), узкий: $(printf '%s' "$SC_NARROW" | grep -c 'py:')"
950
+ fi
951
+ # Несуществующая ссылка обязана быть отказом, а не тихим «сравнили с ничем».
952
+ SC_BAD=$( cd "$SCDIR" && node "$CLI" doctor --run --since net-takoy-vetki 2>&1 ); SC_BADCODE=$?
953
+ if [ "$SC_BADCODE" -ne 0 ] && printf '%s' "$SC_BAD" | grep -qi 'net-takoy-vetki'; then
954
+ ok "--since с несуществующей ссылкой — отказ, а не тихое сравнение с ничем"
955
+ else
956
+ bad "--since проглотил неверную ссылку" "код $SC_BADCODE"
957
+ fi
958
+ rm -rf "$SCDIR"
959
+
699
960
  # --- итог -------------------------------------------------------------------
700
961
  printf '\n'
701
962
  if [ "$FAIL" -eq 0 ]; then
@@ -15,8 +15,16 @@ BAD=0
15
15
  for F in $(git ls-files 'tool/*.mjs' 'tool/**/*.mjs' 2>/dev/null); do
16
16
  node --check "$F" || BAD=1
17
17
  done
18
+ # Разбираем тем интерпретатором, который объявлен в шебанге, а не всегда bash. `bash -n`
19
+ # принимает bash-измы («<<<», массивы, «local») в файле, который объявлен как `sh`, — и
20
+ # переносимость ломается молча, на чужой машине с dash. Так и вышло: четыре гейта, объявленные
21
+ # `#!/usr/bin/env sh`, получили «<<<» и прошли проверку синтаксиса зелёными.
18
22
  for F in $(git ls-files '*.sh' 2>/dev/null); do
19
- bash -n "$F" || BAD=1
23
+ case "$(head -1 "$F")" in
24
+ *bash*) SH=bash ;;
25
+ *) SH=sh ;;
26
+ esac
27
+ "$SH" -n "$F" || BAD=1
20
28
  done
21
29
 
22
30
  [ "$BAD" -eq 0 ] || echo " почини: файл не разбирается — до него не доедет ни одна другая проверка."
@@ -11,8 +11,10 @@
11
11
 
12
12
  import test from "node:test";
13
13
  import assert from "node:assert/strict";
14
- import { parseManifest, manifestWithGate } from "../lib/manifest.mjs";
14
+ import { parseManifest, manifestWithGate, unknownKeys, entryLifecycle } from "../lib/manifest.mjs";
15
15
  import { triggerVerdict, recipeFor, stems, overlap, EXT_LANG, whichSync } from "../lib/repo.mjs";
16
+ import { scopeOutput } from "../lib/scope.mjs";
17
+ import { assessBaseline, ITEMS, BASELINE_TOTAL } from "../lib/baseline.mjs";
16
18
  import { CATALOGS, pickLang, L } from "../i18n/index.mjs";
17
19
  import { badgeMarkdown, BADGE_RE, placesToCheck } from "../commands/badge.mjs";
18
20
  import { dirname } from "node:path";
@@ -199,3 +201,160 @@ test("значок ищется в точке входа и в README, без п
199
201
  assert.equal(places.filter((p) => p === "README.md").length, 1);
200
202
  assert.ok(placesToCheck({}).includes("README.md"), "без entry README всё равно проверяется");
201
203
  });
204
+
205
+ // ЗАЧЕМ. Опечатка в имени поля молча означала «поля нет»: `gate:` вместо `gates:` давало
206
+ // вердикт «гейтов не объявлено», а не «в манифесте опечатка». Тишина неотличима от успеха —
207
+ // тот самый дефект, ради которого весь стандарт существует, только внутри нас самих.
208
+ test("опечатка в поле манифеста называется, а не молчит", () => {
209
+ assert.deepEqual(unknownKeys(parseManifest("aqk: 1\ngate:\n smoke: \"bash x.sh\"\n")), ["gate"]);
210
+ assert.deepEqual(unknownKeys(parseManifest("aqk: 1\nrules: kit/rules\nlessons: incidents\n")), []);
211
+ // Пустой и отсутствующий манифест — не повод ругаться на поля.
212
+ assert.deepEqual(unknownKeys(null), []);
213
+ assert.deepEqual(unknownKeys({}), []);
214
+ });
215
+
216
+ // ЗАЧЕМ. Разбор резал строку по «#» безусловно, в том числе внутри кавычек. Команда с решёткой
217
+ // — `--format "...,c#,..."`, `grep '#!'`, любой цвет `#fff` — молча обрезалась, и гейт запускал
218
+ // НЕ ТУ команду, которая объявлена. Объявленное и исполняемое разошлись бы беззвучно: ровно
219
+ // тот класс, ради которого стандарт существует. Найдено при правке рецепта duplicate-code.
220
+ test("решётка внутри кавычек не считается комментарием", () => {
221
+ const m = parseManifest('gates:\n dup: "npx jscpd --format \"java,c#,php\" ."\n');
222
+ assert.equal(m.gates.dup, 'npx jscpd --format "java,c#,php" .');
223
+ // Настоящий комментарий после команды по-прежнему срезается.
224
+ const c = parseManifest('gates:\n x: "bash a.sh" # пояснение\n');
225
+ assert.equal(c.gates.x, "bash a.sh");
226
+ // И комментарий на отдельной строке.
227
+ assert.deepEqual(Object.keys(parseManifest("# только комментарий\naqk: 1\n")), ["aqk"]);
228
+ });
229
+
230
+ // ЗАЧЕМ. Пункты baseline проверяются НАЛИЧИЕМ признака, и признак обязан быть семейством, а не
231
+ // одним именем: список, знающий только про npm, объявил бы половину мира несоответствующей.
232
+ // Проверяем именно нейтральность — что пункт засчитывается по маркеру любой экосистемы.
233
+ test("baseline: признак засчитывается по любой экосистеме", () => {
234
+ const by = (files) => Object.fromEntries(assessBaseline({ files }).map((r) => [r.key, r]));
235
+ for (const lock of ["package-lock.json", "poetry.lock", "go.sum", "Cargo.lock", "Gemfile.lock", "composer.lock", "mix.lock"]) {
236
+ assert.equal(by([lock]).lockfile.ok, true, lock);
237
+ assert.deepEqual(by([lock]).lockfile.by, { kind: "file", value: lock.toLowerCase() });
238
+ }
239
+ for (const lint of [".eslintrc.json", "ruff.toml", ".golangci.yml", "clippy.toml", ".rubocop.yml", "phpstan.neon", ".swiftlint.yml"]) {
240
+ assert.equal(by([lint]).linter.ok, true, lint);
241
+ }
242
+ // Пустой репозиторий: ни одного признака, и ни одной ложной галочки.
243
+ assert.equal(assessBaseline({}).every((r) => r.ok === false), true);
244
+ });
245
+
246
+ test("baseline: гейт, факт и поле манифеста засчитываются наравне с файлом", () => {
247
+ const one = (arg) => Object.fromEntries(assessBaseline(arg).map((r) => [r.key, r]));
248
+ assert.equal(one({ gateKeys: ["secrets-not-in-code"] }).secretScan.ok, true);
249
+ assert.equal(one({ facts: { has_ci: true } }).pipeline.ok, true);
250
+ assert.equal(one({ manifest: { entry: ["AGENTS.md"] } }).machineReadable.ok, true);
251
+ assert.equal(one({ manifest: { entry: [] } }).machineReadable.ok, false);
252
+ assert.equal(one({ depsText: '"@sentry/node": "^7"' }).errorTracker.ok, true);
253
+ assert.equal(one({ depsText: "sentry-sdk==2.0" }).errorTracker.ok, true);
254
+ });
255
+
256
+ // Число пунктов в методичке — не выдумка кода: если методичка вырастет, а число останется,
257
+ // отчёт начнёт врать о том, сколько осталось человеку.
258
+ test("baseline: заявленное число пунктов совпадает с методичкой", async () => {
259
+ const { readFile } = await import("node:fs/promises");
260
+ const { fileURLToPath } = await import("node:url");
261
+ const doc = await readFile(fileURLToPath(new URL("../../kit/docs/ai/project-baseline.md", import.meta.url)), "utf8");
262
+ const nums = [...doc.matchAll(/^(\d+)\. \*\*/gm)].map((m) => Number(m[1]));
263
+ assert.equal(Math.max(...nums), BASELINE_TOTAL);
264
+ assert.equal(ITEMS.every((i) => i.n <= BASELINE_TOTAL), true);
265
+ });
266
+
267
+ // ЗАЧЕМ. Запись про внешний вид, показанная бэкенду или утилите командной строки, — это совет
268
+ // не по адресу; таким записям перестают верить, и вместе с ними всему каталогу. Признак
269
+ // интерфейса отличает проект со стилями от проекта на тех же языках без них.
270
+ test("триггер по интерфейсу отделяет фронтенд от бэкенда на том же языке", () => {
271
+ const rec = { trigger: { has_ui: true } };
272
+ const base = { langs: new Set(["typescript"]), files: 100, gateKeys: [] };
273
+ assert.equal(triggerVerdict(rec, { ...base, has_ui: true }).applies, true);
274
+ assert.equal(triggerVerdict(rec, { ...base, has_ui: false }).applies, false);
275
+ // Причина сокрытия называется, а не молчит: иначе «не показано» неотличимо от «нечего показать».
276
+ assert.equal(typeof triggerVerdict(rec, { ...base, has_ui: false }).why, "string");
277
+ });
278
+
279
+ // --- зрелость записи ---------------------------------------------------------
280
+ // ЗАЧЕМ. У всех трёх соседей поле зрелости есть, и у всех троих его ЗАПОЛНЯЕТ АВТОР: `lifecycle`
281
+ // у зондов Scorecard, `future`/`obsolete` у критериев значка OpenSSF. Поле, которое объявляет
282
+ // автор, означает доверие к автору, а не факт, — ровно то, против чего построен весь стандарт.
283
+ // Поэтому зрелость здесь ВЫЧИСЛЯЕТСЯ из доказательства, а объявить её нельзя.
284
+ test("зрелость записи считается по доказательству, а не по объявлению", () => {
285
+ const proven = entryLifecycle({ proof: "incidents/README.md, 2026-08-27 «печать в проде»" });
286
+ assert.equal(proven.state, "stable");
287
+ assert.equal(proven.problem, null);
288
+
289
+ const claimed = entryLifecycle({ proof: "это общепринятая хорошая практика" });
290
+ assert.equal(claimed.state, "experimental");
291
+ assert.equal(claimed.problem, null);
292
+ // Причина обязательна: «запись условная» без объяснения неотличимо от придирки.
293
+ assert.equal(typeof claimed.why, "string");
294
+ });
295
+
296
+ test("объявить себя зрелым нельзя — это самооценка", () => {
297
+ for (const claim of ["stable", "experimental"]) {
298
+ const r = entryLifecycle({ lifecycle: claim, proof: "incidents/README.md, 2026-01-01" });
299
+ assert.notEqual(r.problem, null);
300
+ // Вердикт всё равно считается сам: объявление не влияет ни на что, кроме отказа.
301
+ assert.equal(r.state, "stable");
302
+ }
303
+ assert.notEqual(entryLifecycle({ lifecycle: "beta", proof: "incidents/x" }).problem, null);
304
+ });
305
+
306
+ // Единственное состояние, которое ОБЪЯВЛЯЕТСЯ: из фактов записи «её больше не ставят» не
307
+ // выводится никак. Цена объявления — обязательная замена: запись, выведенная в никуда,
308
+ // оставляет человека без ответа на вопрос «а что теперь».
309
+ test("выведенная запись обязана назвать замену", () => {
310
+ const noReplacement = entryLifecycle({ lifecycle: "deprecated", proof: "incidents/x" });
311
+ assert.equal(noReplacement.state, "deprecated");
312
+ assert.notEqual(noReplacement.problem, null);
313
+
314
+ const ok = entryLifecycle({ lifecycle: "deprecated", superseded_by: "no-print-in-prod", proof: "incidents/x" });
315
+ assert.equal(ok.state, "deprecated");
316
+ assert.equal(ok.supersededBy, "no-print-in-prod");
317
+ assert.equal(ok.problem, null);
318
+ });
319
+
320
+
321
+ // --- сужение вывода до дифа --------------------------------------------------
322
+ // ЗАЧЕМ. Первый прогон на живом проекте даёт тысячи находок из кода, который писали годами.
323
+ // Человек видит стену красного и выключает инструмент целиком — это причина номер один, по
324
+ // которой такие проверки снимают. Три независимых проекта из нашего разбора умеют показывать
325
+ // только внесённое дифом (reviewdog, ratchets `--since`, четыре режима шума у react-doctor).
326
+ test("сужение по дифу: находка вне диапазона отбрасывается, внутри — остаётся", () => {
327
+ const files = new Set(["src/new.py"]);
328
+ const r = scopeOutput(["src/new.py:3: печать", "src/old.py:9: печать"], files);
329
+ assert.deepEqual(r.kept, ["src/new.py:3: печать"]);
330
+ assert.equal(r.findings, 1);
331
+ });
332
+
333
+ test("сужение по дифу: «./путь» и «путь» — один и тот же файл", () => {
334
+ const r = scopeOutput(["./src/new.py:3: печать", "src\\new.py:4: печать"], new Set(["src/new.py"]));
335
+ assert.equal(r.findings, 2);
336
+ });
337
+
338
+ // Тот же урок, что стоил починки в _native.sh: родные инструменты печатают путь ВНУТРИ
339
+ // escape-последовательности, и сравнение по границе пути его не видит. Тогда «вывод
340
+ // сократился с 5597 до 5505 строк» выглядело как работающая правка.
341
+ test("сужение по дифу: цвет снимается до сравнения путей", () => {
342
+ const esc = String.fromCharCode(27);
343
+ const line = esc + "[32m" + "src/new.py" + esc + "[0m" + ":3: печать";
344
+ assert.equal(scopeOutput([line], new Set(["src/new.py"])).findings, 1);
345
+ });
346
+
347
+ test("сужение по дифу: строка без пути остаётся, но находкой не считается", () => {
348
+ const r = scopeOutput(["Итого: 4 нарушения", "src/old.py:1: печать"], new Set(["src/new.py"]));
349
+ assert.equal(r.findings, 0);
350
+ assert.equal(r.kept.includes("Итого: 4 нарушения"), true);
351
+ });
352
+
353
+ // САМОЕ ВАЖНОЕ ЗДЕСЬ. Гейт, который печатает вердикт без путей (проверка коммита, проверка
354
+ // конфига конвейера), сузить дифом нельзя. Молча признать его успешным — это ровно та тишина,
355
+ // против которой построен весь стандарт, только теперь внутри нашего же флага.
356
+ test("сужение по дифу: гейт без путей в выводе не сужается и остаётся красным", () => {
357
+ const r = scopeOutput(["коммит не несёт раздела «Сделано:»"], new Set(["src/new.py"]));
358
+ assert.equal(r.scopable, false);
359
+ assert.equal(scopeOutput(["src/old.py:1: печать"], new Set(["src/new.py"])).scopable, true);
360
+ });