agent-quality-kit 0.9.0 → 0.10.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 (57) hide show
  1. package/README.md +69 -10
  2. package/README.ru.md +68 -11
  3. package/kit/docs/ai/index.md +1 -0
  4. package/kit/docs/api-e2e.md +214 -0
  5. package/kit/docs/ready-made-rules.md +85 -0
  6. package/kit/gates/README.md +22 -0
  7. package/kit/gates/api-contract-has-arbiter/README.md +63 -0
  8. package/kit/gates/api-contract-has-arbiter/check.sh +117 -0
  9. package/kit/gates/api-contract-has-arbiter/gate.yml +15 -0
  10. package/kit/gates/api-contract-has-arbiter/green/.github/workflows/ci.yml +12 -0
  11. package/kit/gates/api-contract-has-arbiter/green/openapi.yaml +18 -0
  12. package/kit/gates/api-contract-has-arbiter/red/.github/workflows/ci.yml +11 -0
  13. package/kit/gates/api-contract-has-arbiter/red/openapi.yaml +18 -0
  14. package/kit/gates/ci-actually-fails/check.sh +9 -1
  15. package/kit/gates/commit-explains-itself/check.sh +15 -0
  16. package/kit/gates/complexity-limit/red/deep.go +17 -0
  17. package/kit/gates/complexity-limit/red/deep.rs +17 -0
  18. package/kit/gates/gate-not-weakened/red/suppress.go +5 -0
  19. package/kit/gates/gate-not-weakened/red/suppress.rs +3 -0
  20. package/kit/gates/protection-not-removed/README.md +67 -0
  21. package/kit/gates/protection-not-removed/check.sh +92 -0
  22. package/kit/gates/protection-not-removed/gate.yml +10 -0
  23. package/kit/gates/protection-not-removed/green/.aqk.yml +7 -0
  24. package/kit/gates/protection-not-removed/green/gates-declared.txt +4 -0
  25. package/kit/gates/protection-not-removed/red/.aqk.yml +7 -0
  26. package/kit/gates/protection-not-removed/red/gates-declared.txt +4 -0
  27. package/kit/gates/secrets-not-in-code/red/leak.go +9 -0
  28. package/kit/gates/secrets-not-in-code/red/leak.rs +5 -0
  29. package/kit/gates/todo-without-task/red/later.go +6 -0
  30. package/kit/gates/todo-without-task/red/later.rs +4 -0
  31. package/llms.txt +10 -4
  32. package/package.json +2 -1
  33. package/tool/commands/context.mjs +28 -1
  34. package/tool/commands/doctor.mjs +56 -2
  35. package/tool/commands/probe.mjs +228 -0
  36. package/tool/commands/vitals.mjs +11 -3
  37. package/tool/i18n/en-docs.mjs +8 -0
  38. package/tool/i18n/en-gates.mjs +309 -0
  39. package/tool/i18n/en.mjs +10 -281
  40. package/tool/i18n/ru-docs.mjs +8 -0
  41. package/tool/i18n/ru-gates.mjs +311 -0
  42. package/tool/i18n/ru.mjs +10 -280
  43. package/tool/lib/cadence.mjs +57 -0
  44. package/tool/lib/core.mjs +1 -0
  45. package/tool/lib/history.mjs +82 -0
  46. package/tool/lib/manifest.mjs +1 -1
  47. package/tool/lib/repo.mjs +12 -2
  48. package/tool/program.mjs +7 -0
  49. package/tool/selfcheck/smoke/_fixture.mjs +89 -0
  50. package/tool/selfcheck/smoke/api-contract.test.mjs +79 -0
  51. package/tool/selfcheck/smoke/commit-report.test.mjs +47 -0
  52. package/tool/selfcheck/smoke/verdict.test.mjs +40 -0
  53. package/tool/selfcheck/smoke.sh +179 -6
  54. package/tool/selfcheck/units-cadence.mjs +69 -0
  55. package/tool/selfcheck/units-probe.mjs +100 -0
  56. package/tool/selfcheck/units-repo.mjs +31 -1
  57. package/tool/selfcheck/units-vitals.mjs +19 -0
