agent-quality-kit 0.8.0 → 0.9.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 (38) hide show
  1. package/README.md +92 -9
  2. package/README.ru.md +124 -23
  3. package/kit/docs/ai/project-baseline.md +14 -0
  4. package/kit/docs/ready-made-rules.md +103 -0
  5. package/kit/gates/color-from-token/check.sh +5 -1
  6. package/kit/gates/lesson-has-outcome/check.sh +5 -1
  7. package/kit/gates/mcp-server-resolves/README.md +62 -0
  8. package/kit/gates/mcp-server-resolves/check.sh +110 -0
  9. package/kit/gates/mcp-server-resolves/gate.yml +18 -0
  10. package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
  11. package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
  12. package/llms.txt +19 -4
  13. package/package.json +2 -6
  14. package/tool/commands/context.mjs +9 -5
  15. package/tool/commands/doctor.mjs +83 -14
  16. package/tool/commands/project.mjs +18 -2
  17. package/tool/commands/prove.mjs +1 -0
  18. package/tool/commands/vitals.mjs +159 -0
  19. package/tool/i18n/en-docs.mjs +40 -0
  20. package/tool/i18n/en.mjs +18 -0
  21. package/tool/i18n/index.mjs +36 -3
  22. package/tool/i18n/ru-docs.mjs +40 -0
  23. package/tool/i18n/ru.mjs +18 -0
  24. package/tool/lib/banner.mjs +59 -0
  25. package/tool/lib/brief.mjs +192 -0
  26. package/tool/lib/core.mjs +2 -0
  27. package/tool/lib/manifest.mjs +146 -15
  28. package/tool/lib/prove.mjs +11 -1
  29. package/tool/lib/repo.mjs +31 -1
  30. package/tool/program.mjs +26 -0
  31. package/tool/selfcheck/smoke.sh +350 -1
  32. package/tool/selfcheck/units-banner.mjs +65 -0
  33. package/tool/selfcheck/units-brief.mjs +97 -0
  34. package/tool/selfcheck/units-context.mjs +3 -1
  35. package/tool/selfcheck/units-level.mjs +147 -1
  36. package/tool/selfcheck/units-repo.mjs +134 -0
  37. package/tool/selfcheck/units-vitals.mjs +62 -0
  38. package/tool/selfcheck/units.mjs +3 -75
@@ -1437,6 +1437,355 @@ else
1437
1437
  fi
1438
1438
  rm -rf "$GENP"
1439
1439
 
