agent-quality-kit 0.4.2 → 0.5.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 (41) hide show
  1. package/README.md +101 -12
  2. package/README.ru.md +101 -11
  3. package/kit/gates/README.md +15 -0
  4. package/kit/gates/_native.sh +18 -2
  5. package/kit/gates/_skip.sh +18 -0
  6. package/kit/gates/color-from-token/README.md +52 -0
  7. package/kit/gates/color-from-token/check.sh +69 -0
  8. package/kit/gates/color-from-token/gate.yml +15 -0
  9. package/kit/gates/color-from-token/green/Button.tsx +4 -0
  10. package/kit/gates/color-from-token/green/Panel.vue +4 -0
  11. package/kit/gates/color-from-token/green/card.css +5 -0
  12. package/kit/gates/color-from-token/green/notes.md +2 -0
  13. package/kit/gates/color-from-token/green/tokens.css +7 -0
  14. package/kit/gates/color-from-token/red/Button.tsx +4 -0
  15. package/kit/gates/color-from-token/red/Panel.vue +4 -0
  16. package/kit/gates/color-from-token/red/card.css +5 -0
  17. package/kit/gates/commit-explains-itself/check.sh +25 -2
  18. package/kit/gates/duplicate-code/check.sh +13 -4
  19. package/kit/gates/duplicate-code/gate.yml +11 -2
  20. package/kit/gates/gate-has-samples/check.sh +9 -3
  21. package/kit/gates/gates-are-runnable/check.sh +7 -1
  22. package/kit/gates/gates-run-in-ci/check.sh +7 -1
  23. package/kit/gates/lesson-has-outcome/check.sh +6 -1
  24. package/kit/ratchet/ratchet.sh +9 -2
  25. package/llms.txt +57 -0
  26. package/package.json +2 -1
  27. package/tool/commands/doctor.mjs +62 -7
  28. package/tool/commands/gates.mjs +9 -2
  29. package/tool/commands/project.mjs +7 -4
  30. package/tool/commands/report.mjs +2 -2
  31. package/tool/i18n/en.mjs +38 -0
  32. package/tool/i18n/ru.mjs +43 -0
  33. package/tool/lib/baseline.mjs +87 -0
  34. package/tool/lib/core.mjs +10 -2
  35. package/tool/lib/manifest.mjs +33 -2
  36. package/tool/lib/repo.mjs +7 -0
  37. package/tool/selfcheck/gates.sh +9 -2
  38. package/tool/selfcheck/mutation.sh +95 -0
  39. package/tool/selfcheck/smoke.sh +216 -8
  40. package/tool/selfcheck/syntax.sh +9 -1
  41. package/tool/selfcheck/units.mjs +76 -1