@@ -0,0 +1,89 @@
1
+ // tool/selfcheck/smoke/_fixture.mjs — общая оснастка проверок, гоняющих сам инструмент.
2
+ //
3
+ // ЗАЧЕМ ОТДЕЛЬНЫЙ ФАЙЛ. В `smoke.sh` фикстура «временный проект под git» написана сорок раз
4
+ // подряд одними и теми же четырьмя строками. Это не похожие строки, а ОДНО знание в сорока
5
+ // местах: меняется устройство подопытного проекта — правится сорок мест, и они расходятся.
6
+ //
7
+ // ЧТО ЗДЕСЬ ЗАКРЫТО, кроме дублирования (всё — из разборов флейков и из нашего же опыта):
8
+ // · СВОЙ каталог на проверку и уборка после неё — через `t.after`, а не «не забыть rm»;
9
+ // · ТАЙМАУТ на каждый запуск: висящий подпроцесс иначе держит конвейер до его предела;
10
+ // · ГЕРМЕТИЧНОСТЬ: HOME и TMPDIR в песочнице, сеть выключена. Прогон обязан давать один
11
+ // ответ на любой машине и в любой день — иначе зелёное значит «сегодня совпало».
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
13
+ import { spawnSync } from "node:child_process";
14
+ import { tmpdir } from "node:os";
15
+ import { join, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
19
+ const CLI = join(ROOT, "tool", "program.mjs");
20
+
21
+ // Предел на один запуск. Тридцать секунд — с запасом: самый долгий наш прогон в фикстуре
22
+ // (`start` со всеми записями) укладывается в единицы секунд. Предел нужен не ради скорости,
23
+ // а ради того, чтобы висящий процесс называл себя, а не съедал бюджет задания молча.
24
+ const TIMEOUT_MS = 30000;
25
+
26
+ function env(home) {
27
+ return {
28
+ ...process.env,
29
+ HOME: home,
30
+ USERPROFILE: home,
31
+ TMPDIR: join(home, "tmp"),
32
+ AQK_LANG: "ru",
33
+ // Сеть в тестах — отдельный класс флейков, и у нас она включалась ТОЛЬКО вне конвейера:
34
+ // локально проверки были сетевыми, в конвейере нет. Две разные среды по построению.
35
+ AQK_UPDATE: "0",
36
+ };
37
+ }
38
+
39
+ // Подопытный проект: свой каталог, свой git, свой дом. Файлы задаются картой «путь → текст»,
40
+ // потому что имя файла в такой проверке — часть утверждения, а не деталь.
41
+ function project(t, files = {}, { git = true } = {}) {
42
+ const dir = mkdtempSync(join(tmpdir(), "aqk-smoke-"));
43
+ const home = join(dir, ".home");
44
+ mkdirSync(join(home, "tmp"), { recursive: true });
45
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
46
+ for (const [rel, text] of Object.entries(files)) {
47
+ const full = join(dir, rel);
48
+ mkdirSync(dirname(full), { recursive: true });
49
+ writeFileSync(full, text, "utf8");
50
+ }
51
+ const p = { dir, home };
52
+ if (git) {
53
+ run(p, "git", ["init", "-q", "."]);
54
+ run(p, "git", ["config", "user.email", "t@t"]);
55
+ run(p, "git", ["config", "user.name", "t"]);
56
+ }
57
+ return p;
58
+ }
59
+
60
+ // Запуск чего угодно в каталоге проекта. Возвращается И код, И вывод: проверка, смотрящая
61
+ // только на код, не умеет объяснить провал, а смотрящая только на вывод — не умеет заметить,
62
+ // что команда не роняет прогон.
63
+ function run({ dir, home }, cmd, args = []) {
64
+ const r = spawnSync(cmd, args, {
65
+ cwd: dir, encoding: "utf8", timeout: TIMEOUT_MS, env: env(home),
66
+ });
67
+ return { code: r.status, out: `${r.stdout || ""}${r.stderr || ""}` };
68
+ }
69
+
70
+ const aqk = (p, ...args) => run(p, process.execPath, [CLI, ...args]);
71
+
72
+ // ПОЧЕМУ ГЕЙТ ЗОВЁТСЯ ТОЧКОЙ, А НЕ ПУТЁМ, И ПОЧЕМУ ПУТЬ ЧЕРЕЗ КОСУЮ.
73
+ // Node на Windows отдаёт `C:\Users\…\Temp\aqk-smoke-x`, а Git Bash живёт в POSIX-мире и
74
+ // такого пути не знает: проверка искала спецификации в несуществующем каталоге, не находила
75
+ // ничего и выходила с нулём — то есть краснела не на дефекте, а на переносимости. Поймано
76
+ // конвейером на windows-задании в тот же день, когда файл был написан.
77
+ // Лечится двумя приёмами сразу: каталог отдаётся как «.» вместе с cwd (переводить нечего),
78
+ // а путь до самого скрипта — с прямыми косыми, их Git Bash понимает.
79
+ const posix = (p) => String(p).replace(/\\/g, "/");
80
+ // Подкаталог нужен там, где проверяется не сам проект, а его клон: мелкий клон предложения
81
+ // изменений ведёт себя иначе, чем полная история, и это отдельный класс отказов.
82
+ const gate = (p, name, sub = ".") =>
83
+ run(p, "bash", [posix(join(ROOT, "kit", "gates", name, "check.sh")), sub]);
84
+
85
+ // Наружу — только то, что зовут проверки. `ROOT` и `CLI` остаются внутри: экспорт, который
86
+ // никто не берёт, читается как часть договора и мешает менять внутренности — поймал наш же
87
+ // dead-code через минуту после того, как файл был написан. `run` вернулся, когда появилась
88
+ // проверка, которой нужен сырой git: экспорт заводится под потребителя, а не про запас.
89
+ export { project, run, aqk, gate };
@@ -0,0 +1,79 @@
1
+ // Проверки про договор с чужим кодом: кто держит спецификацию API и умеет ли держатель
2
+ // провалиться. Переехали из smoke.sh 2026-09-09 первыми — как доказательство переезда.
3
+ //
4
+ // ЗАМЕР, ИЗ КОТОРОГО ОНИ ВЗЯЛИСЬ. Стенд, где сервер врёт в каждом поле ответа:
5
+ // spectral (spectral:oas) код 0 — нет контакта, описания, тегов
6
+ // schemathesis, умолчания код 1 — три нарушения схемы в ответах
7
+ // schemathesis -c not_a_server_error код 0 — «18 из 18 прошли»
8
+ // oasdiff breaking без --fail-on код 0 — печатает ломающую правку и молчит кодом
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { project, aqk, gate } from "./_fixture.mjs";
12
+
13
+ const SPEC = "openapi: 3.0.3\ninfo: { title: t, version: 1.0.0 }\npaths: {}\n";
14
+ const workflow = (steps) =>
15
+ `name: ci\non: [push]\njobs:\n contract:\n runs-on: ubuntu-latest\n steps:\n${steps}`;
16
+
17
+ // ЛОВУШКА, ИЗ-ЗА КОТОРОЙ ЗАМЕР ЧУТЬ НЕ ОКАЗАЛСЯ ЛОЖНО-ЗЕЛЁНЫМ: шаг с именем «Spec lint»
18
+ // краснеет не потому, что узнали инструмент, а потому что в имени есть слово-примета.
19
+ // Поэтому здесь у шагов НЕТ слов-подсказок — проверяется опознание инструмента.
20
+ test("обезвреженные проверки контракта API опознаются как проверки", (t) => {
21
+ const p = project(t, {
22
+ ".github/workflows/api.yml": workflow(
23
+ " - name: соответствие сервера схеме\n" +
24
+ " continue-on-error: true\n" +
25
+ " run: schemathesis run openapi.yaml --url http://localhost:8000\n" +
26
+ " - name: ломающие изменения\n" +
27
+ " continue-on-error: true\n" +
28
+ " run: oasdiff breaking base.yaml openapi.yaml --fail-on ERR\n" +
29
+ " - name: ожидания потребителей\n" +
30
+ " continue-on-error: true\n" +
31
+ " run: pact-broker can-i-deploy --pacticipant web\n"),
32
+ });
33
+ const r = gate(p, "ci-actually-fails");
34
+ assert.equal(r.code, 1, `три шага под continue-on-error прошли как чистые:\n${r.out}`);
35
+ });
36
+
37
+ // У записи две красные ветки, и вторая тоньше первой. Проверяются ОБЕ формы сужения и
38
+ // мутация: снял сужение — гейт обязан замолчать, иначе краснеет он не от этого.
39
+ test("арбитр, который не может провалиться, краснеет в обеих формах", (t) => {
40
+ const mk = (tail) => ({
41
+ "openapi.yaml": SPEC,
42
+ ".github/workflows/ci.yml": workflow(
43
+ ` - run: schemathesis run openapi.yaml --url http://localhost:8000${tail}\n`),
44
+ });
45
+ for (const narrow of [" -c not_a_server_error", " --checks not_a_server_error"]) {
46
+ const p = project(t, mk(narrow));
47
+ assert.equal(gate(p, "api-contract-has-arbiter").code, 1, `сужение «${narrow}» не опознано`);
48
+ }
49
+ const full = project(t, mk(""));
50
+ assert.equal(gate(full, "api-contract-has-arbiter").code, 0, "полный арбитр покраснел");
51
+
52
+ const loud = project(t, {
53
+ "openapi.yaml": SPEC,
54
+ ".github/workflows/ci.yml": workflow(
55
+ " - run: schemathesis run openapi.yaml --url http://localhost:8000\n" +
56
+ " - run: oasdiff breaking base.yaml openapi.yaml\n"),
57
+ });
58
+ assert.equal(gate(loud, "api-contract-has-arbiter").code, 1,
59
+ "oasdiff без --fail-on печатает находки и выходит с нулём — это не проверка");
60
+ });
61
+
62
+ // НАЙДЕНО АУДИТОМ ФИЧ, а не образцами: красный и зелёный образцы лежат по одному, а в
63
+ // настоящем проекте записи стоят рядом — и соседняя `ci-actually-fails` держит в своём
64
+ // check.sh список запускалок со ВСЕМИ инструментами про API разом. Проверка находила её и
65
+ // выдавала ложное ЗЕЛЁНОЕ там, где договор не держал никто.
66
+ test("держателем не считается определение соседней записи", (t) => {
67
+ const p = project(t, {
68
+ "src/a.py": "def s():\n return 1\n",
69
+ "openapi.yaml": SPEC,
70
+ ".github/workflows/ci.yml": workflow(" - run: pytest\n"),
71
+ });
72
+ aqk(p, "init");
73
+ aqk(p, "add", "ci-actually-fails");
74
+ aqk(p, "add", "api-contract-has-arbiter");
75
+ const r = gate(p, "api-contract-has-arbiter");
76
+ assert.equal(r.code, 1, `договор без держателя прошёл зелёным:\n${r.out}`);
77
+ // Второй дефект того же прогона: `find` без завершающего -print печатал обойдённые каталоги.
78
+ assert.doesNotMatch(r.out, /\.git|\.aqk/, `в списке спецификаций каталоги:\n${r.out}`);
79
+ });
@@ -0,0 +1,47 @@
1
+ // Мини-отчёт в коммите: где его требовать, а где состав коммита определить нельзя.
2
+ //
3
+ // НАЙДЕНО КОНВЕЙЕРОМ 2026-09-09. Коммит, трогающий ТОЛЬКО журнал, отчёта не требует — сама
4
+ // запись и есть отчёт. Локально так и было. В конвейере тот же коммит покраснел, и причина
5
+ // тоньше, чем кажется:
6
+ // · при разборе предложения изменений GitHub выкладывает синтетический коммит слияния,
7
+ // и гейт правильно спрашивает второго родителя — коммит автора;
8
+ // · но выкладка идёт МЕЛКИМ клоном, и родителя второго родителя в нём нет;
9
+ // · `git show --name-only` на коммите без родителя считает его корневым и выдаёт ВСЁ дерево;
10
+ // · значит «трогает только журнал» перестаёт срабатывать — молча, — и гейт требует отчёт там,
11
+ // где не должен.
12
+ // Проверено опытом: глубина 2 — красный, глубина 3 — чисто.
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { project, run, gate } from "./_fixture.mjs";
16
+
17
+ function repoWithJournalMerge(t) {
18
+ const p = project(t, {
19
+ "incidents/README.md": "# журнал\n",
20
+ "src/a.py": "код\n",
21
+ ".aqk.yml": "lessons: incidents\n",
22
+ });
23
+ run(p, "git", ["add", "-A"]);
24
+ run(p, "git", ["commit", "-q", "-m", "feat: первый", "-m", "Сделано: завёл", "-m", "Не уверен: ни в чём"]);
25
+ run(p, "git", ["checkout", "-q", "-b", "feature"]);
26
+ run(p, "bash", ["-c", "printf '## запись\\n' >> incidents/README.md"]);
27
+ run(p, "git", ["add", "-A"]);
28
+ // Тело НАРОЧНО без «Сделано:»: если освобождение для журнала перестанет работать, гейт
29
+ // покраснеет — то есть проверка умеет отличить исправное от сломанного.
30
+ run(p, "git", ["commit", "-q", "-m", "docs: урок", "-m", "Не уверен: ничего не сломано"]);
31
+ const main = run(p, "bash", ["-c", "git checkout -q master 2>/dev/null || git checkout -q main; git rev-parse --short HEAD"]);
32
+ run(p, "git", ["merge", "-q", "--no-ff", "feature", "-m", `Merge feature into ${main.out.trim()}`]);
33
+ return p;
34
+ }
35
+
36
+ test("коммит только в журнал не требует отчёта и в мелком клоне слияния", (t) => {
37
+ const p = repoWithJournalMerge(t);
38
+ assert.equal(gate(p, "commit-explains-itself").code, 0, "полная история: журнальный коммит покраснел");
39
+
40
+ // Ровно то, что делает конвейер при разборе предложения изменений.
41
+ run(p, "bash", ["-c", "git clone -q --depth 2 file://$PWD shallow2 2>/dev/null"]);
42
+ const r = gate(p, "commit-explains-itself", "shallow2");
43
+ assert.equal(r.code, 0, `мелкий клон: гейт требует отчёт там, где состав определить нельзя:\n${r.out}`);
44
+ // Молчать в этом случае нельзя: пропуск обязан называть себя, иначе выключенная проверка
45
+ // неотличима от работающей — тот самый класс, ради которого весь комплект.
46
+ assert.match(r.out, /не определить|журнал/, `пропуск не назвал причину:\n${r.out}`);
47
+ });
@@ -0,0 +1,40 @@
1
+ // Прогон обязан называть свой вердикт СЛОВАМИ, а не только кодом возврата.
2
+ //
3
+ // НАЙДЕНО АУДИТОМ ФИЧ 2026-09-09. `doctor --run` выходил с единицей и в конце не говорил ни
4
+ // слова о причине: она оставалась в шапке, а внизу человек видел список зелёных гейтов и шёл
5
+ // искать несуществующую поломку. С `--min` вердикт печатался всегда, без него — никогда.
6
+ // Обратная сторона нашего же принципа: молчание неотличимо не только от успеха, но и от отказа.
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { project, aqk } from "./_fixture.mjs";
12
+
13
+ test("прогон называет свой вердикт словами в обоих исходах", (t) => {
14
+ const p = project(t, { "src/a.py": "def s():\n return 1\n" });
15
+ aqk(p, "init");
16
+ // Гейт объявляется ПРЯМО, а не через `add`, и это не лень. `add` выбирает рецепт по тому,
17
+ // что установлено на машине: где есть ruff, у `todo-without-task` берётся родной
18
+ // `ruff check --select FIX,TD`, где нет — переносимый. Значит один и тот же проект получает
19
+ // РАЗНУЮ объявленную команду на разных машинах, и проверка про ВЕРДИКТ начинала зависеть от
20
+ // чужого инструмента. Поймано конвейером: локально зелено, на раннере красно.
21
+ // Здесь проверяется строка вердикта, а не поведение записи каталога, — гейту довольно быть
22
+ // заведомо тихим.
23
+ const man = join(p.dir, ".aqk.yml");
24
+ writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m, 'gates:\n тихий: "true"'), "utf8");
25
+
26
+ // .gitignore нет — прогон красный, и обязан сказать, из-за чего именно.
27
+ const red = aqk(p, "doctor", "--run");
28
+ // Сообщение утверждения обязано нести ВЕСЬ хвост: проверка, которая говорит только «не
29
+ // совпало», отправляет читателя гадать — ровно то, за что мы ругаем чужие проверки.
30
+ const redTail = red.out.trimEnd().split("\n").slice(-6).join("\n");
31
+ assert.notEqual(red.code, 0, `прогон без .gitignore прошёл зелёным:\n${redTail}`);
32
+ assert.match(redTail, /красн/, `вердикт не назван в конце вывода (код ${red.code}):\n${redTail}`);
33
+
34
+ // Причина устранена — и «ничего не сказал» обязано отличаться от «всё проверено».
35
+ writeFileSync(join(p.dir, ".gitignore"), "x\n", "utf8");
36
+ const green = aqk(p, "doctor", "--run");
37
+ const greenTail = green.out.trimEnd().split("\n").slice(-6).join("\n");
38
+ assert.equal(green.code, 0, `прогон остался красным:\n${greenTail}`);
39
+ assert.match(greenTail, /зелён/, `успех не назван словами:\n${greenTail}`);
40
+ });
@@ -23,7 +23,15 @@ PASS=0
23
23
  FAIL=0
