agent-quality-kit 0.11.0 → 0.13.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.
- package/README.md +28 -4
- package/README.ru.md +28 -4
- package/kit/gates/complexity-limit/gate.yml +9 -0
- package/kit/gates/dead-code/gate.yml +9 -0
- package/kit/gates/duplicate-code/gate.yml +5 -0
- package/kit/gates/env-secrets-not-committed/README.md +73 -0
- package/kit/gates/env-secrets-not-committed/check.sh +139 -0
- package/kit/gates/env-secrets-not-committed/gate.yml +21 -0
- package/kit/gates/env-secrets-not-committed/green/.aqk-tracked +10 -0
- package/kit/gates/env-secrets-not-committed/green/.env +10 -0
- package/kit/gates/env-secrets-not-committed/green/.env.production +5 -0
- package/kit/gates/env-secrets-not-committed/green/.env.test +2 -0
- package/kit/gates/env-secrets-not-committed/red/.aqk-tracked +5 -0
- package/kit/gates/env-secrets-not-committed/red/.env +7 -0
- package/kit/gates/no-print-in-prod/gate.yml +9 -0
- package/kit/gates/secrets-not-in-code/gate.yml +20 -0
- package/kit/gates/swallowed-error/gate.yml +9 -0
- package/kit/gates/todo-without-task/gate.yml +9 -0
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/tool/commands/badge.mjs +1 -1
- package/tool/commands/context.mjs +59 -4
- package/tool/commands/doctor.mjs +171 -44
- package/tool/commands/gates.mjs +3 -2
- package/tool/commands/probe.mjs +86 -22
- package/tool/commands/project.mjs +6 -1
- package/tool/commands/report.mjs +1 -1
- package/tool/commands/vitals.mjs +21 -12
- package/tool/i18n/en-docs.mjs +24 -2
- package/tool/i18n/en-gates.mjs +10 -1
- package/tool/i18n/en.mjs +25 -0
- package/tool/i18n/ru-docs.mjs +26 -2
- package/tool/i18n/ru-gates.mjs +10 -1
- package/tool/i18n/ru.mjs +25 -0
- package/tool/lib/adopt.mjs +112 -0
- package/tool/lib/advice.mjs +115 -0
- package/tool/lib/brief.mjs +3 -1
- package/tool/lib/cadence.mjs +46 -1
- package/tool/lib/core.mjs +39 -1
- package/tool/lib/execution.mjs +50 -1
- package/tool/lib/gate-worker.mjs +18 -0
- package/tool/lib/history.mjs +45 -6
- package/tool/lib/manifest.mjs +65 -21
- package/tool/lib/prove.mjs +2 -2
- package/tool/lib/repo.mjs +18 -0
- package/tool/lib/run.mjs +108 -8
- package/tool/selfcheck/gates.sh +12 -2
- package/tool/selfcheck/smoke/first-run.test.mjs +105 -0
- package/tool/selfcheck/smoke/verdict.test.mjs +51 -2
- package/tool/selfcheck/smoke.sh +6 -2
- package/tool/selfcheck/units-cadence.mjs +51 -1
- package/tool/selfcheck/units-context.mjs +64 -1
- package/tool/selfcheck/units-execution.mjs +78 -1
- package/tool/selfcheck/units-level.mjs +99 -1
- package/tool/selfcheck/units-probe.mjs +104 -0
- package/tool/selfcheck/units-repo.mjs +146 -0
- package/tool/selfcheck/units-verdict.mjs +76 -0
- package/tool/selfcheck/units.mjs +34 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// tool/lib/adopt.mjs — проверки, которые у проекта УЖЕ ЕСТЬ. Читаются из его собственных
|
|
2
|
+
// файлов, а не выдумываются.
|
|
3
|
+
//
|
|
4
|
+
// ЗАЧЕМ. Комплект, поставленный в `express` — проект с eslint, mocha и конвейером, — показывал
|
|
5
|
+
// двадцать крестов подряд и «держит машина 0». С точки зрения владельца это неправда: его
|
|
6
|
+
// проверки держат, просто мы считали только СВОИ записи. Мы видели, что конвейер ЕСТЬ
|
|
7
|
+
// (`has_ci`), но не читали, что в нём, и человек должен был переписать в манифест руками то,
|
|
8
|
+
// что мы могли прочитать сами. Отсюда и «не понимает, что у него хорошо»: хорошее уже есть, а
|
|
9
|
+
// мы о нём молчим.
|
|
10
|
+
//
|
|
11
|
+
// ОТДЕЛЬНЫМ ФАЙЛОМ, а не в repo.mjs: там осмотр репозитория ради НАШЕГО каталога, здесь —
|
|
12
|
+
// чтение ЧУЖИХ конфигов, и у каждого источника свой формат, который меняется по своим
|
|
13
|
+
// причинам. Шов вскрыл наш же `file-size-limit`: с python-источниками repo.mjs перевалил бы
|
|
14
|
+
// за 500 строк.
|
|
15
|
+
//
|
|
16
|
+
// ПРЕДЛАГАЕМ, А НЕ ОБЪЯВЛЯЕМ. Гейт, вписанный в чужой манифест без спроса, — это наше решение
|
|
17
|
+
// в чужом файле; и человек обязан видеть ИСТОЧНИК, иначе предложение неотличимо от догадки.
|
|
18
|
+
//
|
|
19
|
+
// Сборка и запуск проверками не считаются: `build` собирает, `start` запускает, судит — ни тот
|
|
20
|
+
// ни другой. Берём только то, что отвечает «прошло или нет».
|
|
21
|
+
const CHECK_NAMES = new Set(["test", "tests", "lint", "typecheck", "type-check", "types", "check"]);
|
|
22
|
+
|
|
23
|
+
// Окружения tox, которые судят, а не гоняют тесты под матрицей версий. Голый `tox` не
|
|
24
|
+
// предлагаем: он проходит все интерпретаторы из envlist, и у человека без пяти питонов это
|
|
25
|
+
// красный прогон на пустом месте. Имена сняты с живых tox-файлов (rich, click, flask).
|
|
26
|
+
const TOX_CHECKS = new Set(["lint", "style", "typing", "types", "type", "mypy", "check", "typecheck"]);
|
|
27
|
+
|
|
28
|
+
// Источники в порядке доверия: при совпадении имени берётся первый. Замер 2026-09-11 на
|
|
29
|
+
// восьми python-проектах: package.json нет ни у кого, Makefile у трёх, `.pre-commit-config.yaml`
|
|
30
|
+
// у семи, tox у пяти, scripts/ у httpx. Шаги конвейера НЕ читаются: там `${{ matrix.x }}`,
|
|
31
|
+
// `PYTHONPATH=…` и обёртки `uv run --locked --group …` — строка, которая работает только в
|
|
32
|
+
// том конвейере, предложенная как локальный гейт, покраснела бы у человека в первую же минуту.
|
|
33
|
+
function proposeGates(files = {}) {
|
|
34
|
+
const out = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const push = (name, cmd, source) => {
|
|
37
|
+
const key = name === "tests" ? "test" : name.replace(/^type-?check$|^types$/, "typecheck");
|
|
38
|
+
if (seen.has(key)) return;
|
|
39
|
+
seen.add(key);
|
|
40
|
+
out.push({ name: key, cmd, source });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const pkg = files["package.json"];
|
|
44
|
+
if (pkg) {
|
|
45
|
+
let scripts = null;
|
|
46
|
+
try { scripts = JSON.parse(pkg)?.scripts; } catch { scripts = null; }
|
|
47
|
+
if (scripts && typeof scripts === "object") {
|
|
48
|
+
for (const name of Object.keys(scripts)) {
|
|
49
|
+
if (!CHECK_NAMES.has(name)) continue;
|
|
50
|
+
// `npm test` — каноничное написание для теста, остальное через `run`.
|
|
51
|
+
push(name, name === "test" || name === "tests" ? "npm test" : `npm run ${name}`, "package.json");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const mk = files.Makefile;
|
|
57
|
+
if (typeof mk === "string") {
|
|
58
|
+
for (const m of mk.matchAll(/^([A-Za-z][\w-]*):/gm)) {
|
|
59
|
+
if (CHECK_NAMES.has(m[1])) push(m[1], `make ${m[1]}`, "Makefile");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// pre-commit — одна команда на всё, что в нём стоит. Хуки называются поимённо: «у вас есть
|
|
64
|
+
// .pre-commit-config.yaml» ничего не говорит, «у вас есть ruff-check и check-yaml» — говорит.
|
|
65
|
+
const pc = files[".pre-commit-config.yaml"];
|
|
66
|
+
if (typeof pc === "string") {
|
|
67
|
+
const ids = [...pc.matchAll(/^\s*-\s*id:\s*["']?([\w.-]+)/gm)].map((m) => m[1]);
|
|
68
|
+
if (ids.length) {
|
|
69
|
+
const shown = ids.slice(0, 4).join(", ") + (ids.length > 4 ? ` +${ids.length - 4}` : "");
|
|
70
|
+
push("pre-commit", "pre-commit run --all-files", `.pre-commit-config.yaml: ${shown}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// tox живёт в двух местах и двух синтаксисах: `[testenv:lint]` в tox.ini (и внутри
|
|
75
|
+
// legacy_tox_ini в pyproject), `[tool.tox.env.lint]` в pyproject.toml нового образца.
|
|
76
|
+
for (const src of ["tox.ini", "pyproject.toml"]) {
|
|
77
|
+
const t = files[src];
|
|
78
|
+
if (typeof t !== "string") continue;
|
|
79
|
+
for (const m of t.matchAll(/^\s*\[(?:testenv:|tool\.tox\.env\.)([\w-]+)\]/gm)) {
|
|
80
|
+
if (TOX_CHECKS.has(m[1])) push(m[1], `tox -e ${m[1]}`, src);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Каталог scripts/ с исполняемыми test, check, lint — соглашение encode (httpx, starlette):
|
|
85
|
+
// конвейер зовёт ровно их. Содержимое не читаем, важно только, что файл есть.
|
|
86
|
+
for (const name of ["test", "check", "lint"]) {
|
|
87
|
+
if (`scripts/${name}` in files) push(name, `scripts/${name}`, "scripts/");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Какие файлы читать — знает тот, кто их разбирает. Список в команде разошёлся бы с разбором
|
|
94
|
+
// в первый же день, когда появится новый источник.
|
|
95
|
+
const ADOPT_FILES = ["package.json", "Makefile", ".pre-commit-config.yaml", "tox.ini", "pyproject.toml"];
|
|
96
|
+
const ADOPT_SCRIPTS = ["scripts/test", "scripts/check", "scripts/lint"];
|
|
97
|
+
|
|
98
|
+
// Чтение с диска — отдельно от разбора: разбор проверяется перебором случаев, чтение — прогоном.
|
|
99
|
+
async function readAdoptFiles(cwd) {
|
|
100
|
+
const { readFile, access } = await import("node:fs/promises");
|
|
101
|
+
const { join } = await import("node:path");
|
|
102
|
+
const files = {};
|
|
103
|
+
for (const n of ADOPT_FILES) {
|
|
104
|
+
try { files[n] = await readFile(join(cwd, n), "utf8"); } catch { /* нет — и ладно */ }
|
|
105
|
+
}
|
|
106
|
+
for (const n of ADOPT_SCRIPTS) {
|
|
107
|
+
try { await access(join(cwd, n)); files[n] = ""; } catch { /* нет — и ладно */ }
|
|
108
|
+
}
|
|
109
|
+
return files;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export { proposeGates, readAdoptFiles };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// tool/lib/advice.mjs — ЧТО СОВЕТОВАТЬ ПРОЕКТУ: какие записи каталога его касаются, с каких
|
|
2
|
+
// начать и какой командой закрыть класс брака прямо сейчас, без комплекта.
|
|
3
|
+
//
|
|
4
|
+
// ОТДЕЛЬНЫМ ФАЙЛОМ, а не в repo.mjs: там осмотр репозитория (факты, триггеры, рецепты), здесь —
|
|
5
|
+
// решение, что из этого сказать человеку и агенту. Одно знание на `doctor`, `probe` и блок для
|
|
6
|
+
// агента: жило в двух файлах и росло в обоих; шов вскрыл наш же file-size-limit — repo.mjs
|
|
7
|
+
// дорос до 494 строк при пределе 500.
|
|
8
|
+
import { triggerVerdict } from "./repo.mjs";
|
|
9
|
+
|
|
10
|
+
// Корзины каталога для ЭТОГО проекта: держит · к установке · закрыто другим арбитром ·
|
|
11
|
+
// неприменимо. Одна раскладка на `doctor` и на блок для агента: жила внутри `doctor`, и блоку
|
|
12
|
+
// пришлось бы завести вторую — а два счёта одного и того же расходятся первыми.
|
|
13
|
+
function catalogBuckets(catalog, facts, covered = new Map()) {
|
|
14
|
+
const held = [], todo = [], skip = [], byOther = [];
|
|
15
|
+
for (const rec of catalog) {
|
|
16
|
+
const v = triggerVerdict(rec, facts);
|
|
17
|
+
if (!v.applies) skip.push([rec, v.why]);
|
|
18
|
+
else if (facts.gateKeys.includes(rec.slug)) held.push(rec);
|
|
19
|
+
else if (covered.has(rec.slug)) byOther.push([rec, covered.get(rec.slug)]);
|
|
20
|
+
else todo.push(rec);
|
|
21
|
+
}
|
|
22
|
+
return { held, todo, skip, byOther };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// С ЧЕГО НАЧАТЬ: три записи вместо двадцати равнозначных крестов.
|
|
26
|
+
//
|
|
27
|
+
// Двадцать одинаковых требований — это ноль требований: закрывают первое попавшееся или не
|
|
28
|
+
// закрывают ничего. Порядок НЕ по нашему вкусу; два признака, оба — факты, которые у нас уже
|
|
29
|
+
// есть:
|
|
30
|
+
// · запись родилась из настоящего отказа (`lifecycle: stable` — `proof` ссылается на журнал
|
|
31
|
+
// шишек), то есть она про боль, которая СЛУЧАЛАСЬ, а не про «хорошую практику»;
|
|
32
|
+
// · её можно закрыть одной готовой командой — цена входа минутная.
|
|
33
|
+
// Сначала то, что и больно, и дёшево.
|
|
34
|
+
//
|
|
35
|
+
// При равенстве признаков — по имени: одинаковый ввод обязан давать одинаковый ответ, иначе
|
|
36
|
+
// человек видит разный совет на двух прогонах подряд и перестаёт верить обоим.
|
|
37
|
+
function startWith(entries, facts, n = 3) {
|
|
38
|
+
const langs = facts?.langs ? [...facts.langs] : [];
|
|
39
|
+
const cheap = (e) => {
|
|
40
|
+
const r = e?.recipes && typeof e.recipes === "object" ? e.recipes : {};
|
|
41
|
+
return [...langs, "native"].some((k) => r[k] && !/\{gate\}/.test(r[k])) ? 1 : 0;
|
|
42
|
+
};
|
|
43
|
+
// Зрелость НЕ поле записи, а вычисляемый признак: `proof` ссылается на журнал шишек. Тот же
|
|
44
|
+
// признак, которым каталог отделяет условную запись с первого дня (`entryLifecycle`).
|
|
45
|
+
// Заводить второй счёт нельзя: разъехавшись, они дали бы разные ответы про одну запись.
|
|
46
|
+
const hurt = (e) => (/incidents\//.test(String(e?.proof || "")) ? 1 : 0);
|
|
47
|
+
return [...entries]
|
|
48
|
+
.sort((a, b) =>
|
|
49
|
+
(hurt(b) + cheap(b)) - (hurt(a) + cheap(a)) ||
|
|
50
|
+
cheap(b) - cheap(a) ||
|
|
51
|
+
String(a.slug).localeCompare(String(b.slug)))
|
|
52
|
+
.slice(0, n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Адрес Biome сверен по реестру github 2026-09-11.
|
|
56
|
+
const BIOME_URL = "https://github.com/biomejs/biome";
|
|
57
|
+
|
|
58
|
+
// Совет по НЕПОКРЫТОМУ классу: команда, которую можно вставить прямо сейчас.
|
|
59
|
+
//
|
|
60
|
+
// Проба находит настоящие дыры и печатала про них «close it: aqk add <имя>» — то есть «поставь
|
|
61
|
+
// нашу штуку». Человек, впервые увидевший комплект, закрывает окно. А готовая однострочная
|
|
62
|
+
// команда под его стек У НАС УЖЕ ЛЕЖИТ в `recipes` записи каталога; мы её не показывали.
|
|
63
|
+
//
|
|
64
|
+
// Замер руками на `requests` (самый скачиваемый python-пакет) 2026-09-10: в
|
|
65
|
+
// `src/requests/utils.py` — 75 коммитов-починок; дописана функция с `except Exception: pass`;
|
|
66
|
+
// их собственные `ruff` и `pytest` дали 0 и на чистой копии, и на подсаженной. Строка, которая
|
|
67
|
+
// поймала бы это, лежала в нашем каталоге всё это время.
|
|
68
|
+
//
|
|
69
|
+
// Переносимый рецепт (`any`) в совет НЕ идёт: он зовёт файл из комплекта, и человеку без
|
|
70
|
+
// комплекта вставить его некуда. Нет родного рецепта под стек — команды нет, и это честнее
|
|
71
|
+
// выдуманной.
|
|
72
|
+
function blindAdvice(entry, facts, hot = {}) {
|
|
73
|
+
const recipes = entry?.recipes && typeof entry.recipes === "object" ? entry.recipes : {};
|
|
74
|
+
// `langs` приходит МНОЖЕСТВОМ, а не массивом — `Array.isArray` тихо давал пустой список, и
|
|
75
|
+
// совет не печатался вовсе. Поймано на живом `requests`: langs = Set(1) { python }.
|
|
76
|
+
const langs = facts?.langs ? [...facts.langs] : [];
|
|
77
|
+
// Тот же порядок, что у `pickRecipe`: свой язык → безъязыковой родной → ничего. Переносимый
|
|
78
|
+
// (`any`) сюда не идёт никогда: он зовёт файл из комплекта, и человеку без комплекта вставить
|
|
79
|
+
// его некуда.
|
|
80
|
+
// Проект на Biome — совет на языке Biome, а не eslint (отзыв с живого проекта 2026-09-11:
|
|
81
|
+
// «начни с трёх» советовал завести eslint проекту на Biome). Одно правило разово через `--only`;
|
|
82
|
+
// `--error-on-warnings` обязателен — правило вне рекомендованных Biome ставит на
|
|
83
|
+
// «предупреждение», и без флага команда выходила с нулём, напечатав находку (Biome 2.5.12).
|
|
84
|
+
const br = typeof entry?.biome_rules === "string" ? entry.biome_rules.trim() : "";
|
|
85
|
+
if (facts?.has_biome && br && br !== "none" && langs.some((l) => l === "javascript" || l === "typescript")) {
|
|
86
|
+
const only = br.split(",").map((x) => x.trim()).filter(Boolean).map((r) => `--only=${r}`).join(" ");
|
|
87
|
+
return { command: `npx @biomejs/biome lint --error-on-warnings ${only} .`, tool: BIOME_URL,
|
|
88
|
+
file: hot.file ?? null, fixes: hot.fixes ?? null, slug: entry?.slug ?? null };
|
|
89
|
+
}
|
|
90
|
+
let cmd = null;
|
|
91
|
+
for (const key of [...langs, "native"]) {
|
|
92
|
+
const r = recipes[key];
|
|
93
|
+
if (!r || /\{gate\}/.test(r)) continue;
|
|
94
|
+
cmd = String(r).replace(/\{dir\}/g, ".")
|
|
95
|
+
// Вычистить то, что относится к НАМ, а не к его проекту. Исключение наших красных
|
|
96
|
+
// образцов нужно УСТАНОВЛЕННОМУ гейту — рядом с ним лежат образцы. Человеку, который
|
|
97
|
+
// команду только копирует, этих каталогов не существует, и флаги про них подрывают
|
|
98
|
+
// доверие: инструмент говорит про чужое хозяйство вместо его кода.
|
|
99
|
+
.replace(/\s--ignore-pattern\s+'[^']*gates\/[^']*'/g, "")
|
|
100
|
+
.replace(/\s--ignore-paths=?\s*'[^']*gates\/[^']*'/g, "")
|
|
101
|
+
.replace(/\s+/g, " ")
|
|
102
|
+
.trim();
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
// Адрес — того инструмента, которым команда начинается: поле `tool` общее на все языки, и
|
|
106
|
+
// python-проекту показывалась ссылка на eslint. Первые три слова, а не одно: `npx knip`,
|
|
107
|
+
// `python -m vulture`. Не совпало — весь список: лишняя ссылка лучше, чем ни одной.
|
|
108
|
+
const urls = entry?.tool ? String(entry.tool).split(/\s+·\s+/) : [];
|
|
109
|
+
const head = cmd ? cmd.split(" ").slice(0, 3) : [];
|
|
110
|
+
const own = urls.find((u) => head.includes(u.replace(/\/+$/, "").split("/").pop()));
|
|
111
|
+
const tool = own ?? (entry?.tool ? String(entry.tool) : null);
|
|
112
|
+
return { command: cmd, tool, file: hot.file ?? null, fixes: hot.fixes ?? null, slug: entry?.slug ?? null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export { catalogBuckets, startWith, blindAdvice };
|
package/tool/lib/brief.mjs
CHANGED
|
@@ -112,7 +112,9 @@ function beginBrief() {
|
|
|
112
112
|
|
|
113
113
|
// Печать краткого итога. Совет — не чаще раза в сутки и с явным способом отказаться: то, что
|
|
114
114
|
// видишь тридцатый раз, перестаёт читаться и пролистывается вместе с настоящими находками рядом.
|
|
115
|
-
// Отметка времени лежит в .aqk
|
|
115
|
+
// Отметка времени лежит в .aqk/ и попадает в .gitignore при `init` (RUNTIME_FILES в core.mjs):
|
|
116
|
+
// это состояние машины, а не проекта. Раньше здесь было написано «.aqk/ в .gitignore» — а
|
|
117
|
+
// `init` туда ничего не клал, и на живом проекте служебный файл уехал в коммит.
|
|
116
118
|
async function finishBrief(buf, state, todoRecs, ok) {
|
|
117
119
|
if (!buf) return;
|
|
118
120
|
buf.restore();
|
package/tool/lib/cadence.mjs
CHANGED
|
@@ -54,4 +54,49 @@ function probeEvery(man) {
|
|
|
54
54
|
return n;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
// ИМЕНА непойманных классов в отметке. Хранилось одно число, `blind: 1`: агент в `context`
|
|
58
|
+
// видел «один класс», человек в `doctor` не видел ничего, и чтобы узнать КАКОЙ, надо было снова
|
|
59
|
+
// запускать пробу — ту самую команду, о которой не вспоминают. Находка, которую не показали,
|
|
60
|
+
// ни от чего не предостерегает.
|
|
61
|
+
//
|
|
62
|
+
// Правило то же, что у `countProbe`: класс, слепой хоть где-то, — непокрыт, а «не смогли
|
|
63
|
+
// проверить» слепотой не считается. Файл — первый, где слеп: пробы идут от самого горячего.
|
|
64
|
+
function blindLines(records) {
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const { entry, file, verdict } of records || []) {
|
|
68
|
+
if (verdict !== "blind" || seen.has(entry)) continue;
|
|
69
|
+
seen.add(entry);
|
|
70
|
+
out.push(`blind-class: ${entry} ${file}`);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Отметка старого формата строк `blind-class:` не несёт — тогда список пуст, и это «имён не
|
|
76
|
+
// знаем», а не «слепых нет»: число `blind:` читается отдельно и остаётся правдой.
|
|
77
|
+
function parseBlind(text) {
|
|
78
|
+
return [...String(text || "").matchAll(/^blind-class:\s*(\S+)\s+(.+?)\s*$/gm)]
|
|
79
|
+
.map((m) => ({ slug: m[1], file: m[2] }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Какие гейты проба ПРОГОНЯЛА. Без этого «объявлен сейчас» читалось как «поставлен после
|
|
83
|
+
// пробы» — а проба как раз и гоняет объявленные: гейт, стоявший ДО неё и брак пропустивший,
|
|
84
|
+
// получал утешительное «поймает ли, покажет следующая» вместо «здесь не ловит». Именно
|
|
85
|
+
// прогонявшиеся, а не объявленные: медленные проба пропускает, и про них «не поймал» — ложь.
|
|
86
|
+
// Старая отметка строки не несёт — null: «не знаем», а не «ничего не прогонялось».
|
|
87
|
+
function parseRan(text) {
|
|
88
|
+
const m = /^ran:[ \t]*(.*)$/m.exec(String(text || ""));
|
|
89
|
+
return m ? new Set(m[1].split(/\s+/).filter(Boolean)) : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Запускаться ли пробе САМОЙ внутри `doctor --run`. Не в конвейере: там это +2–3 минуты сюрпризом
|
|
93
|
+
// в случайном прогоне (отзыв с живого проекта 2026-09-11), и место пробы — отдельная задача. Не в
|
|
94
|
+
// коротком режиме: там хук на воротах коммита. `AQK_PROBE=0` — выключить, `AQK_PROBE=1` — включить
|
|
95
|
+
// и в конвейере.
|
|
96
|
+
function autoProbeAllowed({ brief = false, env = process.env } = {}) {
|
|
97
|
+
if (env.AQK_PROBE === "0" || brief) return false;
|
|
98
|
+
if (env.AQK_PROBE === "1") return true;
|
|
99
|
+
return !(env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI || env.BUILDKITE || env.JENKINS_URL);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan, autoProbeAllowed };
|
package/tool/lib/core.mjs
CHANGED
|
@@ -142,9 +142,47 @@ async function writeIfAbsent(path, content, { force }) {
|
|
|
142
142
|
return true;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// СЛУЖЕБНЫЕ ФАЙЛЫ: состояние ЭТОЙ машины, переписываются каждым прогоном — в git им не место.
|
|
146
|
+
// Один список на `init` (кладёт в .gitignore), `doctor` (предупреждает, если git их видит) и
|
|
147
|
+
// читателей. Отзыв с живого проекта 2026-09-11: `.aqk/last-run.md` однажды закоммитили, и каждый
|
|
148
|
+
// `make check` оставлял изменённый файл. Целиком `.aqk/` не игнорируется: методички и правила в
|
|
149
|
+
// нём — содержимое проекта.
|
|
150
|
+
const RUNTIME_FILES = ["last-run.md", "last-probe.md", "advice-shown", "update-checked"];
|
|
151
|
+
const L_IGNORE_NOTE = LANG === "en"
|
|
152
|
+
? "# aqk: this machine's state — rewritten by every run, it does not belong in git"
|
|
153
|
+
: "# aqk: состояние этой машины — переписывается каждым прогоном, в git ему не место";
|
|
154
|
+
|
|
155
|
+
// Дописать служебные файлы в .gitignore, не трогая чужих строк и не дублируя своих. Возвращает
|
|
156
|
+
// список добавленных строк — `init` называет их вслух: правка чужого файла без слова была бы
|
|
157
|
+
// тем самым «пишу в дерево без спроса».
|
|
158
|
+
async function ensureIgnored(cwd = CWD) {
|
|
159
|
+
const { readFile, writeFile } = await import("node:fs/promises");
|
|
160
|
+
const file = join(cwd, ".gitignore");
|
|
161
|
+
let text = "";
|
|
162
|
+
try { text = await readFile(file, "utf8"); } catch { /* файла нет — создадим */ }
|
|
163
|
+
const have = new Set(text.split(/\r?\n/).map((l) => l.trim()));
|
|
164
|
+
const add = RUNTIME_FILES.map((f) => `${TARGET_DIR}/${f}`).filter((l) => !have.has(l) && !have.has(`/${l}`));
|
|
165
|
+
if (!add.length) return [];
|
|
166
|
+
const head = text && !text.endsWith("\n") ? "\n" : "";
|
|
167
|
+
await writeFile(file, `${text}${head}${text ? "\n" : ""}${L_IGNORE_NOTE}\n${add.join("\n")}\n`, "utf8");
|
|
168
|
+
return add;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Стоит ли хук pre-commit НА САМОМ ДЕЛЕ — в `.git/hooks`, а не в `.pre-commit-config.yaml`:
|
|
172
|
+
// запись в конфиге — намерение, сработает только то, что лежит в гите. Три ответа: true — стоит,
|
|
173
|
+
// false — нет, null — не git или файл не прочитать («не знаем» не сливается с «нет»).
|
|
174
|
+
// Одна функция на `vitals` (подключено ли) и `context` (что сказать агенту перед коммитом).
|
|
175
|
+
async function preCommitHook(cwd = CWD) {
|
|
176
|
+
const { readFile } = await import("node:fs/promises");
|
|
177
|
+
if (!(await exists(join(cwd, ".git")))) return null;
|
|
178
|
+
const hook = join(cwd, ".git", "hooks", "pre-commit");
|
|
179
|
+
if (!(await exists(hook))) return false;
|
|
180
|
+
try { return /pre-commit|aqk/i.test(await readFile(hook, "utf8")); } catch { return null; }
|
|
181
|
+
}
|
|
182
|
+
|
|
145
183
|
export {
|
|
146
184
|
copyDir, writeIfAbsent,
|
|
147
185
|
PKG_ROOT, CWD, DOCS_SRC, RULES_SRC, TARGET_DIR, docPath,
|
|
148
186
|
MANIFEST, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB,
|
|
149
|
-
SELF, REPO_URL, c, exists, die, FEEDBACK_MARK, commandRows,
|
|
187
|
+
SELF, REPO_URL, c, exists, die, FEEDBACK_MARK, commandRows, preCommitHook, RUNTIME_FILES, ensureIgnored,
|
|
150
188
|
};
|
package/tool/lib/execution.mjs
CHANGED
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
|
|
28
28
|
// Умолчание для незнакомой программы: ноль — чисто, единица — находка, остальное — сбой.
|
|
29
29
|
// Это честнее догадки: неизвестный код становится «не знаем», а не «поймал».
|
|
30
|
+
import { existsSync } from "node:fs";
|
|
31
|
+
import { win32 } from "node:path";
|
|
32
|
+
import { whichSync } from "./repo.mjs";
|
|
33
|
+
|
|
30
34
|
const DEFAULT = (code) => code === 1;
|
|
31
35
|
|
|
32
36
|
const ADAPTERS = {
|
|
@@ -64,4 +68,49 @@ function classify(r, isFinding = DEFAULT) {
|
|
|
64
68
|
return { state: "infra_error", reason: "unexpected_exit", code };
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
// КАКОЙ BASH ЗАПУСКАТЬ НА WINDOWS. В System32 лежит bash.exe — заглушка WSL, и в PATH она стоит
|
|
72
|
+
// раньше Git Bash: установщик Git по умолчанию кладёт туда только Git\cmd. Команда гейта
|
|
73
|
+
// `bash kit/gates/x/check.sh .` уезжала в Linux-подсистему — другие программы, другой git,
|
|
74
|
+
// другие пути — и прогон краснел «не из-за кода». Отчёт с живого проекта 2026-09-11: «гейты
|
|
75
|
+
// под Windows берут bash из WSL. Через Git Bash все проходят».
|
|
76
|
+
//
|
|
77
|
+
// Приём не выдуман: на ту же ловушку наступали Claude Code (anthropics/claude-code#23556) и
|
|
78
|
+
// Archon (coleam00/Archon#1326), и общий ответ — не верить слову `bash` из PATH, а брать его
|
|
79
|
+
// рядом с git. Git комплекту нужен всё равно. Явный путь — AQK_BASH: у всего, что мы решаем
|
|
80
|
+
// сами, обязан быть способ решить иначе.
|
|
81
|
+
//
|
|
82
|
+
// Не нашли — null, и команда остаётся как есть. Подставлять заглушку WSL «за неимением» нельзя:
|
|
83
|
+
// это ровно то, от чего мы уходим; `vitals` назовёт bash ненайденным.
|
|
84
|
+
function gitBash({ platform = process.platform, env = process.env, which = whichSync, exists = existsSync } = {}) {
|
|
85
|
+
if (platform !== "win32") return null;
|
|
86
|
+
if (env.AQK_BASH) return env.AQK_BASH;
|
|
87
|
+
const cands = [];
|
|
88
|
+
const git = which("git", env);
|
|
89
|
+
if (git) {
|
|
90
|
+
// Git\cmd\git.exe и Git\mingw64\bin\git.exe — корень на два и на три уровня выше.
|
|
91
|
+
for (const root of [win32.dirname(win32.dirname(git)), win32.dirname(win32.dirname(win32.dirname(git)))]) {
|
|
92
|
+
cands.push(win32.join(root, "bin", "bash.exe"), win32.join(root, "usr", "bin", "bash.exe"));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const base of [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs")]) {
|
|
96
|
+
if (base) cands.push(win32.join(base, "Git", "bin", "bash.exe"));
|
|
97
|
+
}
|
|
98
|
+
return cands.find((p) => exists(p)) || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Подменяется только ПЕРВОЕ слово: внутренний `bash` у обёртки храповика запускает уже Git Bash,
|
|
102
|
+
// а у него свой PATH. Кавычки — путь почти всегда с пробелом («Program Files»).
|
|
103
|
+
function launchable(cmd, bash) {
|
|
104
|
+
const s = String(cmd || "");
|
|
105
|
+
return bash && /^bash(\s|$)/.test(s) ? `"${bash}"${s.slice(4)}` : s;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Одна точка для всех пяти мест, где запускается команда гейта: прогон, доказательство, проба,
|
|
109
|
+
// храповик, `why`. Поиск делается раз на процесс — он смотрит на диск, а гейтов бывает тридцать.
|
|
110
|
+
let cachedBash;
|
|
111
|
+
function gateCommand(cmd) {
|
|
112
|
+
if (cachedBash === undefined) cachedBash = gitBash();
|
|
113
|
+
return launchable(cmd, cachedBash);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { classify, findingCodes, gitBash, launchable, gateCommand };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// tool/lib/gate-worker.mjs — рабочий поток параллельного прогона (`doctor --run --jobs N`).
|
|
2
|
+
//
|
|
3
|
+
// ПОЧЕМУ ПОТОК, А НЕ АСИНХРОННЫЙ ЗАПУСК. Прогон стоит на `spawnSync` с таймаутом: его поведение
|
|
4
|
+
// на зависшем гейте замерено и описано (execution.mjs). Асинхронный `spawn` потребовал бы своего
|
|
5
|
+
// убийства дерева процессов на таймауте — на Windows это отдельная история. Поток выполняет ТОТ ЖЕ
|
|
6
|
+
// `spawnSync` с тем же таймаутом, и семантика прогона не меняется ни в чём, кроме одновременности.
|
|
7
|
+
// Зависимостей это не добавляет: worker_threads встроены в Node.
|
|
8
|
+
import { parentPort } from "node:worker_threads";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { gateCommand } from "./execution.mjs";
|
|
11
|
+
|
|
12
|
+
parentPort.on("message", ({ id, cmd, cwd, timeout }) => {
|
|
13
|
+
const r = spawnSync(gateCommand(cmd), { shell: true, cwd, encoding: "utf8", timeout });
|
|
14
|
+
parentPort.postMessage({
|
|
15
|
+
id, status: r.status, stdout: r.stdout || "", stderr: r.stderr || "",
|
|
16
|
+
error: r.error ? { code: r.error.code || String(r.error.message || r.error) } : null,
|
|
17
|
+
});
|
|
18
|
+
});
|
package/tool/lib/history.mjs
CHANGED
|
@@ -111,19 +111,58 @@ function probeSummary({ caught = 0, blind = 0, unknown = 0, unprobed = 0 } = {})
|
|
|
111
111
|
// · гейт, ПОЗЕЛЕНЕВШИЙ от подсадки, — он смотрит не туда, и это тоже не поимка.
|
|
112
112
|
// Исключены все — вердикт `unknown`, а не `blind`: «не по чему судить» и «никто не ловит»
|
|
113
113
|
// разные факты, и молчание здесь и есть предмет спора.
|
|
114
|
+
//
|
|
115
|
+
// И ещё одно: гейт, СЛОМАННЫЙ подсадкой (работал до, не смог после), мог быть тем самым
|
|
116
|
+
// ловцом. Пока никто другой не поймал, «слеп» про такой класс — неправда, и вердикт `unknown`.
|
|
117
|
+
// Найдено пробой на самом комплекте 2026-09-11: образец лёг на место общей библиотеки
|
|
118
|
+
// kit/gates/_skip.sh, двенадцать гейтов вышли с кодом 2 — включая тот, что этот образец в
|
|
119
|
+
// отдельной папке ловит. Остальные молчали, и проба назвала класс слепым.
|
|
120
|
+
// «ПОКРАСНЕЛ» ≠ «ПОЙМАЛ». Отзыв с живого проекта 2026-09-11: класс «цвет из токена темы» отмечен
|
|
121
|
+
// пойманным линтером, хотя Biome цвета не проверяет, — подсаженный кусок сломал форматирование.
|
|
122
|
+
// Вердикт сравнивал коды возврата и выбрасывал вывод. Теперь гейт засчитывается, только если в
|
|
123
|
+
// его выводе ПОСЛЕ подсадки подсаженный файл назван чаще, чем до неё. Путь ищется в любой
|
|
124
|
+
// форме: `src/x`, `./src/x`, абсолютный из песочницы (подстрока), с обратными слешами Windows.
|
|
125
|
+
// Одно имя файла без каталога не засчитывается: у двух файлов оно бывает одинаковым, и
|
|
126
|
+
// поимка чужого файла выдалась бы за поимку нашего. Безымянное падение — «неизвестно».
|
|
127
|
+
function namesPlant(before, after, rel) {
|
|
128
|
+
const forms = [rel, String(rel).replace(/\//g, "\\")];
|
|
129
|
+
const count = (s) => forms.reduce((n, f) => n + String(s || "").split(f).length - 1, 0);
|
|
130
|
+
return count(after) > count(before);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ПАРА, А НЕ ОДИН ОБРАЗЕЦ. Имени файла мало: Biome, падая на форматировании, тоже называет файл.
|
|
134
|
+
// Поэтому поимка подтверждается зелёным образцом той же записи, положенным в то же место, — тем
|
|
135
|
+
// же приёмом, каким мы требуем от чужого гейта доказательства. Три исхода:
|
|
136
|
+
// caught — на красном покраснел и назвал файл, на зелёном промолчал (или файла не назвал);
|
|
137
|
+
// nameless — покраснел, но подсаженного файла не назвал: упал по своей причине;
|
|
138
|
+
// planting — краснеет и на зелёном, называя тот же файл: падает от самой подсадки.
|
|
139
|
+
// Зелёного образца под это расширение нет — судим по имени файла: пусть слабее, но это не
|
|
140
|
+
// «поймано» на ровном месте, и в README записи такой случай назван.
|
|
141
|
+
function catchVerdict(beforeOut, red, green, rel) {
|
|
142
|
+
if (!namesPlant(beforeOut, red?.out, rel)) return "nameless";
|
|
143
|
+
if (green && green.code === 1 && namesPlant(beforeOut, green.out, rel)) return "planting";
|
|
144
|
+
return "caught";
|
|
145
|
+
}
|
|
146
|
+
|
|
114
147
|
function probeVerdictPaired(before, after) {
|
|
115
148
|
const byName = new Map(after.map((r) => [r.name, r]));
|
|
116
|
-
let usable = 0, alreadyRed = 0, failed = 0, caught = 0;
|
|
149
|
+
let usable = 0, alreadyRed = 0, failed = 0, caught = 0, brokenByPlant = 0, unattributed = 0;
|
|
117
150
|
for (const b of before) {
|
|
118
151
|
const a = byName.get(b.name);
|
|
119
152
|
const broke = (r) => !r || (r.code !== 0 && r.code !== 1);
|
|
120
|
-
if (broke(b) || broke(a)) {
|
|
153
|
+
if (broke(b) || broke(a)) {
|
|
154
|
+
failed++;
|
|
155
|
+
if (!broke(b)) brokenByPlant++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
121
158
|
if (b.code === 1) { alreadyRed++; continue; }
|
|
122
159
|
usable++;
|
|
123
|
-
|
|
160
|
+
// `named === false` — покраснел, но подсаженного файла не назвал. `undefined` — вызывающий
|
|
161
|
+
// вывода не собирал (старые вызовы): тогда прежнее правило.
|
|
162
|
+
if (a.code === 1) { if (a.named === false) unattributed++; else caught++; }
|
|
124
163
|
}
|
|
125
|
-
const verdict =
|
|
126
|
-
return { verdict, usable, alreadyRed, failed, caught };
|
|
164
|
+
const verdict = caught ? "caught" : !usable || brokenByPlant || unattributed ? "unknown" : "blind";
|
|
165
|
+
return { verdict, usable, alreadyRed, failed, caught, brokenByPlant, unattributed };
|
|
127
166
|
}
|
|
128
167
|
|
|
129
168
|
// Счёт непокрытого КЛАССАМИ, а не пробами. Замер на десяти живых репозиториях 2026-09-10:
|
|
@@ -150,4 +189,4 @@ function countProbe(records) {
|
|
|
150
189
|
};
|
|
151
190
|
}
|
|
152
191
|
|
|
153
|
-
export { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe };
|
|
192
|
+
export { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe, namesPlant, catchVerdict };
|
package/tool/lib/manifest.mjs
CHANGED
|
@@ -112,7 +112,7 @@ function unparsedLines(text) {
|
|
|
112
112
|
// Список обязан совпадать с тем, что программа РЕАЛЬНО читает (`man?.<поле>` в tool/):
|
|
113
113
|
// лишнее имя здесь молча узаконивает поле, которое ни на что не влияет, — та же тишина,
|
|
114
114
|
// только с другой стороны. Сверено обходом: aqk, entry, rules, gates, samples, ratchets, lessons.
|
|
115
|
-
const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory", "probe"];
|
|
115
|
+
const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory", "probe", "groups"];
|
|
116
116
|
|
|
117
117
|
// ГДЕ У ПРОЕКТА ЛЕЖИТ РАЗЛОЖЕННЫЙ КОМПЛЕКТ. Список для шапки `doctor`. До 2026-09-08 он был
|
|
118
118
|
// литеральным: `.aqk/rules`, `.aqk/docs`, `AGENTS.md` — независимо от того, что написано в
|
|
@@ -132,12 +132,16 @@ function layoutChecks(man, inKit) {
|
|
|
132
132
|
const entries = (Array.isArray(man?.entry) ? man.entry : [])
|
|
133
133
|
.filter((e) => typeof e === "string" && e.trim())
|
|
134
134
|
.map((e) => e.trim());
|
|
135
|
+
// Третий элемент — обязателен ли пункт. Методички и стандарты `init` кладёт как пособие: проект
|
|
136
|
+
// вправе держать их где-то ещё или не держать вовсе, и их отсутствие — совет, а не приговор
|
|
137
|
+
// прогону (отзыв с живого проекта 2026-09-11: прогон краснел только из-за `.aqk/docs`). Точка
|
|
138
|
+
// входа, .gitignore и .git — обязательны: на них стоит своя проверка вердикта.
|
|
135
139
|
return [
|
|
136
|
-
[field("docs", inKit ? "kit/docs" : ".aqk/docs"), inKit ? L.doctor.docsKit : L.doctor.docs],
|
|
137
|
-
[field("rules", inKit ? "kit/rules" : ".aqk/rules"), inKit ? L.doctor.rulesKit : L.doctor.rules],
|
|
138
|
-
...(entries.length ? entries : ["AGENTS.md"]).map((e) => [e, L.doctor.agents]),
|
|
139
|
-
[".gitignore", L.doctor.gitignore],
|
|
140
|
-
[".git", L.doctor.git],
|
|
140
|
+
[field("docs", inKit ? "kit/docs" : ".aqk/docs"), inKit ? L.doctor.docsKit : L.doctor.docs, false],
|
|
141
|
+
[field("rules", inKit ? "kit/rules" : ".aqk/rules"), inKit ? L.doctor.rulesKit : L.doctor.rules, false],
|
|
142
|
+
...(entries.length ? entries : ["AGENTS.md"]).map((e) => [e, L.doctor.agents, true]),
|
|
143
|
+
[".gitignore", L.doctor.gitignore, true],
|
|
144
|
+
[".git", L.doctor.git, true],
|
|
141
145
|
];
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -188,28 +192,68 @@ function coversOf(man) {
|
|
|
188
192
|
// вывод перестают читать целиком, вместе с настоящими находками.
|
|
189
193
|
const RULE_CODES = /--select[= ]([A-Za-z0-9,]+)/;
|
|
190
194
|
|
|
191
|
-
|
|
195
|
+
// КАКИМ ЛИНТЕРОМ ЗАКРЫТ ГЕЙТ. Отзыв с живого проекта 2026-09-11 (TypeScript на Biome): заявка
|
|
196
|
+
// «lint держит no-print-in-prod» всегда была «не подтверждена» — сверка искала коды ruff в
|
|
197
|
+
// конфиге Biome, где их не бывает никогда. Поле, снимающее шум, само его производило.
|
|
198
|
+
// Порядок: команда гейта; npm-скрипт, который она зовёт; единственный конфиг линтера в проекте.
|
|
199
|
+
function linterOf(cmd, scripts = {}, present = []) {
|
|
200
|
+
let c = String(cmd || "");
|
|
201
|
+
const m = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:-]+)/.exec(c);
|
|
202
|
+
if (m && typeof scripts[m[1]] === "string") c += ` ${scripts[m[1]]}`;
|
|
203
|
+
if (/\bbiome\b/.test(c)) return "biome";
|
|
204
|
+
if (/\beslint\b/.test(c)) return "eslint";
|
|
205
|
+
if (/\bruff\b/.test(c)) return "ruff";
|
|
206
|
+
return present.length === 1 ? present[0] : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Правила записи на языке ЭТОГО линтера. ruff — коды из `--select` рецептов; eslint — имена из
|
|
210
|
+
// `--rule '{…}'`; Biome рецептов не имеет, у записи для него поле `biome_rules` (сверено по схеме
|
|
211
|
+
// конфигурации Biome 2.5.12). `none` — у линтера такого правила нет вовсе. `null` — не знаем.
|
|
212
|
+
function rulesFor(rec, linter) {
|
|
213
|
+
const recipes = Object.values(rec?.recipes && typeof rec.recipes === "object" ? rec.recipes : {}).map(String);
|
|
214
|
+
if (linter === "ruff") {
|
|
215
|
+
const codes = new Set();
|
|
216
|
+
for (const cmd of recipes) { const m = RULE_CODES.exec(cmd); if (m) for (const x of m[1].split(",")) if (x.trim()) codes.add(x.trim()); }
|
|
217
|
+
return codes.size ? [...codes] : null;
|
|
218
|
+
}
|
|
219
|
+
if (linter === "eslint") {
|
|
220
|
+
const names = new Set();
|
|
221
|
+
for (const cmd of recipes) for (const m of cmd.matchAll(/"([@a-z0-9/_-]+)"\s*:\s*\[?\s*"(?:error|warn)"/g)) names.add(m[1]);
|
|
222
|
+
return names.size ? [...names] : null;
|
|
223
|
+
}
|
|
224
|
+
if (linter === "biome") {
|
|
225
|
+
const v = typeof rec?.biome_rules === "string" ? rec.biome_rules.trim() : "";
|
|
226
|
+
if (!v) return null;
|
|
227
|
+
// Поле хранит `группа/правило` — так его ждёт `biome lint --only`; в biome.json правило
|
|
228
|
+
// лежит внутри объекта группы, поэтому ищется по имени без группы.
|
|
229
|
+
return v === "none" ? [] : v.split(",").map((x) => x.trim().split("/").pop()).filter(Boolean);
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// `configs` — тексты конфигов по линтерам: { ruff, eslint, biome, scripts }. Строка (прежний вид
|
|
235
|
+
// вызова) — один общий текст для всех. Исходы: unproven (линтер известен, его правил нет ни в
|
|
236
|
+
// команде, ни в конфиге) · impossible (у линтера такого правила нет) · unknown (линтер не
|
|
237
|
+
// распознан или правил записи для него мы не знаем — «не умею проверить», не обвинение).
|
|
238
|
+
function coversUnproven(man, catalog = [], configs = "") {
|
|
192
239
|
const { covered } = coversOf(man);
|
|
193
240
|
if (!covered.size) return [];
|
|
194
241
|
const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
|
|
195
|
-
const cfg =
|
|
242
|
+
const cfg = typeof configs === "string" ? { ruff: configs, eslint: configs, biome: configs } : (configs || {});
|
|
243
|
+
const present = ["ruff", "eslint", "biome"].filter((k) => cfg[k] && String(cfg[k]).trim() && typeof configs !== "string");
|
|
196
244
|
const out = [];
|
|
197
245
|
|
|
198
246
|
for (const [entry, gate] of covered) {
|
|
199
247
|
const rec = catalog.find((r) => r.slug === entry);
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
if (!
|
|
209
|
-
|
|
210
|
-
const haystack = `${String(gates[gate] || "")}\n${cfg}`;
|
|
211
|
-
const missing = [...codes].filter((c) => !haystack.includes(c));
|
|
212
|
-
if (missing.length === codes.size) out.push({ entry, gate, codes: [...codes] });
|
|
248
|
+
// Запись, которая не выражается правилами НИ ОДНОГО линтера (переносимые проверки), не
|
|
249
|
+
// сверяется вовсе: сверять нечего, и выдумывать вердикт нельзя.
|
|
250
|
+
if (!["ruff", "eslint", "biome"].some((k) => rulesFor(rec, k) !== null)) continue;
|
|
251
|
+
const linter = linterOf(gates[gate], cfg.scripts || {}, present);
|
|
252
|
+
const rules = linter ? rulesFor(rec, linter) : null;
|
|
253
|
+
if (rules === null) { out.push({ entry, gate, codes: [], linter, kind: "unknown" }); continue; }
|
|
254
|
+
if (!rules.length) { out.push({ entry, gate, codes: [], linter, kind: "impossible" }); continue; }
|
|
255
|
+
const haystack = `${String(gates[gate] || "")}\n${String(cfg[linter] || "")}`;
|
|
256
|
+
if (rules.every((r) => !haystack.includes(r))) out.push({ entry, gate, codes: rules, linter, kind: "unproven" });
|
|
213
257
|
}
|
|
214
258
|
return out;
|
|
215
259
|
}
|