agent-quality-kit 0.5.0 → 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.
- package/README.md +36 -1
- package/README.ru.md +19 -1
- package/kit/docs/ready-made-rules.md +40 -0
- package/kit/gates/README.md +20 -0
- package/kit/gates/ci-actually-fails/README.md +42 -0
- package/kit/gates/ci-actually-fails/check.sh +93 -0
- package/kit/gates/ci-actually-fails/gate.yml +14 -0
- package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +14 -0
- package/kit/gates/ci-actually-fails/red/.github/workflows/ci.yml +12 -0
- package/kit/gates/gate-not-weakened/README.md +54 -0
- package/kit/gates/gate-not-weakened/check.sh +72 -0
- package/kit/gates/gate-not-weakened/gate.yml +15 -0
- package/kit/gates/gate-not-weakened/green/checkout.ts +8 -0
- package/kit/gates/gate-not-weakened/green/payments.py +6 -0
- package/kit/gates/gate-not-weakened/green/release.sh +2 -0
- package/kit/gates/gate-not-weakened/red/checkout.ts +9 -0
- package/kit/gates/gate-not-weakened/red/payments.py +6 -0
- package/kit/gates/gate-not-weakened/red/release.sh +2 -0
- package/kit/gates/promise-has-gate/README.md +50 -0
- package/kit/gates/promise-has-gate/check.sh +88 -0
- package/kit/gates/promise-has-gate/gate.yml +14 -0
- package/kit/gates/promise-has-gate/green/.aqk.yml +6 -0
- package/kit/gates/promise-has-gate/green/AGENTS.md +7 -0
- package/kit/gates/promise-has-gate/red/.aqk.yml +6 -0
- package/kit/gates/promise-has-gate/red/AGENTS.md +7 -0
- package/kit/gates/test-has-assertion/README.md +47 -0
- package/kit/gates/test-has-assertion/check.sh +194 -0
- package/kit/gates/test-has-assertion/gate.yml +15 -0
- package/kit/gates/test-has-assertion/green/checkout.test.ts +9 -0
- package/kit/gates/test-has-assertion/green/test_billing.py +17 -0
- package/kit/gates/test-has-assertion/red/checkout.test.ts +8 -0
- package/kit/gates/test-has-assertion/red/test_billing.py +14 -0
- package/kit/rules/general.md +14 -0
- package/llms.txt +1 -0
- package/package.json +3 -2
- package/tool/commands/doctor.mjs +44 -4
- package/tool/commands/gates.mjs +10 -2
- package/tool/commands/project.mjs +7 -1
- package/tool/i18n/en.mjs +17 -0
- package/tool/i18n/ru.mjs +18 -0
- package/tool/i18n/templates-en.mjs +9 -9
- package/tool/i18n/templates-ru.mjs +9 -9
- package/tool/lib/manifest.mjs +37 -1
- package/tool/lib/scope.mjs +96 -0
- package/tool/program.mjs +1 -0
- package/tool/selfcheck/gates.sh +20 -3
- package/tool/selfcheck/lifecycle.mjs +29 -0
- package/tool/selfcheck/smoke.sh +53 -0
- package/tool/selfcheck/units.mjs +85 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Обещание в точке входа подкреплено командой с кодом возврата — или честно помечено как
|
|
3
|
+
# исполняемое человеком.
|
|
4
|
+
#
|
|
5
|
+
# ЗАЧЕМ. Это центральный вопрос всего комплекта. `AGENTS.md` — обычный текст: «никогда не
|
|
6
|
+
# коммитим секреты» и «мы очень стараемся» выглядят одинаково и стоят одинаково, пока никто не
|
|
7
|
+
# спросил, чем первое отличается от второго. Осенью 2026 появился целый класс линтеров обвеса
|
|
8
|
+
# (agnix — 455 правил, agents-lint), и все они проверяют ДОКУМЕНТ: формат, живые ли ссылки,
|
|
9
|
+
# существуют ли скрипты. Ни один не спрашивает, ИСПОЛНИМО ли обещание.
|
|
10
|
+
#
|
|
11
|
+
# ПОЧЕМУ ЯВНАЯ ПОМЕТКА, А НЕ УГАДЫВАНИЕ. Сопоставлять обещание с гейтом по совпадению слов —
|
|
12
|
+
# значит выдавать вердикт по догадке. Пометка ставится один раз и делает документ честнее:
|
|
13
|
+
# у каждого правила видно, кто его сторожит — машина или человек.
|
|
14
|
+
#
|
|
15
|
+
# - **Секреты не в коде.** <!-- aqk: secrets-not-in-code -->
|
|
16
|
+
# - **План до кода.** <!-- aqk: человек -->
|
|
17
|
+
DIR="${1:-.}"
|
|
18
|
+
MAN="$DIR/.aqk.yml"
|
|
19
|
+
[ -f "$MAN" ] || { echo "нет .aqk.yml — проверять нечего"; exit 0; }
|
|
20
|
+
MANTEXT="$(tr -d '\r' < "$MAN")"
|
|
21
|
+
|
|
22
|
+
ENTRIES=$(printf '%s\n' "$MANTEXT" | awk '
|
|
23
|
+
/^entry:/ { if ($0 ~ /\[/) { s=$0; sub(/^entry:[[:space:]]*\[/,"",s); sub(/\].*$/,"",s);
|
|
24
|
+
gsub(/[[:space:]]/,"",s); n=split(s,a,","); for(i=1;i<=n;i++) print a[i]; next }
|
|
25
|
+
g=1; next }
|
|
26
|
+
/^[A-Za-z]/ { g=0 }
|
|
27
|
+
g && /^[[:space:]]*-[[:space:]]/ { s=$0; sub(/^[[:space:]]*-[[:space:]]*/,"",s);
|
|
28
|
+
gsub(/^["\x27]|["\x27]$/,"",s); print s }')
|
|
29
|
+
[ -z "$ENTRIES" ] && { echo "точка входа не объявлена — проверять нечего"; exit 0; }
|
|
30
|
+
|
|
31
|
+
# Объявленные гейты: их имена и есть словарь допустимых пометок.
|
|
32
|
+
GATES=$(printf '%s\n' "$MANTEXT" | awk '/^gates:/{g=1;next} /^[A-Za-z]/{g=0}
|
|
33
|
+
g && /^[[:space:]]+[A-Za-z0-9_-]+:/ { sub(/^[[:space:]]*/,""); sub(/:.*$/,""); print }')
|
|
34
|
+
|
|
35
|
+
OUT=""
|
|
36
|
+
for E in $ENTRIES; do
|
|
37
|
+
F="$DIR/$E"
|
|
38
|
+
[ -f "$F" ] || continue
|
|
39
|
+
R=$(tr -d '\r' < "$F" | awk -v file="$E" -v gates="$GATES" '
|
|
40
|
+
# Внутри блока кода правил не бывает — там примеры.
|
|
41
|
+
/^[[:space:]]*```/ { code = !code; next }
|
|
42
|
+
code { next }
|
|
43
|
+
{
|
|
44
|
+
# Обещание — пункт списка, который либо выделен жирным (обычная форма правила), либо
|
|
45
|
+
# содержит слово долженствования или запрета. Пункт-перечисление файлов обещанием не
|
|
46
|
+
# считается: он ничего не утверждает о поведении.
|
|
47
|
+
# Только маркированный пункт. Нумерованный список — это шаги или пояснения, а не свод
|
|
48
|
+
# правил: «три вещи, которые надо понимать» в нашем же шаблоне выглядели обещаниями и
|
|
49
|
+
# требовали сторожа для утверждения о том, как устроен инструмент.
|
|
50
|
+
if ($0 !~ /^[[:space:]]*[-*][[:space:]]+/) next
|
|
51
|
+
body = $0; sub(/^[[:space:]]*[-*][[:space:]]+/, "", body)
|
|
52
|
+
isRule = (body ~ /^\*\*/) ||
|
|
53
|
+
(body ~ /никогда|всегда|обязан|обязательн|запрещ|нельзя|не должн|каждый|каждая|каждое/) ||
|
|
54
|
+
(body ~ /(^|[^a-z])(must|never|always|required|forbidden|shall)([^a-z]|$)/)
|
|
55
|
+
# Пункт-команда — не обещание. «обязательный минимум проекта прогоном: `aqk doctor`»
|
|
56
|
+
# содержит слово долженствования, но ничего не обещает: это строка справочника.
|
|
57
|
+
if (body ~ /`(node|bash|sh|npx|npm|aqk|make|python|go|cargo) /) next
|
|
58
|
+
if (!isRule) next
|
|
59
|
+
|
|
60
|
+
# Пометка: <!-- aqk: имя-гейта --> или <!-- aqk: человек -->
|
|
61
|
+
# Берём всё до закрывающей скобки комментария и уже потом обрезаем. Класс символов
|
|
62
|
+
# с исключением «-» съедал имя гейта на первом же дефисе: «deps-are-pinned» читалось
|
|
63
|
+
# как «deps», и верная пометка объявлялась ведущей в никуда.
|
|
64
|
+
if (match($0, /<!--[[:space:]]*aqk:[^>]*-->/)) {
|
|
65
|
+
tag = substr($0, RSTART, RLENGTH)
|
|
66
|
+
sub(/^<!--[[:space:]]*aqk:[[:space:]]*/, "", tag)
|
|
67
|
+
sub(/[[:space:]]*-->$/, "", tag)
|
|
68
|
+
sub(/[[:space:]]+$/, "", tag)
|
|
69
|
+
if (tag == "человек" || tag == "human") next
|
|
70
|
+
n = split(gates, G, "\n")
|
|
71
|
+
for (i = 1; i <= n; i++) if (G[i] != "" && G[i] == tag) next
|
|
72
|
+
printf "%s:%d: пометка ведёт в никуда — гейта «%s» нет в манифесте\n", file, FNR, tag
|
|
73
|
+
next
|
|
74
|
+
}
|
|
75
|
+
t = body; sub(/[[:space:]]+$/, "", t)
|
|
76
|
+
printf "%s:%d: обещание ничем не подкреплено: %s\n", file, FNR, substr(t, 1, 90)
|
|
77
|
+
}' 2>/dev/null)
|
|
78
|
+
[ -z "$R" ] || OUT="$OUT$R
|
|
79
|
+
"
|
|
80
|
+
done
|
|
81
|
+
|
|
82
|
+
LEFT="$(printf '%s' "$OUT" | grep -v '^$')"
|
|
83
|
+
[ -z "$LEFT" ] && exit 0
|
|
84
|
+
printf '%s\n' "$LEFT"
|
|
85
|
+
echo " почини: у каждого правила должен быть виден сторож."
|
|
86
|
+
echo " машина: <!-- aqk: имя-гейта --> человек: <!-- aqk: человек -->"
|
|
87
|
+
echo " правило, за которым не следит никто, через месяц отличается от лозунга только длиной."
|
|
88
|
+
exit 1
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
intent: у каждого правила в точке входа виден сторож — команда или названный человек
|
|
2
|
+
intent_en: every rule in the entry point names its enforcer — a command or, honestly, a human
|
|
3
|
+
|
|
4
|
+
# Там, где уже есть манифест и объявленные гейты: без них сопоставлять нечего.
|
|
5
|
+
trigger:
|
|
6
|
+
has_gates: true
|
|
7
|
+
|
|
8
|
+
recipes:
|
|
9
|
+
any: bash {gate}/check.sh {dir}
|
|
10
|
+
|
|
11
|
+
proof: incidents/README.md, 2026-09-06 «тринадцать собственных правил без сторожа» — прогон по
|
|
12
|
+
своему же AGENTS.md показал, что ни одно из тринадцати железных правил не называет, кто за
|
|
13
|
+
ним следит; после разметки одиннадцать оказались исполняемыми человеком, и это стало видно
|
|
14
|
+
впервые
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Как мы работаем
|
|
2
|
+
|
|
3
|
+
## Правила
|
|
4
|
+
|
|
5
|
+
- **Секреты никогда не попадают в код.** Ключи живут в окружении. <!-- aqk: secrets-not-in-code -->
|
|
6
|
+
- **Каждая функция короче пятидесяти строк.** <!-- aqk: человек -->
|
|
7
|
+
- **Обзор кода делает второй человек.** Автор не вливает сам. <!-- aqk: человек -->
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Тест умеет провалиться
|
|
2
|
+
|
|
3
|
+
**Намерение.** «Почини тесты» агент решает буквально: тело теста пустеет, утверждение
|
|
4
|
+
заменяется тавтологией, тест помечается пропуском. Все три способа дают ровно то, что просили —
|
|
5
|
+
зелёный прогон, — и ни один не виден в сводке. Покрытие при этом растёт.
|
|
6
|
+
|
|
7
|
+
**Какой отказ это поймало.** Замер по девятнадцати чужим репозиториям (15 инструментов и 4
|
|
8
|
+
прикладных, около 25 000 файлов): 8 находок. Шесть — выключенные тесты без единого слова
|
|
9
|
+
о причине в `swr`, библиотеке с тридцатью тысячами звёзд; два — в `kodus-ai`. Запись в журнале:
|
|
10
|
+
`incidents/README.md`, 2026-09-06.
|
|
11
|
+
|
|
12
|
+
**Что именно проверяется.** Три случая, в которых тест не может провалиться ни при каких данных:
|
|
13
|
+
|
|
14
|
+
| Красное | Почему |
|
|
15
|
+
|---|---|
|
|
16
|
+
| тело теста пустое (`pass`, `...`, только докстрока, пустые фигурные скобки) | выполнять нечего |
|
|
17
|
+
| утверждение-тавтология (`assert True`, `expect(true).toBe(true)`, `assert 1 == 1`) | истинно всегда |
|
|
18
|
+
| пропуск без причины (`@pytest.mark.skip`, `it.skip(…)`, `xit(…)`, `@Ignore`) | тест выключен, и неизвестно, до каких пор |
|
|
19
|
+
|
|
20
|
+
Причина у пропуска засчитывается в трёх формах: `reason=` в той же строке, `reason=` в
|
|
21
|
+
продолжении многострочного декоратора и объясняющий комментарий над тестом. Это не придирка к
|
|
22
|
+
форме: комментарий сверху — самая частая запись причины, и на замере все находки такого вида
|
|
23
|
+
оказались законными.
|
|
24
|
+
|
|
25
|
+
**Готовый аналог.** Есть, и частями:
|
|
26
|
+
[`flake8-pytest-style`](https://github.com/m-burst/flake8-pytest-style) и
|
|
27
|
+
[`pylint`](https://pylint.readthedocs.io/) ловят часть питоновских случаев;
|
|
28
|
+
[`eslint-plugin-jest`](https://github.com/jest-community/eslint-plugin-jest) — правила
|
|
29
|
+
`expect-expect`, `no-disabled-tests`, `valid-expect` — заметно точнее нас на JavaScript, потому
|
|
30
|
+
что разбирает синтаксис. Если проект на одном языке — ставь их. Эта запись нужна там, где
|
|
31
|
+
языков несколько или где ставить нечего.
|
|
32
|
+
|
|
33
|
+
**Чего НЕ ловит.**
|
|
34
|
+
|
|
35
|
+
- **Тест без утверждения, но с телом.** Вызвали код, проверили, что не упал, — законный стиль:
|
|
36
|
+
утверждение там в самом отсутствии исключения. Первая версия красила такие тесты и дала
|
|
37
|
+
186 находок в двух зрелых проектах — все до одной ложные. Правило снято намеренно.
|
|
38
|
+
- **Тест, утверждающий не то.** `assert response.status_code` вместо `== 200` — проверка
|
|
39
|
+
отвечает на вопрос «может ли тест провалиться», а не «то ли он проверяет».
|
|
40
|
+
- **Языки, кроме python, javascript и typescript, — только частично.** Пустое тело в Go или Ruby
|
|
41
|
+
не разбирается; тавтологии и пропуски ловятся во всех перечисленных расширениях.
|
|
42
|
+
- **Голый `t.Skip()` в Go.** На замере 9 находок из 13 оказались условным пропуском по
|
|
43
|
+
переменной окружения — это управление прогоном, а не выключенный тест. Правило снято.
|
|
44
|
+
- **Скобки внутри строковых литералов считаются наравне с настоящими.** Границы тела теста
|
|
45
|
+
определяются балансом скобок; точный ответ требует разбора языка, а его у переносимой
|
|
46
|
+
проверки нет. Строки внутри незакрытых шаблонных литералов пропускаются целиком — иначе
|
|
47
|
+
тесты, лежащие в фикстурах как текст, читались бы как настоящие.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Тест, который не может провалиться: прогон зелёный, покрытие выросло, проверено ничего.
|
|
3
|
+
#
|
|
4
|
+
# ЗАЧЕМ ИМЕННО ЭТО. «Почини тесты» агент решает буквально: тело теста пустеет, утверждение
|
|
5
|
+
# заменяется тавтологией, тест помечается пропуском. Все три способа дают ровно то, что просили
|
|
6
|
+
# — зелёный прогон, — и ни один не виден в сводке. Это тот же класс, что молчащий гейт: сигнал
|
|
7
|
+
# исчез, а уверенность осталась.
|
|
8
|
+
#
|
|
9
|
+
# ГРАНИЦА НАЙДЕНА ЗАМЕРОМ, А НЕ ВЫДУМАНА. Первая версия красила «тест без утверждения» и дала
|
|
10
|
+
# 186 находок в двух зрелых проектах — все до одной оказались тестами вида «вызвали и проверили,
|
|
11
|
+
# что не упало». Это законный и распространённый стиль: утверждение там в самом отсутствии
|
|
12
|
+
# исключения. Гейт, спорящий о стиле, выключают целиком, поэтому здесь остались только
|
|
13
|
+
# безусловные случаи — те, где тест не может провалиться ни при каких данных.
|
|
14
|
+
DIR="${1:-.}"
|
|
15
|
+
. "$(dirname "$0")/../_skip.sh" 2>/dev/null || SKIP_NAMES=".git .aqk node_modules .venv"
|
|
16
|
+
|
|
17
|
+
# Только файлы тестов. Слово «assert» в обычном коде — не тест, а проверка входа.
|
|
18
|
+
FILES=$(find "$DIR" $(skip_find) -type f \( \
|
|
19
|
+
-name 'test_*.py' -o -name '*_test.py' -o -name 'tests.py' \
|
|
20
|
+
-o -name '*.test.js' -o -name '*.test.jsx' -o -name '*.test.ts' -o -name '*.test.tsx' \
|
|
21
|
+
-o -name '*.test.mjs' -o -name '*.spec.js' -o -name '*.spec.jsx' -o -name '*.spec.ts' \
|
|
22
|
+
-o -name '*.spec.tsx' -o -name '*.spec.mjs' \
|
|
23
|
+
-o -name '*_test.go' -o -name '*_test.rb' -o -name '*_spec.rb' \) -print 2>/dev/null)
|
|
24
|
+
[ -z "$FILES" ] && { echo "файлов тестов не нашлось — эта проверка не про тебя"; exit 0; }
|
|
25
|
+
|
|
26
|
+
# Сгенерированный файл правят не руками: тест в нём написал инструмент. Признак проверяется
|
|
27
|
+
# внутри awk по первым строкам, а не циклом с `head` на файл.
|
|
28
|
+
#
|
|
29
|
+
# ПОЧЕМУ БЕЗ ЦИКЛА. Накопление списка строкой («KEEP="$KEEP $F"») — квадрат по длине: оболочка
|
|
30
|
+
# копирует растущую строку на каждой итерации. На соседней проверке это дало 44 секунды вместо
|
|
31
|
+
# одной на том же репозитории.
|
|
32
|
+
|
|
33
|
+
# Тройная одинарная кавычка нужна разбору докстрок, но написать её внутри awk-программы нельзя:
|
|
34
|
+
# она закроет кавычки самой программы. Передаём переменной.
|
|
35
|
+
Q3="'''"
|
|
36
|
+
|
|
37
|
+
# ОДИН вызов awk на все файлы, а не по вызову на файл: запуск процесса стоит дороже разбора.
|
|
38
|
+
# На репозитории в 7128 файлов проверка занимала 49 секунд, и почти всё это были запуски.
|
|
39
|
+
# shellcheck disable=SC2086
|
|
40
|
+
OUT=$(awk -v q3="$Q3" '
|
|
41
|
+
# --- что считается безусловно сломанным ------------------------------------
|
|
42
|
+
function tautology(l) {
|
|
43
|
+
return l ~ /assert[[:space:]]+(True|true|1)[[:space:]]*($|#)/ ||
|
|
44
|
+
l ~ /assert(True|Equal)\((True|true|1)([[:space:]]*,[[:space:]]*(True|true|1))?\)/ ||
|
|
45
|
+
l ~ /expect\((true|1)\)\.(toBe|toEqual)\((true|1)\)/ ||
|
|
46
|
+
l ~ /assert[[:space:]]+[0-9]+[[:space:]]*==[[:space:]]*[0-9]+/
|
|
47
|
+
}
|
|
48
|
+
# Пропуск без причины. С причиной — законный приём: тест ждёт починки, и это записано.
|
|
49
|
+
# Голый `t.Skip()` из Go сюда НЕ входит: на замере 9 находок из 13 оказались условным
|
|
50
|
+
# пропуском по переменной окружения — это управление прогоном, а не выключенный тест.
|
|
51
|
+
function bareSkip(l) {
|
|
52
|
+
return (l ~ /@(pytest\.mark\.)?skip([[:space:]]*$|\()/ && !reasonNear(l)) ||
|
|
53
|
+
(l ~ /(^|[^a-zA-Z])(x?it|x?describe|test)\.skip\(/ && l !~ /--|TODO|[Ii]ssue|#[0-9]/) ||
|
|
54
|
+
(l ~ /(^|[^a-zA-Z])(xit|xdescribe)\(/) ||
|
|
55
|
+
(l ~ /@Ignore([[:space:]]*$|\(\))/)
|
|
56
|
+
}
|
|
57
|
+
# Строка, которая ничего не делает: заглушка, докстрока, комментарий, хвост из скобок.
|
|
58
|
+
# Сравнением строк, а не регулярным выражением: тройная одинарная кавычка внутри
|
|
59
|
+
# awk-программы закрыла бы кавычки оболочки.
|
|
60
|
+
function isNoop(b, t) {
|
|
61
|
+
t = b; sub(/^[[:space:]]+/, "", t); sub(/[[:space:]]+$/, "", t)
|
|
62
|
+
if (t == "" || t == "pass" || t == "..." || t == "return") return 1
|
|
63
|
+
if (substr(t, 1, 1) == "#") return 1
|
|
64
|
+
if (substr(t, 1, 3) == "\"\"\"" || substr(t, 1, 3) == q3) return 1
|
|
65
|
+
if (substr(t, 1, 2) == "//" || substr(t, 1, 2) == "/*" || substr(t, 1, 1) == "*") return 1
|
|
66
|
+
if (t ~ /^[])};,[:space:]]+$/) return 1
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
function trim(x) { sub(/^[[:space:]]+/, "", x); return substr(x, 1, 80) }
|
|
70
|
+
# Причина, записанная комментарием НАД пропуском, — нормальная форма, и она встречается чаще
|
|
71
|
+
# инлайновой: на замере все 4 оставшиеся находки оказались именно такими, с подробным
|
|
72
|
+
# объяснением в трёх строках выше. Требование писать причину в той же строке — придирка к
|
|
73
|
+
# форме, а придирки к форме и есть то, из-за чего гейты выключают.
|
|
74
|
+
# Причина у пропуска может стоять на следующей строке: многострочный декоратор
|
|
75
|
+
# «@pytest.mark.skip(\n reason="…"\n)» — обычная форма, когда причина длинная.
|
|
76
|
+
function reasonNear(l, k) {
|
|
77
|
+
if (l ~ /reason[[:space:]]*=/) return 1
|
|
78
|
+
if (l !~ /\($/ && l !~ /\([[:space:]]*$/) return 0
|
|
79
|
+
for (k = cursor + 1; k <= n && k <= cursor + 4; k++) {
|
|
80
|
+
if (line[k] ~ /reason[[:space:]]*=/) return 1
|
|
81
|
+
if (line[k] ~ /^[[:space:]]*\)/) return 0
|
|
82
|
+
}
|
|
83
|
+
return 0
|
|
84
|
+
}
|
|
85
|
+
function explainedAbove(i, k, t, w) {
|
|
86
|
+
for (k = i - 1; k >= 1 && k >= i - 4; k--) {
|
|
87
|
+
t = line[k]; sub(/^[[:space:]]+/, "", t)
|
|
88
|
+
if (t == "") continue
|
|
89
|
+
if (substr(t, 1, 2) != "//" && substr(t, 1, 1) != "#" && substr(t, 1, 1) != "*") return 0
|
|
90
|
+
w = split(t, _unused, /[[:space:]]+/)
|
|
91
|
+
if (w >= 4) return 1
|
|
92
|
+
}
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
# Баланс круглых скобок со строки a по строку b, через префиксные суммы. Пересчёт от a при
|
|
96
|
+
# каждом обращении давал кубическую сложность и не укладывался в две минуты на большом
|
|
97
|
+
# репозитории. Приближение: скобки внутри строковых литералов считаются наравне с
|
|
98
|
+
# настоящими — точный ответ требует разбора языка, а его у переносимой проверки нет.
|
|
99
|
+
function bal(a, b) { return pre[b] - pre[a - 1] }
|
|
100
|
+
function balBrace(a, b) { return prb[b] - prb[a - 1] }
|
|
101
|
+
|
|
102
|
+
# Границы файла: awk без gawk не знает ENDFILE, поэтому предыдущий файл разбирается на первой
|
|
103
|
+
# строке следующего, а последний — в END. Буферы обнуляются вместе с именем: общий буфер
|
|
104
|
+
# склеил бы скобки соседних файлов и сдвинул все границы.
|
|
105
|
+
FNR == 1 { if (n > 0) report(); n = 0; delete line; delete pre; delete prb; delete tck; cur = FILENAME; skipFile = 0 }
|
|
106
|
+
FNR <= 5 && /@[Gg]enerated|[Dd]o not edit|DO NOT EDIT|[Aa]utogenerated|[Aa]uto-generated|[Gg]enerated by|сгенерирован/ { skipFile = 1 }
|
|
107
|
+
{
|
|
108
|
+
n++; line[n] = $0
|
|
109
|
+
t = $0; o = gsub(/\(/, "(", t); t = $0; c = gsub(/\)/, ")", t)
|
|
110
|
+
pre[n] = pre[n - 1] + o - c
|
|
111
|
+
t = $0; o = gsub(/\{/, "{", t); t = $0; c = gsub(/\}/, "}", t)
|
|
112
|
+
prb[n] = prb[n - 1] + o - c
|
|
113
|
+
# Чётность обратных кавычек. Строка внутри незакрытого шаблонного литерала — это ДАННЫЕ,
|
|
114
|
+
# а не код: проекты, которые проверяют чужой код, держат в таких литералах целые файлы с
|
|
115
|
+
# тестами внутри. Три находки из четырёх на замере пришли ровно оттуда.
|
|
116
|
+
t = $0; tck[n] = tck[n - 1] + gsub(/`/, "`", t)
|
|
117
|
+
}
|
|
118
|
+
END { if (n > 0) report() }
|
|
119
|
+
|
|
120
|
+
function report( i, j, l, t0, e, ind, start, found, hasBody, b, bi) {
|
|
121
|
+
if (skipFile) return
|
|
122
|
+
for (i = 1; i <= n; i++) {
|
|
123
|
+
l = line[i]; cursor = i
|
|
124
|
+
if (l ~ /^[[:space:]]*(#|\/\/|\*)/) continue
|
|
125
|
+
if (tautology(l) && !explainedAbove(i)) {
|
|
126
|
+
printf "%s:%d: утверждение-тавтология: %s\n", cur, i, trim(l); continue
|
|
127
|
+
}
|
|
128
|
+
if (bareSkip(l) && !explainedAbove(i)) {
|
|
129
|
+
printf "%s:%d: тест пропущен без названной причины: %s\n", cur, i, trim(l); continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# --- python: тело ограничено отступом -----------------------------------
|
|
133
|
+
if (l ~ /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+test[_A-Za-z0-9]*[[:space:]]*\(/) {
|
|
134
|
+
# Тело в той же строке — это не тест, а вложенная функция-фикстура: «def test_function():
|
|
135
|
+
# ...» внутри настоящего теста. Настоящий тест так не пишут. Найдено замером на
|
|
136
|
+
# sentry-python, где такая фикстура объявлялась пустым тестом.
|
|
137
|
+
if (bal(i, i) <= 0 && l !~ /:[[:space:]]*(#.*)?$/) continue
|
|
138
|
+
match(l, /^[[:space:]]*/); ind = RLENGTH; found = 0
|
|
139
|
+
# Подпись бывает на несколько строк, и её закрывающая «):» стоит на отступе самого def.
|
|
140
|
+
# Пока тело искали прямо со следующей строки, эта скобка обрывала поиск: тест объявляли
|
|
141
|
+
# пустым, ни разу не заглянув внутрь. 142 ложных в одном pre-commit.
|
|
142
|
+
start = i
|
|
143
|
+
for (j = i; j <= n; j++) { start = j; if (bal(i, j) <= 0 && line[j] ~ /:[[:space:]]*(#.*)?$/) break }
|
|
144
|
+
|
|
145
|
+
for (j = start + 1; j <= n; j++) {
|
|
146
|
+
b = line[j]
|
|
147
|
+
if (b ~ /^[[:space:]]*$/) continue
|
|
148
|
+
match(b, /^[[:space:]]*/); bi = RLENGTH
|
|
149
|
+
if (bi <= ind) break
|
|
150
|
+
if (!isNoop(b)) { found = 1; break }
|
|
151
|
+
}
|
|
152
|
+
if (!found) printf "%s:%d: тело теста пустое — он не может провалиться: %s\n", cur, i, trim(l)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# --- javascript и typescript: тело ограничено круглой скобкой вызова ----
|
|
157
|
+
# ВЫЗОВ В НАЧАЛЕ СТРОКИ, а не слово «test» где угодно. Пока проверялось вхождение,
|
|
158
|
+
# красным становилось всё подряд: объявление обычной функции с таким именем, тот же текст
|
|
159
|
+
# внутри строковой фикстуры с дифом, он же аргументом в toContain. 51 ложная из 60.
|
|
160
|
+
if (tck[i - 1] % 2 == 1) continue
|
|
161
|
+
t0 = l; sub(/^[[:space:]]+/, "", t0)
|
|
162
|
+
if (t0 ~ /^(await[[:space:]]+)?(it|test)(\.[A-Za-z]+)?[[:space:]]*\(/) {
|
|
163
|
+
# Границей служит КРУГЛАЯ скобка вызова, а не фигурная скобка тела: многострочный вызов
|
|
164
|
+
# с объектом настроек между именем и телом обрывался на этом объекте — скобка
|
|
165
|
+
# открылась и закрылась, тело сочли пустым. 1460 ложных из 1843.
|
|
166
|
+
# Конец вызова — когда закрылись И круглые, И фигурные скобки. По одним круглым
|
|
167
|
+
# строка `for (const p of ["a)b"]) {` закрывала вызов раньше времени: скобка внутри
|
|
168
|
+
# строкового литерала считается наравне с настоящей. Фигурная скобка тела там ещё
|
|
169
|
+
# открыта, и это отличает настоящий конец от мнимого.
|
|
170
|
+
e = i
|
|
171
|
+
for (j = i; j <= n; j++) { e = j; if (bal(i, j) <= 0 && balBrace(i, j) <= 0) break }
|
|
172
|
+
hasBody = 0
|
|
173
|
+
for (j = i; j <= e; j++) if (line[j] ~ /=>|function/) hasBody = 1
|
|
174
|
+
if (!hasBody) continue
|
|
175
|
+
found = 0
|
|
176
|
+
# Однострочная запись: тело живёт на той же строке («it("…", () => expectPass(…));»).
|
|
177
|
+
# Пока сканировали только следующие строки, такой тест объявлялся пустым.
|
|
178
|
+
if (e == i && t0 ~ /=>[^)]*[A-Za-z]/) continue
|
|
179
|
+
# Включая строку e: у стрелочной функции без фигурных скобок тело живёт ровно на ней
|
|
180
|
+
# («it("…", () =>\n expectPass(…));»). Пока сканировали до e-1, такие тесты объявлялись
|
|
181
|
+
# пустыми — 15 ложных из 21 на замере.
|
|
182
|
+
for (j = i + 1; j <= e; j++) if (!isNoop(line[j])) { found = 1; break }
|
|
183
|
+
if (!found) printf "%s:%d: тело теста пустое — он не может провалиться: %s\n", cur, i, trim(l)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
' $FILES 2>/dev/null)
|
|
188
|
+
|
|
189
|
+
LEFT="$(printf '%s' "$OUT" | grep -v '^$' | own_samples_filter "$DIR")"
|
|
190
|
+
[ -z "$LEFT" ] && exit 0
|
|
191
|
+
printf '%s\n' "$LEFT"
|
|
192
|
+
echo " почини: тест обязан уметь провалиться. Пропуск — с названной причиной («reason=…», ссылка на задачу)."
|
|
193
|
+
echo " зелёный прогон без проверки — это уверенность без основания, худший из возможных исходов."
|
|
194
|
+
exit 1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
intent: тест умеет провалиться — тело не пустое, утверждение не тавтология, пропуск с причиной
|
|
2
|
+
intent_en: a test can actually fail — no empty body, no tautology, no skip without a reason
|
|
3
|
+
|
|
4
|
+
# Там, где есть тесты. Триггер по наличию, а не по языку: разбор покрывает python, javascript
|
|
5
|
+
# и typescript, а пропуски и тавтологии ловятся во всех перечисленных расширениях.
|
|
6
|
+
trigger:
|
|
7
|
+
has_tests: true
|
|
8
|
+
|
|
9
|
+
recipes:
|
|
10
|
+
any: bash {gate}/check.sh {dir}
|
|
11
|
+
|
|
12
|
+
proof: incidents/README.md, 2026-09-06 «граница проверки найдена замером» — прогон по
|
|
13
|
+
девятнадцати чужим репозиториям (около 25 000 файлов) даёт 8 находок: шесть выключенных
|
|
14
|
+
тестов без причины в `swr` (30 000 звёзд) и два в `kodus-ai`. Первая версия давала 1843
|
|
15
|
+
находки, из них 1843 ложных
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_total_is_summed():
|
|
5
|
+
order = {"items": [{"price": "1.50"}, {"price": "2.50"}]}
|
|
6
|
+
assert total(order) == 4
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Тест, который проверяет «не падает», — законный стиль: утверждение здесь в самом отсутствии
|
|
10
|
+
# исключения. Эта запись про выключенный тест, а не про то, как его следует писать.
|
|
11
|
+
def test_import_does_not_explode():
|
|
12
|
+
build_report({"items": []})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.skip(reason="ждёт песочницы платёжного шлюза, задача PAY-214")
|
|
16
|
+
def test_refund_is_reversible():
|
|
17
|
+
assert refund(pay(100)) == 0
|
package/kit/rules/general.md
CHANGED
|
@@ -7,6 +7,20 @@
|
|
|
7
7
|
- **Ни одного тихого отказа.** Ошибка обработана и записана либо проброшена.
|
|
8
8
|
- **Границы явные.** На стыках — проверка входа, а не доверие.
|
|
9
9
|
|
|
10
|
+
## Сомнение — повод посмотреть наружу
|
|
11
|
+
|
|
12
|
+
Агент отвечает уверенно всегда: и когда знает, и когда достраивает по памяти. Со стороны это
|
|
13
|
+
неотличимо, а цена разная. Поэтому четыре случая обязаны кончаться поиском, а не догадкой:
|
|
14
|
+
|
|
15
|
+
| Случай | Что происходит без поиска |
|
|
16
|
+
|---|---|
|
|
17
|
+
| не знаешь, как принято **сейчас** | пишется то, что было принято на момент обучения |
|
|
18
|
+
| не знаешь, есть ли **готовое** | пишется свой велосипед, который потом чинить самому |
|
|
19
|
+
| собираешься написать распространённую вещь | половина работы уже сделана кем-то и проверена |
|
|
20
|
+
| помнишь ответ, но **из обучения, а не из проверки** | вспомненный API мог быть переименован или убран |
|
|
21
|
+
|
|
22
|
+
Правило дешевле, чем кажется: поиск стоит минуту, а неверная догадка — правку, ревью и шишку.
|
|
23
|
+
|
|
10
24
|
## Запрещено в готовом коде
|
|
11
25
|
|
|
12
26
|
- отладочная печать;
|
package/llms.txt
CHANGED
|
@@ -22,6 +22,7 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
|
|
|
22
22
|
`npx agent-quality-kit doctor --baseline` (14 of 50 points confirmed by a run, ecosystem-neutral;
|
|
23
23
|
the other 36 are named as a number, not hidden)
|
|
24
24
|
- Fail a pipeline below a level or on a failed gate: `npx agent-quality-kit doctor --run --min 1`
|
|
25
|
+
- Show only what a diff introduced, so a legacy repo is usable from day one: `doctor --run --since main`
|
|
25
26
|
- Exit codes: 0 pass, 1 below the level or a gate failed
|
|
26
27
|
- As a pre-commit hook: `repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit` with
|
|
27
28
|
`id: aqk` (blocking), `aqk-doctor` (read-only) or `aqk-baseline`. pre-commit installs the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-quality-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Turns the rules an agent is supposed to follow into commands with exit codes, and reports which of them actually run. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
],
|
|
44
44
|
"knip": {
|
|
45
45
|
"entry": [
|
|
46
|
-
"tool/selfcheck/units.mjs"
|
|
46
|
+
"tool/selfcheck/units.mjs",
|
|
47
|
+
"tool/selfcheck/lifecycle.mjs"
|
|
47
48
|
],
|
|
48
49
|
"project": [
|
|
49
50
|
"tool/**/*.mjs"
|
package/tool/commands/doctor.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import {
|
|
6
|
+
import { scopeOutput, changedFiles } from "../lib/scope.mjs";
|
|
7
|
+
import { CWD, PKG_ROOT, TARGET_DIR, SELF, c, exists, die } from "../lib/core.mjs";
|
|
7
8
|
import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS } from "../lib/manifest.mjs";
|
|
8
9
|
import { detectFacts, readCatalog, triggerVerdict, recipeFor } from "../lib/repo.mjs";
|
|
9
10
|
import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
|
|
@@ -94,10 +95,27 @@ function declaredGates(man) {
|
|
|
94
95
|
.filter(([, cmd]) => cmd);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
// Ссылка, относительно которой сужается вывод: `--since main`, `--since HEAD~5`.
|
|
99
|
+
// Без значения флаг бессмыслен — молча взять умолчание нельзя: «сужено не тем» неотличимо
|
|
100
|
+
// от «не сужено».
|
|
101
|
+
function sinceRef(argv = process.argv) {
|
|
102
|
+
const i = argv.indexOf("--since");
|
|
103
|
+
if (i === -1) return null;
|
|
104
|
+
const v = argv[i + 1];
|
|
105
|
+
return v && !v.startsWith("-") ? v : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runGates(man, opts = {}) {
|
|
98
109
|
const gates = declaredGates(man);
|
|
99
110
|
if (!gates.length) return { failed: 0, ran: 0, results: [] };
|
|
100
111
|
|
|
112
|
+
// Сужение по дифу — договор с человеком, и он должен видеть, ЧТО именно сужено. Пустой диф
|
|
113
|
+
// называется вслух: иначе «все гейты зелёные» означало бы «сравнили не с тем» и читалось бы
|
|
114
|
+
// как успех. Это тот же класс, что и весь стандарт, только внутри нашего флага.
|
|
115
|
+
const scoped = opts.since ? changedFiles(opts.since, CWD) : null;
|
|
116
|
+
if (opts.since && scoped === null) die(L.doctor.sinceBadRef(opts.since));
|
|
117
|
+
if (scoped) console.log(c.dim(`\n ${L.doctor.sinceHeading(opts.since, scoped.size)}`));
|
|
118
|
+
|
|
101
119
|
console.log(c.bold(`\n ${L.doctor.runHeading}\n`));
|
|
102
120
|
let failed = 0;
|
|
103
121
|
const results = [];
|
|
@@ -118,8 +136,30 @@ function runGates(man) {
|
|
|
118
136
|
console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${cmd}`)}`);
|
|
119
137
|
results.push({ name, cmd, ok: true, secs });
|
|
120
138
|
} else {
|
|
139
|
+
let out = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
|
|
140
|
+
|
|
141
|
+
// Сужение до дифа. Три исхода, и все три называются вслух.
|
|
142
|
+
if (scoped) {
|
|
143
|
+
const s = scopeOutput(out, scoped);
|
|
144
|
+
if (!s.scopable) {
|
|
145
|
+
// Гейт печатает вердикт без путей — сузить нечем. Признать его успешным значило бы
|
|
146
|
+
// выдать провал за тишину; остаётся красным, и причина названа.
|
|
147
|
+
console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.exitCode(code))} ${c.dim(`· ${L.doctor.notScopable}`)}`);
|
|
148
|
+
failed++;
|
|
149
|
+
results.push({ name, cmd, ok: false, secs, code, note: L.doctor.notScopable });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (s.findings === 0) {
|
|
153
|
+
// Долг есть, но не в том, что внёс диф. Зелёный — но с числом спрятанного: молчаливое
|
|
154
|
+
// «всё хорошо» здесь было бы неправдой.
|
|
155
|
+
console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
|
|
156
|
+
results.push({ name, cmd, ok: true, secs, scopedAway: out.length });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
out = s.kept;
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
failed++;
|
|
122
|
-
const out = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
|
|
123
163
|
console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.exitCode(code))} ${c.dim(`· ${secs}s · ${cmd}`)}`);
|
|
124
164
|
for (const line of out.slice(0, 3)) console.log(c.dim(` ${line.slice(0, 100)}`));
|
|
125
165
|
if (out.length > 3) console.log(c.dim(` ${L.doctor.moreLines(out.length - 3)}`));
|
|
@@ -247,7 +287,7 @@ async function cmdDoctor() {
|
|
|
247
287
|
let gateFailed = 0;
|
|
248
288
|
let failedNames = [];
|
|
249
289
|
if (wantRun) {
|
|
250
|
-
const run = runGates(man);
|
|
290
|
+
const run = runGates(man, { since: sinceRef() });
|
|
251
291
|
gateFailed = run.failed;
|
|
252
292
|
failedNames = run.results.filter((r) => !r.ok).map((r) => r.name);
|
|
253
293
|
await writeRunReport({ version, reached, results: run.results });
|
package/tool/commands/gates.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
CWD, PKG_ROOT, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB, MANIFEST, SELF, c, exists, die,
|
|
9
9
|
copyDir,
|
|
10
10
|
} from "../lib/core.mjs";
|
|
11
|
-
import { parseManifest, readManifest, manifestWithGate } from "../lib/manifest.mjs";
|
|
11
|
+
import { parseManifest, readManifest, manifestWithGate, entryLifecycle } from "../lib/manifest.mjs";
|
|
12
12
|
import {
|
|
13
13
|
detectFacts, readCatalog, pickRecipe, triggerVerdict, stems, overlap, matchCatalog,
|
|
14
14
|
} from "../lib/repo.mjs";
|
|
@@ -28,6 +28,13 @@ async function installGate(slug, man, facts) {
|
|
|
28
28
|
if (!(await exists(src))) die(L.add.noSuchGate(slug, `${SELF} doctor`));
|
|
29
29
|
|
|
30
30
|
const rec = { slug, ...parseManifest(await readFile(join(src, "gate.yml"), "utf8")) };
|
|
31
|
+
|
|
32
|
+
// Выведенную запись не ставим. Молча пропустить нельзя — человек пришёл за конкретной
|
|
33
|
+
// проверкой и обязан узнать, кто её заменил; ответ «а что теперь» и есть цена вывода.
|
|
34
|
+
// Проверка ДО копирования: иначе в проекте остаётся папка гейта, которого не будет в манифесте.
|
|
35
|
+
const life = entryLifecycle(rec);
|
|
36
|
+
if (life.state === "deprecated") return { rec, cmd: null, copied: [], declared: false, why: null, retired: life.supersededBy || "" };
|
|
37
|
+
|
|
31
38
|
const dst = join(CWD, PROJECT_GATES, slug);
|
|
32
39
|
await mkdir(dst, { recursive: true });
|
|
33
40
|
const copied = await copyDir(src, dst, { force: false });
|
|
@@ -87,7 +94,8 @@ async function cmdAdd(args) {
|
|
|
87
94
|
console.log(c.dim(` ${L.add.installAnyway}\n`));
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
const { cmd, copied, declared, why, noRecipe } = await installGate(slug, man, facts);
|
|
97
|
+
const { cmd, copied, declared, why, noRecipe, retired } = await installGate(slug, man, facts);
|
|
98
|
+
if (retired !== undefined) die(L.lifecycle.installDeprecated(slug, retired ? `${SELF} add ${retired}` : "—"));
|
|
91
99
|
if (noRecipe) die(L.add.noRecipe(slug, [...facts.langs].join("/") || L.add.thisStack));
|
|
92
100
|
|
|
93
101
|
console.log(c.bold(`\naqk add ${slug}\n`));
|