24
24
 
25
25
  ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; PASS=$((PASS + 1)); }
26
- bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; printf ' %s\n' "${2:-}"; FAIL=$((FAIL + 1)); }
26
+ # Упавшие называются ПОИМЁННО И С ПРИЧИНОЙ в итоге, а не только по ходу. Прогон запускают гейтом, а вывод
27
+ # упавшего гейта обрезается — и причина, напечатанная в середине двухсот строк, до глаз не
28
+ # доезжает. Час поисков в конвейере 2026-09-09 стоил ровно этого: список красных был, а увидеть
29
+ # его было нельзя. Имени мало: следующий круг конвейера показал имя и не показал причину —
30
+ # она печатается сразу под находкой, то есть в середине, которую обрезка и съедает.
31
+ FAILED_NAMES=""
32
+ bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; printf ' %s\n' "${2:-}"; FAIL=$((FAIL + 1));
33
+ FAILED_NAMES="${FAILED_NAMES:+$FAILED_NAMES
34
+ }$1 — ${2:-без пояснения}"; }
27
35
 
28
36
  # Node на Windows видит мир глазами Windows, а Git Bash — глазами POSIX: путь вида
29
37
  # /tmp/tmp.XXXX, отданный в `node -e`, там не существует, и проверка падает не на том, что
