agent-quality-kit 0.2.5 → 0.3.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 (37) hide show
  1. package/README.md +17 -0
  2. package/README.ru.md +16 -0
  3. package/kit/gates/_skip.sh +31 -3
  4. package/kit/gates/commit-explains-itself/gate.yml +1 -0
  5. package/kit/gates/complexity-limit/gate.yml +1 -0
  6. package/kit/gates/dead-code/gate.yml +1 -0
  7. package/kit/gates/deps-are-pinned/gate.yml +1 -0
  8. package/kit/gates/duplicate-code/gate.yml +1 -0
  9. package/kit/gates/entry-links-exist/gate.yml +1 -0
  10. package/kit/gates/file-size-limit/gate.yml +1 -0
  11. package/kit/gates/gate-has-samples/gate.yml +1 -0
  12. package/kit/gates/gates-are-runnable/gate.yml +1 -0
  13. package/kit/gates/gates-run-in-ci/gate.yml +1 -0
  14. package/kit/gates/lesson-has-outcome/gate.yml +1 -0
  15. package/kit/gates/no-print-in-prod/check.sh +3 -1
  16. package/kit/gates/no-print-in-prod/gate.yml +1 -0
  17. package/kit/gates/secrets-not-in-code/gate.yml +1 -0
  18. package/kit/gates/swallowed-error/gate.yml +1 -0
  19. package/kit/gates/todo-without-task/gate.yml +1 -0
  20. package/package.json +1 -1
  21. package/tool/commands/doctor.mjs +38 -41
  22. package/tool/commands/gates.mjs +103 -109
  23. package/tool/commands/project.mjs +84 -85
  24. package/tool/commands/report.mjs +194 -0
  25. package/tool/i18n/en.mjs +423 -0
  26. package/tool/i18n/index.mjs +33 -0
  27. package/tool/i18n/ru.mjs +424 -0
  28. package/tool/i18n/templates-en.mjs +164 -0
  29. package/tool/i18n/templates-ru.mjs +170 -0
  30. package/tool/lib/core.mjs +5 -1
  31. package/tool/lib/manifest.mjs +12 -31
  32. package/tool/lib/repo.mjs +45 -21
  33. package/tool/lib/templates.mjs +38 -182
  34. package/tool/program.mjs +31 -9
  35. package/tool/selfcheck/gates.sh +7 -1
  36. package/tool/selfcheck/smoke.sh +97 -0
  37. package/tool/selfcheck/units.mjs +80 -5
package/tool/program.mjs CHANGED
@@ -17,9 +17,11 @@
17
17
  import { realpathSync } from "node:fs";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { c, SELF } from "./lib/core.mjs";
20
+ import { L } from "./i18n/index.mjs";
20
21
  import { cmdInit, cmdNote, cmdBlob, cmdStart } from "./commands/project.mjs";
21
22
  import { cmdDoctor } from "./commands/doctor.mjs";
22
23
  import { cmdAdd, cmdNew, cmdRatchet, cmdFind, cmdWhy } from "./commands/gates.mjs";
24
+ import { cmdReport } from "./commands/report.mjs";
23
25
 
24
26
  // Разбор аргументов выполняется только при запуске файла как программы. При импорте —
25
27
  // а так его читают модульные проверки tool/selfcheck/units.mjs — CLI запускаться не должен.
