agent-quality-kit 0.11.0 → 0.12.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.
@@ -3,7 +3,7 @@
3
3
  import test from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe } from "../lib/history.mjs";
6
- import { probeableGates, gatesState, extAlternatives, planProbeGates, isCode } from "../commands/probe.mjs";
6
+ import { probeableGates, gatesState, extAlternatives, planProbeGates, blindAdvice, isCode } from "../commands/probe.mjs";
7
7
 
8
8
  // Признак починки берётся из ТЕМЫ коммита, а не из тела: тема — единственное, что пишут все,
9
9
  // и единственное, что видно в `git log --oneline`. Три написания, потому что репозитории
@@ -220,6 +220,21 @@ test("парный вердикт: гейт позеленел от подсад
220
220
  assert.equal(probeVerdictPaired(before, after).verdict, "unknown");
221
221
  });
222
222
 
223
+ // Гейт работал ДО подсадки и сломался ОТ неё. Он мог быть тем самым ловцом — «никто не ловит»
224
+ // о нём сказать нельзя. Найдено пробой на самом комплекте 2026-09-11: образец лёг на место
225
+ // kit/gates/_skip.sh, общей библиотеки двенадцати гейтов, все двенадцать вышли с кодом 2 —
226
+ // включая gate-not-weakened, который в отдельной папке этот образец ловит. Остальные молчали,
227
+ // и проба назвала класс слепым; doctor повторил это человеку жирным.
228
+ test("парный вердикт: подсадка сломала работавший гейт — «не смогли», а не «слеп»", () => {
229
+ const { before, after } = pairs([["lint", 0], ["units", 0]], [["lint", 2], ["units", 0]]);
230
+ const r = probeVerdictPaired(before, after);
231
+ assert.equal(r.verdict, "unknown");
232
+ assert.equal(r.brokenByPlant, 1);
233
+ // Поимка соседом остаётся поимкой: знание получено, и сломанный гейт его не отменяет.
234
+ const other = pairs([["lint", 0], ["units", 0]], [["lint", 2], ["units", 1]]);
235
+ assert.equal(probeVerdictPaired(other.before, other.after).verdict, "caught");
236
+ });
237
+
223
238
  test("парный вердикт: гейтов нет вовсе", () => {
224
239
  assert.equal(probeVerdictPaired([], []).verdict, "unknown");
225
240
  });