@@ -35,6 +43,26 @@ node_in() { D="$1"; shift; ( cd "$D" && node "$@" ); }
35
43
  WORK="$(mktemp -d)"
36
44
  trap 'rm -rf "$WORK"' EXIT
37
45
 
46
+ # ГЕРМЕТИЧНОСТЬ. Три строки, каждая закрывает свой класс отказов, найденный сплошным чтением
47
+ # и замерами 2026-09-09. Прогон обязан давать один и тот же ответ на любой машине и в любой
48
+ # день — иначе «116 зелёных» означает не «работает», а «сегодня совпало».
49
+ #
50
+ # 1. TMPDIR внутрь $WORK. В файле 65 вызовов `mktemp -d` и ОДИН trap — на $WORK. Прерванный
51
+ # прогон оставлял 64 каталога, а два из них несут по копии всего дерева (`cp -r tool kit`,
52
+ # ≈4000 файлов). Одна строка вместо правки шестидесяти пяти: mktemp читает TMPDIR, и вся
53
+ # временная работа попадает под уже существующую уборку.
54
+ # 2. HOME в песочницу. Комплект пишет `~/.config/aqk/feedback-shown` — просьбу про звезду
55
+ # показывают один раз НА МАШИНУ. Из 65 фикстур свой HOME задавали 8. Значит на машине
56
+ # разработчика (отметка лежит с сентября) `init` молчит, а на свежем раннере — говорит:
57
+ # один и тот же прогон получает РАЗНЫЙ ввод. Плюс прогон гадил в настоящий домашний каталог.
58
+ # 3. AQK_UPDATE=0. `doctor --brief` ходит в реестр npm по сети с таймаутом 3 с — и только вне
59
+ # конвейера (`updateWanted` выключается при CI). То есть локально тесты сетевые, а в
60
+ # конвейере нет. Сеть в тестах — отдельный класс флейков во всех разборах; здесь она ещё и
61
+ # делает две среды разными по построению.
62
+ export TMPDIR="$WORK/tmp"; mkdir -p "$TMPDIR"
63
+ export HOME="$WORK/home"; export USERPROFILE="$HOME"; mkdir -p "$HOME"
64
+ export AQK_UPDATE=0
65
+
38
66
  printf '\n\033[1mtool/selfcheck/smoke.sh\033[0m\n\n'