@@ -63,19 +65,39 @@ if (IS_MAIN) {
63
65
  case "blob":
64
66
  await cmdBlob();
65
67
  break;
66
- default:
68
+ case "report":
69
+ await cmdReport();
70
+ break;
71
+ default: {
72
+ // Ширина колонки считается, а не подбирается пробелами: строки в двух языках разной
73
+ // длины, и вручную выровненная справка на втором языке разъезжается.
74
+ const h = L.help;
75
+ const rows = [
76
+ [`${SELF} init`, h.init],
77
+ [`${SELF} init --force`, h.initForce],
78
+ [`${SELF} start`, h.start],
79
+ [`${SELF} doctor`, h.doctor],
80
+ [`${SELF} doctor --run`, h.doctorRun],
81
+ [`${SELF} add ${h.name}`, h.add],
82
+ [`${SELF} find "…"`, h.find],
83
+ [`${SELF} why "…"`, h.why],
84
+ [`${SELF} ratchet ${h.name}`, h.ratchet],
85
+ [`${SELF} new ${h.name}`, h.new],
86
+ [`${SELF} note "…"`, h.note],
87
+ [`${SELF} blob`, h.blob],
88
+ [`${SELF} report`, h.report],
89
+ ];
90
+ const width = Math.max(...rows.map(([cmdText]) => cmdText.length));
91
+ const lines = rows.map(([cmdText, text]) => ` ${c.bold(cmdText.padEnd(width))} ${text}`);
67
92
  console.log(`
68
- ${c.bold("aqk")} — оснастка для разработки с агентами
93
+ ${c.bold("aqk")} — ${h.tagline}
69
94
 
70
- ${c.bold(`${SELF} init`)} разложить правила и методички в текущий проект
71
- ${c.bold(`${SELF} init --force`)} перезаписать уже существующие файлы
72
- ${c.bold(`${SELF} start`)} кода ещё нет: сторожа дня 0 и порядок работы
73
- ${c.bold(`${SELF} doctor`)} проверить, что разложено и чего не хватает\n ${c.bold(`${SELF} doctor --run`)} ещё и запустить объявленные гейты\n ${c.bold(`${SELF} add`)} <имя> поставить гейт из каталога в проект\n ${c.bold(`${SELF} find`)} "…" есть ли уже такой гейт — сверка по намерению\n ${c.bold(`${SELF} why`)} "…" поймал ошибку — почему её не поймал сторож\n ${c.bold(`${SELF} ratchet`)} <имя> храповик: старые нарушения — долг, новые не пускать\n ${c.bold(`${SELF} new`)} <имя> заготовка своего гейта для каталога
74
- ${c.bold(`${SELF} note`)} "…" записать урок в общий журнал шишек
75
- ${c.bold(`${SELF} blob`)} собрать методички в один файл GOD_AI.md
95
+ ${lines.join("\n")}
76
96
 
77
- ${c.dim("Без установки: npx agent-quality-kit init")}
97
+ ${c.dim(h.noInstall)}
98
+ ${c.dim(h.language)}
78
99
  `);
79
100
  process.exit(cmd ? 1 : 0);
101
+ }
80
102
  }
81
103
  }
