agent-quality-kit 0.7.0 → 0.9.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 (61) hide show
  1. package/README.md +135 -9
  2. package/README.ru.md +167 -23
  3. package/kit/docs/ai/project-baseline.md +14 -0
  4. package/kit/docs/ready-made-rules.md +103 -0
  5. package/kit/gates/_skip.sh +61 -1
  6. package/kit/gates/ci-not-hijackable/README.md +56 -0
  7. package/kit/gates/ci-not-hijackable/check.sh +73 -0
  8. package/kit/gates/ci-not-hijackable/gate.yml +19 -0
  9. package/kit/gates/ci-not-hijackable/green/.github/workflows/triage.yml +19 -0
  10. package/kit/gates/ci-not-hijackable/red/.github/workflows/triage.yml +18 -0
  11. package/kit/gates/color-from-token/check.sh +10 -2
  12. package/kit/gates/color-from-token/green/Button.tsx +2 -0
  13. package/kit/gates/complexity-limit/check.sh +6 -7
  14. package/kit/gates/duplicate-code/check.sh +5 -1
  15. package/kit/gates/entry-links-exist/check.sh +4 -1
  16. package/kit/gates/entry-links-exist/green/AGENTS.md +2 -0
  17. package/kit/gates/file-size-limit/check.sh +1 -1
  18. package/kit/gates/lesson-has-outcome/check.sh +5 -1
  19. package/kit/gates/mcp-server-resolves/README.md +62 -0
  20. package/kit/gates/mcp-server-resolves/check.sh +110 -0
  21. package/kit/gates/mcp-server-resolves/gate.yml +18 -0
  22. package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
  23. package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
  24. package/kit/gates/secrets-not-in-code/check.sh +16 -3
  25. package/kit/gates/secrets-not-in-code/green/testdata/certificate/key.pem +3 -0
  26. package/kit/gates/todo-without-task/check.sh +1 -1
  27. package/kit/gates/todo-without-task/green/app.py +1 -0
  28. package/llms.txt +38 -2
  29. package/package.json +2 -3
  30. package/tool/commands/context.mjs +264 -0
  31. package/tool/commands/doctor.mjs +104 -28
  32. package/tool/commands/learn.mjs +159 -0
  33. package/tool/commands/project.mjs +19 -2
  34. package/tool/commands/prove.mjs +1 -0
  35. package/tool/commands/report.mjs +33 -1
  36. package/tool/commands/vitals.mjs +159 -0
  37. package/tool/i18n/en-docs.mjs +125 -1
  38. package/tool/i18n/en.mjs +39 -36
  39. package/tool/i18n/index.mjs +36 -3
  40. package/tool/i18n/ru-docs.mjs +127 -1
  41. package/tool/i18n/ru.mjs +39 -36
  42. package/tool/lib/banner.mjs +59 -0
  43. package/tool/lib/brief.mjs +192 -0
  44. package/tool/lib/core.mjs +32 -1
  45. package/tool/lib/evidence.mjs +124 -0
  46. package/tool/lib/manifest.mjs +173 -15
  47. package/tool/lib/prove.mjs +24 -2
  48. package/tool/lib/repo.mjs +31 -1
  49. package/tool/lib/scope.mjs +10 -1
  50. package/tool/lib/templates.mjs +1 -0
  51. package/tool/program.mjs +45 -23
  52. package/tool/selfcheck/smoke.sh +592 -3
  53. package/tool/selfcheck/units-banner.mjs +65 -0
  54. package/tool/selfcheck/units-brief.mjs +97 -0
  55. package/tool/selfcheck/units-context.mjs +188 -0
  56. package/tool/selfcheck/units-evidence.mjs +83 -0
  57. package/tool/selfcheck/units-learn.mjs +88 -0
  58. package/tool/selfcheck/units-level.mjs +211 -3
  59. package/tool/selfcheck/units-repo.mjs +134 -0
  60. package/tool/selfcheck/units-vitals.mjs +62 -0
  61. package/tool/selfcheck/units.mjs +4 -75
@@ -1,14 +1,17 @@
1
1
  // tool/selfcheck/units-level.mjs — проверки уровня и доказательства гейтов.
2
2
  //
3
3
  // ОТДЕЛЬНЫМ ФАЙЛОМ, а не в units.mjs: тот перерос собственный предел в 500 строк, и поймал
4
- // это наш же гейт `file-size-limit` на прогоне. Шов по смыслу: здесь всё про то, чем ступень
5
- // отличается от заявления,остальное осталось на месте.
4
+ // это наш же гейт `file-size-limit` на прогоне. Шов по смыслу: здесь всё, что программа
5
+ // вычитывает ИЗ МАНИФЕСТА и объявляет о проекте, ступень, доказательство гейтов и раскладка
6
+ // (где правила, методички, точка входа). Общее у них одно и важное: ответ обязан приходить из
7
+ // манифеста, а не из умолчаний, совпадающих с нашими собственными значениями.
6
8
  //
7
9
  // node --test tool/selfcheck/units-level.mjs
8
10
  import test from "node:test";
9
11
  import assert from "node:assert/strict";