39
67
 
40
68
  # --- 1. синтаксис самой программы ------------------------------------------
@@ -345,10 +373,24 @@ if [ -f "$BLOBDIR/GOD_AI.md" ]; then
345
373
  bad "blob собрал не все методички" "в kit/docs $EXPECT_MD, в склейке $GOT_MD"
346
374
  fi
347
375
  # Ссылки на соседние файлы внутри склейки ведут в никуда: соседей рядом больше нет.
348
- if grep -qE '\]\((?!https?:)[^)]*\.md\)' "$BLOBDIR/GOD_AI.md" 2>/dev/null; then
376
+ #
377
+ # ДВА ПРОХОДА, А НЕ ОПЕРЕЖАЮЩАЯ ПРОВЕРКА. Здесь стояло `grep -qE '...(?!https?:)...'`, и это
378
+ # была ЛОЖЬ: `(?!` — синтаксис PCRE, в POSIX ERE его нет. GNU grep печатает предупреждение и
379
+ # не находит ничего, ugrep падает с ошибкой разбора — в обоих случаях `if` уходит в `else`, и
380
+ # проверка печатала зелёное НА ЛЮБЫХ ДАННЫХ. Ровно тот грех, ради поимки которого написан весь
381
+ # комплект, внутри прибора, который его ищет. Найдено сплошным чтением 2026-09-09.
382
+ #
383
+ # Поэтому же ниже проверяется САМА ПРОВЕРКА: подделываем склейку с относительной ссылкой и
384
+ # требуем, чтобы её нашли. Без этого следующая такая опечатка снова проедет зелёной.
385
+ rel_links() { grep -oE '\]\([^)]+\.md[^)]*\)' "$1" 2>/dev/null | grep -vE '^\]\(https?:' | grep -q .; }
386
+ printf '%s' "$(cat "$BLOBDIR/GOD_AI.md")" > "$BLOBDIR/doctored.md"
387
+ printf '\nсм. [соседний файл](other.md)\n' >> "$BLOBDIR/doctored.md"
388
+ if rel_links "$BLOBDIR/GOD_AI.md"; then
349
389
  bad "в склейке остались ссылки на соседние файлы" "внутри одного файла они ведут в никуда"
390
+ elif ! rel_links "$BLOBDIR/doctored.md"; then
391
+ bad "проверка ссылок не может покраснеть" "подделанная склейка с относительной ссылкой прошла"
350
392
  else
351
- ok "ссылки на соседние файлы в склейке сняты"
393
+ ok "ссылки на соседние файлы сняты, и проверка это умеет заметить"
352
394
  fi
353
395
  else
354
396
  bad "blob не создал GOD_AI.md" "$BLOBDIR"
@@ -579,7 +621,7 @@ REPDIR="$(mktemp -d)"
579
621
  (
580
622
  cd "$REPDIR" && git init -q . && mkdir -p src &&
581
623
  printf 'a = 1 # noqa\n' > src/a.py &&
582
- node "$CLI" start > /tmp/aqk-start.log 2>&1
624
+ node "$CLI" start > "$WORK/start.log" 2>&1
583
625
  )
584
626
  REP_OUT=$( cd "$REPDIR" && node "$CLI" report 2>&1 ); REP_CODE=$?
585
627
  if [ "$REP_CODE" -ne 0 ] && printf '%s' "$REP_OUT" | grep -q '❌ gate-not-weakened'; then
@@ -591,7 +633,7 @@ else
591
633
  G_LS=$( cd "$REPDIR" && ls gates 2>&1 | tr '\n' ' ' )
592
634
  G_DECL=$( cd "$REPDIR" && sed -n '/^gates:/,$p' .aqk.yml 2>/dev/null | grep -cE '^[[:space:]]+[A-Za-z0-9_-]+:' )
593
635
  G_OUT=$( cd "$REPDIR" && bash gates/gate-not-weakened/check.sh . 2>&1 | head -2 ); G_CODE=$?
594
- 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' ' ')»"
636
+ bad "report не отличает красное от зелёного" "код отчёта $REP_CODE; гейт напрямую: код $G_CODE, вывод «$(printf '%s' "$G_OUT" | tr '\n' ' ')»; в gates/: «$G_LS»; объявлено гейтов: $G_DECL; хвост start: «$(tail -4 "$WORK/start.log" 2>/dev/null | tr '\n' ' ')»"
595
637
  fi