@@ -358,3 +373,91 @@ test("счёт непокрытого: исходы разложены по св
358
373
  test("счёт непокрытого: пусто не роняет", () => {
359
374
  assert.deepEqual(countProbe([]), { blindClasses: 0, caughtClasses: 0, unknownClasses: 0, probes: 0 });
360
375
  });
376
+
377
+ // ─────────────────────────────────────────────────────────────────────────────
378
+ // Непокрытый класс объясняется, а не называется. Написано ДО кода 2026-09-10.
379
+ //
380
+ // ЗАЧЕМ. Проба находит настоящие дыры — и печатает про них «close it: aqk add <имя>», то есть
381
+ // «поставь нашу штуку». Человек, который видит комплект впервые, закрывает окно.
382
+ //
383
+ // А готовая однострочная команда под его стек У НАС УЖЕ ЛЕЖИТ, в `recipes` записи каталога. Мы
384
+ // её просто не показываем. Замер руками на `requests` (самый скачиваемый python-пакет): в
385
+ // `src/requests/utils.py` — 75 коммитов-починок; дописана функция с `except Exception: pass`;
386
+ // их собственные `ruff` и `pytest` дали 0 и на чистой копии, и на подсаженной. Строка, которая
387
+ // бы это поймала, — `ruff check --select BLE,TRY400,SIM105 .` — лежала в нашем каталоге всё это
388
+ // время.
389
+ //
390
+ // Польза обязана быть видна ДО установки комплекта: скопировал строку, увидел находки у себя —
391
+ // и только тогда думаешь, ставить ли нас. Отчёт, который учит, а не отчитывается.
392
+ test("совет по непокрытому классу даёт команду под стек, а не «поставь нас»", () => {
393
+ const entry = {
394
+ slug: "swallowed-error",
395
+ recipes: {
396
+ any: "bash {gate}/check.sh {dir}",
397
+ python: "ruff check --select BLE,TRY400,SIM105 {dir}",
398
+ },
399
+ };
400
+ const a = blindAdvice(entry, { langs: new Set(["python"]) }, { file: "src/requests/utils.py", fixes: 75 });
401
+ assert.equal(a.command, "ruff check --select BLE,TRY400,SIM105 .",
402
+ "команда обязана быть готовой к вставке: {dir} подставлен, {gate} не годится");
403
+ assert.equal(a.file, "src/requests/utils.py");
404
+ assert.equal(a.fixes, 75);
405
+ });
406
+
407
+ // Переносимый рецепт зовёт наш файл — вставить его человеку, у которого комплекта нет, нельзя.
408
+ // Тогда команды нет, и совет остаётся один: поставить запись.
409
+ test("совет: переносимый рецепт вставить некуда — команда не выдумывается", () => {
410
+ const entry = { slug: "x", recipes: { any: "bash {gate}/check.sh {dir}" } };
411
+ assert.equal(blindAdvice(entry, { langs: new Set(["python"]) }, { file: "a.py", fixes: 1 }).command, null);
412
+ });
413
+
414
+ test("совет: у стека нет своего рецепта — команды нет", () => {
415
+ const entry = { slug: "x", recipes: { python: "ruff check {dir}" } };
416
+ assert.equal(blindAdvice(entry, { langs: new Set(["go"]) }, { file: "a.go", fixes: 2 }).command, null);
417
+ });
418
+
419
+ // Python-проекту показывалась ссылка на eslint и knip: поле `tool` у записи общее на все языки.
420
+ // Совет, в котором половина не про тебя, читается весь как «не про тебя».
421
+ test("совет: адрес инструмента — того, которым начинается команда", () => {
422
+ const entry = {
423
+ slug: "dead-code",
424
+ tool: "https://github.com/jendrikseipp/vulture · https://github.com/webpro-nl/knip",
425
+ recipes: { python: "vulture --min-confidence 60 {dir}", javascript: "npx knip" },
426
+ };
427
+ assert.equal(blindAdvice(entry, { langs: new Set(["python"]) }).tool, "https://github.com/jendrikseipp/vulture");
428
+ // Команды нет или имя не совпало — весь список: лучше лишняя ссылка, чем ни одной.
429
+ assert.equal(blindAdvice(entry, { langs: new Set(["go"]) }).tool, entry.tool);
430
+ assert.equal(blindAdvice({ slug: "x" }, { langs: new Set() }).tool, null);
431
+ });
432
+
433
+ test("совет: пустая запись не роняет разбор", () => {
434
+ assert.equal(blindAdvice({}, { langs: new Set() }, {}).command, null);
435
+ assert.equal(blindAdvice({}, {}, {}).command, null);
436
+ });
437
+
438
+ // Безъязыковой родной рецепт годится в совет так же, как языковой: секреты ищутся в любом
439
+ // файле, и именно они чаще всего оказывались в непокрытых на чужих проектах.
440
+ test("совет: безъязыковой родной рецепт тоже даёт команду", () => {
441
+ const entry = { slug: "secrets-not-in-code",
442
+ recipes: { native: "gitleaks dir --no-banner {dir}", any: "bash {gate}/check.sh {dir}" } };
443
+ const a = blindAdvice(entry, { langs: new Set(["python"]) }, { file: "a.py", fixes: 9 });
444
+ assert.equal(a.command, "gitleaks dir --no-banner .");
445
+ });
446
+
447
+ // Из совета вычищается то, что относится к НАМ, а не к его проекту. Исключение наших красных
448
+ // образцов (`gates/*/red`) нужно установленному гейту — рядом с ним лежат образцы. Человеку,
449
+ // который команду только копирует, эти флаги бессмысленны и подрывают доверие: он видит, что
450
+ // инструмент говорит про какие-то чужие каталоги, которых у него нет.
451
+ test("совет: исключения наших образцов в команду не попадают", () => {
452
+ const entry = { slug: "no-print-in-prod", recipes: { javascript:
453
+ "eslint --no-config-lookup --ignore-pattern 'gates/*/red/**' --ignore-pattern 'gates/*/green/**' --rule '{\"no-console\":\"error\"}' {dir}" } };
454
+ const a = blindAdvice(entry, { langs: new Set(["javascript"]) }, {});
455
+ assert.ok(!a.command.includes("gates/"), `в совете остались наши каталоги: ${a.command}`);
456
+ assert.ok(a.command.includes("no-console"), "правило потерялось вместе с исключениями");
457
+ assert.ok(a.command.startsWith("eslint "), a.command);
458
+ });
459
+
460
+ test("совет: команда без наших исключений не портится", () => {
461
+ const entry = { slug: "x", recipes: { python: "ruff check --select T20 {dir}" } };
462
+ assert.equal(blindAdvice(entry, { langs: new Set(["python"]) }, {}).command, "ruff check --select T20 .");
463
+ });
@@ -8,7 +8,8 @@
8
8
  // node --test tool/selfcheck/units-repo.mjs
9
9
  import test from "node:test";
10
10
  import assert from "node:assert/strict";
11
- import { triggerVerdict, recipeFor, EXT_LANG, whichSync, browserServerAdvice, MARKS, isApiSpec } from "../lib/repo.mjs";
11
+ import { triggerVerdict, recipeFor, EXT_LANG, whichSync, browserServerAdvice, MARKS, isApiSpec, startWith } from "../lib/repo.mjs";
12
+ import { proposeGates } from "../lib/adopt.mjs";
12
13
  import { CATALOGS, L } from "../i18n/index.mjs";
13
14
  import { dirname } from "node:path";
14
15
 
@@ -162,3 +163,124 @@ test("у каждого признака репозитория есть объ
162
163
  // запись каталога, выключенная навсегда и молча. Сторожим ту сторону, которая ломает.
163
164
  }
164
165
  });