10
12
  import { commandFor } from "../lib/prove.mjs";
11
- import { assessLevel } from "../lib/manifest.mjs";
13
+ import { assessLevel, layoutChecks, unknownKeys, KNOWN_KEYS, parseManifest, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
14
+ import { pickLang, langFromText } from "../i18n/index.mjs";
12
15
 
13
16
  // --- доказательство гейтов ------------------------------------------------------------
14
17
  // ЗАЧЕМ. Ступень AQK-2 называлась «гейты доказаны» и проверяла существование двух папок.
@@ -58,3 +61,208 @@ test("обе обёртки снимаются вместе", () => {
58
61
  "bash gates/_native.sh gates/x/green ruff check gates/x/green"
59
62
  );
60
63
  });
64
+
65
+ // Отзыв второго пользователя, 2026-09-08: на Windows `prove` объявил два ИСПРАВНЫХ гейта
66
+ // сломанными. Путь к образцу собирался `path.join`, то есть `gates\x\red`, и уезжал в строку
67
+ // команды — а её исполняет `sh`, который обратный слэш съедает как экранирование: остаётся
68
+ // `gatesxred`. Каталога нет → `find` молчит → код 0 → «промолчал на КРАСНОМ образце».
69
+ // Проверка идёт здесь, а не в самом сборщике пути: `commandFor` — единственная дверь, через
70
+ // которую каталог попадает в оболочку, и закрывать её надо там, кто бы путь ни собрал.
71
+ test("каталог образца уходит в оболочку с прямыми слэшами", () => {
72
+ assert.equal(
73
+ commandFor("bash gates/x/check.sh .", "gates\\x\\red"),
74
+ "bash gates/x/check.sh gates/x/red",
75
+ );
76
+ });
77
+
78
+ // Тот же путь едет ВТОРЫМ адресом — первым аргументом фильтра образцов. Пропустить его значит
79
+ // починить половину: фильтр не узнает образец и спрячет ровно то, что образец обязан показать.
80
+ test("обёртка родного инструмента тоже получает прямые слэши", () => {
81
+ assert.equal(
82
+ commandFor("bash gates/_native.sh . npx knip --directory .", "gates\\x\\red"),
83
+ "bash gates/_native.sh gates/x/red npx knip --directory gates/x/red",
84
+ );
85
+ });
86
+
87
+ // --- где у проекта лежат правила и методички ----------------------------------
88
+ // Отзыв второго пользователя, 2026-09-08: `doctor` рисовал два красных креста за сделанное.
89
+ // У проекта `rules: .temper/rules`, правила на месте, гейт entry-links-exist их видит, уровень
90
+ // AQK-1 считается ПО МАНИФЕСТУ — а список в шапке проверял литеральные `.aqk/rules` и
91
+ // `.aqk/docs` и советовал сделать сделанное. Уровень и вывод расходились в разные стороны:
92
+ // хуже неверного вывода только вывод, который расходится с собственным вердиктом.
93
+ test("каталог правил берётся из манифеста, а не из умолчания", () => {
94
+ const paths = layoutChecks({ rules: ".temper/rules" }, false).map(([p]) => p);
95
+ assert.ok(paths.includes(".temper/rules"), "путь из манифеста обязан попасть в список");
96
+ assert.ok(!paths.includes(".aqk/rules"), "умолчание обязано уступить манифесту");
97
+ });
98
+
99
+ // Поля `docs:` не было вовсе: перенести методички было НЕКУДА, и проект, разложивший их иначе,
100
+ // получал крест без единого способа его снять. Умолчание остаётся для тех, кто поля не завёл.
101
+ test("каталог методичек тоже берётся из манифеста", () => {
102
+ const paths = layoutChecks({ docs: ".temper/docs" }, false).map(([p]) => p);
103
+ assert.ok(paths.includes(".temper/docs"));
104
+ assert.ok(!paths.includes(".aqk/docs"));
105
+ });
106
+
107
+ test("без манифеста остаются умолчания", () => {
108
+ const paths = layoutChecks(null, false).map(([p]) => p);
109
+ assert.ok(paths.includes(".aqk/rules") && paths.includes(".aqk/docs"));
110
+ });
111
+
112
+ // В самом комплекте лежат оригиналы, а не разложенная копия: копия завтра разошлась бы с ними.
113
+ test("внутри комплекта проверяются его собственные каталоги", () => {
114
+ const paths = layoutChecks({ rules: "kit/rules" }, true).map(([p]) => p);
115
+ assert.ok(paths.includes("kit/rules") && paths.includes("kit/docs"));
116
+ });
117
+
118
+ // Поле, которое программа читает, обязано быть в списке известных: иначе манифест с ним
119
+ // получает предупреждение «неизвестное поле» за то, что работает.
120
+ test("docs — известное поле манифеста", () => {
121
+ assert.ok(KNOWN_KEYS.includes("docs"));
122
+ assert.deepEqual(unknownKeys({ docs: ".aqk/docs" }), []);
123
+ });
124
+
125
+ // --- covers: запись закрыта другим арбитром -----------------------------------
126
+ // Просьба первого чужого пользователя, 2026-09-08, названная им первой: «нельзя сказать, что
127
+ // эта запись у нас закрыта другим гейтом. complexity-limit, no-print-in-prod, swallowed-error
128
+ // держит biome — одним арбитром, точнее переносимого. doctor каждый прогон печатает
129
+ // „применимо, но не поставлено: 5“ — неправду».
130
+ //
131
+ // Неправда в НАШЕМ выводе — самая дорогая из возможных: весь стандарт стоит на том, что вывод
132
+ // не врёт. Поэтому поле есть, но оно не признание на слово: гейт, который «закрывает», обязан
133
+ // быть объявлен в gates:. Иначе covers: становится способом объявить защиту, которой нет, —
134
+ // то самое, против чего написан комплект.
135
+ test("вложенный список в квадратных скобках разбирается как список", () => {
136
+ const man = parseManifest("covers:\n lint: [no-print-in-prod, swallowed-error]\n");
137
+ assert.deepEqual(man.covers.lint, ["no-print-in-prod", "swallowed-error"]);
138
+ });
139
+
140
+ test("covers отдаёт связь «запись → чем закрыта»", () => {
141
+ const man = parseManifest("gates:\n lint: \"biome ci .\"\ncovers:\n lint: [no-print-in-prod, swallowed-error]\n");
142
+ const { covered } = coversOf(man);
143
+ assert.equal(covered.get("no-print-in-prod"), "lint");
144
+ assert.equal(covered.get("swallowed-error"), "lint");
145
+ });
146
+
147
+ // Гейт, которого нет в gates:, не закрывает ничего. Промолчать здесь значит выдать
148
+ // несуществующего арбитра за существующего — ровно тот отказ, ради которого всё написано.
149
+ test("закрывать может только объявленный гейт", () => {
150
+ const man = parseManifest("gates:\n lint: \"biome ci .\"\ncovers:\n biome: [complexity-limit]\n");
151
+ const { covered, unknownGates } = coversOf(man);
152
+ assert.equal(covered.size, 0, "необъявленный гейт не закрывает ничего");
153
+ assert.deepEqual(unknownGates, ["biome"]);
154
+ });
155
+
156
+ test("пустой covers ничего не ломает", () => {
157
+ const { covered, unknownGates } = coversOf(parseManifest("aqk: 1\n"));
158
+ assert.equal(covered.size, 0);
159
+ assert.deepEqual(unknownGates, []);
160
+ });
161
+
162
+ test("covers — известное поле манифеста", () => {
163
+ assert.ok(KNOWN_KEYS.includes("covers"));
164
+ assert.deepEqual(unknownKeys({ covers: {} }), []);
165
+ });
166
+
167
+ // --- язык вывода: настройка ПРОЕКТА, а не машины ------------------------------
168
+ // Просьба первого чужого пользователя: «язык берётся из LC_ALL/LANG, а на Windows их просто
169
+ // нет: русский проект получает английский вывод. AQK_LANG=ru чинит, но у следующего человека
170
+ // будет своё. Место этому в .aqk.yml». Он прав: язык репозитория — свойство репозитория,
171
+ // а локаль — свойство машины, на которой его сегодня открыли.
172
+ //
173
+ // Порядок намеренный: переменная окружения ВЫШЕ манифеста. Человек, набравший AQK_LANG=en
174
+ // руками, хочет английский именно сейчас — и спорить с ним манифестом значит отнять последнее
175
+ // средство. Манифест выше локали: он про проект, локаль про машину.
176
+ test("манифест задаёт язык, когда переменной окружения нет", () => {
177
+ assert.equal(pickLang({ LANG: "en_US.UTF-8" }, { lang: "ru" }), "ru");
178
+ });
179
+
180
+ test("переменная окружения сильнее манифеста", () => {
181
+ assert.equal(pickLang({ AQK_LANG: "en" }, { lang: "ru" }), "en");
182
+ });
183
+
184
+ test("без манифеста всё как раньше — локаль, потом английский", () => {
185
+ assert.equal(pickLang({ LANG: "ru_RU.UTF-8" }, null), "ru");
186
+ assert.equal(pickLang({}, null), "en");
187
+ });
188
+
189
+ test("мусор в поле lang не молчит, а просто не действует", () => {
190
+ assert.equal(pickLang({}, { lang: "клингонский" }), "en");
191
+ });
192
+
193
+ // Сокращённый разбор языка в i18n/index.mjs существует потому, что каталог строк нужен раньше,
194
+ // чем кто-либо успеет прочитать манифест целиком. Два разбора одного файла — то же, что два
195
+ // свода правил: через месяц они расходятся, и непонятно, какой настоящий. Сверяем ответы.
196
+ test("сокращённый разбор языка не расходится с настоящим", () => {
197
+ for (const text of [
198
+ 'aqk: 1\nlang: ru\ngates:\n lint: "true"\n',
199
+ "aqk: 1\nlang: 'en'\n",
200
+ 'aqk: 1\nlang: "ru" # комментарий\n',
201
+ "aqk: 1\ngates:\n lang: ru\n", // вложенный ключ — не язык проекта
202
+ "aqk: 1\n",
203
+ ]) {
204
+ assert.equal(langFromText(text), String(parseManifest(text).lang || ""), text);
205
+ }
206
+ });
207
+
208
+ // --- заявка covers сверяется, а не принимается на слово -----------------------
209
+ // Поле `covers:` я завёл этим же утром и сам записал в коммит: «снимает запись с долга по
210
+ // СЛОВУ человека; проверить, что чужой гейт ловит то же самое, машина не может». К вечеру
211
+ // выяснилось, что это не теория. Запуск на настоящем `ruff.toml` из живого проекта: девятнадцать
212
+ // групп правил в `extend-select`, и `print()` не ловится — группы `T20` среди них нет.
213
+ // То есть заявка «no-print-in-prod держит наш lint» была бы ЛОЖНОЙ, а запись ушла бы из долга.
214
+ //
215
+ // Проверяется ровно то, что можно: у записи каталога в рецепте стоят коды правил
216
+ // (`ruff check --select T20`). Если ни команда закрывающего гейта, ни конфиг линтера этих кодов
217
+ // не называют — заявка не подтверждена. Это не «ложь», а «не подтверждено»: правило могло
218
+ // прийти из плагина или пресета, и объявлять такое ошибкой значит краснеть на нормальном укладе.
219
+ test("заявка подтверждена, когда коды правил есть в команде гейта", () => {
220
+ const man = parseManifest('gates:\n lint: "ruff check --select T20,BLE ."\ncovers:\n lint: [no-print-in-prod]\n');
221
+ const catalog = [{ slug: "no-print-in-prod", recipes: { python: "ruff check --select T20 {dir}" } }];
222
+ assert.deepEqual(coversUnproven(man, catalog, ""), []);
223
+ });
224
+
225
+ test("заявка не подтверждена, когда кодов нет нигде", () => {
226
+ const man = parseManifest('gates:\n lint: "ruff check ."\ncovers:\n lint: [no-print-in-prod]\n');
227
+ const catalog = [{ slug: "no-print-in-prod", recipes: { python: "ruff check --select T20 {dir}" } }];
228
+ assert.deepEqual(coversUnproven(man, catalog, ""), [{ entry: "no-print-in-prod", gate: "lint", codes: ["T20"] }]);
229
+ });
230
+
231
+ // Правило может стоять не в команде, а в конфиге линтера — это нормальный уклад, и краснеть
232
+ // на нём нельзя. Настоящий пример: extend-select в ruff.toml.
233
+ test("коды правил в конфиге линтера тоже подтверждают заявку", () => {
234
+ const man = parseManifest('gates:\n lint: "ruff check ."\ncovers:\n lint: [no-print-in-prod]\n');
235
+ const catalog = [{ slug: "no-print-in-prod", recipes: { python: "ruff check --select T20 {dir}" } }];
236
+ assert.deepEqual(coversUnproven(man, catalog, 'extend-select = ["I", "T20", "B"]'), []);
237
+ });
238
+
239
+ // У записи без кодов правил в рецепте сверять нечего — молчим, а не выдумываем вердикт.
240
+ test("запись без кодов правил в рецепте не порождает придирки", () => {
241
+ const man = parseManifest('gates:\n lint: "true"\ncovers:\n lint: [duplicate-code]\n');
242
+ const catalog = [{ slug: "duplicate-code", recipes: { any: "bash {gate}/check.sh {dir}" } }];
243
+ assert.deepEqual(coversUnproven(man, catalog, ""), []);
244
+ });
245
+
246
+ // --- строка манифеста, которую разбор не понял, не исчезает молча ---------------
247
+ // Найдено 2026-09-09 случайно: подсаживал падающий гейт с именем «плохой», чтобы посмотреть
248
+ // на строку присутствия, — и прогон вышел с НУЛЁМ. Гейт не упал: его вообще не было. Разбор
249
+ // принимает имена только латиницей, а строку, которая под это не подошла, ВЫБРАСЫВАЛ без слова.
250
+ //
251
+ // Это наш класс в чистом виде: человек объявил проверку, видит её в файле, а она не
252
+ // существует. Хуже опечатки в имени поля — ту мы называем с 2026-09-06, а эту не называли.
253
+ // Чинится не расширением алфавита, а голосом: любая непонятая строка обязана быть названа.
254
+ test("непонятая строка манифеста называется с номером", () => {
255
+ const bad = unparsedLines('aqk: 1\ngates:\n ok: "true"\n плохой: "false"\n');
256
+ assert.equal(bad.length, 1);
257
+ assert.equal(bad[0].line, 4);
258
+ assert.match(bad[0].text, /плохой/);
259
+ });
260
+
261
+ test("правильный манифест не порождает жалоб", () => {
262
+ assert.deepEqual(unparsedLines('aqk: 1\nentry:\n - AGENTS.md\ngates:\n ok: "true"\n'), []);
263
+ });
264
+
265
+ // Комментарии и пустые строки — не находка: они и не должны разбираться.
266
+ test("комментарии и пустые строки не считаются потерянными", () => {
267
+ assert.deepEqual(unparsedLines("# заметка\n\naqk: 1\n # ещё\n"), []);
268
+ });
@@ -0,0 +1,134 @@
1
+ // tool/selfcheck/units-repo.mjs — осмотр репозитория: язык по расширению, триггеры записей,
2
+ // выбор рецепта, поиск программы в PATH, совет про браузер у агента.
3
+ //
4
+ // ОТДЕЛЬНЫМ ФАЙЛОМ, а не в units.mjs: тот снова перерос собственный предел в 500 строк, и поймал
5
+ // это наш же file-size-limit. Шов по смыслу: здесь всё, что программа УЗНАЁТ О ЧУЖОМ РЕПОЗИТОРИИ
6
+ // и что из этого следует, — а в units.mjs осталось то, что она делает со своими данными.
7
+ //
8
+ // node --test tool/selfcheck/units-repo.mjs
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { triggerVerdict, recipeFor, EXT_LANG, whichSync, browserServerAdvice, MARKS } from "../lib/repo.mjs";
12
+ import { CATALOGS, L } from "../i18n/index.mjs";
13
+ import { dirname } from "node:path";
14
+
15
+ const facts = (over = {}) => ({ langs: new Set(), files: 0, ...over });
16
+
17
+ // --- опознание языка по расширению -------------------------------------------
18
+ // Найдено на самом aqk: вся программа лежит в .mjs, и запись про отладочную печать
19
+ // пряталась с пояснением «нет языков: javascript» — в проекте, целиком на JavaScript.
20
+ test("расширение .mjs — это JavaScript", () => {
21
+ assert.equal(EXT_LANG[".mjs"], "javascript");
22
+ assert.equal(EXT_LANG[".cjs"], "javascript");
23
+ assert.equal(EXT_LANG[".mts"], "typescript");
24
+ assert.equal(EXT_LANG[".py"], "python");
25
+ });
26
+
27
+
28
+ // --- триггер ------------------------------------------------------------------
29
+ test("без триггера запись не показывается", () => {
30
+ assert.equal(triggerVerdict({}, facts()).applies, false);
31
+ });
32
+
33
+ test("условия складываются по И: одно ложное скрывает запись", () => {
34
+ const rec = { trigger: { langs: "python", files_gt: "10" } };
35
+ assert.equal(triggerVerdict(rec, facts({ langs: new Set(["python"]), files: 50 })).applies, true);
36
+ assert.equal(triggerVerdict(rec, facts({ langs: new Set(["python"]), files: 3 })).applies, false);
37
+ assert.equal(triggerVerdict(rec, facts({ langs: new Set(["go"]), files: 50 })).applies, false);
38
+ });
39
+
40
+ test("причина, по которой запись скрыта, называется словами", () => {
41
+ const v = triggerVerdict({ trigger: { langs: "python, typescript" } }, facts({ langs: new Set(["go"]) }));
42
+ // Сверяем с каталогом, а не с буквами: текст переводится, а выбор причины — нет.
43
+ assert.equal(v.why, L.trigger.noLangs("python, typescript"));
44
+ });
45
+
46
+ test("always: false значит «никогда не применимо», а не «условие пропущено»", () => {
47
+ const v = triggerVerdict({ trigger: { always: "false" } }, facts());
48
+ assert.equal(v.applies, false);
49
+ });
50
+
51
+ test("неизвестное условие скрывает запись, а не пропускает её", () => {
52
+ // Молча пропустить незнакомое условие значит показать запись всем подряд.
53
+ const v = triggerVerdict({ trigger: { has_kubernetes: "true" } }, facts());
54
+ assert.equal(v.applies, false);
55
+ assert.equal(v.why, L.trigger.unknown("has_kubernetes"));
56
+ });
57
+
58
+ // --- выбор рецепта ------------------------------------------------------------
59
+ test("без родного языка берётся переносимый рецепт, {dir} подставляется", () => {
60
+ const cmd = recipeFor({ slug: "x", recipes: { any: "bash {gate}/check.sh {dir}" } }, facts());
61
+ assert.match(cmd, /check\.sh \.$/);
62
+ });
63
+
64
+ test("рецепта нет — так и сказано, а не пустая строка", () => {
65
+ assert.equal(recipeFor({ slug: "x", recipes: {} }, facts()), L.recipe.none);
66
+ });
67
+
68
+
69
+ // --- поиск программы в PATH ---------------------------------------------------
70
+ // ЗАЧЕМ. Раньше наличие программы проверялось через `command -v` в оболочке. На Windows
71
+ // оболочка — cmd.exe, где такой команды нет, и ответ был «не установлено» ДЛЯ ЛЮБОЙ
72
+ // программы: родной рецепт становился недостижим, гейт молча вставал на слабейший
73
+ // переносимый вариант, а прогон показывал зелёное. Нашлось на чужом прогоне, не у нас.
74
+ test("программа в PATH находится, несуществующая — нет", () => {
75
+ assert.ok(whichSync("node"), "node обязан находиться: им же запущена эта проверка");
76
+ assert.equal(whichSync("нет-такой-программы-12345"), null);
77
+ assert.equal(whichSync(""), null);
78
+ });
79
+
80
+ test("поиск не зависит от оболочки — работает с пустым окружением", () => {
81
+ // Тот самый случай: оболочки нет или она другая. Ответ обязан быть «не нашли»,
82
+ // а не исключение и не ложное «нашли».
83
+ assert.equal(whichSync("node", { PATH: "" }), null);
84
+ const dir = dirname(process.execPath);
85
+ assert.ok(whichSync(process.platform === "win32" ? "node" : "node", { PATH: dir }));
86
+ });
87
+
88
+ test("команда путём, а не именем, ищется на диске, а не в PATH", () => {
89
+ assert.ok(whichSync(process.execPath));
90
+ assert.equal(whichSync("./нет-такого-файла.sh"), null);
91
+ });
92
+
93
+
94
+ // --- совет про браузерный MCP-сервер ------------------------------------------
95
+ // Решение владельца 2026-09-08: инструмент, дающий агенту браузер, надо РЕКОМЕНДОВАТЬ.
96
+ // Возражение про нейтральность к вендору здесь не работает: MCP — межвендорный протокол,
97
+ // и сервер одинаково нужен Cursor, Codex и Claude Code.
98
+ //
99
+ // Но совет показывается не всем. Проекту без интерфейса браузер не нужен, а совет, показанный
100
+ // не тому, стоит доверия всем остальным советам — та же норма, что у записей каталога.
101
+ test("совет про браузер даётся проекту с интерфейсом, у которого сервера нет", () => {
102
+ assert.ok(browserServerAdvice({ has_ui: true }, ""));
103
+ });
104
+
105
+ test("проекту без интерфейса совет не даётся", () => {
106
+ assert.equal(browserServerAdvice({ has_ui: false }, ""), null);
107
+ });
108
+
109
+ // Уже поставил — молчим. Совет, повторяемый тому, кто его выполнил, читается как шум,
110
+ // и следующий совет он пролистает вместе с этим.
111
+ test("сервер уже объявлен — совета нет", () => {
112
+ const cfg = '{"mcpServers":{"browser":{"command":"npx","args":["-y","chrome-devtools-mcp@1.9.0"]}}}';
113
+ assert.equal(browserServerAdvice({ has_ui: true }, cfg), null);
114
+ const pw = '{"mcpServers":{"b":{"command":"npx","args":["@playwright/mcp@0.0.80"]}}}';
115
+ assert.equal(browserServerAdvice({ has_ui: true }, pw), null);
116
+ });
117
+
118
+ // Признак репозитория без объяснения — это запись каталога, ВЫКЛЮЧЕННАЯ НАВСЕГДА. Триггер
119
+ // с неизвестным ключом даёт «условие программа не умеет считать», и запись не показывается
120
+ // никому и никогда. Поймано на себе 2026-09-08: завёл has_mcp в списке признаков, объяснение
121
+ // не завёл, и новая запись стала неприменимой в любом репозитории. Видно это было только в
122
+ // выводе doctor на чужой папке — ни один прогон не краснел.
123
+ test("у каждого признака репозитория есть объяснение на обоих языках", () => {
124
+ const names = MARKS.map(([n]) => n);
125
+ for (const lang of ["ru", "en"]) {
126
+ const flags = CATALOGS[lang].trigger.flags;
127
+ const missing = names.filter((n) => !flags[n]);
128
+ assert.deepEqual(missing, [], `${lang}: нет объяснения для ${missing.join(", ")}`);
129
+ // Обратной проверки нет намеренно, и это не лень. Часть признаков считается не по наличию
130
+ // файла, а обходом содержимого (has_db, has_tests, has_ui), и в этом списке их нет.
131
+ // Риск несимметричен: объяснение без признака — мёртвая строка, признак без объяснения —
132
+ // запись каталога, выключенная навсегда и молча. Сторожим ту сторону, которая ломает.
133
+ }
134
+ });
@@ -0,0 +1,62 @@
1
+ // tool/selfcheck/units-vitals.mjs — «всё ли у самого комплекта подключено».
2
+ //
3
+ // ЗАЧЕМ ЭТА КОМАНДА. `doctor` смотрит на РЕПОЗИТОРИЙ, `prove` — на гейты, `context` — на
4
+ // состояние. На саму обвязку не смотрит никто: стоят ли инструменты, которых требуют
5
+ // объявленные гейты; прописан ли хук в `.git/hooks` на самом деле; получает ли агент состояние.
6
+ // Сегодня это выясняется красным гейтом посреди коммита — в худший момент из возможных.
7
+ //
8
+ // node --test tool/selfcheck/units-vitals.mjs
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { vitalsRows, vitalsVerdict } from "../commands/vitals.mjs";
12
+
13
+ const ok = { tools: [], unparsed: 0, preCommit: true, sessionHook: true, version: null };
14
+
15
+ // ТРИ СОСТОЯНИЯ, А НЕ ДВА. «Не подключено» и «не знаем» — разные ответы, и сливать их значит
16
+ // врать ровно тем способом, против которого написан весь комплект.
17
+ test("неизвестное не выдаётся за исправное", () => {
18
+ const rows = vitalsRows({ ...ok, preCommit: null });
19
+ const hook = rows.find((r) => r.key === "preCommit");
20
+ assert.equal(hook.ok, null, "не смогли посмотреть — значит неизвестно, а не «нет»");
21
+ // И «нет» — тоже отдельное состояние, не равное ни «да», ни «неизвестно».
22
+ assert.equal(vitalsRows({ ...ok, preCommit: false }).find((r) => r.key === "preCommit").ok, "no");
23
+ });
24
+
25
+ test("отсутствующий инструмент объявленного гейта — отказ, а не мелочь", () => {
26
+ const rows = vitalsRows({ ...ok, tools: [{ gate: "lint", prog: "ruff", found: false }] });
27
+ const t = rows.find((r) => r.key === "tools");
28
+ assert.equal(t.ok, false);
29
+ assert.match(t.detail, /ruff/);
30
+ assert.match(t.detail, /lint/, "названо, КАКОЙ гейт останется без арбитра");
31
+ });
32
+
33
+ test("все инструменты на месте — строка зелёная", () => {
34
+ const rows = vitalsRows({ ...ok, tools: [{ gate: "lint", prog: "ruff", found: true }] });
35
+ assert.equal(rows.find((r) => r.key === "tools").ok, true);
36
+ });
37
+
38
+ test("непонятые строки манифеста попадают в вердикт", () => {
39
+ const rows = vitalsRows({ ...ok, unparsed: 2 });
40
+ assert.equal(rows.find((r) => r.key === "manifest").ok, false);
41
+ });
42
+
43
+ // Код возврата: красное — отказ, неизвестное — не отказ. Иначе команда краснела бы у всех,
44
+ // у кого просто нет `.claude/`, и её выключили бы в первый день.
45
+ test("вердикт краснеет от отказов, но не от незнания и не от выбора", () => {
46
+ assert.equal(vitalsVerdict(vitalsRows(ok)), 0);
47
+ assert.equal(vitalsVerdict(vitalsRows({ ...ok, preCommit: null, sessionHook: null })), 0);
48
+ // Хука нет — это решение человека (гоняет в конвейере), а не поломка. Команда, которая
49
+ // кричит «сломано» про выбор, перестаёт читаться вместе с настоящими отказами.
50
+ assert.equal(vitalsVerdict(vitalsRows({ ...ok, preCommit: false, sessionHook: false })), 0);
51
+ assert.equal(vitalsVerdict(vitalsRows({ ...ok, unparsed: 1 })), 1);
52
+ assert.equal(vitalsVerdict(vitalsRows({ ...ok, tools: [{ gate: "g", prog: "x", found: false }] })), 1);
53
+ });
54
+
55
+ // Устаревшая версия — не отказ: человек мог закрепить её сознательно.
56
+ test("старая версия сообщается, но не роняет", () => {
57
+ const rows = vitalsRows({ ...ok, version: { current: "0.8.0", latest: "0.9.0" } });
58
+ const v = rows.find((r) => r.key === "version");
59
+ assert.equal(v.ok, null);
60
+ assert.match(v.detail, /0\.9\.0/);
61
+ assert.equal(vitalsVerdict(rows), 0);
62
+ });
@@ -12,7 +12,7 @@
12
12
  import test from "node:test";