1440
+ # --- 95. covers: запись закрыта другим арбитром, но только объявленным ---------
1441
+ # Просьба первого чужого пользователя, названная им первой: у него complexity-limit,
1442
+ # no-print-in-prod и swallowed-error держит biome, а doctor каждый прогон печатал «применимо,
1443
+ # но не поставлено: 5» — неправду. Неправда в СОБСТВЕННОМ выводе дороже всех прочих: весь
1444
+ # стандарт стоит на том, что вывод не врёт. Но поле не признание на слово: гейт, который
1445
+ # «закрывает», обязан быть объявлен в gates:, иначе covers становится способом объявить защиту,
1446
+ # которой нет. Проверяются оба конца сразу.
1447
+ COVP="$(mktemp -d)"
1448
+ ( cd "$COVP" && git init -q . && mkdir -p src rules &&
1449
+ printf 'const x=1;\n' > src/a.ts && printf '# вход\n' > AGENTS.md &&
1450
+ printf 'правило\n' > rules/r.md && printf '.x\n' > .gitignore &&
1451
+ printf 'aqk: 1\nentry: [AGENTS.md]\nrules: rules\ngates:\n lint: "true"\ncovers:\n lint: [no-print-in-prod, swallowed-error]\n biome: [duplicate-code]\n' > .aqk.yml ) >/dev/null 2>&1
1452
+ COV=$( cd "$COVP" && AQK_LANG=ru node "$CLI" doctor 2>&1 )
1453
+ if printf '%s' "$COV" | grep -q "закрыто другим арбитром 2" &&
1454
+ printf '%s' "$COV" | grep -q "covers называет гейт, которого нет" &&
1455
+ printf '%s' "$COV" | grep -q "biome" &&
1456
+ ! printf '%s' "$COV" | grep -q "неизвестное поле"; then
1457
+ ok "covers снимает запись с долга — и только когда закрывающий гейт объявлен"
1458
+ else
1459
+ bad "covers посчитан неверно" "$(printf '%s' "$COV" | grep -E 'Итого|covers' | head -3)"
1460
+ fi
1461
+ rm -rf "$COVP"
1462
+
1463
+ # --- 96. язык вывода — настройка проекта, а не машины -------------------------
1464
+ # Просьба первого чужого пользователя: «язык берётся из LC_ALL/LANG, а на Windows их просто нет:
1465
+ # русский проект получает английский вывод». Проверяются оба конца порядка сразу: манифест
1466
+ # сильнее локали, но переменная окружения сильнее манифеста — иначе у человека, набравшего
1467
+ # AQK_LANG=en руками, не остаётся способа получить английский.
1468
+ LNGP="$(mktemp -d)"
1469
+ ( cd "$LNGP" && git init -q . && printf '# вход\n' > AGENTS.md &&
1470
+ printf 'aqk: 1\nlang: ru\nentry: [AGENTS.md]\n' > .aqk.yml ) >/dev/null 2>&1
1471
+ BY_MAN=$( cd "$LNGP" && env -u AQK_LANG LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 node "$CLI" context 2>&1 | head -1 )
1472
+ BY_ENV=$( cd "$LNGP" && AQK_LANG=en node "$CLI" context 2>&1 | head -1 )
1473
+ if printf '%s' "$BY_MAN" | grep -q "состояние этого репозитория" &&
1474
+ printf '%s' "$BY_ENV" | grep -q "the state of this repository"; then
1475
+ ok "язык берётся из манифеста поверх локали, а переменная окружения — поверх манифеста"
1476
+ else
1477
+ bad "порядок выбора языка нарушен" "по манифесту: $BY_MAN | по окружению: $BY_ENV"
1478
+ fi
1479
+ rm -rf "$LNGP"
1480
+
1481
+ # --- 97. совет про браузер у агента — только тому, кому он нужен --------------
1482
+ # Решение владельца 2026-09-08: инструмент, дающий агенту браузер, надо рекомендовать. Но совет,
1483
+ # показанный не тому, стоит доверия всем остальным советам — та же норма, что у записей каталога.
1484
+ # Проверяются все три конца: интерфейс есть и сервера нет → совет; интерфейса нет → тишина;
1485
+ # сервер уже стоит → тишина.
1486
+ BRWP="$(mktemp -d)"
1487
+ ( cd "$BRWP" && git init -q . && mkdir -p src && printf 'body{color:#fff}\n' > src/a.css &&
1488
+ printf 'export const A=1\n' > src/a.tsx && printf '# вход\n' > AGENTS.md ) >/dev/null 2>&1
1489
+ WITH_UI=$( cd "$BRWP" && AQK_LANG=ru node "$CLI" doctor 2>&1 )
1490
+ ( cd "$BRWP" && printf '{"mcpServers":{"b":{"command":"npx","args":["-y","chrome-devtools-mcp@1.9.0"]}}}\n' > .mcp.json )
1491
+ WITH_SRV=$( cd "$BRWP" && AQK_LANG=ru node "$CLI" doctor 2>&1 )
1492
+ NOUIP="$(mktemp -d)"
1493
+ ( cd "$NOUIP" && git init -q . && printf 'print(1)\n' > a.py && printf '# вход\n' > AGENTS.md ) >/dev/null 2>&1
1494
+ NO_UI=$( cd "$NOUIP" && AQK_LANG=ru node "$CLI" doctor 2>&1 )
1495
+ if printf '%s' "$WITH_UI" | grep -q "нет браузера" &&
1496
+ ! printf '%s' "$WITH_SRV" | grep -q "нет браузера" &&
1497
+ ! printf '%s' "$NO_UI" | grep -q "нет браузера"; then
1498
+ ok "совет про браузер даётся проекту с интерфейсом и молчит, когда сервер уже есть"
1499
+ else
1500
+ bad "совет про браузер показан не тому" "с ui: $(printf '%s' "$WITH_UI" | grep -c 'нет браузера'), с сервером: $(printf '%s' "$WITH_SRV" | grep -c 'нет браузера'), без ui: $(printf '%s' "$NO_UI" | grep -c 'нет браузера')"
1501
+ fi
1502
+ rm -rf "$BRWP" "$NOUIP"
1503
+
1504
+ # --- 98. заявка covers сверяется, а не принимается на слово -------------------
1505
+ # Поле covers завели утром того же дня, и тогда же честно записали: оно снимает запись с долга
1506
+ # ПО СЛОВУ человека. К вечеру это перестало быть теорией: запуск на настоящем ruff.toml из живого
1507
+ # проекта показал девятнадцать групп правил в extend-select и НЕ пойманный print() — группы T20
1508
+ # среди них нет. Заявка «no-print-in-prod держит наш lint» была бы ложной, а запись ушла бы из
1509
+ # долга. Поле, снимающее неправду из вывода, само стало бы способом её произвести.
1510
+ CVUP="$(mktemp -d)"
1511
+ ( cd "$CVUP" && git init -q . && printf 'x=1\n' > a.py && printf '# вход\n' > AGENTS.md &&
1512
+ printf 'aqk: 1\nentry: [AGENTS.md]\ngates:\n lint: "ruff check ."\ncovers:\n lint: [no-print-in-prod]\n' > .aqk.yml ) >/dev/null 2>&1
1513
+ ( cd "$CVUP" && printf 'extend-select = ["I","B","UP","SIM"]\n' > ruff.toml )
1514
+ WITHOUT=$( cd "$CVUP" && AQK_LANG=ru node "$CLI" doctor 2>&1 )
1515
+ ( cd "$CVUP" && printf 'extend-select = ["I","B","UP","SIM","T20"]\n' > ruff.toml )
1516
+ WITHT20=$( cd "$CVUP" && AQK_LANG=ru node "$CLI" doctor 2>&1 )
1517
+ if printf '%s' "$WITHOUT" | grep -q "заявка не подтверждена" &&
1518
+ printf '%s' "$WITHOUT" | grep -q "T20" &&
1519
+ ! printf '%s' "$WITHT20" | grep -q "заявка не подтверждена"; then
1520
+ ok "covers сверяется с кодами правил: без T20 говорит вслух, с T20 молчит"
1521
+ else
1522
+ bad "сверка заявки covers не работает" "без T20: $(printf '%s' "$WITHOUT" | grep -c 'не подтверждена'), с T20: $(printf '%s' "$WITHT20" | grep -c 'не подтверждена')"
1523
+ fi
1524
+ rm -rf "$CVUP"
1525
+
1526
+ # --- 99. краткий режим: присутствие видно, совет не надоедает -----------------
1527
+ # Хук pre-commit молчит на успехе — это его умолчание. Комплект, который всё держит, для
1528
+ # человека неотличим от невставленного: «скачал и че дальше» — дословная жалоба владельца.
1529
+ # Это ровно тот порок, против которого написан комплект, только у нас самих.
1530
+ # Проверяются три вещи сразу: строка есть всегда; совет приходит один раз, а не на каждый
1531
+ # прогон; переменная его выключает.
1532
+ BRFP="$(mktemp -d)"
1533
+ ( cd "$BRFP" && git init -q . && printf 'x=1\n' > a.py && printf '# вход\n' > AGENTS.md &&
1534
+ printf '.x\n' > .gitignore && mkdir -p rules .aqk/docs && printf 'п\n' > rules/r.md &&
1535
+ printf 'aqk: 1\nentry: [AGENTS.md]\nrules: rules\ngates:\n ok: "true"\n' > .aqk.yml ) >/dev/null 2>&1
1536
+ B1=$( cd "$BRFP" && AQK_LANG=ru node "$CLI" doctor --run --min 1 --brief 2>&1 ); B1C=$?
1537
+ B2=$( cd "$BRFP" && AQK_LANG=ru node "$CLI" doctor --run --min 1 --brief 2>&1 )
1538
+ rm -f "$BRFP/.aqk/advice-shown"
1539
+ B3=$( cd "$BRFP" && AQK_ADVICE=0 AQK_LANG=ru node "$CLI" doctor --run --min 1 --brief 2>&1 )
1540
+ if [ "$B1C" -eq 0 ] &&
1541
+ printf '%s' "$B1" | grep -qE "^(❖ )?AQK держит" &&
1542
+ printf '%s' "$B1" | grep -q "поставить:" &&
1543
+ printf '%s' "$B2" | grep -qE "^(❖ )?AQK держит" &&
1544
+ ! printf '%s' "$B2" | grep -q "поставить:" &&
1545
+ ! printf '%s' "$B3" | grep -q "поставить:" &&
1546
+ [ "$(printf '%s\n' "$B2" | wc -l)" -le 2 ]; then
1547
+ ok "краткий режим: строка есть всегда, совет один раз в сутки и выключается"
1548
+ else
1549
+ bad "краткий режим ведёт себя не так" "первый: $(printf '%s' "$B1" | head -1) · второй строк: $(printf '%s\n' "$B2" | wc -l)"
1550
+ fi
1551
+ rm -rf "$BRFP"
1552
+
1553
+ # --- 100. в awk-программах гейтов нет повторителей {n} ------------------------
1554
+ # Часть сборок mawk не понимает повторители в регулярках. Условие с {4} не
1555
+ # совпадает НИКОГДА, и гейт молча выходит с нулём на собственном красном образце: ровно тот
1556
+ # отказ, против которого написан комплект. Поймано сборкой docker-образа 2026-09-08 — на хосте
1557
+ # gawk, и ни один прогон не краснел. Проверка статическая и потому дешёвая; настоящий прогон
1558
+ # под старым awk делает задание `image` в конвейере — там образ, в котором дефект и нашёлся.
1559
+ # ВАЖНО, ЧТО ИМЕННО СБОРКА: mawk 1.3.4 20240123 (ubuntu-latest, эта машина) повторители
1560
+ # ПОНИМАЕТ, mawk 1.3.4 20200120 (node:22-slim) — нет. Замерено 2026-09-09. Поэтому «прогнать
1561
+ # под mawk» ничего не значит без указания, под каким,
1562
+ # а прочитать исходники — можем.
1563
+ AWKBAD=""
1564
+ for G in "$ROOT"/kit/gates/*/check.sh "$ROOT"/kit/gates/_skip.sh; do
1565
+ [ -f "$G" ] || continue
1566
+ # Только строки внутри awk-программ; комментарии оболочки (#) не считаем.
1567
+ # Только регулярки AWK: `t ~ /…/` и образец-действие `/…/ {`. Шаблоны для grep, лежащие в
1568
+ # переменных оболочки, сюда не попадают намеренно — GNU grep и busybox grep повторители
1569
+ # понимают оба, и краснеть на них значит краснеть на нормальном укладе.
1570
+ HIT=$(sed 's/^[[:space:]]*#.*$//' "$G" | grep -nE '(~[[:space:]]*/|^[[:space:]]*/)' \
1571
+ | grep -E '\{[0-9]+(,[0-9]*)?\}' | head -2)
1572
+ [ -n "$HIT" ] && AWKBAD="${AWKBAD:+$AWKBAD; }$(basename "$(dirname "$G")"): $(printf '%s' "$HIT" | head -1 | cut -c1-60)"
1573
+ done
1574
+ if [ -z "$AWKBAD" ]; then
1575
+ ok "в проверках каталога нет повторителей {n} — они не работают под mawk"
1576
+ else
1577
+ bad "повторитель {n} в проверке: под mawk она молчит" "$AWKBAD"
1578
+ fi
1579
+
1580
+ # --- 101. проверка версии: раз в сутки, не в конвейере, молча при отказе ------
1581
+ # До этой строки комплект не делал ни одного исходящего запроса. Раз делает — обязан делать
1582
+ # ровно то, что о нём написано: спрашивать реестр не чаще раза в сутки и НЕ спрашивать в
1583
+ # конвейере. Само наличие новой версии здесь не проверяется — она зависит от реестра, а
1584
+ # проверка, зависящая от чужого сервера, краснеет по чужой воле. Сравнение версий проверено
1585
+ # модульно; здесь — то, что вокруг него.
1586
+ UPDP="$(mktemp -d)"
1587
+ ( cd "$UPDP" && git init -q . && printf 'x=1\n' > a.py && printf '# вход\n' > AGENTS.md &&
1588
+ printf '.x\n' > .gitignore && mkdir -p rules .aqk/docs && printf 'п\n' > rules/r.md &&
1589
+ printf 'aqk: 1\nentry: [AGENTS.md]\nrules: rules\ngates:\n ok: "true"\n' > .aqk.yml ) >/dev/null 2>&1
1590
+ ( cd "$UPDP" && env -u CI -u GITHUB_ACTIONS AQK_LANG=ru node "$CLI" doctor --run --min 1 --brief ) >/dev/null 2>&1
1591
+ ASKED=$([ -f "$UPDP/.aqk/update-checked" ] && echo да || echo нет)
1592
+ rm -f "$UPDP/.aqk/update-checked"
1593
+ ( cd "$UPDP" && CI=true AQK_LANG=ru node "$CLI" doctor --run --min 1 --brief ) >/dev/null 2>&1
1594
+ IN_CI=$([ -f "$UPDP/.aqk/update-checked" ] && echo да || echo нет)
1595
+ rm -f "$UPDP/.aqk/update-checked"
1596
+ ( cd "$UPDP" && env -u CI AQK_UPDATE=0 AQK_LANG=ru node "$CLI" doctor --run --min 1 --brief ) >/dev/null 2>&1
1597
+ OFF=$([ -f "$UPDP/.aqk/update-checked" ] && echo да || echo нет)
1598
+ # «нет сети» — тоже допустимый исход первого случая: молчание при отказе и есть требование.
1599
+ if [ "$IN_CI" = "нет" ] && [ "$OFF" = "нет" ]; then
1600
+ ok "версия спрашивается вне конвейера ($ASKED), не спрашивается в конвейере и при AQK_UPDATE=0"
1601
+ else
1602
+ bad "проверка версии спрашивает там, где не должна" "вне CI: $ASKED, в CI: $IN_CI, выключено: $OFF"
1603
+ fi
1604
+ rm -rf "$UPDP"
1605
+
1606
+ # --- 102. строка манифеста, которую разбор не понял, называется вслух ---------
1607
+ # Найдено случайно 2026-09-09: подсадили падающий гейт с именем «плохой», чтобы посмотреть на
1608
+ # вывод, — прогон вышел с НУЛЁМ. Гейт не упал: его не существовало. Разбор берёт имена только
1609
+ # латиницей, а строку, не подошедшую под это, выбрасывал без единого слова. Человек видит
1610
+ # проверку в файле, а её нет — наш класс в чистом виде, и хуже опечатки в имени поля: ту мы
1611
+ # называем с 2026-09-06, а эту не называли вовсе.
1612
+ UNPP="$(mktemp -d)"
1613
+ ( cd "$UNPP" && git init -q . && printf '# вход\n' > AGENTS.md &&
1614
+ printf 'aqk: 1\nentry: [AGENTS.md]\nlang: ru\ngates:\n ok: "true"\n плохой: "false"\n' > .aqk.yml ) >/dev/null 2>&1
1615
+ UNP=$( cd "$UNPP" && node "$CLI" doctor 2>&1 )
1616
+ ( cd "$UNPP" && printf 'aqk: 1\nentry: [AGENTS.md]\nlang: ru\ngates:\n ok: "true"\n bad: "false"\n' > .aqk.yml )
1617
+ OKM=$( cd "$UNPP" && node "$CLI" doctor 2>&1 )
1618
+ if printf '%s' "$UNP" | grep -q "НЕ ДЕЙСТВУЕТ" &&
1619
+ printf '%s' "$UNP" | grep -q "строка 6" &&
1620
+ ! printf '%s' "$OKM" | grep -q "НЕ ДЕЙСТВУЕТ"; then
1621
+ ok "непонятая строка манифеста называется с номером, понятая — молчит"
1622
+ else
1623
+ bad "потерянная строка манифеста не названа" "$(printf '%s' "$UNP" | grep -c 'НЕ ДЕЙСТВУЕТ') на кириллице, $(printf '%s' "$OKM" | grep -c 'НЕ ДЕЙСТВУЕТ') на латинице"
1624
+ fi
1625
+ rm -rf "$UNPP"
1626
+
1627
+ # --- 103. vitals: отказ роняет, выбор и незнание — нет ------------------------
1628
+ # `doctor` смотрит на репозиторий, `prove` — на гейты, `context` — на состояние. На саму
1629
+ # обвязку не смотрел никто: стоят ли инструменты объявленных гейтов, прописан ли хук в
1630
+ # .git/hooks НА САМОМ ДЕЛЕ. Раньше это выяснялось красным гейтом посреди коммита.
1631
+ # Первая версия ставила крест хуку, которого нет, — и на нашем же репозитории вышло два креста
1632
+ # за сознательное решение (pre-commit локально не ставим, гоняем в CI). Команда, которая кричит
1633
+ # «сломано» про выбор, перестаёт читаться вместе с настоящими отказами. Проверяются оба конца.
1634
+ VITP="$(mktemp -d)"
1635
+ ( cd "$VITP" && git init -q . && printf '# вход\n' > AGENTS.md &&
1636
+ printf 'aqk: 1\nentry: [AGENTS.md]\nlang: ru\ngates:\n ok: "true"\n' > .aqk.yml ) >/dev/null 2>&1
1637
+ VOK=$( cd "$VITP" && AQK_LANG=ru node "$CLI" vitals 2>&1 ); VOK_C=$?
1638
+ ( cd "$VITP" && printf 'aqk: 1\nentry: [AGENTS.md]\nlang: ru\ngates:\n lint: "инструментакоторогонет ."\n' > .aqk.yml )
1639
+ VBAD=$( cd "$VITP" && AQK_LANG=ru node "$CLI" vitals 2>&1 ); VBAD_C=$?
1640
+ if [ "$VOK_C" -eq 0 ] && [ "$VBAD_C" -eq 1 ] &&
1641
+ printf '%s' "$VBAD" | grep -q "НЕ НАЙДЕНЫ" &&
1642
+ printf '%s' "$VOK" | grep -q "хук pre-commit"; then
1643
+ ok "vitals: пропавший инструмент роняет прогон, отсутствие хука — нет"
1644
+ else
1645
+ bad "vitals путает отказ с выбором" "исправный код $VOK_C, сломанный код $VBAD_C"
1646
+ fi
1647
+ rm -rf "$VITP"
1648
+
1649
+ # --- 106. совещательный гейт назван всегда и не роняет прогон никогда ---------
1650
+ # Два отказа, найденные 2026-09-09 сверкой документации с кодом. ПЕРВЫЙ: зелёный совещательный
1651
+ # печатался обычной галочкой — гейт, который уронить сборку НЕ МОЖЕТ, по выводу неотличим от
1652
+ # того, который может, и список `advisory:` был виден только в день, когда он покраснел. Ровно
1653
+ # та тишина, против которой написан стандарт, только про сам прибор. ВТОРОЙ: при `--since` гейт,
1654
+ # чей вывод не содержит путей, шёл через ветку «сузить нечем» с безусловным failed++ — то есть
1655
+ # совещательный ронял прогон. Проверено сравнением с кодом до починки: было 1, стало 0.
1656
+ # Команда гейта — ФАЙЛ, а не строка с `;`. На windows `spawnSync(shell: true)` запускает
1657
+ # cmd.exe, а не sh: там `echo x; exit 1` печатается целиком и выходит с НУЛЁМ. Первая версия
1658
+ # этой проверки так и провалилась в конвейере на windows — фикстура, а не код. Заодно урок про
1659
+ # сам тест: утверждение «зелёный помечен» искало подстроку «совещательный», которая есть и в
1660
+ # пометке КРАСНОГО совещательного, — то есть проходило бы и при полном отсутствии починки.
1661
+ # Теперь ищется текст, который бывает только на зелёной строке.
1662
+ ADVP="$(mktemp -d)"
1663
+ (
1664
+ cd "$ADVP" && git init -q . && git config user.email a@b && git config user.name a
1665
+ printf '# вход\n' > AGENTS.md && mkdir -p r && printf 'x\n' > r/a.md
1666
+ printf '#!/bin/sh\necho "вердикт без путей"\nexit 1\n' > noscope.sh
1667
+ printf '#!/bin/sh\nexit 0\n' > quiet.sh
1668
+ printf 'aqk: 1\nentry: [AGENTS.md]\nrules: r\ngates:\n adv: "bash quiet.sh"\nadvisory:\n - adv\n' > .aqk.yml
1669
+ git add -A && git commit -qm "Сделано: основа. Не уверен: ничего"
1670
+ ) >/dev/null 2>&1
1671
+ ADVBASE=$( cd "$ADVP" && git rev-parse --abbrev-ref HEAD )
1672
+ ( cd "$ADVP" && git checkout -qb feat && printf 'y\n' > r/b.md && git add -A &&
1673
+ git commit -qm "Сделано: файл. Не уверен: ничего" ) >/dev/null 2>&1
1674
+ ADVGREEN=$( cd "$ADVP" && AQK_LANG=ru node "$CLI" doctor --run --min 1 2>&1 )
1675
+ ( cd "$ADVP" && printf 'aqk: 1\nentry: [AGENTS.md]\nrules: r\ngates:\n adv: "bash noscope.sh"\nadvisory:\n - adv\n' > .aqk.yml )
1676
+ ( cd "$ADVP" && node "$CLI" doctor --run --min 1 --since "$ADVBASE" >/dev/null 2>&1 ); ADV_C=$?
1677
+ ( cd "$ADVP" && printf 'aqk: 1\nentry: [AGENTS.md]\nrules: r\ngates:\n adv: "bash noscope.sh"\n' > .aqk.yml )
1678
+ ( cd "$ADVP" && node "$CLI" doctor --run --min 1 --since "$ADVBASE" >/dev/null 2>&1 ); BLOCK_C=$?
1679
+ if printf '%s' "$ADVGREEN" | grep -q "уронить прогон не может" && [ "$ADV_C" -eq 0 ] && [ "$BLOCK_C" -eq 1 ]; then
1680
+ ok "совещательный назван и на зелёном, и не роняет прогон даже когда сузить нечем"
1681
+ else
1682
+ bad "совещательный гейт неотличим или роняет прогон" "зелёный помечен: $(printf '%s' "$ADVGREEN" | grep -c 'уронить прогон не может'), код совещательного $ADV_C, код блокирующего $BLOCK_C"
1683
+ fi
1684
+ rm -rf "$ADVP"
1685
+
1686
+ # --- 107. prove пропускает запись без её программы, а не обвиняет её ----------
1687
+ # ТО ЖЕ, ЧТО НАШЁЛ ВТОРОЙ ПОЛЬЗОВАТЕЛЬ, ТОЛЬКО ДРУГОЙ ДВЕРЬЮ. Переносимый рецепт бывает обёрткой
1688
+ # вокруг готового инструмента: первое слово команды тогда `bash`, и по нему не видно, чего не
1689
+ # хватает. Без программы обёртка краснеет на ОБОИХ образцах, и prove объявлял исправный гейт
1690
+ # сломанным — «краснеет на исправном коде». От prove зависят ступень AQK-2 и значок, то есть
1691
+ # отсутствие чужой программы отбирало у проекта уровень. Обвинение вместо диагноза.
1692
+ # Приёмка (gates.sh) и мутационная проверка поле `requires` читают давно; prove — не читал.
1693
+ # Проверено 2026-09-09 на своём же репозитории: не стояли slopcheck и zizmor, prove дал два
1694
+ # обвинения и код 1. Проверяются ОБА конца: без поля — обвинение и код 1, с полем — пропуск и 0.
1695
+ PRVP="$(mktemp -d)"
1696
+ (
1697
+ cd "$PRVP" && git init -q . && printf '# вход\n' > AGENTS.md && mkdir -p r s/wrap/red s/wrap/green
1698
+ printf 'x\n' > s/wrap/red/a.txt && printf 'x\n' > s/wrap/green/a.txt
1699
+ # Обёртка вокруг программы, которой на машине нет: краснеет на обоих образцах.
1700
+ printf '#!/bin/sh\nпрограммыкоторойнет "$1" || exit 2\n' > wrap.sh
1701
+ # Настоящая проверка: prove требует, чтобы хоть один гейт был ДОКАЗАН, иначе проект,
1702
+ # у которого всё недоказуемо, получал бы уровень ни за что. Команда обязана кончаться
1703
+ # каталогом — иначе подставлять образец некуда, и гейт сам станет недоказуемым.
1704
+ printf '#!/bin/sh\ngrep -rq НЕЛЬЗЯ "$1" && exit 1\nexit 0\n' > real.sh
1705
+ printf 'aqk: 1\nentry: [AGENTS.md]\nrules: r\nsamples: s\ngates:\n wrap: "sh wrap.sh ."\n real: "sh real.sh ."\n' > .aqk.yml
1706
+ ) >/dev/null 2>&1
1707
+ # Второй гейт обязан быть доказуемым: prove требует хотя бы одного доказанного.
1708
+ ( cd "$PRVP" && mkdir -p s/real/red s/real/green &&
1709
+ printf 'НЕЛЬЗЯ\n' > s/real/red/a.txt && printf 'можно\n' > s/real/green/a.txt )
1710
+ PRV_NOREQ=$( cd "$PRVP" && AQK_LANG=ru node "$CLI" prove 2>&1 ); PRV_NOREQ_C=$?
1711
+ ( cd "$PRVP" && printf 'intent: обёртка\nrequires: программыкоторойнет\nrecipes:\n any: "sh wrap.sh {dir}"\n' > s/wrap/gate.yml )
1712
+ PRV_REQ=$( cd "$PRVP" && AQK_LANG=ru node "$CLI" prove 2>&1 ); PRV_REQ_C=$?
1713
+ # Тот же вопрос задаёт `vitals`, и до 2026-09-09 он отвечал на него иначе: печатал «все
1714
+ # инструменты на месте», потому что смотрел только на ПЕРВОЕ СЛОВО команды — `sh`, который есть
1715
+ # всегда. Две команды об одном репозитории говорили разное; читатель верил той, что зеленее.
1716
+ PRV_VIT=$( cd "$PRVP" && AQK_LANG=ru node "$CLI" vitals 2>&1 )
1717
+ if [ "$PRV_NOREQ_C" -ne 0 ] && [ "$PRV_REQ_C" -eq 0 ] &&
1718
+ printf '%s' "$PRV_REQ" | grep -q "НЕ ПРОВЕРЕНА здесь" &&
1719
+ printf '%s' "$PRV_VIT" | grep -q "программыкоторойнет"; then
1720
+ ok "prove и vitals одинаково видят программу из requires: пропуск, а не обвинение"
1721
+ else
1722
+ bad "prove путает «нечем проверить» со «сломан», либо vitals её не видит" \
1723
+ "без поля код $PRV_NOREQ_C, с полем код $PRV_REQ_C, vitals назвал: $(printf '%s' "$PRV_VIT" | grep -c 'программыкоторойнет')"
1724
+ fi
1725
+ rm -rf "$PRVP"
1726
+
1727
+ # --- 104. опись в AGENTS.md покрывает все исходники ---------------------------
1728
+ # AGENTS.md — первое, что читает агент, и раздел «Где что лежит» для него карта. Карта,
1729
+ # отставшая от дерева, хуже её отсутствия: агент уверен, что видел всё. Проверено 2026-09-09 —
1730
+ # в описи не было восьми файлов из четырнадцати добавленных за неделю (`brief.mjs`, `banner.mjs`
1731
+ # и шесть `units-*.mjs`), и заметить это можно было только сверкой руками. Тот же класс, что
1732
+ # «гейт объявлен и не существует»: расхождение молчит.
1733
+ MISSING=""
1734
+ for F in "$ROOT"/tool/lib/*.mjs "$ROOT"/tool/commands/*.mjs "$ROOT"/tool/selfcheck/*; do
1735
+ B=$(basename "$F")
1736
+ grep -qF "$B" "$ROOT/AGENTS.md" || MISSING="$MISSING $B"
1737
+ done
1738
+ if [ -z "$MISSING" ]; then
1739
+ ok "опись «Где что лежит» называет каждый исходник"
1740
+ else
1741
+ bad "AGENTS.md не называет файлы" "$MISSING"
1742
+ fi
1743
+
1744
+ # --- 105. два README не расходятся структурно --------------------------------
1745
+ # AGENTS.md требует, чтобы README и его перевод не расходились, и до сегодня требование не
1746
+ # работало: на 2026-09-09 в английском было 23 раздела, в русском 22 — «Первый прогон на
1747
+ # настоящем проекте» отсутствовал целиком, а «Как ввести правило» стояло в другом разделе.
1748
+ # Читатель одного языка получал инструкцию, которой у читателя другого не было. Сверяется
1749
+ # ПОСЛЕДОВАТЕЛЬНОСТЬ уровней, а не тексты: заголовки на разных языках сравнивать нельзя.
1750
+ # Строки внутри ``` не считаются: в примере .aqkignore есть строка, начинающаяся с #.
1751
+ levels() {
1752
+ awk '/^```/ { inf = !inf; next } !inf && /^#/ { sub(/[^#].*/, ""); print }' "$1"
1753
+ }
1754
+ if [ "$(levels "$ROOT/README.md")" = "$(levels "$ROOT/README.ru.md")" ]; then
1755
+ ok "README.md и README.ru.md несут одни и те же разделы ($(levels "$ROOT/README.md" | wc -l | tr -d ' '))"
1756
+ else
1757
+ bad "README и перевод разошлись структурно" \
1758
+ "англ. $(levels "$ROOT/README.md" | wc -l | tr -d ' '), рус. $(levels "$ROOT/README.ru.md" | wc -l | tr -d ' ')"
1759
+ fi
1760
+
1761
+ # --- 108. init доходит до конца, когда дом недоступен для записи ---------------
1762
+ # НАШЁЛ КОНВЕЙЕР, А НЕ ПРОГОН. Одноразовая пометка «просьбу про звезду уже показывали» пишется
1763
+ # в $HOME/.config/aqk. В контейнере, запущенном `--user 1001:127`, у этого uid нет записи в
1764
+ # /etc/passwd, homedir() даёт «/», и mkdir падает с EACCES на «/.config» — роняя ВЕСЬ init.
1765
+ # То есть любой, кто набрал команду из нашей же документации по docker, получал падение вместо
1766
+ # установки. Локально не воспроизводилось по случайности: uid разработчика 1000 совпадает с
1767
+ # пользователем `node` в образе, у которого дом есть; на раннере uid 1001 — не совпадает.
1768
+ # Удобство не имеет права ронять работу команды. Молчать о нём тоже нельзя — это swallowed-error.
1769
+ HOMEP="$(mktemp -d)"; HOMEH="$(mktemp -d)"
1770
+ chmod 500 "$HOMEH" 2>/dev/null
1771
+ # На windows chmod прав не отнимает: там эта проверка ничего не проверяет и говорит об этом,
1772
+ # а не зеленеет молча. Тот же приём, что «НЕ ПРОВЕРЕНА здесь» в приёмке каталога.
1773
+ if ( : > "$HOMEH/проба" ) 2>/dev/null; then
1774
+ rm -f "$HOMEH/проба"
1775
+ ok "init при недоступном доме — НЕ ПРОВЕРЕНА здесь: chmod прав не отнимает"
1776
+ else
1777
+ ( cd "$HOMEP" && git init -q . ) >/dev/null 2>&1
1778
+ HOMEOUT=$( cd "$HOMEP" && HOME="$HOMEH" AQK_LANG=ru node "$CLI" init 2>&1 ); HOME_C=$?
1779
+ if [ "$HOME_C" -eq 0 ] && [ -f "$HOMEP/.aqk.yml" ] &&
1780
+ printf '%s' "$HOMEOUT" | grep -q "запомнить не удалось"; then
1781
+ ok "init доходит до конца при недоступном доме и называет, чего не смог"
1782
+ else
1783
+ bad "init падает или молчит, когда дом недоступен для записи" \
1784
+ "код $HOME_C, манифест $([ -f "$HOMEP/.aqk.yml" ] && echo есть || echo нет)"
1785
+ fi
1786
+ fi
1787
+ chmod 700 "$HOMEH" 2>/dev/null; rm -rf "$HOMEP" "$HOMEH"
1788
+
1440
1789
  # --- итог -------------------------------------------------------------------
