agent-quality-kit 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +136 -12
  2. package/README.ru.md +119 -11
  3. package/kit/docs/ready-made-rules.md +40 -0
  4. package/kit/gates/README.md +35 -0
  5. package/kit/gates/_native.sh +18 -2
  6. package/kit/gates/_skip.sh +18 -0
  7. package/kit/gates/ci-actually-fails/README.md +42 -0
  8. package/kit/gates/ci-actually-fails/check.sh +93 -0
  9. package/kit/gates/ci-actually-fails/gate.yml +14 -0
  10. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +14 -0
  11. package/kit/gates/ci-actually-fails/red/.github/workflows/ci.yml +12 -0
  12. package/kit/gates/color-from-token/README.md +52 -0
  13. package/kit/gates/color-from-token/check.sh +69 -0
  14. package/kit/gates/color-from-token/gate.yml +15 -0
  15. package/kit/gates/color-from-token/green/Button.tsx +4 -0
  16. package/kit/gates/color-from-token/green/Panel.vue +4 -0
  17. package/kit/gates/color-from-token/green/card.css +5 -0
  18. package/kit/gates/color-from-token/green/notes.md +2 -0
  19. package/kit/gates/color-from-token/green/tokens.css +7 -0
  20. package/kit/gates/color-from-token/red/Button.tsx +4 -0
  21. package/kit/gates/color-from-token/red/Panel.vue +4 -0
  22. package/kit/gates/color-from-token/red/card.css +5 -0
  23. package/kit/gates/commit-explains-itself/check.sh +25 -2
  24. package/kit/gates/duplicate-code/check.sh +13 -4
  25. package/kit/gates/duplicate-code/gate.yml +11 -2
  26. package/kit/gates/gate-has-samples/check.sh +9 -3
  27. package/kit/gates/gate-not-weakened/README.md +54 -0
  28. package/kit/gates/gate-not-weakened/check.sh +72 -0
  29. package/kit/gates/gate-not-weakened/gate.yml +15 -0
  30. package/kit/gates/gate-not-weakened/green/checkout.ts +8 -0
  31. package/kit/gates/gate-not-weakened/green/payments.py +6 -0
  32. package/kit/gates/gate-not-weakened/green/release.sh +2 -0
  33. package/kit/gates/gate-not-weakened/red/checkout.ts +9 -0
  34. package/kit/gates/gate-not-weakened/red/payments.py +6 -0
  35. package/kit/gates/gate-not-weakened/red/release.sh +2 -0
  36. package/kit/gates/gates-are-runnable/check.sh +7 -1
  37. package/kit/gates/gates-run-in-ci/check.sh +7 -1
  38. package/kit/gates/lesson-has-outcome/check.sh +6 -1
  39. package/kit/gates/promise-has-gate/README.md +50 -0
  40. package/kit/gates/promise-has-gate/check.sh +88 -0
  41. package/kit/gates/promise-has-gate/gate.yml +14 -0
  42. package/kit/gates/promise-has-gate/green/.aqk.yml +6 -0
  43. package/kit/gates/promise-has-gate/green/AGENTS.md +7 -0
  44. package/kit/gates/promise-has-gate/red/.aqk.yml +6 -0
  45. package/kit/gates/promise-has-gate/red/AGENTS.md +7 -0
  46. package/kit/gates/test-has-assertion/README.md +47 -0
  47. package/kit/gates/test-has-assertion/check.sh +194 -0
  48. package/kit/gates/test-has-assertion/gate.yml +15 -0
  49. package/kit/gates/test-has-assertion/green/checkout.test.ts +9 -0
  50. package/kit/gates/test-has-assertion/green/test_billing.py +17 -0
  51. package/kit/gates/test-has-assertion/red/checkout.test.ts +8 -0
  52. package/kit/gates/test-has-assertion/red/test_billing.py +14 -0
  53. package/kit/ratchet/ratchet.sh +9 -2
  54. package/kit/rules/general.md +14 -0
  55. package/llms.txt +58 -0
  56. package/package.json +4 -2
  57. package/tool/commands/doctor.mjs +106 -11
  58. package/tool/commands/gates.mjs +18 -3
  59. package/tool/commands/project.mjs +13 -4
  60. package/tool/commands/report.mjs +2 -2
  61. package/tool/i18n/en.mjs +55 -0
  62. package/tool/i18n/ru.mjs +61 -0
  63. package/tool/i18n/templates-en.mjs +9 -9
  64. package/tool/i18n/templates-ru.mjs +9 -9
  65. package/tool/lib/baseline.mjs +87 -0
  66. package/tool/lib/core.mjs +10 -2
  67. package/tool/lib/manifest.mjs +69 -2
  68. package/tool/lib/repo.mjs +7 -0
  69. package/tool/lib/scope.mjs +96 -0
  70. package/tool/program.mjs +1 -0
  71. package/tool/selfcheck/gates.sh +29 -5
  72. package/tool/selfcheck/lifecycle.mjs +29 -0
  73. package/tool/selfcheck/mutation.sh +95 -0
  74. package/tool/selfcheck/smoke.sh +269 -8
  75. package/tool/selfcheck/syntax.sh +9 -1
  76. package/tool/selfcheck/units.mjs +160 -1