596
638
  if [ -f "$REPDIR/.aqk/report.md" ] && grep -q '^## ' "$REPDIR/.aqk/report.md"; then
597
639
  ok "report сохраняет .aqk/report.md"
@@ -1731,7 +1773,7 @@ rm -rf "$PRVP"
1731
1773
  # и шесть `units-*.mjs`), и заметить это можно было только сверкой руками. Тот же класс, что
1732
1774
  # «гейт объявлен и не существует»: расхождение молчит.
1733
1775
  MISSING=""
1734
- for F in "$ROOT"/tool/lib/*.mjs "$ROOT"/tool/commands/*.mjs "$ROOT"/tool/selfcheck/*; do
1776
+ for F in "$ROOT"/tool/lib/*.mjs "$ROOT"/tool/commands/*.mjs "$ROOT"/tool/selfcheck/* "$ROOT"/tool/selfcheck/*/*; do
1735
1777
  B=$(basename "$F")
1736
1778
  grep -qF "$B" "$ROOT/AGENTS.md" || MISSING="$MISSING $B"
1737
1779
  done
@@ -1786,6 +1828,132 @@ else
1786
1828
  fi
1787
1829
  chmod 700 "$HOMEH" 2>/dev/null; rm -rf "$HOMEP" "$HOMEH"
1788
1830
 