166
+
167
+ // ─────────────────────────────────────────────────────────────────────────────
168
+ // Чужие проверки, которые у проекта УЖЕ есть. Написано ДО кода 2026-09-10.
169
+ //
170
+ // ЗАЧЕМ. Поставил комплект в `express` — проект с eslint, mocha и конвейером — и первое, что он
171
+ // увидел: двадцать крестов подряд и «держит машина 0». Это неправда с его точки зрения: его
172
+ // проверки держат, просто мы считаем только СВОИ записи.
173
+ //
174
+ // Мы видим, что конвейер ЕСТЬ (`has_ci`), но не читаем, что в нём. Человек должен вручную
175
+ // переписать в манифест то, что мы могли прочитать сами. Отсюда и «не понимает, что хорошо»:
176
+ // хорошее у него уже есть, а мы о нём молчим.
177
+ //
178
+ // Предлагаем, а не объявляем: гейт, вписанный без спроса, — это чужое решение в чужом файле.
179
+ test("чужие проверки: из package.json берутся test, lint и проверка типов", () => {
180
+ const pkg = JSON.stringify({ scripts: {
181
+ test: "mocha", lint: "eslint .", typecheck: "tsc --noEmit",
182
+ build: "rollup -c", start: "node server.js", prepare: "husky",
183
+ } });
184
+ const got = proposeGates({ "package.json": pkg });
185
+ assert.deepEqual(got.map((g) => g.name).sort(), ["lint", "test", "typecheck"]);
186
+ assert.equal(got.find((g) => g.name === "test").cmd, "npm test");
187
+ assert.equal(got.find((g) => g.name === "lint").cmd, "npm run lint");
188
+ });
189
+
190
+ // `npm test` и `npm run test` — разные написания одного; берём каноничное. А `build` и `start`
191
+ // проверками не являются: они собирают и запускают, а не судят.
192
+ test("чужие проверки: сборка и запуск проверками не считаются", () => {
193
+ const pkg = JSON.stringify({ scripts: { build: "tsc", start: "node .", dev: "vite" } });
194
+ assert.deepEqual(proposeGates({ "package.json": pkg }), []);
195
+ });
196
+
197
+ // Makefile — вторая по частоте точка входа, и в python-проектах чаще первая.
198
+ test("чужие проверки: цели Makefile тоже видны", () => {
199
+ const mk = "install:\n\tpip install -e .\n\ntest:\n\tpytest -q\n\nlint:\n\truff check .\n";
200
+ const got = proposeGates({ Makefile: mk });
201
+ assert.deepEqual(got.map((g) => g.name).sort(), ["lint", "test"]);
202
+ assert.equal(got.find((g) => g.name === "test").cmd, "make test");
203
+ });
204
+
205
+ test("чужие проверки: нечего предложить — пустой список, а не выдумка", () => {
206
+ assert.deepEqual(proposeGates({}), []);
207
+ assert.deepEqual(proposeGates({ "package.json": "{ не json" }), []);
208
+ });
209
+
210
+ // Источник называется: человек обязан видеть, ОТКУДА мы это взяли, иначе предложение
211
+ // неотличимо от нашей догадки.
212
+ test("чужие проверки: у каждого предложения назван источник", () => {
213
+ const got = proposeGates({ "package.json": JSON.stringify({ scripts: { test: "jest" } }) });
214
+ assert.equal(got[0].source, "package.json");
215
+ });
216
+
217
+ // Python-проекты. Замер 2026-09-11 на восьми живых (requests, click, flask, httpx, black,
218
+ // fastapi, pydantic, rich): package.json нет ни у кого, Makefile у трёх — и пятеро из восьми
219
+ // слышали «у вас ничего нет». А `.pre-commit-config.yaml` лежит у семи, tox — у пяти (у click и
220
+ // flask в pyproject.toml), у httpx — исполняемые scripts/test и scripts/check.
221
+ // Куски ниже сняты с этих репозиториев, а не придуманы.
222
+ test("чужие проверки: pre-commit предлагается целиком, хуки названы поимённо", () => {
223
+ const cfg = "repos:\n - repo: https://github.com/pre-commit/pre-commit-hooks\n hooks:\n" +
224
+ " - id: check-yaml\n - id: end-of-file-fixer\n" +
225
+ " - repo: https://github.com/astral-sh/ruff-pre-commit\n hooks:\n - id: ruff-check\n";
226
+ const got = proposeGates({ ".pre-commit-config.yaml": cfg });
227
+ assert.equal(got.length, 1);
228
+ assert.equal(got[0].cmd, "pre-commit run --all-files");
229
+ assert.match(got[0].source, /ruff-check/, "человек видит, ЧТО там стоит, а не только файл");
230
+ });
231
+
232
+ test("чужие проверки: из tox берутся окружения-проверки, а не матрица версий", () => {
233
+ const ini = "[tox]\nenvlist = py{310,311}-{default}\n\n[testenv]\ncommands = pytest\n\n" +
234
+ "[testenv:lint]\ncommands = ruff check .\n\n[testenv:docs]\ncommands = sphinx-build\n\n[testenv:{,ci-}pypy3]\n";
235
+ assert.deepEqual(proposeGates({ "tox.ini": ini }).map((g) => g.cmd), ["tox -e lint"]);
236
+ // click и flask держат tox в pyproject.toml, новым синтаксисом.
237
+ const py = "[tool.tox]\nenv_list = [\"py3\"]\n\n[tool.tox.env.style]\ncommands = []\n\n" +
238
+ "[tool.tox.env.typing]\ncommands = []\n\n[tool.tox.env.docs]\ncommands = []\n";
239
+ const got = proposeGates({ "pyproject.toml": py });
240
+ assert.deepEqual(got.map((g) => g.cmd).sort(), ["tox -e style", "tox -e typing"]);
241
+ assert.equal(got[0].source, "pyproject.toml");
242
+ });
243
+
244
+ test("чужие проверки: исполняемые scripts/test и scripts/check — как у httpx", () => {
245
+ const got = proposeGates({ "scripts/test": "", "scripts/check": "", "scripts/publish": "" });
246
+ assert.deepEqual(got.map((g) => g.cmd).sort(), ["scripts/check", "scripts/test"]);
247
+ });
248
+
249
+ // ─────────────────────────────────────────────────────────────────────────────
250
+ // С чего начать: три записи вместо двадцати равнозначных крестов. Написано ДО кода.
251
+ //
252
+ // ЗАЧЕМ. После установки человек видит двадцать крестов одинаковой формы и не знает, за что
253
+ // взяться. Двадцать одинаковых требований — это ноль требований: закрывают первое попавшееся
254
+ // или не закрывают ничего.
255
+ //
256
+ // Порядок НЕ ПО НАШЕМУ ВКУСУ. Два признака, оба — факты, которые у нас уже есть:
257
+ // · запись родилась из настоящего отказа (`proof` ссылается на журнал шишек — тот же
258
+ // признак, которым каталог отделяет условную запись) — она про боль, которая случалась, а не про «хорошую практику»;
259
+ // · её можно закрыть ОДНОЙ ГОТОВОЙ КОМАНДОЙ — значит цена входа минутная.
260
+ // Сначала то, что и больно, и дёшево.
261
+ test("с чего начать: сперва рождённые из отказа и закрываемые одной командой", () => {
262
+ const list = [
263
+ { slug: "praktika", proof: "хорошая практика", recipes: { any: "bash {gate}/c.sh {dir}" } },
264
+ { slug: "bol-i-deshevo", proof: "incidents/README.md, 2026-09-01", recipes: { native: "gitleaks dir {dir}" } },
265
+ { slug: "bol-no-dorogo", proof: "incidents/README.md, 2026-08-02", recipes: { any: "bash {gate}/c.sh {dir}" } },
266
+ { slug: "deshevo", proof: "методичка", recipes: { python: "ruff check {dir}" } },
267
+ ];
268
+ assert.deepEqual(startWith(list, { langs: new Set(["python"]) }, 3).map((e) => e.slug),
269
+ ["bol-i-deshevo", "deshevo", "bol-no-dorogo"]);
270
+ });
271
+
272
+ test("с чего начать: список короче трёх не ломается", () => {
273
+ assert.deepEqual(startWith([], { langs: new Set() }, 3), []);
274
+ const one = [{ slug: "a", proof: "incidents/README.md", recipes: {} }];
275
+ assert.deepEqual(startWith(one, { langs: new Set() }, 3).map((e) => e.slug), ["a"]);
276
+ });
277
+
278
+ // Порядок обязан быть УСТОЙЧИВЫМ: одинаковый ввод — одинаковый ответ, иначе человек видит
279
+ // разный совет на двух прогонах подряд и перестаёт верить обоим.
280
+ test("с чего начать: при равенстве признаков порядок стабилен", () => {
281
+ const list = [
282
+ { slug: "b", proof: "incidents/README.md", recipes: {} },
283
+ { slug: "a", proof: "incidents/README.md", recipes: {} },
284
+ ];
285
+ assert.deepEqual(startWith(list, { langs: new Set() }, 2).map((e) => e.slug), ["a", "b"]);
286
+ });
@@ -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, browserServerAdvice, MARKS } from "../lib/repo.mjs";
15
+ import { triggerVerdict, recipeFor, pickRecipe, 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";
@@ -398,3 +398,36 @@ test("наборы файлов правил совпадают на обоих
398
398
 
399
399
 
400
400
 
401
+
402
+ // ─────────────────────────────────────────────────────────────────────────────
403
+ // Рецепт, не привязанный к языку. Написано ДО кода 2026-09-10.
404
+ //
405
+ // ЗАЧЕМ. Ключи рецептов — языки: `python`, `javascript`, `go`. Это верно для линтеров, но
406
+ // неверно для инструментов, которым язык безразличен. `secrets-not-in-code` объявлен
407
+ // `always: true`, готовый аналог у него — `gitleaks`, и он ищет ключи в любом файле.
408
+ //
409
+ // Без такого ключа выбор один из двух, и оба плохие: продублировать `gitleaks` под восемь
410
+ // языков (тот самый повтор, против которого у нас есть гейт) или оставить запись без родного
411
+ // рецепта. Второе и было: замер на десяти проектах показал секреты в НЕПОКРЫТЫХ у axios и у
412
+ // всех четырёх python-проектов — то есть там, где польза наибольшая, команды для вставки не
413
+ // было.
414
+ //
415
+ // Порядок предпочтения: свой язык → безъязыковой родной → переносимый. Родной сильнее
416
+ // переносимого (у `gitleaks` сотни форматов токенов и чтение истории), но только если его есть
417
+ // чем выполнить: непоставленная программа даёт «not found», а это отсутствие сигнала, выданное
418
+ // за успех.
419
+ test("рецепт: безъязыковой родной берётся, когда своего языка нет", () => {
420
+ const rec = { recipes: { native: "true --scan", any: "bash {gate}/check.sh {dir}" } };
421
+ assert.equal(pickRecipe(rec, { langs: new Set(["python"]) }), "true --scan");
422
+ });
423
+
424
+ test("рецепт: свой язык сильнее безъязыкового", () => {
425
+ const rec = { recipes: { python: "true --py", native: "true --scan", any: "bash x {dir}" } };
426
+ assert.equal(pickRecipe(rec, { langs: new Set(["python"]) }), "true --py");
427
+ });
428
+
429
+ // Программы нет — родной не берётся ни в каком виде, иначе гейт встанет с «not found».
430
+ test("рецепт: безъязыковой без программы уступает переносимому", () => {
431
+ const rec = { recipes: { native: "aqk-nesuschestvuyuschiy --scan", any: "bash {gate}/check.sh {dir}" } };
432
+ assert.equal(pickRecipe(rec, { langs: new Set(["python"]) }), "bash {gate}/check.sh {dir}");
433
+ });