@@ -16,17 +16,17 @@ const AGENTS_MD = `# AGENTS.md
16
16
 
17
17
  ## Железные правила
18
18
 
19
- - **План до кода.** Нетривиальная задача начинается с плана, который человек одобрил словами.
20
- - **Красный тест до кода.** Сначала проверка, которая падает, потом реализация.
21
- - **Максимум 3 попытки.** Не решил за три — стоп и человеку, а не четвёртый заход.
22
- - **Секреты только в окружении.** Никогда в коде, логах и коммитах.
23
- - **Только файлы из задачи.** Заодно ничего не чиним.
24
- - **Готово = доказано.** Назови арбитра: тест, живой прогон, сверка с источником. «Выглядит
19
+ - **План до кода.** Нетривиальная задача начинается с плана, который человек одобрил словами. <!-- aqk: человек -->
20
+ - **Красный тест до кода.** Сначала проверка, которая падает, потом реализация. <!-- aqk: человек -->
21
+ - **Максимум 3 попытки.** Не решил за три — стоп и человеку, а не четвёртый заход. <!-- aqk: человек -->
22
+ - **Секреты только в окружении.** Никогда в коде, логах и коммитах. <!-- aqk: secrets-not-in-code -->
23
+ - **Только файлы из задачи.** Заодно ничего не чиним. <!-- aqk: человек -->
24
+ - **Готово = доказано.** Назови арбитра: тест, живой прогон, сверка с источником. «Выглядит <!-- aqk: человек -->
25
25
  рабочим» — не готово.
26
- - **Ошибку не глотать.** Либо обработана и залогирована, либо проброшена.
27
- - **Развилка — вопрос человеку.** Отступление от принятого решения не оформляется комментарием
26
+ - **Ошибку не глотать.** Либо обработана и залогирована, либо проброшена. <!-- aqk: swallowed-error -->
27
+ - **Развилка — вопрос человеку.** Отступление от принятого решения не оформляется комментарием <!-- aqk: человек -->
28
28
  в коде.
29
- - **Отчёт о работе с комплектом — командой, а не словами.** Закончил — выполни \`aqk report\`.
29
+ - **Отчёт о работе с комплектом — командой, а не словами.** Закончил — выполни \`aqk report\`. <!-- aqk: человек -->
30
30
  Он собирается прогоном: пересказ по памяти всегда выбирает удобное и молчит о том, что гейт
31
31
  стоит на слабейшем рецепте.
32
32
 
@@ -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,57 @@ 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
+
89
+ // ЗРЕЛОСТЬ ЗАПИСИ. Каталог без зрелости — это список, в котором нельзя отличить проверенное от
90
+ // свежего; при шестнадцати записях это держится на памяти, при чужих записях — уже нет.
91
+ //
92
+ // ПОЧЕМУ ВЫЧИСЛЯЕТСЯ, А НЕ ОБЪЯВЛЯЕТСЯ. Поле зрелости есть у всех троих соседей — `lifecycle`
93
+ // у зондов Scorecard, `future`/`obsolete` у критериев значка OpenSSF — и у всех троих его
94
+ // заполняет автор. Значение, которое написал автор, означает доверие к автору, а не факт: это
95
+ // ровно тот способ, которым «зелёный» перестаёт что-либо значить. Здесь зрелость считается по
96
+ // доказательству записи, и объявить её нельзя — попытка отклоняется приёмкой каталога.
97
+ //
98
+ // Исключение одно: `deprecated`. «Запись больше не ставят» из её собственных файлов не выводится
99
+ // никак — это решение, а не факт. Цена решения — обязательная замена: запись, выведенная в
100
+ // никуда, оставляет человека без ответа на вопрос «а что теперь».
101
+ const LIFECYCLE_COMPUTED = ["stable", "experimental"];
102
+
103
+ function entryLifecycle(rec) {
104
+ const declared = typeof rec?.lifecycle === "string" ? rec.lifecycle.trim() : "";
105
+ const supersededBy = typeof rec?.superseded_by === "string" ? rec.superseded_by.trim() : "";
106
+ // Тот же признак, которым каталог отделяет условную запись с первого дня: доказательство
107
+ // ссылается на журнал шишек — значит, запись родилась из настоящей поломки, а не из
108
+ // «это хорошая практика». Признак один на всю программу: разъехавшись, он дал бы приёмке
109
+ // и отчёту разные ответы про одну и ту же запись.
110
+ const proven = /incidents\//.test(String(rec?.proof || ""));
111
+ const state = declared === "deprecated" ? "deprecated" : proven ? "stable" : "experimental";
112
+ const why = L.lifecycle[state];
113
+
114
+ let problem = null;
115
+ if (declared === "deprecated" && !supersededBy) problem = L.lifecycle.noReplacement;
116
+ else if (LIFECYCLE_COMPUTED.includes(declared)) problem = L.lifecycle.notDeclarable(declared);
117
+ else if (declared && declared !== "deprecated") problem = L.lifecycle.unknown(declared);
118
+
119
+ return { state, why, supersededBy: supersededBy || null, problem };
120
+ }
121
+
58
122
  async function readManifest() {
59
123
  const p = join(CWD, MANIFEST);
60
124
  if (!(await exists(p))) return null;
@@ -118,4 +182,7 @@ function manifestWithGate(text, slug, cmd) {
118
182
  return { text: out, why: null };
119
183
  }
120
184
 
121
- export { parseManifest, readManifest, assessLevel, manifestWithGate };
185
+ export {
186
+ parseManifest, readManifest, assessLevel, manifestWithGate, unknownKeys, KNOWN_KEYS,
187
+ entryLifecycle,
188
+ };
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
  };
@@ -0,0 +1,96 @@
1
+ // tool/lib/scope.mjs — сужение вывода гейта до того, что внёс диф.
2
+ //
3
+ // ЗАЧЕМ. Первый прогон в живом проекте показывает долг, накопленный годами: на репозитории в
4
+ // 36 тысяч файлов это тысячи находок. Человек видит стену красного, понимает, что разобрать её
5
+ // нельзя, и выключает проверку целиком. Это причина номер один, по которой такие инструменты
6
+ // снимают, — и три независимых проекта из нашего разбора умеют показывать только внесённое
7
+ // (reviewdog, `--since` у ratchets, четыре режима шума у react-doctor).
8
+ //
9
+ // ПОЧЕМУ ФИЛЬТР ВЫВОДА, А НЕ СПИСОК ФАЙЛОВ ГЕЙТУ. Гейт — произвольная команда оболочки: у
10
+ // каждого инструмента свой способ принять список файлов, а у переносимых проверок его нет
11
+ // вовсе. Фильтр вывода работает с любым гейтом, ничего не требуя от записи каталога.
12
+ //
13
+ // ЧЕГО ЭТОТ ФИЛЬТР НЕ УМЕЕТ И НЕ ДЕЛАЕТ ВИД, ЧТО УМЕЕТ. Он сужает до ФАЙЛА, а не до строки.
14
+ // Находка в файле, который диф трогал, показывается целиком, даже если она в нетронутой строке.
15
+ // Сужение до строки требует разбора формата каждого инструмента — то есть ровно той привязки
16
+ // к инструменту, которой у нас нет.
17
+
18
+ import { spawnSync } from "node:child_process";
19
+
20
+ // Один и тот же файл приезжает в трёх видах: `src/a.py`, `./src/a.py` и `src\a.py` на Windows.
21
+ function normPath(p) {
22
+ return String(p).replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
23
+ }
24
+
25
+ // Цвет снимается ДО поиска путей. Родные инструменты печатают путь внутри
26
+ // escape-последовательности, и сравнение видит не «src/a.py», а обрывок с управляющими
27
+ // символами. На живом проекте это уже стоило одной починки, которая выглядела работающей:
28
+ // вывод «сократился» с 5597 строк до 5505, то есть не сократился.
29
+ const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
30
+
31
+ // Кандидат в путь: слово с расширением, начинающимся с БУКВЫ. Требование буквы отсекает номера
32
+ // версий — «0.5.0» иначе читается как файл с расширением «0», и строка итога про версию
33
+ // принималась бы за находку и отбрасывалась.
34
+ const CANDIDATE = /[\w.@+-]+(?:\/[\w.@+-]+)*\.[A-Za-z][A-Za-z0-9]{0,9}/g;
35
+
36
+ function inScope(candidate, files) {
37
+ const c = normPath(candidate);
38
+ if (files.has(c)) return true;
39
+ // Совпадение по хвосту в обе стороны: инструмент печатает то абсолютный путь, то голое имя
40
+ // файла. Здесь лучше ошибиться в сторону «показать»: спрятанная находка — это тишина,
41
+ // а лишняя показанная — просто шум, который человек отметает глазами.
42
+ for (const f of files) {
43
+ if (c.endsWith(`/${f}`) || f.endsWith(`/${c}`)) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ // Возвращает: что осталось показать, сколько среди этого НАХОДОК и можно ли этот гейт сузить.
49
+ //
50
+ // `scopable: false` — важнее всего остального. Гейт, который печатает вердикт без путей
51
+ // (проверка сообщения коммита, проверка конфига конвейера), сузить дифом нельзя. Признать его
52
+ // успешным на этом основании значило бы получить зелёное молчание там, где проверка провалилась,
53
+ // — ровно та тишина, против которой построен весь стандарт. Вызывающий обязан оставить такой
54
+ // гейт красным и сказать, почему он не сужен.
55
+ function scopeOutput(lines, files) {
56
+ const kept = [];
57
+ let findings = 0;
58
+ let scopable = false;
59
+
60
+ for (const raw of lines) {
61
+ const plain = String(raw).replace(ANSI, "");
62
+ const candidates = plain.match(CANDIDATE) || [];
63
+ if (!candidates.length) {
64
+ // Строка без пути — это шапка, итог или пояснение. Показываем: без неё находка теряет
65
+ // контекст. Находкой не считаем: иначе гейт никогда не сузился бы до нуля.
66
+ kept.push(raw);
67
+ continue;
68
+ }
69
+ scopable = true;
70
+ if (candidates.some((c) => inScope(c, files))) {
71
+ kept.push(raw);
72
+ findings++;
73
+ }
74
+ }
75
+ return { kept, findings, scopable };
76
+ }
77
+
78
+ // Файлы, изменённые относительно ссылки. Новые файлы, ещё не добавленные в индекс, тоже входят:
79
+ // их код так же нов, как и остальной диф, а из `git diff` они не видны.
80
+ function changedFiles(ref, cwd) {
81
+ const git = (args) => {
82
+ const r = spawnSync("git", args, { cwd, encoding: "utf8" });
83
+ return r.status === 0 ? String(r.stdout || "") : null;
84
+ };
85
+ // Точка расхождения, а не сама ссылка: сравнение с веткой, ушедшей вперёд, показало бы
86
+ // чужие изменения как свои. Если базы нет (ссылка — коммит в той же линии), берём её саму.
87
+ const base = (git(["merge-base", ref, "HEAD"]) || "").trim() || ref;
88
+ const diff = git(["diff", "--name-only", base]);
89
+ if (diff === null) return null;
90
+ const untracked = git(["ls-files", "--others", "--exclude-standard"]) || "";
91
+ return new Set(
92
+ `${diff}\n${untracked}`.split("\n").map((l) => normPath(l.trim())).filter(Boolean)
93
+ );
94
+ }
95
+
96
+ export { scopeOutput, changedFiles };
package/tool/program.mjs CHANGED
@@ -82,6 +82,7 @@ if (IS_MAIN) {
82
82
  [`${SELF} start`, h.start],
83
83
  [`${SELF} doctor`, h.doctor],
84
84
  [`${SELF} doctor --run`, h.doctorRun],
85
+ [`${SELF} doctor --run --since main`, h.doctorSince],
85
86
  [`${SELF} add ${h.name}`, h.add],
86
87
  [`${SELF} find "…"`, h.find],
87
88
  [`${SELF} why "…"`, h.why],
@@ -22,6 +22,14 @@ field() { sed -n "s/^$2:[[:space:]]*\(.*\)$/\1/p" "$1" | head -1; }
22
22
  printf '\n\033[1mtool/selfcheck/gates.sh\033[0m\n\n'
23
23
  [ -d "$CAT" ] || { echo " каталога гейтов нет"; exit 1; }
24
24
 
25
+ # Зрелость каждой записи одной таблицей. Правило живёт в entryLifecycle (tool/lib/manifest.mjs);
26
+ # повторять его здесь на sh нельзя — второй источник истины расходится с первым молча.
27
+ # Язык принудительно русский: это внутренняя проверка комплекта, и её вывод целиком русский.
28
+ # Без этого на машине с английской локалью половина строки печаталась по-русски, половина —
29
+ # по-английски, в одном предложении.
30
+ LIFE="$(AQK_LANG=ru node "$ROOT/tool/selfcheck/lifecycle.mjs" 2>/dev/null)"
31
+ life_field() { printf '%s\n' "$LIFE" | awk -F'|' -v s="$1" -v n="$2" '$1==s{print $n}'; }
32
+
25
33
  for GATE in "$CAT"/*/; do
26
34
  SLUG="$(basename "$GATE")"
27
35
  YML="$GATE/gate.yml"
@@ -59,14 +67,30 @@ for GATE in "$CAT"/*/; do
59
67
  # Два раздела README обязательны нормой каталога. Пока их не проверяла машина, четыре записи
60
68
  # из четырнадцати жили без раздела про готовый аналог и четыре — без «чего НЕ ловит».
61
69
  # Правило нормы, за которым не следит машина, — это пожелание.
62
- grep -qiE 'готовы(й аналог|й инструмент|е правил)|готового аналога' "$GATE/README.md" 2>/dev/null \
70
+ # Без `-i` и через `tr -d '\r'`. На Windows этот шаг отклонял ВЕСЬ каталог — 32 из 47 —
71
+ # то есть первый же прогон комплекта у человека на Windows выглядел как «здесь всё сломано».
72
+ # Две причины сняты разом, потому что порознь ни одна локально не воспроизводится: markdown
73
+ # приезжает туда с CRLF (`.gitattributes` держит LF только для `*.sh`), а `grep -i` с
74
+ # кириллицей в сборке MSYS ведёт себя не так, как GNU grep. Регистр первой буквы назван
75
+ # явно — это дешевле, чем полагаться на сворачивание регистра в чужой сборке grep.
76
+ README_TEXT="$(tr -d '\r' < "$GATE/README.md" 2>/dev/null)"
77
+ printf '%s\n' "$README_TEXT" | grep -qE '[Гг]отовы(й аналог|й инструмент|е правил)|[Гг]отового аналога' \
63
78
  || bad "$SLUG: в README нет раздела про готовый аналог — «не искал» и «нет» разные утверждения"
64
- grep -qiE 'чего НЕ ловит' "$GATE/README.md" 2>/dev/null \
79
+ printf '%s\n' "$README_TEXT" | grep -qE 'чего НЕ ловит|Чего НЕ ловит|чего не ловит|Чего не ловит' \
65
80
  || bad "$SLUG: в README нет раздела «чего НЕ ловит» — граница записи обязана быть названа"
66
81
 
67
- case "$PROOF" in
68
- *incidents/*) : ;;
69
- *) warn "$SLUG: доказательство не ссылается на журнал шишек запись условная" ;;
82
+ # Зрелость не объявляют — её считают. Попытка написать `lifecycle: stable` руками отклоняется:
83
+ # поле, которое заполняет автор, означает доверие к автору, а не факт. Это ровно тот способ,
84
+ # которым «зелёный» у соседей перестал что-либо значить,см. kit/gates/README.md.
85
+ LIFE_PROBLEM="$(life_field "$SLUG" 4)"
86
+ # continue, а не просто отметка: запись с неверным объявлением уже отклонена, и гонять по ней
87
+ # образцы значит посчитать её и в отклонённых, и в принятых — итог начинает врать.
88
+ [ -n "$LIFE_PROBLEM" ] && { bad "$SLUG: $LIFE_PROBLEM"; continue; }
89
+
90
+ STATE="$(life_field "$SLUG" 2)"
91
+ case "$STATE" in
92
+ experimental) warn "$SLUG: доказательство не ссылается на журнал шишек — запись условная" ;;
93
+ deprecated) warn "$SLUG: выведена из употребления, заменяет её «$(life_field "$SLUG" 3)»" ;;
70
94
  esac
71
95
 
72
96
  # --- образцы ---------------------------------------------------------------
@@ -0,0 +1,29 @@
1
+ // tool/selfcheck/lifecycle.mjs — зрелость записей каталога, одной таблицей.
2
+ //
3
+ // ЗАЧЕМ ОТДЕЛЬНЫМ ФАЙЛОМ, А НЕ КУСКОМ gates.sh. Правило зрелости живёт в `entryLifecycle`
4
+ // (tool/lib/manifest.mjs) и оттуда же читается программой. Повторить его на sh значило бы
5
+ // завести второй источник истины: через месяц приёмка и отчёт расходятся, и про одну и ту же
6
+ // запись машина говорит разное. Здесь — только печать; решение принимает та же функция.
7
+ //
8
+ // node tool/selfcheck/lifecycle.mjs → slug|состояние|замена|проблема
9
+ //
10
+ // Код возврата — число записей с проблемой объявления.
11
+
12
+ import { readCatalog } from "../lib/repo.mjs";
13
+ import { entryLifecycle } from "../lib/manifest.mjs";
14
+ import { L } from "../i18n/index.mjs";
15
+
16
+ const catalog = await readCatalog();
17
+ const slugs = new Set(catalog.map((r) => r.slug));
18
+ let bad = 0;
19
+
20
+ for (const rec of catalog) {
21
+ const { state, supersededBy, problem } = entryLifecycle(rec);
22
+ // Замена, которой нет в каталоге, — это ответ «а что теперь», ведущий в никуда. Проверяется
23
+ // здесь, а не в чистой функции: та не знает про остальные записи и не должна знать.
24
+ const why = problem || (supersededBy && !slugs.has(supersededBy) ? L.lifecycle.unknownReplacement(supersededBy) : "");
25
+ if (why) bad++;
26
+ console.log(`${rec.slug}|${state}|${supersededBy || ""}|${why}`);
27
+ }
28
+
29
+ process.exit(bad);
@@ -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"