agent-quality-kit 0.10.0 → 0.11.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 +8 -3
- package/README.ru.md +8 -3
- package/kit/gates/_native.sh +4 -0
- package/kit/gates/_skip.sh +7 -0
- package/kit/gates/api-contract-has-arbiter/check.sh +7 -0
- package/kit/gates/color-from-token/check.sh +7 -0
- package/kit/gates/commit-explains-itself/check.sh +8 -2
- package/kit/gates/complexity-limit/check.sh +22 -4
- package/kit/gates/complexity-limit/gate.yml +2 -2
- package/kit/gates/complexity-limit/red/deep.js +15 -0
- package/kit/gates/duplicate-code/check.sh +7 -0
- package/kit/gates/duplicate-code/gate.yml +5 -1
- package/kit/gates/file-size-limit/check.sh +7 -0
- package/kit/gates/file-size-limit/red/big.js +600 -0
- package/kit/gates/gate-not-weakened/check.sh +7 -0
- package/kit/gates/gate-not-weakened/green/suppress.js +4 -0
- package/kit/gates/gate-not-weakened/red/suppress.js +5 -0
- package/kit/gates/mcp-server-resolves/check.sh +7 -0
- package/kit/gates/no-phantom-package/check.sh +7 -0
- package/kit/gates/no-print-in-prod/gate.yml +3 -3
- package/kit/gates/personal-config-not-shared/check.sh +7 -0
- package/kit/gates/protection-not-removed/check.sh +22 -13
- package/kit/gates/secrets-not-in-code/check.sh +7 -0
- package/kit/gates/secrets-not-in-code/green/config.js +4 -0
- package/kit/gates/secrets-not-in-code/red/leak.js +6 -0
- package/kit/gates/swallowed-error/gate.yml +3 -3
- package/kit/gates/test-has-assertion/check.sh +7 -0
- package/kit/gates/test-has-assertion/green/checkout.test.js +5 -0
- package/kit/gates/test-has-assertion/red/checkout.test.js +8 -0
- package/kit/gates/test-not-adjusted/check.sh +8 -2
- package/kit/gates/todo-without-task/check.sh +7 -0
- package/kit/gates/todo-without-task/gate.yml +2 -2
- package/kit/gates/todo-without-task/green/app.js +2 -0
- package/kit/gates/todo-without-task/red/later.js +4 -0
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/tool/commands/badge.mjs +1 -1
- package/tool/commands/context.mjs +8 -0
- package/tool/commands/doctor.mjs +18 -148
- package/tool/commands/gates.mjs +2 -0
- package/tool/commands/probe.mjs +197 -41
- package/tool/commands/report.mjs +1 -1
- package/tool/i18n/en-docs.mjs +2 -0
- package/tool/i18n/en-gates.mjs +19 -5
- package/tool/i18n/en.mjs +3 -0
- package/tool/i18n/ru-docs.mjs +2 -0
- package/tool/i18n/ru-gates.mjs +20 -6
- package/tool/i18n/ru.mjs +3 -0
- package/tool/lib/evidence.mjs +15 -2
- package/tool/lib/execution.mjs +67 -0
- package/tool/lib/history.mjs +79 -8
- package/tool/lib/protection.mjs +52 -0
- package/tool/lib/prove.mjs +40 -13
- package/tool/lib/run.mjs +165 -0
- package/tool/selfcheck/gates.sh +32 -4
- package/tool/selfcheck/smoke/_fixture.mjs +24 -2
- package/tool/selfcheck/smoke/fail-closed.test.mjs +112 -0
- package/tool/selfcheck/smoke/own-samples.test.mjs +131 -0
- package/tool/selfcheck/smoke/protection.test.mjs +101 -0
- package/tool/selfcheck/smoke/release-tools.test.mjs +55 -0
- package/tool/selfcheck/smoke.sh +15 -1
- package/tool/selfcheck/units-context.mjs +24 -0
- package/tool/selfcheck/units-evidence.mjs +34 -0
- package/tool/selfcheck/units-execution.mjs +88 -0
- package/tool/selfcheck/units-level.mjs +32 -1
- package/tool/selfcheck/units-probe.mjs +282 -22
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// tool/selfcheck/smoke/own-samples.test.mjs — запись каталога не смеет выдавать СВОИ ЖЕ красные
|
|
2
|
+
// образцы за находки пользователя.
|
|
3
|
+
//
|
|
4
|
+
// ЗАЧЕМ. `aqk add` кладёт в чужой проект гейт ВМЕСТЕ с образцами: без них `prove` ничего не
|
|
5
|
+
// докажет. Образцы существуют, чтобы быть неправильными. Значит любой сканирующий гейт обязан их
|
|
6
|
+
// не замечать — иначе человек ставит защиту и получает вечно красный конвейер на файлах, которые
|
|
7
|
+
// положили МЫ. Обход для этого есть давно (`own_samples_filter`), и переносимые рецепты им
|
|
8
|
+
// пользуются.
|
|
9
|
+
//
|
|
10
|
+
// ЧТО СЛОМАЛОСЬ. Обход построчный: он вырезает строки, содержащие путь образца. `ruff` перешёл на
|
|
11
|
+
// многострочные блоки — путь уехал в отдельную строку («--> путь:строка:колонка»), а сообщение и
|
|
12
|
+
// кусок исходника остались, и вырезать их фильтру нечем. Найдено 2026-09-10 на фикстуре пробы:
|
|
13
|
+
// переносимый рецепт на том же проекте молчит, родной краснеет.
|
|
14
|
+
//
|
|
15
|
+
// ПОЧЕМУ ПРОВЕРКА, А НЕ ТОЛЬКО ПРАВКА. Правка чинит `ruff` сегодня. Формат вывода меняет любой
|
|
16
|
+
// инструмент и не предупреждает — без этой проверки та же дыра вернётся молча и с другим именем.
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { project, run, aqk } from "./_fixture.mjs";
|
|
23
|
+
|
|
24
|
+
// Записи с РОДНЫМ рецептом. Дыра была не в ruff: она в том, что общий обход построчный, а вывод
|
|
25
|
+
// инструмента — нет. Значит проверять надо не один инструмент, а каждый, который каталог зовёт.
|
|
26
|
+
//
|
|
27
|
+
// Таблица, а не список из четырёх имён: рецепт добавляют в каталог, и покрытие обязано
|
|
28
|
+
// появляться вместе с ним, а не через месяц. Каждая строка — чем наполнить проект, чтобы
|
|
29
|
+
// `aqk add` выбрал ИМЕННО этот рецепт, и какой командой её видно.
|
|
30
|
+
//
|
|
31
|
+
// Инструмента нет на машине — проверка ПРОПУСКАЕТСЯ вслух, а не зеленеет: «нечем проверить
|
|
32
|
+
// здесь» и «проверено» разные факты, и весь комплект написан про эту разницу.
|
|
33
|
+
const NATIVE = [
|
|
34
|
+
{ entry: "todo-without-task", tool: "ruff", files: { "src/app.py": "def add(a, b):\n return a + b\n" } },
|
|
35
|
+
{ entry: "no-print-in-prod", tool: "ruff", files: { "src/app.py": "def add(a, b):\n return a + b\n" } },
|
|
36
|
+
{ entry: "swallowed-error", tool: "ruff", files: { "src/app.py": "def add(a, b):\n return a + b\n" } },
|
|
37
|
+
{ entry: "complexity-limit", tool: "ruff", files: { "src/app.py": "def add(a, b):\n return a + b\n" } },
|
|
38
|
+
{ entry: "dead-code", tool: "vulture", files: { "src/app.py": "def add(a, b):\n return a + b\n\n\nprint(add(1, 2))\n" } },
|
|
39
|
+
{ entry: "duplicate-code", tool: "pylint", files: { "src/app.py": "def add(a, b):\n return a + b\n" } },
|
|
40
|
+
// javascript/typescript: eslint зовут четыре записи. Проект наполняется .js, чтобы `add`
|
|
41
|
+
// выбрал именно родной рецепт, а не переносимый.
|
|
42
|
+
{ entry: "todo-without-task", tool: "eslint", files: { "src/app.js": "export const add = (a, b) => a + b;\n" } },
|
|
43
|
+
{ entry: "no-print-in-prod", tool: "eslint", files: { "src/app.js": "export const add = (a, b) => a + b;\n" } },
|
|
44
|
+
{ entry: "swallowed-error", tool: "eslint", files: { "src/app.js": "export const add = (a, b) => a + b;\n" } },
|
|
45
|
+
{ entry: "complexity-limit", tool: "eslint", files: { "src/app.js": "export const add = (a, b) => a + b;\n" } },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const RUFF_ENTRIES = NATIVE.filter((n) => n.tool === "ruff").map((n) => n.entry);
|
|
49
|
+
|
|
50
|
+
// Наличие инструмента спрашивается в НАСТОЯЩЕЙ среде, а не в песочнице проекта. Песочница
|
|
51
|
+
// подменяет HOME, а инструменты, поставленные `pip --user` (vulture, pylint), — это python-
|
|
52
|
+
// скрипты, которые ищут свои модули в $HOME/.local/lib. С чужим HOME импорт падает, и
|
|
53
|
+
// установленный инструмент объявлялся отсутствующим: проверка молча пропускалась вместо того,
|
|
54
|
+
// чтобы что-то проверить. Та же шишка записана в шапке `smoke.sh` про PYTHONUSERBASE.
|
|
55
|
+
const have = (cmd) => spawnSync(cmd, ["--version"], { encoding: "utf8", timeout: 30000 }).status === 0;
|
|
56
|
+
|
|
57
|
+
function declaredCommand(p, name) {
|
|
58
|
+
const man = readFileSync(join(p.dir, ".aqk.yml"), "utf8");
|
|
59
|
+
// Жадно и до КОНЦА СТРОКИ: команда рецепта сама содержит кавычки (`eslint --rule '{"x":1}'`),
|
|
60
|
+
// и нежадный разбор обрывал её на первой внутренней — тест падал на своей же выборке, а
|
|
61
|
+
// винил рецепт.
|
|
62
|
+
const m = man.match(new RegExp(`^\\s*${name}:\\s*"(.*)"\\s*$`, "m"));
|
|
63
|
+
return m ? m[1] : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const name of RUFF_ENTRIES) {
|
|
67
|
+
test(`«${name}»: родной рецепт не краснеет на СВОИХ образцах`, (t) => {
|
|
68
|
+
if (!have("ruff")) return t.skip("ruff не установлен — проверить нечем");
|
|
69
|
+
// Чистый python-файл: находок у пользователя нет по построению, и всё красное может прийти
|
|
70
|
+
// только из образцов, которые положил `add`.
|
|
71
|
+
const p = project(t, { "src/app.py": "def add(a, b):\n return a + b\n" });
|
|
72
|
+
const init = aqk(p, "init");
|
|
73
|
+
assert.equal(init.code, 0, init.out);
|
|
74
|
+
const add = aqk(p, "add", name);
|
|
75
|
+
assert.equal(add.code, 0, add.out);
|
|
76
|
+
|
|
77
|
+
const cmd = declaredCommand(p, name);
|
|
78
|
+
assert.ok(cmd, `в манифесте нет команды для «${name}»`);
|
|
79
|
+
if (!/ruff/.test(cmd)) return t.skip(`выбран не ruff, а «${cmd}» — эта проверка про родной рецепт`);
|
|
80
|
+
|
|
81
|
+
const r = run(p, "bash", ["-c", cmd]);
|
|
82
|
+
assert.equal(r.code, 0,
|
|
83
|
+
`гейт покраснел на проекте без единой находки пользователя — значит выдал наши образцы ` +
|
|
84
|
+
`за его код.\nкоманда: ${cmd}\nвывод:\n${r.out}`);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// КОНТРОЛЬНАЯ. Заглушить чужой вывод легко — и так же легко заглушить его весь. Проверка выше
|
|
89
|
+
// одна означала бы «гейт молчит», а не «гейт молчит О НАШИХ ОБРАЗЦАХ»: инструмент, лишённый
|
|
90
|
+
// голоса, проходит её идеально. Поэтому тот же проект, но с НАСТОЯЩЕЙ находкой пользователя.
|
|
91
|
+
// Маркер долга собирается ИЗ КУСКОВ намеренно. Написанный целиком, он живёт в исходнике
|
|
92
|
+
// комплекта — и наш собственный `todo-without-task` считает его нашим долгом. Поймано этим же
|
|
93
|
+
// гейтом через минуту после того, как файл был написан.
|
|
94
|
+
const MARKER = ["TO", "DO"].join("");
|
|
95
|
+
for (const [name, file, code] of [
|
|
96
|
+
["todo-without-task", "src/debt.py", `def f():\n # ${MARKER}: переписать\n return 1\n`],
|
|
97
|
+
["no-print-in-prod", "src/loud.py", "def f():\n print('привет')\n return 1\n"],
|
|
98
|
+
]) {
|
|
99
|
+
test(`«${name}»: на находке ПОЛЬЗОВАТЕЛЯ тот же рецепт краснеет`, (t) => {
|
|
100
|
+
if (!have("ruff")) return t.skip("ruff не установлен — проверить нечем");
|
|
101
|
+
const p = project(t, { "src/app.py": "def add(a, b):\n return a + b\n", [file]: code });
|
|
102
|
+
assert.equal(aqk(p, "init").code, 0);
|
|
103
|
+
assert.equal(aqk(p, "add", name).code, 0);
|
|
104
|
+
const cmd = declaredCommand(p, name);
|
|
105
|
+
if (!/ruff/.test(cmd)) return t.skip(`выбран не ruff, а «${cmd}»`);
|
|
106
|
+
const r = run(p, "bash", ["-c", cmd]);
|
|
107
|
+
assert.notEqual(r.code, 0,
|
|
108
|
+
`гейт промолчал о настоящей находке — значит его заглушили целиком.\nкоманда: ${cmd}\nвывод:\n${r.out}`);
|
|
109
|
+
assert.ok(r.out.includes(file.split("/").pop()),
|
|
110
|
+
`гейт покраснел, но не назвал файл ${file}. Вывод:\n${r.out}`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Общая проверка по ТАБЛИЦЕ: любой родной рецепт, чей инструмент здесь есть, обязан молчать на
|
|
115
|
+
// проекте, где единственные «нарушения» — образцы, которые положил `add`.
|
|
116
|
+
for (const { entry, tool, files } of NATIVE) {
|
|
117
|
+
test(`«${entry}» (${tool}): родной рецепт молчит о наших образцах`, (t) => {
|
|
118
|
+
if (!have(tool)) return t.skip(`${tool} не установлен — проверить нечем, и это НЕ «проверено»`);
|
|
119
|
+
const p = project(t, files);
|
|
120
|
+
assert.equal(aqk(p, "init").code, 0);
|
|
121
|
+
const add = aqk(p, "add", entry);
|
|
122
|
+
if (add.code !== 0) return t.skip(`запись не ставится здесь: ${add.out.trim().split("\n")[0]}`);
|
|
123
|
+
const cmd = declaredCommand(p, entry);
|
|
124
|
+
assert.ok(cmd, `в манифесте нет команды для «${entry}»`);
|
|
125
|
+
if (!cmd.includes(tool)) return t.skip(`выбран не ${tool}, а «${cmd}» — эта проверка про родной рецепт`);
|
|
126
|
+
const r = run(p, "bash", ["-c", cmd]);
|
|
127
|
+
assert.equal(r.code, 0,
|
|
128
|
+
`гейт покраснел на проекте без единой находки пользователя — значит выдал наши образцы ` +
|
|
129
|
+
`за его код.\nкоманда: ${cmd}\nвывод:\n${r.out}`);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Снимок объявленной защиты: проверка ТОЛЬКО СРАВНИВАЕТ, а пишет его отдельная явная операция.
|
|
2
|
+
// Написаны ДО правки.
|
|
3
|
+
//
|
|
4
|
+
// ОПЫТ, ИЗ КОТОРОГО ОНИ ВЗЯЛИСЬ (2026-09-09). Проверка при отсутствии реестра создавала его
|
|
5
|
+
// сама и выходила с нулём. В мелком клоне, где удаление реестра лежит ГЛУБЖЕ выкладки, свидетель
|
|
6
|
+
// (история git) слеп — и проверка записала УЖЕ ОСЛАБЛЕННЫЙ набор как новый эталон:
|
|
7
|
+
//
|
|
8
|
+
// снят снимок объявленной защиты: 2 гейтов → ratchets/gates-declared.txt
|
|
9
|
+
// код: 0 ← гейт «secrets» исчез и УЗАКОНЕН
|
|
10
|
+
//
|
|
11
|
+
// Храповик крутился назад. Отсюда правило: отсутствующий снимок — не зелёное, и проверка,
|
|
12
|
+
// выносящая вердикт, не имеет права менять то, о чём судит.
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { project, run, aqk, gate } from "./_fixture.mjs";
|
|
18
|
+
|
|
19
|
+
const MAN = (gates) =>
|
|
20
|
+
`aqk: 1\nentry: [AGENTS.md]\nratchets: ratchets\ngates:\n${gates.map((g) => ` ${g}: "true"\n`).join("")}`;
|
|
21
|
+
const REG = "# Снимок объявленной защиты. Набор может только РАСТИ.\n";
|
|
22
|
+
|
|
23
|
+
test("отсутствующий снимок — не зелёное, и проверка его не создаёт", (t) => {
|
|
24
|
+
const p = project(t, { ".aqk.yml": MAN(["lint", "secrets"]), "AGENTS.md": "# свод\n" });
|
|
25
|
+
const r = gate(p, "protection-not-removed");
|
|
26
|
+
assert.notEqual(r.code, 0, `снимка нет, а проверка зелёная:\n${r.out}`);
|
|
27
|
+
assert.equal(existsSync(join(p.dir, "ratchets", "gates-declared.txt")), false,
|
|
28
|
+
"проверка создала файл, о котором судит");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("мелкий клон не узаконивает ослабленный набор", (t) => {
|
|
32
|
+
const p = project(t, {
|
|
33
|
+
".aqk.yml": MAN(["lint", "secrets", "tests"]),
|
|
34
|
+
"AGENTS.md": "# свод\n",
|
|
35
|
+
"ratchets/gates-declared.txt": `${REG}lint\nsecrets\ntests\n`,
|
|
36
|
+
});
|
|
37
|
+
run(p, "git", ["add", "-A"]);
|
|
38
|
+
run(p, "git", ["commit", "-q", "-m", "первый", "-m", "Сделано: x", "-m", "Не уверен: y"]);
|
|
39
|
+
// Обход: убрали гейт И удалили снимок.
|
|
40
|
+
run(p, "bash", ["-c", `printf '%s' '${MAN(["lint", "tests"])}' > .aqk.yml && git rm -q ratchets/gates-declared.txt`]);
|
|
41
|
+
run(p, "git", ["commit", "-qam", "обход", "-m", "Сделано: x", "-m", "Не уверен: y"]);
|
|
42
|
+
// ...и ушли вперёд, чтобы удаление оказалось глубже выкладки конвейера.
|
|
43
|
+
for (let i = 0; i < 5; i++) {
|
|
44
|
+
run(p, "bash", ["-c", `echo ${i} > f${i}.txt`]);
|
|
45
|
+
run(p, "git", ["add", "-A"]);
|
|
46
|
+
run(p, "git", ["commit", "-qm", `работа ${i}`, "-m", "Сделано: x", "-m", "Не уверен: y"]);
|
|
47
|
+
}
|
|
48
|
+
run(p, "bash", ["-c", "git clone -q --depth 3 file://$PWD shallow 2>/dev/null"]);
|
|
49
|
+
const r = gate(p, "protection-not-removed", "shallow");
|
|
50
|
+
assert.notEqual(r.code, 0, `мелкий клон получил зелёное на ослабленном наборе:\n${r.out}`);
|
|
51
|
+
const reg = join(p.dir, "shallow", "ratchets", "gates-declared.txt");
|
|
52
|
+
assert.equal(existsSync(reg), false, "проверка записала ослабленный набор новым эталоном");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Снимок, отставший от манифеста, — тоже расхождение: иначе гейт, добавленный и убранный до
|
|
56
|
+
// обновления снимка, уходит незамеченным. Храповик обязан требовать записи, а не догонять сам.
|
|
57
|
+
test("снимок, отставший от манифеста, — не зелёное и не дописывается молча", (t) => {
|
|
58
|
+
const p = project(t, {
|
|
59
|
+
".aqk.yml": MAN(["lint", "secrets"]),
|
|
60
|
+
"AGENTS.md": "# свод\n",
|
|
61
|
+
"ratchets/gates-declared.txt": `${REG}lint\n`,
|
|
62
|
+
});
|
|
63
|
+
const r = gate(p, "protection-not-removed");
|
|
64
|
+
assert.notEqual(r.code, 0, `снимок отстал, а проверка зелёная:\n${r.out}`);
|
|
65
|
+
const body = readFileSync(join(p.dir, "ratchets", "gates-declared.txt"), "utf8");
|
|
66
|
+
assert.doesNotMatch(body, /secrets/, "проверка дописала снимок сама");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("совпадающий снимок — зелёное; снятый без причины — красное", (t) => {
|
|
70
|
+
const ok = project(t, {
|
|
71
|
+
".aqk.yml": MAN(["lint", "secrets"]),
|
|
72
|
+
"AGENTS.md": "# свод\n",
|
|
73
|
+
"ratchets/gates-declared.txt": `${REG}lint\nsecrets\n`,
|
|
74
|
+
});
|
|
75
|
+
assert.equal(gate(ok, "protection-not-removed").code, 0, "совпадающий снимок покраснел");
|
|
76
|
+
|
|
77
|
+
const gone = project(t, {
|
|
78
|
+
".aqk.yml": MAN(["lint"]),
|
|
79
|
+
"AGENTS.md": "# свод\n",
|
|
80
|
+
"ratchets/gates-declared.txt": `${REG}lint\nsecrets\n`,
|
|
81
|
+
});
|
|
82
|
+
assert.equal(gate(gone, "protection-not-removed").code, 1, "снятый без причины прошёл");
|
|
83
|
+
|
|
84
|
+
const named = project(t, {
|
|
85
|
+
".aqk.yml": MAN(["lint"]),
|
|
86
|
+
"AGENTS.md": "# свод\n",
|
|
87
|
+
"ratchets/gates-declared.txt": `${REG}lint\nsecrets # снят: закрыт гейтом lint\n`,
|
|
88
|
+
});
|
|
89
|
+
assert.equal(gate(named, "protection-not-removed").code, 0, "снятый с причиной покраснел");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Писать снимок — дело явной операции, а не проверки. `add` и есть эта операция: он уже меняет
|
|
93
|
+
// манифест, и запись в снимок — часть того же действия.
|
|
94
|
+
test("aqk add записывает гейт в снимок", (t) => {
|
|
95
|
+
const p = project(t, { "src/a.py": "x = 1\n" });
|
|
96
|
+
aqk(p, "init");
|
|
97
|
+
aqk(p, "add", "todo-without-task");
|
|
98
|
+
const reg = join(p.dir, "ratchets", "gates-declared.txt");
|
|
99
|
+
assert.equal(existsSync(reg), true, "add не создал снимок");
|
|
100
|
+
assert.match(readFileSync(reg, "utf8"), /todo-without-task/, "add не записал гейт в снимок");
|
|
101
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Конвейер выпуска обязан ставить всё, что нужно ОБЪЯВЛЕННЫМ гейтам.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ. Шаг выпуска гоняет `doctor --run --min 1`. Гейт, чьей программы нет, выходит с кодом
|
|
4
|
+
// 2 «не найден инструмент» — и выпуск встаёт на ровном месте, уже после метки. Так и вышло
|
|
5
|
+
// 2026-09-10: объявили `test-not-adjusted`, его арбитр `checkwash` в publish.yml не ставился,
|
|
6
|
+
// и выпуск 0.10.1 упал между меткой и публикацией.
|
|
7
|
+
//
|
|
8
|
+
// В самом publish.yml написано: «этот файл однажды уже разошёлся с ci.yml». Разошёлся второй
|
|
9
|
+
// раз — значит нужен сторож, а не третья правка руками. Список выводится из МАНИФЕСТА: какие
|
|
10
|
+
// программы объявленные записи называют полем `requires`, те и обязаны ставиться.
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
18
|
+
|
|
19
|
+
// Имена объявленных гейтов — из .aqk.yml, а не из каталога: ставить нужно то, что гоняется.
|
|
20
|
+
function declaredGates() {
|
|
21
|
+
const man = readFileSync(join(ROOT, ".aqk.yml"), "utf8").split("\n");
|
|
22
|
+
const out = [];
|
|
23
|
+
let inGates = false;
|
|
24
|
+
for (const line of man) {
|
|
25
|
+
if (/^gates:/.test(line)) { inGates = true; continue; }
|
|
26
|
+
if (/^[A-Za-z]/.test(line)) inGates = false;
|
|
27
|
+
const m = inGates && /^\s+([A-Za-z0-9_-]+):\s*\S/.exec(line);
|
|
28
|
+
if (m) out.push(m[1]);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requiredProgram(slug) {
|
|
34
|
+
const yml = join(ROOT, "kit", "gates", slug, "gate.yml");
|
|
35
|
+
if (!existsSync(yml)) return null;
|
|
36
|
+
const m = /^requires:\s*(\S+)/m.exec(readFileSync(yml, "utf8"));
|
|
37
|
+
return m ? m[1] : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("конвейер выпуска ставит программы всех объявленных записей", () => {
|
|
41
|
+
// ЧИТАЮТСЯ ТОЛЬКО СТРОКИ УСТАНОВКИ, а не файл целиком. Первая редакция искала имя по всему
|
|
42
|
+
// тексту — и нашла `checkwash` В КОММЕНТАРИИ, объявив сторожа зелёным ровно тогда, когда
|
|
43
|
+
// выпуск падал. Тот же грех, что ловим весь день, в проверке, написанной против него.
|
|
44
|
+
const wf = readFileSync(join(ROOT, ".github", "workflows", "publish.yml"), "utf8");
|
|
45
|
+
const installed = wf
|
|
46
|
+
.split("\n")
|
|
47
|
+
.filter((l) => /^\s*(pipx install|npm i -g|uv tool install)\s/.test(l))
|
|
48
|
+
.join("\n");
|
|
49
|
+
const missing = declaredGates()
|
|
50
|
+
.map((g) => requiredProgram(g))
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.filter((prog) => !installed.includes(prog));
|
|
53
|
+
assert.deepEqual(missing, [],
|
|
54
|
+
`publish.yml не ставит: ${missing.join(", ")} — выпуск встанет после метки`);
|
|
55
|
+
});
|
package/tool/selfcheck/smoke.sh
CHANGED
|
@@ -60,6 +60,15 @@ trap 'rm -rf "$WORK"' EXIT
|
|
|
60
60
|
# конвейере нет. Сеть в тестах — отдельный класс флейков во всех разборах; здесь она ещё и
|
|
61
61
|
# делает две среды разными по построению.
|
|
62
62
|
export TMPDIR="$WORK/tmp"; mkdir -p "$TMPDIR"
|
|
63
|
+
# PYTHONUSERBASE — ДО подмены HOME и из-за неё. Инструменты, поставленные `pip --user`
|
|
64
|
+
# (`vulture`, `checkwash`), это python-скрипты, которые ищут свои модули в
|
|
65
|
+
# `$HOME/.local/lib/python*/site-packages`. С песочницей вместо HOME импорт падает, а гейт,
|
|
66
|
+
# чей арбитр упал, печатает «арбитр покраснел на ЗЕЛЁНОМ образце» — то есть проверка врёт о
|
|
67
|
+
# записи каталога, а не о своей поломке. Найдено 2026-09-09 в тот же час, когда инструменты
|
|
68
|
+
# были поставлены: до установки этот путь кода не исполнялся вовсе.
|
|
69
|
+
# Переменная — штатный способ указать python, где искать пользовательские пакеты; бинарям
|
|
70
|
+
# (`ruff`, `zizmor`, `slopcheck`) она не нужна, и им песочница не мешала.
|
|
71
|
+
export PYTHONUSERBASE="${PYTHONUSERBASE:-$HOME/.local}"
|
|
63
72
|
export HOME="$WORK/home"; export USERPROFILE="$HOME"; mkdir -p "$HOME"
|
|
64
73
|
export AQK_UPDATE=0
|
|
65
74
|
|
|
@@ -723,7 +732,12 @@ BDIR="$(mktemp -d)"
|
|
|
723
732
|
)
|
|
724
733
|
B_OUT=$( cd "$BDIR" && node "$CLI" badge 2>&1 )
|
|
725
734
|
B_LVL=$(printf '%s' "$B_OUT" | sed -n 's|.*img.shields.io/badge/AQK-\([0-9]\)-.*|\1|p' | head -1)
|
|
726
|
-
|
|
735
|
+
# ЗНАЧОК СВЕРЯЕТСЯ С ДОКАЗАННЫМ УРОВНЕМ. `badge` гоняет доказательство сам, а `doctor` без
|
|
736
|
+
# `--run` его не гоняет и честно говорит «не доказано» — это РАЗНЫЕ вопросы, и сравнивать их
|
|
737
|
+
# нельзя. Пока доказательство ни на что не влияло в этой фикстуре, оба ответа совпадали
|
|
738
|
+
# случайно; как только у проекта появился храповик, они разошлись, и проверка обвинила
|
|
739
|
+
# исправную программу. Найдено 2026-09-10 правкой, не трогавшей ни значок, ни уровень.
|
|
740
|
+
D_LVL=$( cd "$BDIR" && node "$CLI" doctor --run 2>&1 | sed -n 's/.*Уровень: AQK-\([0-9]\).*/\1/p' | head -1 )
|
|
727
741
|
if [ -n "$B_LVL" ] && [ "$B_LVL" = "$D_LVL" ]; then
|
|
728
742
|
ok "badge выдаёт значок с уровнем doctor (AQK-$B_LVL)"
|
|
729
743
|
else
|
|
@@ -186,3 +186,27 @@ test("без --full свод не вливается — только ссылк
|
|
|
186
186
|
assert.doesNotMatch(t, /Правило одно/);
|
|
187
187
|
assert.match(t, /AGENTS\.md/);
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
// --- СОСТОЯНИЯ ПРОБЫ: пять, а веток было три ---------------------------------------------
|
|
191
|
+
// ЗАЧЕМ. Блок читает машина: пустое место и «всё прикрыто» она примет за разрешение. Состояний
|
|
192
|
+
// у пробы пять — `never`, `off`, `unknown`, `stale`, `fresh`, — а печатались три ветки: «не
|
|
193
|
+
// делалась», «есть непрокрытое», иначе «всё прикрыто». В последнюю падали ДВА состояния, где мы
|
|
194
|
+
// как раз ничего не знаем:
|
|
195
|
+
// off — пробу выключили в манифесте (`probe: 0`);
|
|
196
|
+
// unknown — счётчик коммитов не взять либо в манифесте непонятое значение.
|
|
197
|
+
// Обе печатались как «в местах пробы всё прикрыто» — то есть выключенная и несостоявшаяся
|
|
198
|
+
// проверка выдавались за проверку, сказавшую «чисто».
|
|
199
|
+
test("выключенная и несостоявшаяся проба не выдаются за «всё прикрыто»", () => {
|
|
200
|
+
// Якорь берётся из САМОГО текста, а не из представления о нём: первая редакция искала слово
|
|
201
|
+
// «прикрыт», которого в строке про чистую пробу нет, — и краснела на исправном выводе.
|
|
202
|
+
const clean = text({ probe: { state: "fresh", behind: 3, blind: 0 } });
|
|
203
|
+
assert.match(clean, /каждый применимый класс/, "строка про чистую пробу пропала");
|
|
204
|
+
|
|
205
|
+
const off = text({ probe: { state: "off", behind: null, blind: 0 } });
|
|
206
|
+
assert.doesNotMatch(off, /всё прикрыто|прикрыто ничем нет/,
|
|
207
|
+
`выключенная проба выдана за покрытие: ${off}`);
|
|
208
|
+
assert.match(off, /выключен|отключен/i, `не сказано, что проба выключена: ${off}`);
|
|
209
|
+
|
|
210
|
+
const unknown = text({ probe: { state: "unknown", behind: null, blind: 0 } });
|
|
211
|
+
assert.match(unknown, /НЕИЗВЕСТНО|неизвестн/i, `несостоявшаяся проба не названа: ${unknown}`);
|
|
212
|
+
});
|
|
@@ -81,3 +81,37 @@ test("покрытие: файл вне всех целей остаётся н
|
|
|
81
81
|
assert.deepEqual(cov.silent.get("tool/a.js"), ["size"]);
|
|
82
82
|
assert.deepEqual(cov.uncovered, [".github/workflows/ci.yml"]);
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
// --- НАЗВАН ПРОВЕРКОЙ — ЭТО ТОЛЬКО ВЫВОД ------------------------------------------------
|
|
86
|
+
// ЗАЧЕМ. Пути брались из КОМАНДЫ и вывода разом: `pathsIn(cmd + "\n" + out)`. Гейт, не
|
|
87
|
+
// напечатавший ни слова, делал «названным» любой файл, упомянутый в строке запуска. Опыт
|
|
88
|
+
// 2026-09-09: молчащая проверка `sh checks/quiet.sh .` дала
|
|
89
|
+
// названо проверкой: [ 'checks/quiet.sh' ]
|
|
90
|
+
// Так «доказывалась» у нас каждая правка `kit/gates/*/check.sh` и `tool/selfcheck/*.sh` —
|
|
91
|
+
// то есть самих проверок, где правда важнее всего.
|
|
92
|
+
//
|
|
93
|
+
// Но и выбросить команду нельзя: она отвечает на ДРУГОЙ вопрос — куда гейт был направлен.
|
|
94
|
+
// Файл, названный в команде и не упомянутый в выводе, — это «обойдён молча», а не «никто не
|
|
95
|
+
// смотрел»: разница между «просмотрен и чист» и «не просмотрен» и есть предмет этого модуля.
|
|
96
|
+
test("молчащий гейт не «называет» файл из своей команды, но и не теряет его", async () => {
|
|
97
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
98
|
+
const results = [{ name: "тихий", cmd: "sh checks/quiet.sh .", out: "" }];
|
|
99
|
+
const r = coverage(["checks/quiet.sh"], results, () => false);
|
|
100
|
+
assert.deepEqual([...r.covered.keys()], [], "путь из команды выдан за находку проверки");
|
|
101
|
+
assert.deepEqual([...r.silent.keys()], ["checks/quiet.sh"], "файл, куда гейт направлен, потерян");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("гейт, направленный на конкретный файл, накрывает именно его", async () => {
|
|
105
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
106
|
+
const results = [{ name: "линт", cmd: "eslint src/a.js", out: "" }];
|
|
107
|
+
const r = coverage(["src/a.js", "src/b.js"], results, () => false);
|
|
108
|
+
assert.deepEqual([...r.silent.keys()], ["src/a.js"]);
|
|
109
|
+
assert.deepEqual(r.uncovered, ["src/b.js"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("напечатанный путь по-прежнему означает «названо»", async () => {
|
|
113
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
114
|
+
const results = [{ name: "линт", cmd: "eslint .", out: "src/a.js:3: находка" }];
|
|
115
|
+
const r = coverage(["src/a.js"], results, () => false);
|
|
116
|
+
assert.deepEqual([...r.covered.keys()], ["src/a.js"]);
|
|
117
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// tool/selfcheck/units-execution.mjs — исход ЗАПУСКА процесса, отдельно от смысла находки.
|
|
2
|
+
// Написаны ДО реализации.
|
|
3
|
+
//
|
|
4
|
+
// ЗАЧЕМ. `prove.mjs` считал находкой ЛЮБОЙ ненулевой код на красном образце. Опыт 2026-09-09:
|
|
5
|
+
// проверка, которая ВИСНЕТ на красном образце и молчит на зелёном, получала вердикт
|
|
6
|
+
// `proven: 1, ok: true` — то есть зависший гейт объявлялся ловящим брак. Это `pytest || true`
|
|
7
|
+
// в функции, которая считает наш главный уровень.
|
|
8
|
+
//
|
|
9
|
+
// ПОЧЕМУ НЕЛЬЗЯ ОДНИМ ПРАВИЛОМ «1 — находка, 2+ — сбой». Замер по настоящим инструментам:
|
|
10
|
+
// ruff чисто 0 · находка 1 (и синтаксическая ошибка тоже 1) · неверный флаг 2
|
|
11
|
+
// · НЕСУЩЕСТВУЮЩИЙ ПУТЬ → 0 и «All checks passed!»
|
|
12
|
+
// eslint чисто 0 · находка 1 · настройка или внутренняя ошибка 2
|
|
13
|
+
// vulture чисто 0 · НАХОДКА 3 · плохой ввод 1 · ошибка CLI 2
|
|
14
|
+
// pylint чисто 0 · находка битовой маской (2 ошибка, 4 предупреждение, 8, 16)
|
|
15
|
+
// · ОШИБКА ВЫЗОВА 32
|
|
16
|
+
// Общее правило переврало бы vulture и pylint в обе стороны. Значит знание о кодах живёт
|
|
17
|
+
// рядом с инструментом — нормализующим адаптером, а протокол остаётся простым.
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { classify, findingCodes } from "../lib/execution.mjs";
|
|
21
|
+
|
|
22
|
+
// Вход — то, что отдаёт spawnSync: { status, signal, error }.
|
|
23
|
+
const R = (over = {}) => ({ status: 0, signal: null, error: undefined, ...over });
|
|
24
|
+
|
|
25
|
+
test("ноль — чисто, объявленный код находки — находка", () => {
|
|
26
|
+
assert.equal(classify(R({ status: 0 })).state, "clean");
|
|
27
|
+
assert.equal(classify(R({ status: 1 })).state, "finding");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Убитый по таймауту процесс отдаёт status null. Прежний код превращал его в 124 и считал
|
|
31
|
+
// находкой — а сам же комментарий рядом называл это «не знаем».
|
|
32
|
+
test("таймаут — не находка, а сбой инструмента", () => {
|
|
33
|
+
const r = classify(R({ status: null, signal: "SIGTERM" }));
|
|
34
|
+
assert.equal(r.state, "infra_error");
|
|
35
|
+
assert.equal(r.reason, "timeout");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("убийство сигналом — сбой инструмента", () => {
|
|
39
|
+
const r = classify(R({ status: null, signal: "SIGKILL" }));
|
|
40
|
+
assert.equal(r.state, "infra_error");
|
|
41
|
+
assert.match(r.reason, /timeout|signal/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Процесс не запустился вовсе: нет программы, нет прав, нет каталога.
|
|
45
|
+
// Node при истечении срока ставит И signal, И error с кодом ETIMEDOUT. Если смотреть на
|
|
46
|
+
// error первым и не различать его код, таймаут называется «ошибкой запуска»: состояние верное,
|
|
47
|
+
// причина ложная. Поймано первым же контрпримером после правки — вердикт стал верным, а
|
|
48
|
+
// объяснение врало.
|
|
49
|
+
test("таймаут не выдаётся за ошибку запуска: у Node при сроке стоит и signal, и error", () => {
|
|
50
|
+
const timedOut = { status: null, signal: "SIGTERM",
|
|
51
|
+
error: Object.assign(new Error("spawnSync /bin/sh ETIMEDOUT"), { code: "ETIMEDOUT" }) };
|
|
52
|
+
assert.equal(classify(timedOut).reason, "timeout");
|
|
53
|
+
const notFound = { status: null, signal: null,
|
|
54
|
+
error: Object.assign(new Error("spawnSync ENOENT"), { code: "ENOENT" }) };
|
|
55
|
+
assert.equal(classify(notFound).reason, "spawn_error");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("ошибка запуска — сбой инструмента, а не находка", () => {
|
|
59
|
+
const r = classify(R({ status: null, signal: null, error: new Error("spawn ENOENT") }));
|
|
60
|
+
assert.equal(r.state, "infra_error");
|
|
61
|
+
assert.equal(r.reason, "spawn_error");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("неожиданный код — сбой инструмента, а не находка", () => {
|
|
65
|
+
const r = classify(R({ status: 2 }));
|
|
66
|
+
assert.equal(r.state, "infra_error");
|
|
67
|
+
assert.equal(r.reason, "unexpected_exit");
|
|
68
|
+
assert.equal(r.code, 2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Знание о кодах живёт рядом с инструментом. Числа — из замера, а не из памяти.
|
|
72
|
+
test("адаптеры знают свои коды: vulture 3 — находка, pylint 32 — ошибка вызова", () => {
|
|
73
|
+
assert.equal(classify(R({ status: 3 }), findingCodes("vulture")).state, "finding");
|
|
74
|
+
assert.equal(classify(R({ status: 1 }), findingCodes("vulture")).state, "infra_error");
|
|
75
|
+
|
|
76
|
+
assert.equal(classify(R({ status: 2 }), findingCodes("pylint")).state, "finding");
|
|
77
|
+
assert.equal(classify(R({ status: 24 }), findingCodes("pylint")).state, "finding");
|
|
78
|
+
assert.equal(classify(R({ status: 32 }), findingCodes("pylint")).state, "infra_error");
|
|
79
|
+
|
|
80
|
+
// Незнакомая программа — умолчание: 0 чисто, 1 находка, остальное сбой. Это ЧЕСТНЕЕ, чем
|
|
81
|
+
// догадка: неизвестный код становится «не знаем», а не «поймал».
|
|
82
|
+
assert.equal(classify(R({ status: 1 }), findingCodes("неизвестный-инструмент")).state, "finding");
|
|
83
|
+
assert.equal(classify(R({ status: 3 }), findingCodes("неизвестный-инструмент")).state, "infra_error");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("наши собственные обёртки: 2 — «нет инструмента», а не находка", () => {
|
|
87
|
+
assert.equal(classify(R({ status: 2 }), findingCodes("bash")).state, "infra_error");
|
|
88
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// node --test tool/selfcheck/units-level.mjs
|
|
10
10
|
import test from "node:test";
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
|
-
import { commandFor } from "../lib/prove.mjs";
|
|
12
|
+
import { commandFor, verdict } from "../lib/prove.mjs";
|
|
13
13
|
import { assessLevel, layoutChecks, unknownKeys, KNOWN_KEYS, parseManifest, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
|
|
14
14
|
import { pickLang, langFromText } from "../i18n/index.mjs";
|
|
15
15
|
|
|
@@ -266,3 +266,34 @@ test("правильный манифест не порождает жалоб",
|
|
|
266
266
|
test("комментарии и пустые строки не считаются потерянными", () => {
|
|
267
267
|
assert.deepEqual(unparsedLines("# заметка\n\naqk: 1\n # ещё\n"), []);
|
|
268
268
|
});
|
|
269
|
+
|
|
270
|
+
// --- ВЕРДИКТ ДОКАЗАТЕЛЬСТВА: один доказанный гейт не покрывает недоказанный -------------
|
|
271
|
+
// ЗАЧЕМ. Прежнее правило было `broken === 0 && proven > 0`. Оно позволяло ОДНОМУ доказанному
|
|
272
|
+
// гейту компенсировать сколько угодно недоказанных: проект с пятью объявленными проверками,
|
|
273
|
+
// из которых четыре не смогли отработать, получал AQK-2 за счёт пятой. Ступень называется
|
|
274
|
+
// «гейты доказаны», а доказан был один.
|
|
275
|
+
//
|
|
276
|
+
// Различие тонкое и обязательное: «нечем доказывать» бывает ЗАКОННЫМ (нет образцов, нет
|
|
277
|
+
// программы на этой машине, стоит рецепт под другой язык) — такое ступень не отнимает.
|
|
278
|
+
// А «запускали и не смогло отработать» — это сбой, и он ступень отнимает: иначе таймаут
|
|
279
|
+
// арбитра снова становится способом получить зелёное.
|
|
280
|
+
test("сбой арбитра отнимает ступень, законная недоказуемость — нет", () => {
|
|
281
|
+
assert.equal(verdict([{ state: "proven" }]).ok, true);
|
|
282
|
+
assert.equal(verdict([]).ok, false, "доказывать нечего — не доказано");
|
|
283
|
+
assert.equal(verdict([{ state: "unprovable", why: "no-samples" }]).ok, false,
|
|
284
|
+
"ни один гейт не доказан");
|
|
285
|
+
|
|
286
|
+
// Законная недоказуемость рядом с доказанным гейтом ступень не отнимает.
|
|
287
|
+
assert.equal(verdict([
|
|
288
|
+
{ state: "proven" },
|
|
289
|
+
{ state: "unprovable", why: "needs-program" },
|
|
290
|
+
]).ok, true);
|
|
291
|
+
|
|
292
|
+
// А вот сбой арбитра — отнимает, сколько бы соседей ни было доказано.
|
|
293
|
+
assert.equal(verdict([
|
|
294
|
+
{ state: "proven" },
|
|
295
|
+
{ state: "unprovable", why: "infra", reason: "timeout" },
|
|
296
|
+
]).ok, false, "таймаут арбитра компенсирован соседним гейтом");
|
|
297
|
+
|
|
298
|
+
assert.equal(verdict([{ state: "proven" }, { state: "broken" }]).ok, false);
|
|
299
|
+
});
|