13
13
  import assert from "node:assert/strict";
14
14
  import { parseManifest, manifestWithGate, unknownKeys, entryLifecycle, advisorySet, KNOWN_KEYS } from "../lib/manifest.mjs";
15
- import { triggerVerdict, recipeFor, stems, overlap, EXT_LANG, whichSync } from "../lib/repo.mjs";
15
+ import { triggerVerdict, recipeFor, stems, overlap, EXT_LANG, whichSync, browserServerAdvice, MARKS } from "../lib/repo.mjs";
16
16
  import { scopeOutput, splitAdvice } from "../lib/scope.mjs";
17
17
  import { assessBaseline, ITEMS, BASELINE_TOTAL } from "../lib/baseline.mjs";
18
18
  import { CATALOGS, pickLang, L } from "../i18n/index.mjs";
@@ -21,16 +21,6 @@ import { dirname } from "node:path";
21
21
 
22
22
  const facts = (over = {}) => ({ langs: new Set(), files: 0, ...over });
23
23
 
24
- // --- опознание языка по расширению -------------------------------------------
25
- // Найдено на самом aqk: вся программа лежит в .mjs, и запись про отладочную печать
26
- // пряталась с пояснением «нет языков: javascript» — в проекте, целиком на JavaScript.
27
- test("расширение .mjs — это JavaScript", () => {
28
- assert.equal(EXT_LANG[".mjs"], "javascript");
29
- assert.equal(EXT_LANG[".cjs"], "javascript");
30
- assert.equal(EXT_LANG[".mts"], "typescript");
31
- assert.equal(EXT_LANG[".py"], "python");
32
- });
33
-
34
24
  // --- разбор манифеста ---------------------------------------------------------