1831
+ # --- 109. probe: находит слепоту и не выдаёт её за покрытие ---------------------
1832
+ # ЗАЧЕМ КОМАНДА. `doctor` говорит «держит машина N» — N из чего? `prove` доказывает, что гейт
1833
+ # ловит брак НА СВОЁМ образце. Ни один не отвечает: что в ЭТОМ репозитории не прикрыто ничем.
1834
+ # Замер 2026-09-09 на самом комплекте: настоящий файл, подсаженные проглоченная ошибка и печать,
1835
+ # 21 объявленный гейт — покраснело НОЛЬ. Узнать об этом было нечем.
1836
+ # Проверяются ОБА конца: класс, который объявленный гейт ловит, обязан быть зелёным, а тот,
1837
+ # которого не ловит никто, — красным. Проверка одного конца пропустила бы команду, которая
1838
+ # всегда говорит «не прикрыто», и команду, которая всегда говорит «прикрыто».
1839
+ PRBP="$(mktemp -d)"
1840
+ (
1841
+ cd "$PRBP" && git init -q . && git config user.email a@b && git config user.name a
1842
+ mkdir -p src && printf 'def send(to):\n return 1\n' > src/mailer.py
1843
+ git add -A && git commit -qm "feat: почта"
1844
+ printf '# правка\n' >> src/mailer.py && git add -A && git commit -qm "fix: письма не уходили"
1845
+ node "$CLI" init && node "$CLI" add todo-without-task
1846
+ # Коммитим ВСЁ, что положили init и add: иначе дерево грязное ещё до пробы, и проверка
1847
+ # «probe ничего не пишет» меряла бы мою же фикстуру, а не команду.
1848
+ git add -A && git commit -qm "Сделано: комплект. Не уверен: ничего"
1849
+ ) >/dev/null 2>&1
1850
+ PRB=$( cd "$PRBP" && AQK_LANG=ru node "$CLI" probe --top 1 2>&1 ); PRB_C=$?
1851
+ # Слепое: secrets-not-in-code не объявлен, образец под .py у него есть — обязан быть красным.
1852
+ # Прикрытое: todo-without-task объявлен — обязан быть зелёным и назвать, кто поймал.
1853
+ if [ "$PRB_C" -eq 0 ] &&
1854
+ printf '%s' "$PRB" | grep -q "НЕ ЛОВИТ НИКТО" &&
1855
+ printf '%s' "$PRB" | grep -q "ловит: todo-without-task" &&
1856
+ printf '%s' "$PRB" | grep -q "починок в истории: 1"; then
1857
+ ok "probe: слепые классы названы, прикрытый назван поимённо, прогон не уронен"
1858
+ else
1859
+ bad "probe не различает слепое и прикрытое" "код $PRB_C, слепых $(printf '%s' "$PRB" | grep -c 'НЕ ЛОВИТ'), пойманных $(printf '%s' "$PRB" | grep -c 'ловит:')"
1860
+ fi
1861
+ # КОД человека проба не трогает: образец живёт во временном каталоге. Если бы он попал в
1862
+ # репозиторий, команда осмотра стала бы командой правки — и её выключили бы в тот же день.
1863
+ # Своя отметка каденции — исключение, и единственное: она лежит в `.aqk/`, там же, где отчёт
1864
+ # прогона, и без неё каденция невозможна (следующий прогон не узнает, что проба была).
1865
+ # Проверяется именно это разделение: за пределами `.aqk/` — ни следа.
1866
+ DIRTY=$( cd "$PRBP" && git status --porcelain | grep -cv ' \.aqk/' )
1867
+ if [ "$DIRTY" -eq 0 ]; then
1868
+ ok "probe не трогает код: пишет только свою отметку в .aqk/"
1869
+ else
1870
+ bad "probe оставил следы вне .aqk/" "$(cd "$PRBP" && git status --porcelain | grep -v ' \.aqk/' | head -3 | tr '\n' ' ')"
1871
+ fi
1872
+ rm -rf "$PRBP"
1873
+
1874
+ # --- 111. проба случается САМА, один раз, и попадает агенту в контекст -----------
1875
+ # ГЛАВНОЕ В ЭТОЙ ПРОВЕРКЕ — не механика, а замысел. Владелец сформулировал так: «команду, о
1876
+ # которой надо вспомнить, агент не вспомнит, а человек о ней не узнает». Это тот же класс, что
1877
+ # файл, который можно не прочитать, — и весь комплект написан против него. Значит `probe`,
1878
+ # оставленная ручной, отвечает на важнейший вопрос и не задаётся никем.
1879
+ # Проверяются три вещи, и каждая — отдельный способ провалиться:
1880
+ # 1. прогон делает пробу САМ, когда её не делали;
1881
+ # 2. второй прогон подряд её НЕ повторяет — иначе каденция превращается в шум;
1882
+ # 3. агент узнаёт результат из блока состояния, не зная про команду.
1883
+ CADP="$(mktemp -d)"
1884
+ (
1885
+ cd "$CADP" && git init -q . && git config user.email a@b && git config user.name a
1886
+ mkdir -p src && printf 'def s():\n return 1\n' > src/a.py
1887
+ git add -A && git commit -qm "feat: старт"
1888
+ printf '# п\n' >> src/a.py && git add -A && git commit -qm "fix: не работало"
1889
+ node "$CLI" init && node "$CLI" add todo-without-task
1890
+ ) >/dev/null 2>&1
1891
+ CTX0=$( cd "$CADP" && AQK_LANG=ru node "$CLI" context 2>&1 )
1892
+ RUN1=$( cd "$CADP" && AQK_LANG=ru node "$CLI" doctor --run 2>&1 )
1893
+ RUN2=$( cd "$CADP" && AQK_LANG=ru node "$CLI" doctor --run 2>&1 )
1894
+ CTX1=$( cd "$CADP" && AQK_LANG=ru node "$CLI" context 2>&1 )
1895
+ if printf '%s' "$CTX0" | grep -q "НЕИЗВЕСТНО" &&
1896
+ printf '%s' "$RUN1" | grep -q "ещё не делали" &&
1897
+ ! printf '%s' "$RUN2" | grep -q "ещё не делали" &&
1898
+ printf '%s' "$CTX1" | grep -q "Не прикрыто ничем"; then
1899
+ ok "проба запускается прогоном сама, не повторяется и попадает агенту в контекст"
1900
+ else
1901
+ bad "каденция пробы не работает" \
1902
+ "до: $(printf '%s' "$CTX0" | grep -c 'НЕИЗВЕСТНО'), первый: $(printf '%s' "$RUN1" | grep -c 'ещё не делали'), второй: $(printf '%s' "$RUN2" | grep -c 'ещё не делали'), после: $(printf '%s' "$CTX1" | grep -c 'Не прикрыто')"
1903
+ fi
1904
+ # Выключатель обязан быть у всего, что случается само: иначе первый же, кому это помешало,
1905
+ # выключит весь прогон, а не одну пробу.
1906
+ CADP2="$(mktemp -d)"
1907
+ ( cd "$CADP2" && git init -q . && git config user.email a@b && git config user.name a
1908
+ mkdir -p src && printf 'def s():\n return 1\n' > src/a.py
1909
+ git add -A && git commit -qm "feat: старт"
1910
+ node "$CLI" init && node "$CLI" add todo-without-task ) >/dev/null 2>&1
1911
+ OFF=$( cd "$CADP2" && AQK_PROBE=0 AQK_LANG=ru node "$CLI" doctor --run 2>&1 )
1912
+ if ! printf '%s' "$OFF" | grep -q "ещё не делали"; then
1913
+ ok "AQK_PROBE=0 выключает пробу, не трогая прогон"
1914
+ else
1915
+ bad "проба игнорирует выключатель" "AQK_PROBE=0 не подействовал"
1916
+ fi
1917
+ rm -rf "$CADP" "$CADP2"
1918
+
1919
+ # --- переехавшие проверки: встроенный раннер --------------------------------
1920
+ # ПОЧЕМУ ЗДЕСЬ МОСТ, А НЕ ВТОРАЯ КОМАНДА. Проверки переезжают на `node --test` по одной, и всё
1921
+ # это время у прогона обязана оставаться ОДНА точка входа и ОДИН счётчик: два числа в двух
1922
+ # местах через месяц разойдутся, и никто не заметит, что половина не запускается.
1923
+ #
1924
+ # ЗАЧЕМ ПЕРЕЕЗД. У встроенного раннера есть то, чего нет у этого файла и не появится: свой
1925
+ # каталог и уборка на каждую проверку, таймаут на каждую, точечный перезапуск одной по имени
1926
+ # (`--test-name-pattern`). Диагностика отказа падает с сорока секунд до одной. Проверено на
1927
+ # Node 20.20.2 — той версии, что стоит в конвейере.
1928
+ NODE_SMOKE="$ROOT/tool/selfcheck/smoke"
1929
+ if [ -d "$NODE_SMOKE" ]; then
1930
+ NS_OUT=$(cd "$ROOT" && node --test --test-reporter=tap "$NODE_SMOKE"/*.test.mjs 2>&1)
1931
+ # Разбираем только верхний уровень TAP: вложенные строки идут с отступом.
1932
+ while IFS= read -r NS_LINE; do
1933
+ case "$NS_LINE" in
1934
+ "ok "*) ok "${NS_LINE#*- }" ;;
1935
+ "not ok "*)
1936
+ # ОБЪЯСНЕНИЕ ОБЯЗАНО ДОЕХАТЬ. Первая редакция моста печатала только «не прошло» и совет
1937
+ # запустить вручную — то есть отправляла гадать ровно там, где ответ уже был получен.
1938
+ # Стоило это трёх кругов конвейера: проверка падала только на раннере, а сообщение
1939
+ # утверждения оставалось в выводе, который мост выбрасывал. TAP кладёт его в блок после
1940
+ # строки `not ok`, полем `error:`.
1941
+ # TAP кладёт многострочное сообщение блочным скаляром: строка `error: |-`, а сам текст
1942
+ # идёт следующими строками с отступом. Первая редакция брала только строку `error:` и
1943
+ # печатала «|-» — то есть снова ничего.
1944
+ NS_WHY=$(printf '%s\n' "$NS_OUT" | awk '
1945
+ /^[[:space:]]+error:/ { inerr = 1; sub(/^[[:space:]]*error:[[:space:]]*/, ""); if ($0 != "|-" && $0 != "") print; next }
1946
+ inerr && /^[[:space:]]+(code|stack|failureType|type|duration_ms):/ { inerr = 0 }
1947
+ inerr && /^[[:space:]]*\.\.\.[[:space:]]*$/ { inerr = 0 }
1948
+ inerr { sub(/^[[:space:]]+/, ""); print }
1949
+ ' | head -6 | tr '\n' ' ')
1950
+ bad "${NS_LINE#*- }" "${NS_WHY:-подробности: node --test $NODE_SMOKE/*.test.mjs}" ;;
1951
+ esac
1952
+ done <<EOF
1953
+ $(printf '%s\n' "$NS_OUT" | grep -E '^(ok|not ok) ')
1954
+ EOF
1955
+ fi
1956
+
1789
1957
  # --- итог -------------------------------------------------------------------