@@ -0,0 +1,87 @@
1
+ // tool/lib/baseline.mjs — обязательный минимум проекта, проверяемый прогоном.
2
+ //
3
+ // ЗАЧЕМ. `kit/docs/ai/project-baseline.md` — 50 пунктов «что обязано быть, чтобы работу можно
4
+ // было отдать агентам». До сих пор это было единственное место, где комплект просил верить на
5
+ // слово, что человек прочитал и сверился. Ручной проход по живому проекту нашёл настоящее:
6
+ // логирование не задано вовсе, трекера ошибок нет, задачи конвейера не запускались ни разу.
7
+ // Дисциплина не масштабируется — то, что проверяется машиной, проверяет машина.
8
+ //
9
+ // ЧЕСТНАЯ ГРАНИЦА. Машина проверяет НАЛИЧИЕ, а не работоспособность: «линтер настроен» — не то
10
+ // же, что «линтер ловит». Поэтому вывод говорит, чем именно доказан пункт, а непроверяемые
11
+ // пункты называются числом, а не прячутся.
12
+ //
13
+ // НЕЙТРАЛЬНОСТЬ К СТЕКУ — условие, а не пожелание. Каждый признак — это семейство маркеров
14
+ // разных экосистем; пункт засчитывается по любому из них. Список, знающий только про npm,
15
+ // объявил бы половину мира несоответствующей.
16
+
17
+ // Совпадение по имени файла в корне: точное имя, либо имя, начинающееся с образца (`.eslintrc*`).
18
+ function hit(files, names) {
19
+ const low = files.map((f) => f.toLowerCase());
20
+ return names.find((n) => {
21
+ const k = n.toLowerCase();
22
+ return k.endsWith("*") ? low.some((f) => f.startsWith(k.slice(0, -1))) : low.includes(k);
23
+ });
24
+ }
25
+
26
+ // Пункты, которые машина может подтвердить фактом, а не мнением. Остальные сорок с лишним
27
+ // остаются человеку — и называются вслух, чтобы «не проверено» не читалось как «в порядке».
28
+ const ITEMS = [
29
+ { n: 1, key: "oneCommand", files: ["makefile", "justfile", "taskfile.yml", "taskfile.yaml", "rakefile", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml", "package.json", "mise.toml", "pixi.toml"] },
30
+ { n: 2, key: "lockfile", files: ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "poetry.lock", "pipfile.lock", "uv.lock", "requirements.txt", "go.sum", "cargo.lock", "gemfile.lock", "composer.lock", "gradle.lockfile", "packages.lock.json", "pubspec.lock", "mix.lock"] },
31
+ { n: 3, key: "sameEnv", files: ["dockerfile", "containerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml", ".devcontainer", "devcontainer.json", "flake.nix", "shell.nix", ".tool-versions", ".nvmrc", ".python-version", ".ruby-version", ".sdkmanrc", "mise.toml", "asdf.toml"] },
32
+ { n: 6, key: "formatter", files: [".editorconfig", ".prettierrc*", "prettier.config*", "rustfmt.toml", ".rustfmt.toml", ".clang-format", ".scalafmt.conf", ".rubocop.yml", "biome.json", "biome.jsonc", ".dprint.json", "dprint.json"] },
33
+ { n: 7, key: "linter", files: [".eslintrc*", "eslint.config*", "ruff.toml", ".ruff.toml", ".flake8", ".pylintrc", ".golangci.yml", ".golangci.yaml", "clippy.toml", ".rubocop.yml", "phpstan.neon", "psalm.xml", "detekt.yml", ".swiftlint.yml", "biome.json", ".credo.exs", "checkstyle.xml"] },
34
+ { n: 8, key: "types", files: ["tsconfig.json", "jsconfig.json", "mypy.ini", ".mypy.ini", "pyrightconfig.json", "sorbet", "go.mod", "cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "stack.yaml", "dune-project"] },
35
+ { n: 9, key: "secretScan", files: [".gitleaks.toml", "gitleaks.toml", ".secrets.baseline", ".trufflehogignore", ".talismanrc", ".gitguardian.yml", ".gitguardian.yaml"], gate: "secrets-not-in-code" },
36
+ { n: 10, key: "fileSize", gate: "file-size-limit" },
37
+ { n: 11, key: "ownInvariants", gate: "no-print-in-prod" },
38
+ { n: 13, key: "tests", fact: "has_tests" },
39
+ { n: 19, key: "pipeline", fact: "has_ci" },
40
+ { n: 30, key: "errorTracker", deps: ["sentry", "rollbar", "bugsnag", "honeybadger", "airbrake", "appsignal", "datadog", "newrelic", "new-relic", "elastic-apm", "opentelemetry", "glitchtip"] },
41
+ { n: 39, key: "machineReadable", manifestField: "entry" },
42
+ { n: 42, key: "rulesInRepo", manifestField: "rules" },
43
+ ];
44
+
45
+ // Файлы, в которых объявляют зависимости. Один список на все экосистемы: пункт про трекер
46
+ // ошибок нейтрален, а знать про один только npm — значит объявить половину мира несоответствующей.
47
+ const DEP_FILES = [
48
+ "package.json", "requirements.txt", "pyproject.toml", "pipfile", "poetry.lock", "uv.lock",
49
+ "go.mod", "cargo.toml", "gemfile", "composer.json", "build.gradle", "build.gradle.kts",
50
+ "pom.xml", "mix.exs", "pubspec.yaml", "project.clj", "deps.edn",
51
+ ];
52
+
53
+ // Чем подтверждён пункт, возвращается СТРУКТУРОЙ, а не готовой фразой: текст переводится,
54
+ // а факт — нет. Собранная здесь строка утекла бы в английский вывод по-русски; так и вышло.
55
+ /**
56
+ * Вход — только факты, никакого ввода-вывода: функция чистая и проверяется модульно.
57
+ * files — имена файлов и каталогов в корне репозитория
58
+ * gateKeys — гейты, объявленные в манифесте
59
+ * facts — то, что уже насчитал осмотр репозитория (has_ci, has_tests)
60
+ * manifest — разобранный .aqk.yml
61
+ * depsText — склеенное содержимое файлов зависимостей, в нижнем регистре
62
+ */
63
+ function assessBaseline({ files = [], gateKeys = [], facts = {}, manifest = {}, depsText = "" }) {
64
+ return ITEMS.map((it) => {
65
+ if (it.gate && gateKeys.includes(it.gate)) return { n: it.n, key: it.key, ok: true, by: { kind: "gate", value: it.gate } };
66
+ if (it.fact) return { n: it.n, key: it.key, ok: Boolean(facts[it.fact]), by: { kind: "fact", value: it.fact } };
67
+ if (it.manifestField) {
68
+ const v = manifest?.[it.manifestField];
69
+ const filled = Array.isArray(v) ? v.length > 0 : Boolean(String(v || "").trim());
70
+ return { n: it.n, key: it.key, ok: filled, by: { kind: "manifest", value: it.manifestField } };
71
+ }
72
+ if (it.deps) {
73
+ const found = it.deps.find((d) => depsText.includes(d));
74
+ return { n: it.n, key: it.key, ok: Boolean(found), by: found ? { kind: "dep", value: found } : null };
75
+ }
76
+ const f = hit(files, it.files || []);
77
+ return { n: it.n, key: it.key, ok: Boolean(f), by: f ? { kind: "file", value: f } : null };
78
+ });
79
+ }
80
+
81
+ // Всего пунктов в методичке. Число не выводится из кода: методичка — текст, и её длину знает
82
+ // только она сама. Сверяется проверкой, чтобы не разошлось молча.
83
+ const BASELINE_TOTAL = 50;
84
+
85
+ // Наружу — только то, что зовут. Экспорт, который никто не импортирует, читается как «это
86
+ // часть договора» и мешает менять внутренности; `hit` остался внутри. Поймал наш же dead-code.
87
+ export { assessBaseline, ITEMS, DEP_FILES, BASELINE_TOTAL };
package/tool/lib/core.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  import { access, readdir, mkdir, copyFile, writeFile } from "node:fs/promises";
8
8
  import { constants } from "node:fs";
9
9
  import { fileURLToPath } from "node:url";
10
- import { dirname, join, resolve, relative } from "node:path";
10
+ import { dirname, join, relative, resolve, sep } from "node:path";
11
11
  import { homedir } from "node:os";
12
12
 
13
13
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -71,6 +71,14 @@ const RATCHET_LIB = `${RATCHET_DIR}/_ratchet.sh`;
71
71
  // то и другое врёт о том, видел человек просьбу или нет.
72
72
  const FEEDBACK_MARK = join(homedir(), ".config", "aqk", "feedback-shown");
73
73
 
74
+ // Путь, попадающий в ДОКУМЕНТ, всегда пишется через «/». `relative()` отдаёт разделитель
75
+ // платформы, и на Windows склейка методичек и отчёт получались с «kit\\docs» вместо «kit/docs»:
76
+ // артефакт, который человек читает и пересылает, оказывался разным на разных системах. Найдено
77
+ // заданием конвейера на windows-latest — шестьдесят шесть проверок на Linux этого не видели.
78
+ function docPath(from, to) {
79
+ return relative(from, to).split(sep).join("/");
80
+ }
81
+
74
82
  async function copyDir(src, dst, { force }) {
75
83
  await mkdir(dst, { recursive: true });
76
84
  const entries = await readdir(src, { withFileTypes: true });
@@ -98,7 +106,7 @@ async function writeIfAbsent(path, content, { force }) {
98
106
 
99
107
  export {
100
108
  copyDir, writeIfAbsent,
101
- PKG_ROOT, CWD, DOCS_SRC, RULES_SRC, TARGET_DIR,
109
+ PKG_ROOT, CWD, DOCS_SRC, RULES_SRC, TARGET_DIR, docPath,
102
110
  MANIFEST, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB,
103
111
  SELF, REPO_URL, c, exists, die, FEEDBACK_MARK,
104
112
  };
@@ -12,11 +12,24 @@ import { L } from "../i18n/index.mjs";
12
12
  // Разбор ограниченного подмножества YAML: ключ, вложенный на один уровень ключ, список.
13
13
  // НАМЕРЕННО без библиотеки: манифест обязан быть настолько простым, чтобы его разбирал
14
14
  // кусок кода, который читается за минуту. Сложный манифест никто не заполнит.
15
+ // Срезает комментарий по правилу YAML: решётка начинает комментарий только с начала строки
16
+ // или после пробела. Безусловное `replace(/#.*$/)` молча обрезало команду
17
+ // `npx jscpd --format "java,c#,php"` на «c» — гейт запускал не то, что объявлено, и об этом
18
+ // никто не узнавал. Объявленное и исполняемое обязаны совпадать: на этом стоит весь стандарт.
19
+ //
20
+ // Пары кавычек не отслеживаем намеренно: в рецептах кавычки соседние, а не вложенные
21
+ // (`"bash x.sh --format "a,b" ."`), и подсчёт пар решил бы, что «c#» стоит снаружи.
22
+ function stripComment(raw) {
23
+ const i = raw.search(/(^|\s)#/);
24
+ if (i < 0) return raw;
25
+ return raw[i] === "#" ? raw.slice(0, i) : raw.slice(0, i + 1);
26
+ }
27
+
15
28
  function parseManifest(text) {
16
29
  const out = {};
17
30
  let section = null;
18
31
  for (const raw of text.split("\n")) {
19
- const line = raw.replace(/#.*$/, "").replace(/\s+$/, "");
32
+ const line = stripComment(raw).replace(/\s+$/, "");
20
33
  if (!line.trim()) continue;
21
34
  const indented = /^\s/.test(line);
22
35
  const listItem = line.trim().startsWith("- ");
@@ -55,6 +68,24 @@ function parseManifest(text) {
55
68
  return out;
56
69
  }
57
70
 
71
+ // Поля, которые манифест знает. Список здесь, а не в схеме-файле: зависимостей у программы
72
+ // нет, а схема на восемь ключей, которую надо валидировать библиотекой, стоит дороже, чем
73
+ // защищает.
74
+ //
75
+ // ЗАЧЕМ ЭТО ВООБЩЕ. Разбор принимает любое имя поля. Опечатка `gate:` вместо `gates:` молча
76
+ // означала «гейтов не объявлено»: вердикт выдавался неверный, а причина не называлась. Это
77
+ // ровно тот класс, против которого построен стандарт — тишина неотличима от успеха, — только
78
+ // внутри самой программы.
79
+ // Список обязан совпадать с тем, что программа РЕАЛЬНО читает (`man?.<поле>` в tool/):
80
+ // лишнее имя здесь молча узаконивает поле, которое ни на что не влияет, — та же тишина,
81
+ // только с другой стороны. Сверено обходом: aqk, entry, rules, gates, samples, ratchets, lessons.
82
+ const KNOWN_KEYS = ["aqk", "entry", "rules", "gates", "samples", "ratchets", "lessons"];
83
+
84
+ function unknownKeys(man) {
85
+ if (!man || typeof man !== "object" || Array.isArray(man)) return [];
86
+ return Object.keys(man).filter((k) => !KNOWN_KEYS.includes(k));
87
+ }
88
+
58
89
  async function readManifest() {
59
90
  const p = join(CWD, MANIFEST);
60
91
  if (!(await exists(p))) return null;
@@ -118,4 +149,4 @@ function manifestWithGate(text, slug, cmd) {
118
149
  return { text: out, why: null };
119
150
  }
120
151
 
121
- export { parseManifest, readManifest, assessLevel, manifestWithGate };
152
+ export { parseManifest, readManifest, assessLevel, manifestWithGate, unknownKeys, KNOWN_KEYS };
package/tool/lib/repo.mjs CHANGED
@@ -49,6 +49,11 @@ async function detectFacts(man) {
49
49
  let files = 0;
50
50
  let hasDb = false;
51
51
  let hasTests = false;
52
+ // Признак интерфейса: стили или однофайловые компоненты. Нужен записям про внешний вид —
53
+ // без него запрет литерального цвета показывался бы каждому бэкенду, библиотеке и CLI на
54
+ // JS, где интерфейса нет вовсе. Записи, показанной не тому, не верят, и каталог теряет
55
+ // доверие целиком, а не одной строкой.
56
+ let hasUi = false;
52
57
 
53
58
  async function walk(dir, depth) {
54
59
  if (depth > 4 || files > 4000) return;
@@ -72,6 +77,7 @@ async function detectFacts(man) {
72
77
  files++;
73
78
  if (/\.(test|spec)\.[a-z]+$/i.test(it.name) || /^test_.*\.py$/i.test(it.name) || /_test\.go$/i.test(it.name)) hasTests = true;
74
79
  if (it.name.endsWith(".sql")) hasDb = true;
80
+ if (/\.(css|scss|sass|less|styl|vue|svelte|astro)$/i.test(it.name)) hasUi = true;
75
81
  const dot = it.name.lastIndexOf(".");
76
82
  if (dot > 0) {
77
83
  const lang = EXT_LANG[it.name.slice(dot)];
@@ -88,6 +94,7 @@ async function detectFacts(man) {
88
94
  files,
89
95
  has_db: hasDb,
90
96
  has_tests: hasTests,
97
+ has_ui: hasUi,
91
98
  has_gates: Object.values(gates).some((c) => String(c || "").trim()),
92
99
  gateKeys: Object.keys(gates),
93
100
  };
@@ -59,9 +59,16 @@ for GATE in "$CAT"/*/; do
59
59
  # Два раздела README обязательны нормой каталога. Пока их не проверяла машина, четыре записи
60
60
  # из четырнадцати жили без раздела про готовый аналог и четыре — без «чего НЕ ловит».
61
61
  # Правило нормы, за которым не следит машина, — это пожелание.
62
- grep -qiE 'готовы(й аналог|й инструмент|е правил)|готового аналога' "$GATE/README.md" 2>/dev/null \
62
+ # Без `-i` и через `tr -d '\r'`. На Windows этот шаг отклонял ВЕСЬ каталог — 32 из 47 —
63
+ # то есть первый же прогон комплекта у человека на Windows выглядел как «здесь всё сломано».
64
+ # Две причины сняты разом, потому что порознь ни одна локально не воспроизводится: markdown
65
+ # приезжает туда с CRLF (`.gitattributes` держит LF только для `*.sh`), а `grep -i` с
66
+ # кириллицей в сборке MSYS ведёт себя не так, как GNU grep. Регистр первой буквы назван
67
+ # явно — это дешевле, чем полагаться на сворачивание регистра в чужой сборке grep.
68
+ README_TEXT="$(tr -d '\r' < "$GATE/README.md" 2>/dev/null)"
69
+ printf '%s\n' "$README_TEXT" | grep -qE '[Гг]отовы(й аналог|й инструмент|е правил)|[Гг]отового аналога' \
63
70
  || bad "$SLUG: в README нет раздела про готовый аналог — «не искал» и «нет» разные утверждения"
64
- grep -qiE 'чего НЕ ловит' "$GATE/README.md" 2>/dev/null \
71
+ printf '%s\n' "$README_TEXT" | grep -qE 'чего НЕ ловит|Чего НЕ ловит|чего не ловит|Чего не ловит' \
65
72
  || bad "$SLUG: в README нет раздела «чего НЕ ловит» — граница записи обязана быть названа"
66
73
 
67
74
  case "$PROOF" in
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ # tool/selfcheck/mutation.sh — доказывает, что гейт краснеет на КЛАССЕ примеров, а не на одном.
3
+ #
4
+ # ЗАЧЕМ. Пара образцов red/green доказывает ровно одно срабатывание. Одна проверка `.aqkignore`
5
+ # была написана так, что не могла покраснеть, и это заметили случайно; сколько таких ещё —
6
+ # неизвестно (PROJECT.md §9 п.4). Гейт, который не может упасть, неотличим от работающего.
7
+ #
8
+ # КАК. Образец меняется так, что вердикт МЕНЯТЬСЯ НЕ ОБЯЗАН: сдвиг строк, перевод строк в
9
+ # windows-формат. Если после такой правки красный позеленел или зелёный покраснел — гейт
10
+ # опирался не на то, что заявляет. Идея взята из мутационного тестирования правил в
11
+ # millionco/react-doctor (packages/fuzz), приёмы — свои, зависимостей не добавлено.
12
+ #
13
+ # ПОЧЕМУ ИМЕННО ЭТИ ДВЕ. Обе сохраняют смысл по построению и обе бьют по настоящим болям:
14
+ # сдвиг строк ловит проверки, привязанные к номеру строки; CRLF ловит те, что сломаются на
15
+ # windows-чекауте, — а конвейера на Windows у нас до сих пор нет (PROJECT.md §9 п.2).
16
+ #
17
+ # bash tool/selfcheck/mutation.sh
18
+
19
+ set -uo pipefail
20
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
21
+ CAT="$ROOT/kit/gates"
22
+ PASS=0; FAIL=0; SKIP=0
23
+
24
+ ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; PASS=$((PASS+1)); }
25
+ bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); }
26
+ skip() { printf ' \033[2m·\033[0m %s\n' "$1"; SKIP=$((SKIP+1)); }
27
+
28
+ printf '\n\033[1mtool/selfcheck/mutation.sh\033[0m\n\n'
29
+ [ -d "$CAT" ] || { echo " каталога гейтов нет"; exit 1; }
30
+
31
+ # Сдвиг строк: три пустые строки в начало каждого файла. Номера всех строк меняются, смысл —
32
+ # нет. Проверка, которая помнит номер, а не содержание, здесь и ломается.
33
+ mutate_blank_lines() {
34
+ find "$1" -type f 2>/dev/null | while IFS= read -r F; do
35
+ { printf '\n\n\n'; cat "$F"; } > "$F.mut" && mv "$F.mut" "$F"
36
+ done
37
+ }
38
+
39
+ # Перевод строк в windows-формат. Смысл файла тот же, но у проверки, которая ищет по «конец
40
+ # строки», перед ним оказывается \r — и она перестаёт видеть то, что видела.
41
+ mutate_crlf() {
42
+ find "$1" -type f 2>/dev/null | while IFS= read -r F; do
43
+ sed 's/$/\r/' "$F" > "$F.mut" && mv "$F.mut" "$F"
44
+ done
45
+ }
46
+
47
+ for GATE in "$CAT"/*/; do
48
+ SLUG="$(basename "$GATE")"
49
+ YML="$GATE/gate.yml"
50
+ [ -f "$YML" ] || continue
51
+ [ -d "$GATE/red" ] && [ -d "$GATE/green" ] || continue
52
+
53
+ # Только переносимый рецепт. Родные инструменты требуют установленной программы, и на
54
+ # машине без неё «не проверено» было бы неотличимо от «проверено»: ровно та тишина, против
55
+ # которой всё это написано. Записи без `any` называются вслух, а не пропускаются молча.
56
+ RECIPE="$(sed -n 's/^[[:space:]]*any:[[:space:]]*\(.*\)$/\1/p' "$YML" | head -1)"
57
+ if [ -z "$RECIPE" ]; then
58
+ skip "$SLUG: нет переносимого рецепта — мутации проверять нечем"
59
+ continue
60
+ fi
61
+
62
+ GATE_FAILED=0
63
+ for MUT in blank_lines crlf; do
64
+ for KIND in red green; do
65
+ TMP="$(mktemp -d)"
66
+ cp -r "$GATE/$KIND" "$TMP/$KIND"
67
+ "mutate_$MUT" "$TMP/$KIND"
68
+
69
+ # {gate} остаётся настоящим каталогом гейта: мутируется ОБРАЗЕЦ, а не сама проверка.
70
+ CMD="$(echo "$RECIPE" | sed "s|{gate}|$GATE|; s|{dir}|$TMP/$KIND|")"
71
+ OUT="$(eval "$CMD" 2>&1)"; CODE=$?
72
+ rm -rf "$TMP"
73
+
74
+ if [ "$KIND" = red ] && [ "$CODE" -eq 0 ]; then
75
+ bad "$SLUG / $MUT: красный образец позеленел — гейт держался за то, чего не заявляет"
76
+ GATE_FAILED=1
77
+ elif [ "$KIND" = green ] && [ "$CODE" -ne 0 ]; then
78
+ bad "$SLUG / $MUT: зелёный образец покраснел — гейт ругается на исправный код"
79
+ printf ' \033[2m%s\033[0m\n' "$(printf '%s' "$OUT" | head -1 | cut -c1-100)"
80
+ GATE_FAILED=1
81
+ fi
82
+ done
83
+ done
84
+ [ "$GATE_FAILED" -eq 0 ] && ok "$SLUG: вердикт пережил сдвиг строк и windows-переносы"
85
+ done
86
+
87
+ printf '\n'
88
+ if [ "$FAIL" -eq 0 ]; then
89
+ printf ' \033[32mвердикт устойчив: %s гейтов\033[0m' "$PASS"
90
+ else
91
+ printf ' \033[31mвердикт поплыл: %s\033[0m' "$FAIL"
92
+ fi
93
+ [ "$SKIP" -gt 0 ] && printf ' \033[2m(без переносимого рецепта: %s)\033[0m' "$SKIP"
94
+ printf '\n\n'
95
+ exit "$FAIL"
@@ -384,18 +384,22 @@ rm -rf "$GODIR"
384
384
 
385
385
  # --- 24. просьба про звезду и обратную связь — один раз на машину -----------
386
386
  # Печатается один раз на установку (не на проект): второй init на этой же HOME её не повторяет.
387
+ # USERPROFILE задаётся рядом с HOME: `os.homedir()` на Windows читает именно его, и без этого
388
+ # отметка уезжала в настоящий домашний каталог раннера — к этой проверке она там уже лежала от
389
+ # предыдущих прогонов init, и просьба не печаталась. Изоляция, которая не изолирует, хуже её
390
+ # отсутствия: проверка краснела не на дефекте.
387
391
  # Отметка живёт вне репозитория — внутри .aqk/ она либо закоммитится в чужой проект, либо
388
392
  # потеряется при повторном init --force.
389
393
  FAKEHOME="$(mktemp -d)"
390
394
  D1="$(mktemp -d)"
391
- OUT1=$( cd "$D1" && HOME="$FAKEHOME" node "$CLI" init 2>&1 )
395
+ OUT1=$( cd "$D1" && HOME="$FAKEHOME" USERPROFILE="$FAKEHOME" node "$CLI" init 2>&1 )
392
396
  if printf '%s' "$OUT1" | grep -qi 'звезд'; then
393
397
  ok "первый init на новой машине зовёт поставить звезду"
394
398
  else
395
399
  bad "первый init не упомянул звезду/обратную связь" "$OUT1"
396
400
  fi
397
401
  D2="$(mktemp -d)"
398
- OUT2=$( cd "$D2" && HOME="$FAKEHOME" node "$CLI" init 2>&1 )
402
+ OUT2=$( cd "$D2" && HOME="$FAKEHOME" USERPROFILE="$FAKEHOME" node "$CLI" init 2>&1 )
399
403
  if printf '%s' "$OUT2" | grep -qi 'звезд'; then
400
404
  bad "init повторил просьбу про звезду на той же машине" "второй проект, та же HOME"
401
405
  else
@@ -428,7 +432,9 @@ rm -f /tmp/aqk-broken-doc-links.$$
428
432
  # --- 26. doctor печатает версию комплекта ------------------------------------
429
433
  # Баг-репорт без версии нечем привязать к коммиту — заметили, заполняя .github/ISSUE_TEMPLATE/,
430
434
  # где просили версию из шапки doctor, а шапка её не печатала вовсе.
431
- PKGVER=$(node -e "console.log(require('$ROOT/package.json').version)")
435
+ # Путь отдаётся оболочкой, а Node на Windows не понимает «/d/a/…» из Git Bash. Читаем из
436
+ # текущего каталога, а не подставляем абсолютный путь в код.
437
+ PKGVER=$( cd "$ROOT" && node -p "require('./package.json').version" )
432
438
  DOCVER=$( cd "$ROOT" && node "$CLI" doctor 2>&1 | head -3)
433
439
  if printf '%s' "$DOCVER" | grep -qF "$PKGVER"; then
434
440
  ok "doctor печатает версию комплекта ($PKGVER)"
@@ -532,16 +538,25 @@ fi
532
538
  rm -rf "$SHDIR" "$CEDIR"
533
539
 
534
540
  # --- 32. вывод действительно на двух языках -----------------------------------
541
+ # Утечку русского ищем СЛОВАМИ, а не диапазоном [а-яА-ЯёЁ]. Диапазон непереносим: в сборке grep
542
+ # из MSYS он сравнивает байты, и «—», «·», ««»» из обычной типографики попадают в него — на
543
+ # Windows проверка насчитывала пять «кириллических» строк в чисто английском выводе. Литеральные
544
+ # слова совпадают одинаково везде, что тот же прогон и подтвердил.
535
545
  # Сверка ключей каталогов (units.mjs) доказывает, что строки не разошлись, но не доказывает,
536
546
  # что выбор языка вообще доехал до вывода. Это проверяется только запуском.
537
547
  EN_OUT=$(AQK_LANG=en node "$CLI" 2>&1)
538
548
  RU_OUT=$(AQK_LANG=ru node "$CLI" 2>&1)
539
549
  if printf '%s' "$EN_OUT" | grep -q "install a gate from the catalogue" &&
540
- ! printf '%s' "$EN_OUT" | grep -q '[а-яА-ЯёЁ]' &&
550
+ ! printf '%s' "$EN_OUT" | grep -qE 'гейт|каталог|проверк|уровен|репозитор' &&
541
551
  printf '%s' "$RU_OUT" | grep -q "поставить гейт из каталога"; then
542
552
  ok "справка печатается на двух языках, в английской нет кириллицы"
543
553
  else
544
- bad "выбор языка не доехал до вывода" "$(printf '%s' "$EN_OUT" | head -4)"
554
+ # Диагностика по каждому условию отдельно. Прежняя печатала первые строки вывода по ним
555
+ # видно, что вывод английский, и совершенно не видно, какая из трёх сверок не сошлась.
556
+ EN_HAS=$(printf '%s' "$EN_OUT" | grep -c "install a gate from the catalogue")
557
+ EN_CYR=$(printf '%s' "$EN_OUT" | grep -cE 'гейт|каталог|проверк|уровен|репозитор')
558
+ RU_HAS=$(printf '%s' "$RU_OUT" | grep -c "поставить гейт из каталога")
559
+ bad "выбор языка не доехал до вывода" "англ.фраза=$EN_HAS кириллица_в_англ=$EN_CYR рус.фраза=$RU_HAS"
545
560
  fi
546
561
 
547
562
  # --- 33. ссылка на репозиторий ведёт в репозиторий -----------------------------
@@ -549,7 +564,7 @@ fi
549
564
  # «github:владелец/репозиторий»), просьба про звезду поехала на github.com/agent-quality-kit —
550
565
  # несуществующую страницу. Единственное место, где мы просим человека о чём-то, вело в никуда.
551
566
  FBDIR="$(mktemp -d)"; FBPROJ="$(mktemp -d)"
552
- FB_OUT=$( cd "$FBPROJ" && git init -q . && HOME="$FBDIR" node "$CLI" init 2>&1 )
567
+ FB_OUT=$( cd "$FBPROJ" && git init -q . && HOME="$FBDIR" USERPROFILE="$FBDIR" node "$CLI" init 2>&1 )
553
568
  if printf '%s' "$FB_OUT" | grep -qE 'https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'; then
554
569
  ok "просьба про звезду ведёт на репозиторий, а не на github.com/<имя пакета>"
555
570
  else
@@ -565,13 +580,19 @@ REPDIR="$(mktemp -d)"
565
580
  (
566
581
  cd "$REPDIR" && git init -q . && mkdir -p src &&
567
582
  printf 'def f():\n print("debug")\n' > src/a.py &&
568
- node "$CLI" start >/dev/null 2>&1
583
+ node "$CLI" start > /tmp/aqk-start.log 2>&1
569
584
  )
570
585
  REP_OUT=$( cd "$REPDIR" && node "$CLI" report 2>&1 ); REP_CODE=$?
571
586
  if [ "$REP_CODE" -ne 0 ] && printf '%s' "$REP_OUT" | grep -q '❌ no-print-in-prod'; then
572
587
  ok "report краснеет кодом возврата и называет упавший гейт"
573
588
  else
574
- bad "report не отличает красное от зелёного" "код $REP_CODE"
589
+ # Код возврата отчёта не говорит, ПОЧЕМУ он ноль: гейт не сработал, не установился или
590
+ # установился не тот. Спрашиваем сам гейт напрямую — это и есть разница между «отчёт врёт»
591
+ # и «проверка не ловит на этой системе».
592
+ G_LS=$( cd "$REPDIR" && ls gates 2>&1 | tr '\n' ' ' )
593
+ G_DECL=$( cd "$REPDIR" && sed -n '/^gates:/,$p' .aqk.yml 2>/dev/null | grep -cE '^[[:space:]]+[A-Za-z0-9_-]+:' )
594
+ G_OUT=$( cd "$REPDIR" && bash gates/no-print-in-prod/check.sh . 2>&1 | head -2 ); G_CODE=$?
595
+ bad "report не отличает красное от зелёного" "код отчёта $REP_CODE; гейт напрямую: код $G_CODE, вывод «$(printf '%s' "$G_OUT" | tr '\n' ' ')»; в gates/: «$G_LS»; объявлено гейтов: $G_DECL; хвост start: «$(tail -4 /tmp/aqk-start.log 2>/dev/null | tr '\n' ' ')»"
575
596
  fi
576
597
  if [ -f "$REPDIR/.aqk/report.md" ] && grep -q '^## ' "$REPDIR/.aqk/report.md"; then
577
598
  ok "report сохраняет .aqk/report.md"
@@ -696,6 +717,193 @@ else
696
717
  fi
697
718
  rm -rf "$BDIR"
698
719
 
720
+ # --- 41. порог различает «ступень ниже» и «упал гейт» -------------------------
721
+ # ЗАЧЕМ. При упавшем гейте печаталось «Порог AQK-1 НЕ пройден: сейчас AQK-1» — утверждение,
722
+ # противоречащее само себе. Человек шёл чинить манифест, а падал гейт. Это две разные
723
+ # развилки, и сообщение обязано их различать, иначе оно отправляет чинить не то.
724
+ TDIR="$(mktemp -d)"
725
+ ( cd "$TDIR" && git init -q . && node "$CLI" init >/dev/null 2>&1 )
726
+ printf 'gates:\n always-fails: "false"\n' >> "$TDIR/.aqk.yml"
727
+ OUT_GATE="$( cd "$TDIR" && node "$CLI" doctor --run --min 1 2>&1 )"; RC_GATE=$?
728
+ # Ступень ниже порога: пустой манифест без входа и правил.
729
+ EDIR="$(mktemp -d)"; ( cd "$EDIR" && git init -q . && printf 'aqk: 1\n' > .aqk.yml )
730
+ OUT_LVL="$( cd "$EDIR" && node "$CLI" doctor --min 3 2>&1 )"; RC_LVL=$?
731
+ if [ "$RC_GATE" -ne 0 ] && [ "$RC_LVL" -ne 0 ] &&
732
+ printf '%s' "$OUT_GATE" | grep -q 'always-fails' &&
733
+ ! printf '%s' "$OUT_GATE" | grep -qE '(НЕ пройден|NOT passed): (сейчас|currently) AQK-1' &&
734
+ printf '%s' "$OUT_LVL" | grep -qE '(НЕ пройден|NOT passed)'; then
735
+ ok "порог различает упавший гейт и недобранную ступень"
736
+ else
737
+ bad "сообщение о пороге не различает две развилки" "гейт: $(printf '%s' "$OUT_GATE" | tail -2 | tr '\n' ' ')"
738
+ fi
739
+ rm -rf "$TDIR" "$EDIR"
740
+
741
+ # --- 42. duplicate-code: пара упорядочена, а не как отдал обход ---------------
742
+ # ЗАЧЕМ. Ключ пары складывался в порядке, в котором файлы отдал find, а он разный на разных
743
+ # системах (здесь — по хешу имени, не по алфавиту и не по времени создания). Реестр, снятый
744
+ # на одной машине, краснел в конвейере целиком: те же дубли читались как новые. Хуже: храповик
745
+ # объявлял их исправленными и вычёркивал — реестр портился. Инвариант: пара внутри строки
746
+ # всегда лексикографическая, тогда ключ одинаков на любой системе.
747
+ DD="$(mktemp -d)"
748
+ BLOCK='def f():\n a = 1\n b = 2\n c = 3\n d = 4\n e = 5\n g = 6\n h = 7\n return a\n'
749
+ printf "$BLOCK" > "$DD/zz.py"; printf "$BLOCK" > "$DD/aa.py"
750
+ LINE="$(bash "$ROOT/kit/gates/duplicate-code/check.sh" "$DD" 2>&1 | sed "s#$DD/##g" | grep 'одинаков' | head -1)"
751
+ FIRST="${LINE%% и *}"; REST="${LINE#* и }"; SECOND="${REST%%:*}"
752
+ if [ -n "$LINE" ] && [ "${FIRST%%:*}" \< "$SECOND" ]; then
753
+ ok "duplicate-code упорядочивает пару лексикографически"
754
+ else
755
+ bad "пара идёт в порядке обхода — реестр разъедется между машинами" "$LINE"
756
+ fi
757
+ rm -rf "$DD"
758
+
759
+ # --- 43. храповик: ключ переживает сдвиг строки в ПЕРВОМ файле пары -----------
760
+ # ЗАЧЕМ. keys() убирал номер строки шаблоном ':<число>:', а у первого файла пары за номером
761
+ # идёт ' и '. Номер оставался в ключе, и сдвиг кода в первом файле читался как новое
762
+ # нарушение — ровно то, от чего храповик защищает.
763
+ RDIR="$(mktemp -d)"
764
+ printf '# реестр\na.tsx: и b.tsx: одинаковый кусок\n' > "$RDIR/reg.txt"
765
+ bash "$ROOT/kit/ratchet/ratchet.sh" "$RDIR/reg.txt" \
766
+ printf 'a.tsx:171 и b.tsx:188: одинаковый кусок\n' >/dev/null 2>&1; RC_SHIFT=$?
767
+ if [ "$RC_SHIFT" -eq 0 ]; then
768
+ ok "храповик: номер строки убран у обоих файлов пары"
769
+ else
770
+ bad "сдвиг строки в первом файле пары читается как новое нарушение" "код $RC_SHIFT"
771
+ fi
772
+ rm -rf "$RDIR"
773
+
774
+ # --- 44. манифест называет опечатку в имени поля -------------------------------
775
+ # ЗАЧЕМ. Разбор принимает любое имя поля. `gate:` вместо `gates:` молча означало «гейтов не
776
+ # объявлено»: вердикт выходил неверный, а причина не называлась. Человек шёл искать ошибку в
777
+ # проекте, а она была в одной букве манифеста. Тишина неотличима от успеха — тот самый класс,
778
+ # против которого построен стандарт, только внутри самой программы.
779
+ MDIR="$(mktemp -d)"
780
+ ( cd "$MDIR" && git init -q . && node "$CLI" init >/dev/null 2>&1 )
781
+ sed -i 's/^gates:/gate:/' "$MDIR/.aqk.yml"
782
+ OUT_TYPO="$( cd "$MDIR" && node "$CLI" doctor 2>&1 )"
783
+ OUT_OK="$( cd "$MDIR" && sed -i 's/^gate:/gates:/' .aqk.yml && node "$CLI" doctor 2>&1 )"
784
+ if printf '%s' "$OUT_TYPO" | grep -qE '(does not know|не знает).*gate' &&
785
+ ! printf '%s' "$OUT_OK" | grep -qE '(does not know|не знает)'; then
786
+ ok "манифест называет неизвестное поле и молчит на верном"
787
+ else
788
+ bad "опечатка в поле манифеста проходит молча" "$(printf '%s' "$OUT_TYPO" | grep -i 'know\|знает' | head -1)"
789
+ fi
790
+ rm -rf "$MDIR"
791
+
792
+ # --- 45. родной рецепт не читает то, что не читают переносимые -----------------
793
+ # ЗАЧЕМ. own_samples_filter знал про образцы гейтов и .aqkignore, но не про SKIP_NAMES:
794
+ # их применяли только переносимые проверки при обходе, а родному инструменту список не
795
+ # доставался вовсе. На живом проекте (Django + React, 2750 файлов кода) первой находкой
796
+ # duplicate-code оказались методички САМОГО комплекта в .aqk/docs — родной jscpd прошёлся
797
+ # по каталогу, который положил init. Вывод — 5597 строк. Такой гейт выключают целиком,
798
+ # ровно как сказано в шапке _skip.sh про 94% чужих находок.
799
+ NDIR="$(mktemp -d)"
800
+ mkdir -p "$NDIR/.aqk/docs" "$NDIR/node_modules/pkg" "$NDIR/src"
801
+ printf 'нарушение\n' > "$NDIR/.aqk/docs/guide.md"
802
+ printf 'нарушение\n' > "$NDIR/node_modules/pkg/index.js"
803
+ printf 'нарушение\n' > "$NDIR/src/mine.py"
804
+ # «Инструмент» печатает пути и возвращает отказ — как настоящий родной линтер.
805
+ # Пути в цветовых кодах — как их печатает jscpd: имя каталога идёт не после «/» и не с начала
806
+ # строки, а сразу за escape-последовательностью. Фильтр по границе пути их не видел, и на живом
807
+ # проекте вывод сократился с 5597 строк до 5505 — то есть не сократился.
808
+ cat > "$NDIR/fake-tool.sh" <<'EOT'
809
+ printf ' - \033[1m\033[32m.aqk/docs/guide.md:markdown\033[39m\033[22m [8:1 - 20:5]\n'
810
+ printf ' - \033[1m\033[32mnode_modules/pkg/index.js:javascript\033[39m\033[22m [1:1 - 9:2]\n'
811
+ printf ' - \033[1m\033[32msrc/mine.py:python\033[39m\033[22m [1:1 - 9:2]\n'
812
+ exit 1
813
+ EOT
814
+ OUT_N="$(cd "$NDIR" && sh "$ROOT/kit/gates/_native.sh" . sh ./fake-tool.sh 2>&1)"
815
+ if printf '%s' "$OUT_N" | grep -q 'src/mine.py' &&
816
+ ! printf '%s' "$OUT_N" | grep -q '\.aqk/docs' &&
817
+ ! printf '%s' "$OUT_N" | grep -q 'node_modules'; then
818
+ ok "родной рецепт молчит про .aqk и node_modules, но видит свой код"
819
+ else
820
+ bad "родной инструмент выдаёт то, что переносимые не читают" "$(printf '%s' "$OUT_N" | tr '\n' ' ')"
821
+ fi
822
+ rm -rf "$NDIR"
823
+
824
+ # --- 46. commit-explains-itself и синтетический merge-коммит -------------------
825
+ # ЗАЧЕМ. При разборе предложения изменений GitHub выкладывает не коммит автора, а синтетический
826
+ # merge-коммит с сообщением «Merge <sha> into <sha>». Гейт читал именно его, не находил разделов
827
+ # отчёта и краснел — на КАЖДОМ предложении изменений в КАЖДОМ проекте, куда его поставили.
828
+ # Поймано настоящим прогоном конвейера на этой же ветке, а не рассуждением.
829
+ CDIR="$(mktemp -d)"
830
+ (
831
+ cd "$CDIR" && git init -q . && git config user.email a@b.c && git config user.name a
832
+ printf 'один\n' > f.txt && git add -A
833
+ git commit -q -m "feat: первый" -m "Сделано: завёл файл" -m "Не уверен: ни в чём"
834
+ git checkout -q -b feature
835
+ printf 'два\n' >> f.txt && git add -A
836
+ git commit -q -m "feat: второй" -m "Сделано: дописал строку" -m "Не уверен: ни в чём"
837
+ git checkout -q master 2>/dev/null || git checkout -q main
838
+ # Ровно та форма сообщения, которую делает GitHub для ветки предложения изменений.
839
+ git merge -q --no-ff feature -m "Merge $(git rev-parse --short feature) into $(git rev-parse --short HEAD)"
840
+ ) >/dev/null 2>&1
841
+ OUT_M="$(bash "$ROOT/kit/gates/commit-explains-itself/check.sh" "$CDIR" 2>&1)"; RC_M=$?
842
+ # Вторая форма — та, что делает кнопка Merge на сайте. Слова другие, случай тот же: сообщение
843
+ # сочинил не автор. Шаблон «Merge … into …» её не ловил, и main покраснел бы после первого же
844
+ # вливания через кнопку.
845
+ CDIR2="$(mktemp -d)"
846
+ (
847
+ cd "$CDIR2" && git init -q . && git config user.email a@b.c && git config user.name a
848
+ printf 'один\n' > f.txt && git add -A
849
+ git commit -q -m "feat: первый" -m "Сделано: завёл файл" -m "Не уверен: ни в чём"
850
+ git checkout -q -b feature
851
+ printf 'два\n' >> f.txt && git add -A
852
+ git commit -q -m "feat: второй" -m "Сделано: дописал строку" -m "Не уверен: ни в чём"
853
+ git checkout -q master 2>/dev/null || git checkout -q main
854
+ git merge -q --no-ff feature -m "Merge pull request #15 from owner/feature"
855
+ ) >/dev/null 2>&1
856
+ bash "$ROOT/kit/gates/commit-explains-itself/check.sh" "$CDIR2" >/dev/null 2>&1; RC_PR=$?
857
+ rm -rf "$CDIR2"
858
+ if [ "$RC_M" -eq 0 ] && [ "$RC_PR" -eq 0 ]; then
859
+ ok "commit-explains-itself смотрит на коммит автора, а не на merge-коммит конвейера"
860
+ else
861
+ bad "гейт краснеет на слитом предложении изменений" "checkout-форма: $RC_M, кнопка Merge: $RC_PR"
862
+ fi
863
+ rm -rf "$CDIR"
864
+
865
+ # --- 47. doctor --baseline ставит галочки прогоном, а не по памяти -------------
866
+ # ЗАЧЕМ. Методичка про обязательный минимум — 50 пунктов — была единственным местом, где
867
+ # комплект просил верить на слово, что человек её прочитал и сверился. Ручной проход по живому
868
+ # проекту нашёл настоящее (логирование не задано, задачи конвейера не запускались ни разу),
869
+ # но дисциплина не масштабируется. Проверяем главное: значок ставит признак, а не автор.
870
+ BDIR2="$(mktemp -d)"
871
+ ( cd "$BDIR2" && git init -q . && node "$CLI" init >/dev/null 2>&1 )
872
+ OUT_EMPTY="$( cd "$BDIR2" && node "$CLI" doctor --baseline 2>&1 )"
873
+ # Кладём общепринятые признаки трёх РАЗНЫХ экосистем: нейтральность к стеку — условие, а не
874
+ # пожелание. Проверка, знающая только про npm, объявила бы половину мира несоответствующей.
875
+ printf 'x\n' > "$BDIR2/Cargo.lock"; printf 'x\n' > "$BDIR2/ruff.toml"; printf 'x\n' > "$BDIR2/Dockerfile"
876
+ OUT_FULL="$( cd "$BDIR2" && node "$CLI" doctor --baseline 2>&1 )"
877
+ BEFORE=$(printf '%s' "$OUT_EMPTY" | grep -c '✔' || true)
878
+ AFTER=$(printf '%s' "$OUT_FULL" | grep -c '✔' || true)
879
+ if printf '%s' "$OUT_FULL" | grep -qE 'cargo.lock' &&
880
+ printf '%s' "$OUT_FULL" | grep -qE 'ruff.toml' &&
881
+ printf '%s' "$OUT_FULL" | grep -qE 'dockerfile' &&
882
+ [ "$AFTER" -gt "$BEFORE" ]; then
883
+ ok "doctor --baseline засчитывает признаки разных экосистем и называет, чем подтверждено"
884
+ else
885
+ bad "baseline не видит признаков или не называет доказательство" "было ✔ $BEFORE, стало $AFTER"
886
+ fi
887
+ rm -rf "$BDIR2"
888
+
889
+ # --- 48. start не бросает установку из-за одной записи ------------------------
890
+ # ЗАЧЕМ. Записи вроде dead-code нужен настоящий инструмент; переносимого рецепта у неё нет.
891
+ # На машине без него установка ПАДАЛА целиком: человек получал три сторожа вместо двенадцати и
892
+ # ни слова про остальные девять. Найдено прогоном на Windows, где нет ни ruff, ни vulture.
893
+ # Воспроизводим без Windows: урезаем PATH до одного node — инструментов не видно так же.
894
+ NRDIR="$(mktemp -d)"; NRBIN="$(mktemp -d)"
895
+ ln -sf "$(command -v node)" "$NRBIN/node"
896
+ ( cd "$NRDIR" && git init -q . && mkdir -p src && printf 'def f():\n print("debug")\n' > src/a.py )
897
+ NR_OUT=$( cd "$NRDIR" && PATH="$NRBIN" node "$CLI" start 2>&1 ); NR_CODE=$?
898
+ NR_GATES=$( ls "$NRDIR/gates" 2>/dev/null | grep -cv '^_' )
899
+ if [ "$NR_CODE" -eq 0 ] && [ "$NR_GATES" -ge 5 ] &&
900
+ [ -f "$NRDIR/gates/no-print-in-prod/check.sh" ]; then
901
+ ok "start пропускает запись без пригодного инструмента и ставит остальные ($NR_GATES)"
902
+ else
903
+ bad "start бросил установку из-за одной записи" "код $NR_CODE, поставлено $NR_GATES, хвост: $(printf '%s' "$NR_OUT" | tail -2 | tr '\n' ' ')"
904
+ fi
905
+ rm -rf "$NRDIR" "$NRBIN"
906
+
699
907
  # --- итог -------------------------------------------------------------------
700
908
  printf '\n'
701
909
  if [ "$FAIL" -eq 0 ]; then