@@ -33,9 +33,15 @@ for GATE in "$CAT"/*/; do
33
33
  PROOF="$(field "$YML" proof)"
34
34
  [ -n "$INTENT" ] || bad "$SLUG: пустое поле intent — по нему идёт дедупликация"
35
35
 
36
+ # Английское намерение обязательно для НАШЕГО каталога: программа печатает его тем, кто
37
+ # пришёл с англоязычной площадки, и запись без перевода показала бы им кириллицу. Для
38
+ # чужой записи, принесённой со стороны, поле остаётся необязательным — программа тогда
39
+ # покажет то, что есть. Требовать перевод от вкладчика значит закрыть вклад половине.
40
+ [ -n "$(field "$YML" intent_en)" ] || bad "$SLUG: нет intent_en — намерение на английском"
41
+
36
42
  # Заготовка от `aqk new` не должна проехать как запись: незаполненный гейт — мёртвое
37
43
  # правило, а мёртвое правило учит игнорировать и живые.
38
- if grep -q 'ЗАПОЛНИ' "$YML" "$GATE/README.md" "$GATE/check.sh" 2>/dev/null; then
44
+ if grep -qE 'ЗАПОЛНИ|FILL IN|FILL_IN' "$YML" "$GATE/README.md" "$GATE/check.sh" 2>/dev/null; then
39
45
  bad "$SLUG: заготовка не заполнена — остались метки ЗАПОЛНИ"
40
46
  continue
41
47
  fi
@@ -13,6 +13,10 @@
13
13
 
14
14
  set -uo pipefail
15
15
 
16
+ # Язык вывода закреплён: проверки ниже сверяют русский текст, а без этой строки они зависели бы
17
+ # от локали машины — на англоязычном раннере зелёное стало бы красным без единой правки в коде.
18
+ export AQK_LANG=ru
19
+
16
20
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
17
21
  CLI="$ROOT/tool/program.mjs"
18
22
  PASS=0
@@ -527,6 +531,99 @@ else
527
531
  fi
528
532
  rm -rf "$SHDIR" "$CEDIR"
529
533
 
534
+ # --- 32. вывод действительно на двух языках -----------------------------------
535
+ # Сверка ключей каталогов (units.mjs) доказывает, что строки не разошлись, но не доказывает,
536
+ # что выбор языка вообще доехал до вывода. Это проверяется только запуском.
537
+ EN_OUT=$(AQK_LANG=en node "$CLI" 2>&1)
538
+ RU_OUT=$(AQK_LANG=ru node "$CLI" 2>&1)
539
+ if printf '%s' "$EN_OUT" | grep -q "install a gate from the catalogue" &&
540
+ ! printf '%s' "$EN_OUT" | grep -q '[а-яА-ЯёЁ]' &&
541
+ printf '%s' "$RU_OUT" | grep -q "поставить гейт из каталога"; then
542
+ ok "справка печатается на двух языках, в английской нет кириллицы"
543
+ else
544
+ bad "выбор языка не доехал до вывода" "$(printf '%s' "$EN_OUT" | head -4)"
545
+ fi
546
+
547
+ # --- 33. ссылка на репозиторий ведёт в репозиторий -----------------------------
548
+ # Ссылку собирали из имени пакета. Когда имя стало коротким («agent-quality-kit» вместо
549
+ # «github:владелец/репозиторий»), просьба про звезду поехала на github.com/agent-quality-kit —
550
+ # несуществующую страницу. Единственное место, где мы просим человека о чём-то, вело в никуда.
551
+ FBDIR="$(mktemp -d)"; FBPROJ="$(mktemp -d)"
552
+ FB_OUT=$( cd "$FBPROJ" && git init -q . && HOME="$FBDIR" node "$CLI" init 2>&1 )
553
+ if printf '%s' "$FB_OUT" | grep -qE 'https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'; then
554
+ ok "просьба про звезду ведёт на репозиторий, а не на github.com/<имя пакета>"
555
+ else
556
+ bad "ссылка на репозиторий собрана неверно" "$(printf '%s' "$FB_OUT" | grep -i github | head -2)"
557
+ fi
558
+ rm -rf "$FBDIR" "$FBPROJ"
559
+
560
+ # --- 34. обязательная форма отчёта -------------------------------------------
561
+ # ЗАЧЕМ. Первый чужой прогон дал отчёт «12 гейтов зелёные» — при том что все 12 стояли на
562
+ # слабейшем рецепте, а половина методичек не была прочитана. Пересказ по памяти выбирает
563
+ # удобное; отчёт обязан собираться прогоном.
564
+ REPDIR="$(mktemp -d)"
565
+ (
566
+ cd "$REPDIR" && git init -q . && mkdir -p src &&
567
+ printf 'def f():\n print("debug")\n' > src/a.py &&
568
+ node "$CLI" start >/dev/null 2>&1
569
+ )
570
+ REP_OUT=$( cd "$REPDIR" && node "$CLI" report 2>&1 ); REP_CODE=$?
571
+ if [ "$REP_CODE" -ne 0 ] && printf '%s' "$REP_OUT" | grep -q '❌ no-print-in-prod'; then
572
+ ok "report краснеет кодом возврата и называет упавший гейт"
573
+ else
574
+ bad "report не отличает красное от зелёного" "код $REP_CODE"
575
+ fi
576
+ if [ -f "$REPDIR/.aqk/report.md" ] && grep -q '^## ' "$REPDIR/.aqk/report.md"; then
577
+ ok "report сохраняет .aqk/report.md"
578
+ else
579
+ bad "report не сохранил файл отчёта" "$REPDIR/.aqk/report.md"
580
+ fi
581
+ # Путь к методичке ИЩЕТСЯ: baseline лежит в подпапке ai/, и жёстко вписанный путь уже соврал.
582
+ if printf '%s' "$REP_OUT" | grep -q '📖 .aqk/docs/ai/project-baseline.md'; then
583
+ ok "report находит методичку в подпапке, а не пишет путь наизусть"
584
+ else
585
+ bad "report не нашёл project-baseline.md" "$(printf '%s' "$REP_OUT" | grep -i baseline | head -1)"
586
+ fi
587
+ rm -rf "$REPDIR"
588
+
589
+ # --- 35. note пишет в журнал ЭТОГО проекта, а не в чужой ---------------------
590
+ # Команда требовала клон нашего репозитория и писала урок туда, игнорируя lessons: из
591
+ # манифеста проекта. Найдено первым чужим прогоном: человек завёл журнал руками.
592
+ NOTEDIR="$(mktemp -d)"; NOTEHOME="$(mktemp -d)"
593
+ (
594
+ cd "$NOTEDIR" && git init -q . && git config user.email t@t && git config user.name t &&
595
+ node "$CLI" init >/dev/null 2>&1 &&
596
+ mkdir -p incidents &&
597
+ sed -i 's|^lessons: ""|lessons: incidents|' .aqk.yml &&
598
+ printf '**Вывод.** 🔧 завели проверку\n' | HOME="$NOTEHOME" AQK_HOME="" node "$CLI" note "шишка" >/dev/null 2>&1
599
+ )
600
+ if [ -f "$NOTEDIR/incidents/README.md" ] && grep -q 'шишка' "$NOTEDIR/incidents/README.md"; then
601
+ ok "note пишет в journal этого проекта — lessons: из манифеста"
602
+ else
603
+ bad "note проигнорировал lessons: и ушёл искать чужой клон" "$NOTEDIR/incidents/README.md"
604
+ fi
605
+ rm -rf "$NOTEDIR" "$NOTEHOME"
606
+
607
+ # --- 36. .aqkignore прячет принесённый извне код ------------------------------
608
+ # ЗАЧЕМ. В чужом проекте референс, принесённый из другого репозитория, попадал в находки
609
+ # всех сканирующих гейтов. Единственным лечением была правка КОПИИ _skip.sh в проекте —
610
+ # то есть настройка правкой чужого файла, которую затрёт следующий `aqk add`.
611
+ IGNDIR="$(mktemp -d)"
612
+ mkdir -p "$IGNDIR/third-party/inner" "$IGNDIR/src"
613
+ printf 'def f():\n print("свой")\n' > "$IGNDIR/src/mine.py"
614
+ printf 'def f():\n print("чужой")\n' > "$IGNDIR/third-party/inner/theirs.py"
615
+ OUT_BEFORE="$(bash "$ROOT/kit/gates/no-print-in-prod/check.sh" "$IGNDIR" 2>&1)"
616
+ printf '# принесено из другого репозитория\nthird-party/\n' > "$IGNDIR/.aqkignore"
617
+ OUT_AFTER="$(bash "$ROOT/kit/gates/no-print-in-prod/check.sh" "$IGNDIR" 2>&1)"
618
+ if printf '%s' "$OUT_BEFORE" | grep -q 'theirs.py' &&
619
+ ! printf '%s' "$OUT_AFTER" | grep -q 'theirs.py' &&
620
+ printf '%s' "$OUT_AFTER" | grep -q 'mine.py'; then
621
+ ok ".aqkignore прячет чужой код и не трогает свой"
622
+ else
623
+ bad ".aqkignore не работает" "до: $(printf '%s' "$OUT_BEFORE" | head -2) | после: $(printf '%s' "$OUT_AFTER" | head -2)"
624
+ fi
625
+ rm -rf "$IGNDIR"
626
+
530
627
  # --- итог -------------------------------------------------------------------
531
628
  printf '\n'
532
629
  if [ "$FAIL" -eq 0 ]; then
@@ -12,7 +12,9 @@
12
12
  import test from "node:test";
13
13
  import assert from "node:assert/strict";
14
14
  import { parseManifest, manifestWithGate } from "../lib/manifest.mjs";
15
- import { triggerVerdict, recipeFor, stems, overlap, EXT_LANG } from "../lib/repo.mjs";
15
+ import { triggerVerdict, recipeFor, stems, overlap, EXT_LANG, whichSync } from "../lib/repo.mjs";
16
+ import { CATALOGS, pickLang, L } from "../i18n/index.mjs";
17
+ import { dirname } from "node:path";
16
18
 
17
19
  const facts = (over = {}) => ({ langs: new Set(), files: 0, ...over });
18
20
 
@@ -52,7 +54,8 @@ test("условия складываются по И: одно ложное с
52
54
 
53
55
  test("причина, по которой запись скрыта, называется словами", () => {
54
56
  const v = triggerVerdict({ trigger: { langs: "python, typescript" } }, facts({ langs: new Set(["go"]) }));
55
- assert.match(v.why, /нет языков: python, typescript/);
57
+ // Сверяем с каталогом, а не с буквами: текст переводится, а выбор причины — нет.
58
+ assert.equal(v.why, L.trigger.noLangs("python, typescript"));
56
59
  });
57
60
 
58
61
  test("always: false значит «никогда не применимо», а не «условие пропущено»", () => {
@@ -64,7 +67,7 @@ test("неизвестное условие скрывает запись, а н
64
67
  // Молча пропустить незнакомое условие значит показать запись всем подряд.
65
68
  const v = triggerVerdict({ trigger: { has_kubernetes: "true" } }, facts());
66
69
  assert.equal(v.applies, false);
67
- assert.match(v.why, /не умеет считать/);
70
+ assert.equal(v.why, L.trigger.unknown("has_kubernetes"));
68
71
  });
69
72
 
70
73
  // --- выбор рецепта ------------------------------------------------------------
@@ -74,7 +77,7 @@ test("без родного языка берётся переносимый р
74
77
  });
75
78
 
76
79
  test("рецепта нет — так и сказано, а не пустая строка", () => {
77
- assert.equal(recipeFor({ slug: "x", recipes: {} }, facts()), "рецепт не описан");
80
+ assert.equal(recipeFor({ slug: "x", recipes: {} }, facts()), L.recipe.none);
78
81
  });
79
82
 
80
83
  // --- дедупликация по намерению ------------------------------------------------
@@ -101,5 +104,77 @@ test("гейт дописывается в блок gates и не дублиру
101
104
  test("без блока gates программа объясняет, чего не хватает", () => {
102
105
  const r = manifestWithGate("aqk: 1\n", "x", "bash y.sh");
103
106
  assert.equal(r.text, null);
104
- assert.match(r.why, /нет блока gates/);
107
+ assert.equal(r.why, L.manifest.noGatesBlock);
108
+ });
109
+
110
+ // --- каталоги строк не расходятся ---------------------------------------------
111
+ // ЗАЧЕМ. «Поддерживаем два языка» — утверждение, которое обязана держать машина, а не память
112
+ // того, кто правил вывод в последний раз. Забытый ключ в одном каталоге даёт `undefined` в
113
+ // выводе — не отказ, а тихую порчу текста ровно у того, кто пришёл на втором языке.
114
+ // В массивы заходим тоже: ступени уровней и многострочные пояснения лежат массивами, и без
115
+ // этого проверка сравнивала бы их как один непрозрачный «object» — то есть не сравнивала.
116
+ // Заодно сверяется длина: пояснение из трёх строк на одном языке и из двух на другом — тоже
117
+ // расхождение.
118
+ function keyPaths(obj, prefix = "") {
119
+ const out = [];
120
+ for (const [k, v] of Object.entries(obj)) {
121
+ const path = prefix ? `${prefix}.${k}` : k;
122
+ if (v && typeof v === "object") out.push(...keyPaths(v, path));
123
+ else out.push(`${path}:${typeof v}`);
124
+ }
125
+ return out.sort();
126
+ }
127
+
128
+ test("оба каталога строк несут одни и те же ключи одного типа", () => {
129
+ const a = keyPaths(CATALOGS.ru);
130
+ const b = keyPaths(CATALOGS.en);
131
+ const onlyRu = a.filter((k) => !b.includes(k));
132
+ const onlyEn = b.filter((k) => !a.includes(k));
133
+ assert.deepEqual(onlyRu, [], `есть только в ru: ${onlyRu.join(", ")}`);
134
+ assert.deepEqual(onlyEn, [], `есть только в en: ${onlyEn.join(", ")}`);
135
+ assert.ok(a.length > 0);
136
+ });
137
+
138
+ test("ни одна строка вывода не осталась пустой", () => {
139
+ for (const [lang, cat] of Object.entries(CATALOGS)) {
140
+ for (const path of keyPaths(cat)) {
141
+ const [key, kind] = path.split(":");
142
+ if (kind !== "string") continue;
143
+ const value = key.split(".").reduce((o, k) => o[k], cat);
144
+ assert.ok(value.trim().length > 0, `пустая строка ${lang}.${key}`);
145
+ }
146
+ }
147
+ });
148
+
149
+ test("язык берётся из AQK_LANG, потом из локали, иначе английский", () => {
150
+ assert.equal(pickLang({ AQK_LANG: "ru" }), "ru");
151
+ assert.equal(pickLang({ AQK_LANG: "en_US.UTF-8", LANG: "ru_RU.UTF-8" }), "en");
152
+ assert.equal(pickLang({ LANG: "ru_RU.UTF-8" }), "ru");
153
+ assert.equal(pickLang({ LC_ALL: "ru_RU.UTF-8", LANG: "en_US.UTF-8" }), "ru");
154
+ assert.equal(pickLang({ LANG: "de_DE.UTF-8" }), "en");
155
+ assert.equal(pickLang({}), "en");
156
+ });
157
+
158
+ // --- поиск программы в PATH ---------------------------------------------------
159
+ // ЗАЧЕМ. Раньше наличие программы проверялось через `command -v` в оболочке. На Windows
160
+ // оболочка — cmd.exe, где такой команды нет, и ответ был «не установлено» ДЛЯ ЛЮБОЙ
161
+ // программы: родной рецепт становился недостижим, гейт молча вставал на слабейший
162
+ // переносимый вариант, а прогон показывал зелёное. Нашлось на чужом прогоне, не у нас.
163
+ test("программа в PATH находится, несуществующая — нет", () => {
164
+ assert.ok(whichSync("node"), "node обязан находиться: им же запущена эта проверка");
165
+ assert.equal(whichSync("нет-такой-программы-12345"), null);
166
+ assert.equal(whichSync(""), null);
167
+ });
168
+
169
+ test("поиск не зависит от оболочки — работает с пустым окружением", () => {
170
+ // Тот самый случай: оболочки нет или она другая. Ответ обязан быть «не нашли»,
171
+ // а не исключение и не ложное «нашли».
172
+ assert.equal(whichSync("node", { PATH: "" }), null);
173
+ const dir = dirname(process.execPath);
174
+ assert.ok(whichSync(process.platform === "win32" ? "node" : "node", { PATH: dir }));
175
+ });
176
+
177
+ test("команда путём, а не именем, ищется на диске, а не в PATH", () => {
178
+ assert.ok(whichSync(process.execPath));
179
+ assert.equal(whichSync("./нет-такого-файла.sh"), null);
105
180
  });