1790
1958
  printf '\n'
1791
1959
  if [ "$FAIL" -eq 0 ]; then
@@ -1795,4 +1963,9 @@ else
1795
1963
  fi
1796
1964
 
1797
1965
  printf ' \033[2mне покрыто: СОДЕРЖАНИЕ документов (сверяется опись и структура, не текст),\n установка с GitHub через npx\033[0m\n\n'
1966
+
1967
+ # ИМЕНА УПАВШИХ — САМОЙ ПОСЛЕДНЕЙ СТРОКОЙ, и это не косметика. Прогон запускают гейтом, а вывод
1968
+ # упавшего гейта обрезается: видно голову и хвост. Значит единственное место, где список
1969
+ # гарантированно доедет до глаз, — конец. Проверено прогоном с нарочно сломанной проверкой.
1970
+ [ "$FAIL" -eq 0 ] || printf ' \033[31mупало: %s\033[0m\n\n' "$FAILED_NAMES"
1798
1971
  exit "$FAIL"
@@ -0,0 +1,69 @@
1
+ // Проверки каденции пробы. Написаны ДО кода.
2
+ //
3
+ // ЗАЧЕМ ЭТО ВООБЩЕ. Владелец сформулировал точнее, чем было в замысле: «команду, о которой надо
4
+ // вспомнить, агент не вспомнит, а человек о ней не узнает». Это тот же класс, что файл, который
5
+ // можно не прочитать, — и решать его надо так же: не напоминанием, а тем, что оно случается
6
+ // само. Единица — коммиты, а не сутки: репозиторий, в котором месяц не работали, перепроверять
7
+ // незачем, а сто коммитов за день перепроверить надо.
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { probeDue, probeState, probeEvery, PROBE_EVERY } from "../lib/cadence.mjs";
11
+
12
+ test("порог по умолчанию — сто коммитов, и он назван числом, а не спрятан", () => {
13
+ assert.equal(PROBE_EVERY, 100);
14
+ });
15
+
16
+ test("проба нужна, когда с прошлой прошло не меньше порога", () => {
17
+ assert.equal(probeDue({ at: 100 }, 199, 100), false);
18
+ assert.equal(probeDue({ at: 100 }, 200, 100), true);
19
+ assert.equal(probeDue({ at: 100 }, 350, 100), true);
20
+ });
21
+
22
+ // Пробы не было НИКОГДА — это не «свежая». Молчание здесь означало бы «всё прикрыто»,
23
+ // а прикрыто ли — неизвестно.
24
+ test("если пробы не было вовсе — она нужна", () => {
25
+ assert.equal(probeDue(null, 1, 100), true);
26
+ assert.equal(probeDue({}, 500, 100), true);
27
+ });
28
+
29
+ // История короче порога: проба всё равно нужна один раз, иначе новый репозиторий узнает,
30
+ // чего он не видит, только на сто первом коммите.
31
+ test("в молодом репозитории проба нужна сразу, а не после сотого коммита", () => {
32
+ assert.equal(probeDue(null, 3, 100), true);
33
+ });
34
+
35
+ test("счётчик коммитов неизвестен — состояние неизвестно, а не «свежо»", () => {
36
+ assert.equal(probeDue({ at: 10 }, null, 100), false);
37
+ });
38
+
39
+ // Состояние для человека и для агента: три исхода, и они не сливаются.
40
+ test("состояние пробы: не делалась, устарела, свежая", () => {
41
+ assert.deepEqual(probeState(null, 40, 100), { state: "never", behind: null });
42
+ assert.deepEqual(probeState({ at: 10 }, 40, 100), { state: "fresh", behind: 30 });
43
+ assert.deepEqual(probeState({ at: 10 }, 210, 100), { state: "stale", behind: 200 });
44
+ assert.deepEqual(probeState({ at: 10 }, null, 100), { state: "unknown", behind: null });
45
+ });
46
+
47
+ // Порог — свойство ПРОЕКТА, а не наше: сто коммитов на репозитории с десятком коммитов в час
48
+ // это трижды в день, а на редком проекте столько не наберётся никогда.
49
+ test("порог берётся из манифеста, умолчание остаётся при пустом поле", () => {
50
+ assert.equal(probeEvery({ probe: "250" }), 250);
51
+ assert.equal(probeEvery({ probe: 250 }), 250);
52
+ assert.equal(probeEvery({}), PROBE_EVERY);
53
+ assert.equal(probeEvery(null), PROBE_EVERY);
54
+ assert.equal(probeEvery({ probe: "" }), PROBE_EVERY);
55
+ });
56
+
57
+ // Ноль — это «не делать», а не «делать всегда»: выключатель в манифесте нужен тому, кто не
58
+ // может передать переменную окружения, — например конвейеру чужой площадки.
59
+ test("ноль выключает пробу", () => {
60
+ assert.equal(probeEvery({ probe: 0 }), 0);
61
+ assert.equal(probeDue(null, 5, 0), true);
62
+ });
63
+
64
+ // Неразобранное значение обязано быть НАЗВАНО, а не подменено умолчанием: иначе в манифесте
65
+ // написано одно, а происходит другое — ровно та тихая неправда, против которой весь комплект.
66
+ test("непонятое значение порога — null, а не тихое умолчание", () => {
67
+ assert.equal(probeEvery({ probe: "часто" }), null);
68
+ assert.equal(probeEvery({ probe: -5 }), null);
69
+ });