1441
1790
  printf '\n'
1442
1791
  if [ "$FAIL" -eq 0 ]; then
@@ -1445,5 +1794,5 @@ else
1445
1794
  printf ' \033[31mпровалено: %s из %s\033[0m\n\n' "$FAIL" "$((PASS + FAIL))"
1446
1795
  fi
1447
1796
 
1448
- printf ' \033[2mне покрыто: содержание документов, установка с GitHub через npx\033[0m\n\n'
1797
+ printf ' \033[2mне покрыто: СОДЕРЖАНИЕ документов (сверяется опись и структура, не текст),\n установка с GitHub через npx\033[0m\n\n'
1449
1798
  exit "$FAIL"
@@ -0,0 +1,65 @@
1
+ // tool/selfcheck/units-banner.mjs — заставка: первая и почти единственная встреча с человеком.
2
+ //
3
+ // ЗАЧЕМ ПРОВЕРЯТЬ КАРТИНКУ. Не ради красоты. Заставка шириной больше окна разъезжается в кашу
4
+ // и портит именно то впечатление, ради которого её и добавили. А в терминале без UTF-8 графика
5
+ // Брайлем превращается в вопросительные знаки — и человек решает, что инструмент сломан.
6
+ //
7
+ // node --test tool/selfcheck/units-banner.mjs
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { banner, BANNER_WIDTH } from "../lib/banner.mjs";
11
+
12
+ test("заставка влезает в узкое окно", () => {
13
+ const w = Math.max(...banner().split("\n").map((l) => [...l].length));
14
+ assert.ok(w <= 40, `ширина ${w}, а бывают окна и в 40 колонок`);
15
+ assert.equal(w, BANNER_WIDTH, "объявленная ширина обязана совпадать с настоящей");
16
+ });
17
+
18
+ // Версии в заставке НЕТ намеренно — решение владельца: выпуски частые, и номер в картинке
19
+ // устаревает быстрее всего остального. За версией есть `--version`, она печатается отдельно.
20
+ test("версии в заставке нет", () => {
21
+ assert.doesNotMatch(banner(), /\d+\.\d+\.\d+/);
22
+ });
23
+
24
+ // Терминал без UTF-8 превратит Брайль в мусор. Тогда честнее короткая строка, чем каша,
25
+ // по которой человек решит, что инструмент сломан.
26
+ test("без UTF-8 вместо графики короткая строка", () => {
27
+ const plain = banner({ LANG: "C" });
28
+ assert.doesNotMatch(plain, /[⣿█]/);
29
+ assert.match(plain, /aqk/i);
30
+ });
31
+
32
+ test("отказ от графики уважается переменной", () => {
33
+ assert.doesNotMatch(banner({ LANG: "ru_RU.UTF-8", AQK_NO_ART: "1" }), /[⣿█]/);
34
+ });
35
+
36
+ // Кошка центрируется по ВИДИМОЙ части, а не по началу строки: пустой Брайль `⠀` занимает место
37
+ // и ничего не рисует. При равных отступах рисунок выглядел сдвинутым вправо на целый знак —
38
+ // поймано глазом владельца, а не прогоном, и потому закреплено здесь.
39
+ test("кошка стоит по центру надписи", () => {
40
+ const lines = banner().split("\n");
41
+ const visible = (l, blanks) => {
42
+ const a = [...l];
43
+ let lo = -1, hi = -1;
44
+ a.forEach((ch, i) => { if (!blanks.has(ch)) { if (lo < 0) lo = i; hi = i; } });
45
+ return lo < 0 ? null : (lo + hi) / 2;
46
+ };
47
+ const catMid = Math.max(...lines.slice(0, 6).map((l) => visible(l, new Set([" ", "⠀"]))));
48
+ const artMid = Math.max(...lines.slice(7, 13).map((l) => visible(l, new Set([" "]))));
49
+ assert.ok(Math.abs(catMid - artMid) <= 1, `центры разошлись: кошка ${catMid}, буквы ${artMid}`);
50
+ });
51
+
52
+ // Подпись центрируется по видимому центру букв, а не по краю строки. Отступ, подобранный на
53
+ // глаз, держится до первой правки рисунка — посчитанный переживёт её.
54
+ test("подпись стоит по центру надписи", () => {
55
+ const lines = banner().split("\n");
56
+ const mid = (l, blanks) => {
57
+ const a = [...l];
58
+ let lo = -1, hi = -1;
59
+ a.forEach((ch, i) => { if (!blanks.has(ch)) { if (lo < 0) lo = i; hi = i; } });
60
+ return lo < 0 ? null : (lo + hi) / 2;
61
+ };
62
+ const letters = Math.max(...lines.slice(7, 13).map((l) => mid(l, new Set([" "]))));
63
+ const tag = mid(lines[lines.length - 1], new Set([" "]));
64
+ assert.ok(Math.abs(letters - tag) <= 1, `подпись не по центру: буквы ${letters}, подпись ${tag}`);
65
+ });
@@ -0,0 +1,97 @@
1
+ // tool/selfcheck/units-brief.mjs — короткая строка присутствия и её ограничитель.
2
+ //
3
+ // ЗАЧЕМ ЭТО ВООБЩЕ. Владелец: «скачал и че дальше, не понятно, работает он вообще или нет».
4
+ // Хук pre-commit молчит на успехе — это его умолчание, проверено по документации: вывод
5
+ // показывается только при провале. То есть комплект, который всё держит, для человека
6
+ // неотличим от невставленного. Это ровно наш собственный порок: тишина неотличима от успеха.
7
+ //
8
+ // node --test tool/selfcheck/units-brief.mjs
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { briefLine, adviceDue, pickAdvice, updateNotice, updateWanted } from "../lib/brief.mjs";
12
+ import { CATALOGS } from "../i18n/index.mjs";
13
+
14
+ const T = CATALOGS.ru;
15
+
16
+ // Строка присутствия печатается ВСЕГДА, в том числе когда всё хорошо: именно тогда она и нужна.
17
+ test("строка присутствия несёт числа и уровень", () => {
18
+ const s = briefLine({ held: 12, todo: 3, level: 2, red: [] }, T);
19
+ assert.match(s, /12/);
20
+ assert.match(s, /3/);
21
+ assert.match(s, /AQK-2/);
22
+ });
23
+
24
+ // Красное называется первым и поимённо: человек должен видеть, что чинить, не листая вывод.
25
+ test("упавшие гейты названы в самой строке", () => {
26
+ const s = briefLine({ held: 12, todo: 0, level: 1, red: ["file-size-limit", "duplicate-code"] }, T);
27
+ assert.match(s, /file-size-limit/);
28
+ assert.match(s, /duplicate-code/);
29
+ });
30
+
31
+ // Уровень может быть не вычислен — и тогда так и говорим, а не подставляем ноль.
32
+ test("невычисленный уровень не выдумывается", () => {
33
+ const s = briefLine({ held: 0, todo: 5, level: -1, red: [] }, T);
34
+ assert.doesNotMatch(s, /AQK--1|AQK-0/);
35
+ });
36
+
37
+ // --- ограничитель совета -----------------------------------------------------
38
+ // Совет на КАЖДОМ коммите превращается в шум, а шум пролистывают вместе с настоящими
39
+ // находками. Раз в сутки — это заметно и не мешает.
40
+ test("совет не повторяется чаще раза в сутки", () => {
41
+ const now = Date.parse("2026-09-08T20:00:00Z");
42
+ assert.equal(adviceDue(null, now), true, "первый раз показывается");
43
+ assert.equal(adviceDue("2026-09-08T19:00:00Z", now), false, "час назад — рано");
44
+ assert.equal(adviceDue("2026-09-07T19:00:00Z", now), true, "сутки прошли");
45
+ });
46
+
47
+ test("испорченная отметка времени не мешает показать совет", () => {
48
+ assert.equal(adviceDue("не дата", Date.parse("2026-09-08T20:00:00Z")), true);
49
+ });
50
+
51
+ // --- выбор совета ------------------------------------------------------------
52
+ // Один совет за раз, а не список: список читается как «у вас всё плохо» и не помогает выбрать.
53
+ test("советуется одна запись, самая первая из непоставленных", () => {
54
+ const a = pickAdvice([{ slug: "secrets-not-in-code" }, { slug: "file-size-limit" }]);
55
+ assert.equal(a.slug, "secrets-not-in-code");
56
+ });
57
+
58
+ test("советовать нечего — совета нет, а не пустая строка", () => {
59
+ assert.equal(pickAdvice([]), null);
60
+ });
61
+
62
+ // --- уведомление об обновлении -----------------------------------------------
63
+ // Владелец: «выпускаем часто, никто не будет обновлять — скачают старую версию, а мы там уже
64
+ // ошибки исправили». Верно: у pre-commit версия закреплена в rev:, у GitHub Action в теге,
65
+ // и сами они не двигаются. У `npx` без версии проблемы нет — он всегда берёт свежее.
66
+ //
67
+ // АВТООБНОВЛЕНИЯ НЕТ НАМЕРЕННО. В этот же день выпущена запись, краснеющая на `@latest`:
68
+ // «версия не закреплена, завтра приедет другая». Инструмент, который сам себя подменяет, стоя
69
+ // на воротах коммита, делал бы ровно то, что мы запрещаем другим.
70
+ test("новая версия называется, старая не тревожит", () => {
71
+ assert.ok(updateNotice("0.8.0", "0.9.0", {}, T));
72
+ assert.equal(updateNotice("0.9.0", "0.9.0", {}, T), null);
73
+ assert.equal(updateNotice("0.9.0", "0.8.0", {}, T), null, "откат назад — не обновление");
74
+ });
75
+
76
+ // Сравнение по числам, а не по строкам: «0.10.0» строкой меньше «0.9.0», и уведомление
77
+ // пропало бы ровно на десятом выпуске — тихо и надолго.
78
+ test("версии сравниваются числами, а не как текст", () => {
79
+ assert.ok(updateNotice("0.9.0", "0.10.0", {}, T));
80
+ assert.equal(updateNotice("0.10.0", "0.9.0", {}, T), null);
81
+ assert.ok(updateNotice("1.2.9", "1.10.0", {}, T));
82
+ });
83
+
84
+ // Способ обновления зависит от того, как поставлено: у pre-commit это autoupdate, а не npm.
85
+ // Совет «сделай npm i -g» человеку, у которого хук, — это совет мимо, и он его не выполнит.
86
+ test("совет об обновлении соответствует способу установки", () => {
87
+ assert.match(updateNotice("0.8.0", "0.9.0", { PRE_COMMIT: "1" }, T), /autoupdate/);
88
+ assert.doesNotMatch(updateNotice("0.8.0", "0.9.0", {}, T), /autoupdate/);
89
+ });
90
+
91
+ // В конвейере уведомление — шум и лишний запрос: там версия закреплена сознательно, и человека,
92
+ // который бы его прочитал, у экрана нет.
93
+ test("в конвейере и при отказе проверять не спрашиваем вовсе", () => {
94
+ assert.equal(updateWanted({ CI: "true" }), false);
95
+ assert.equal(updateWanted({ AQK_UPDATE: "0" }), false);
96
+ assert.equal(updateWanted({}), true);
97
+ });
@@ -163,7 +163,9 @@ test("чужие настройки переживают установку ху
163
163
  // не отстал от диспетчера: команда, добавленная в switch и забытая в списке, роняет её.
164
164
  test("карта команд не отстаёт от диспетчера", async () => {
165
165
  const src = await readFile(new URL("../program.mjs", import.meta.url), "utf8");
166
- const dispatched = [...src.matchAll(/^\s{4}case "([a-z-]+)":/gm)].map((m) => m[1]);
166
+ // Флаговые формы (`--version`, `-v`) в карту не входят: карта перечисляет КОМАНДЫ, а флаг —
167
+ // второе имя той же команды. Требовать их здесь значило бы дублировать строку справки.
168
+ const dispatched = [...src.matchAll(/^\s{4}case "([a-z][a-z-]*)":/gm)].map((m) => m[1]);
167
169
  assert.ok(dispatched.length >= 10, `в диспетчере найдено ${dispatched.length} команд — разбор сломался`);
168
170
  const listed = new Set(commandRows(CATALOGS.ru).map((r) => r.name));
169
171
  const missing = dispatched.filter((n) => !listed.has(n));