35
25
  test("список читается и строкой в скобках, и пунктами", () => {
36
26
  assert.deepEqual(parseManifest("entry: [AGENTS.md, CLAUDE.md]").entry, ["AGENTS.md", "CLAUDE.md"]);
@@ -43,46 +33,6 @@ test("вложенный блок читается словарём, комме
43
33
  assert.equal(m.rules, "kit/rules");
44
34
  });
45
35
 
46
- // --- триггер ------------------------------------------------------------------
47
- test("без триггера запись не показывается", () => {
48
- assert.equal(triggerVerdict({}, facts()).applies, false);
49
- });
50
-
51
- test("условия складываются по И: одно ложное скрывает запись", () => {
52
- const rec = { trigger: { langs: "python", files_gt: "10" } };
53
- assert.equal(triggerVerdict(rec, facts({ langs: new Set(["python"]), files: 50 })).applies, true);
54
- assert.equal(triggerVerdict(rec, facts({ langs: new Set(["python"]), files: 3 })).applies, false);
55
- assert.equal(triggerVerdict(rec, facts({ langs: new Set(["go"]), files: 50 })).applies, false);
56
- });
57
-
58
- test("причина, по которой запись скрыта, называется словами", () => {
59
- const v = triggerVerdict({ trigger: { langs: "python, typescript" } }, facts({ langs: new Set(["go"]) }));
60
- // Сверяем с каталогом, а не с буквами: текст переводится, а выбор причины — нет.
61
- assert.equal(v.why, L.trigger.noLangs("python, typescript"));
62
- });
63
-
64
- test("always: false значит «никогда не применимо», а не «условие пропущено»", () => {
65
- const v = triggerVerdict({ trigger: { always: "false" } }, facts());
66
- assert.equal(v.applies, false);
67
- });
68
-
69
- test("неизвестное условие скрывает запись, а не пропускает её", () => {
70
- // Молча пропустить незнакомое условие значит показать запись всем подряд.
71
- const v = triggerVerdict({ trigger: { has_kubernetes: "true" } }, facts());
72
- assert.equal(v.applies, false);
73
- assert.equal(v.why, L.trigger.unknown("has_kubernetes"));
74
- });
75
-
76
- // --- выбор рецепта ------------------------------------------------------------
77
- test("без родного языка берётся переносимый рецепт, {dir} подставляется", () => {
78
- const cmd = recipeFor({ slug: "x", recipes: { any: "bash {gate}/check.sh {dir}" } }, facts());
79
- assert.match(cmd, /check\.sh \.$/);
80
- });
81
-
82
- test("рецепта нет — так и сказано, а не пустая строка", () => {
83
- assert.equal(recipeFor({ slug: "x", recipes: {} }, facts()), L.recipe.none);
84
- });
85
-
86
36
  // --- дедупликация по намерению ------------------------------------------------
87
37
  test("разные намерения не путаются служебными словами", () => {
88
38
  // Найдено на `aqk new dead-code-not-shipped`: слова not/in/code давали ложное совпадение
@@ -158,30 +108,6 @@ test("язык берётся из AQK_LANG, потом из локали, ин
158
108
  assert.equal(pickLang({}), "en");
159
109
  });
160
110
 
161
- // --- поиск программы в PATH ---------------------------------------------------
162
- // ЗАЧЕМ. Раньше наличие программы проверялось через `command -v` в оболочке. На Windows
163
- // оболочка — cmd.exe, где такой команды нет, и ответ был «не установлено» ДЛЯ ЛЮБОЙ
164
- // программы: родной рецепт становился недостижим, гейт молча вставал на слабейший
165
- // переносимый вариант, а прогон показывал зелёное. Нашлось на чужом прогоне, не у нас.
166
- test("программа в PATH находится, несуществующая — нет", () => {
167
- assert.ok(whichSync("node"), "node обязан находиться: им же запущена эта проверка");
168
- assert.equal(whichSync("нет-такой-программы-12345"), null);
169
- assert.equal(whichSync(""), null);
170
- });
171
-
172
- test("поиск не зависит от оболочки — работает с пустым окружением", () => {
173
- // Тот самый случай: оболочки нет или она другая. Ответ обязан быть «не нашли»,
174
- // а не исключение и не ложное «нашли».
175
- assert.equal(whichSync("node", { PATH: "" }), null);
176
- const dir = dirname(process.execPath);
177
- assert.ok(whichSync(process.platform === "win32" ? "node" : "node", { PATH: dir }));
178
- });
179
-
180
- test("команда путём, а не именем, ищется на диске, а не в PATH", () => {
181
- assert.ok(whichSync(process.execPath));
182
- assert.equal(whichSync("./нет-такого-файла.sh"), null);
183
- });
184
-
185
111
  // --- значок уровня ------------------------------------------------------------
186
112
  // ЗАЧЕМ. Значок печатает одна функция, а читает его обратно другое выражение — в том же
187
113
  // файле, но независимо. Разойдись они, и `badge --check` перестал бы узнавать собственный
@@ -469,3 +395,6 @@ test("наборы файлов правил совпадают на обоих
469
395
  assert.deepEqual(en, ru);
470
396
  assert.equal(ru.length > 0, true);
471
397
  });
398
+
399
+
400
+