agent-quality-kit 0.9.0 → 0.10.1
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 +69 -10
- package/README.ru.md +68 -11
- package/kit/docs/ai/index.md +1 -0
- package/kit/docs/api-e2e.md +214 -0
- package/kit/docs/ready-made-rules.md +85 -0
- package/kit/gates/README.md +22 -0
- package/kit/gates/api-contract-has-arbiter/README.md +63 -0
- package/kit/gates/api-contract-has-arbiter/check.sh +117 -0
- package/kit/gates/api-contract-has-arbiter/gate.yml +15 -0
- package/kit/gates/api-contract-has-arbiter/green/.github/workflows/ci.yml +12 -0
- package/kit/gates/api-contract-has-arbiter/green/openapi.yaml +18 -0
- package/kit/gates/api-contract-has-arbiter/red/.github/workflows/ci.yml +11 -0
- package/kit/gates/api-contract-has-arbiter/red/openapi.yaml +18 -0
- package/kit/gates/ci-actually-fails/check.sh +9 -1
- package/kit/gates/commit-explains-itself/check.sh +15 -0
- package/kit/gates/complexity-limit/red/deep.go +17 -0
- package/kit/gates/complexity-limit/red/deep.rs +17 -0
- package/kit/gates/gate-not-weakened/red/suppress.go +5 -0
- package/kit/gates/gate-not-weakened/red/suppress.rs +3 -0
- package/kit/gates/protection-not-removed/README.md +67 -0
- package/kit/gates/protection-not-removed/check.sh +101 -0
- package/kit/gates/protection-not-removed/gate.yml +10 -0
- package/kit/gates/protection-not-removed/green/.aqk.yml +7 -0
- package/kit/gates/protection-not-removed/green/gates-declared.txt +4 -0
- package/kit/gates/protection-not-removed/red/.aqk.yml +7 -0
- package/kit/gates/protection-not-removed/red/gates-declared.txt +4 -0
- package/kit/gates/secrets-not-in-code/red/leak.go +9 -0
- package/kit/gates/secrets-not-in-code/red/leak.rs +5 -0
- package/kit/gates/todo-without-task/red/later.go +6 -0
- package/kit/gates/todo-without-task/red/later.rs +4 -0
- package/llms.txt +10 -4
- package/package.json +2 -1
- package/tool/commands/context.mjs +36 -1
- package/tool/commands/doctor.mjs +56 -2
- package/tool/commands/gates.mjs +2 -0
- package/tool/commands/probe.mjs +228 -0
- package/tool/commands/vitals.mjs +11 -3
- package/tool/i18n/en-docs.mjs +10 -0
- package/tool/i18n/en-gates.mjs +309 -0
- package/tool/i18n/en.mjs +10 -281
- package/tool/i18n/ru-docs.mjs +10 -0
- package/tool/i18n/ru-gates.mjs +311 -0
- package/tool/i18n/ru.mjs +10 -280
- package/tool/lib/cadence.mjs +57 -0
- package/tool/lib/core.mjs +1 -0
- package/tool/lib/evidence.mjs +15 -2
- package/tool/lib/execution.mjs +67 -0
- package/tool/lib/history.mjs +82 -0
- package/tool/lib/manifest.mjs +1 -1
- package/tool/lib/protection.mjs +52 -0
- package/tool/lib/prove.mjs +40 -13
- package/tool/lib/repo.mjs +12 -2
- package/tool/program.mjs +7 -0
- package/tool/selfcheck/smoke/_fixture.mjs +89 -0
- package/tool/selfcheck/smoke/api-contract.test.mjs +79 -0
- package/tool/selfcheck/smoke/commit-report.test.mjs +47 -0
- package/tool/selfcheck/smoke/protection.test.mjs +101 -0
- package/tool/selfcheck/smoke/release-tools.test.mjs +55 -0
- package/tool/selfcheck/smoke/verdict.test.mjs +40 -0
- package/tool/selfcheck/smoke.sh +194 -7
- package/tool/selfcheck/units-cadence.mjs +69 -0
- 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 +100 -0
- package/tool/selfcheck/units-repo.mjs +31 -1
- package/tool/selfcheck/units-vitals.mjs +19 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// tool/lib/protection.mjs — снимок объявленной защиты: кто его пишет и в каком виде.
|
|
2
|
+
//
|
|
3
|
+
// ОТДЕЛЬНЫМ МОДУЛЕМ, а не строкой в `add`: у формата снимка два читателя — эта запись и
|
|
4
|
+
// проверка `kit/gates/protection-not-removed/check.sh`. Знание об одном файле, размазанное по
|
|
5
|
+
// двум местам, однажды разъедется; здесь оно собрано с той стороны, где на JS.
|
|
6
|
+
// Вторая копия неизбежна: проверка написана на POSIX sh и разделить с ней код нельзя —
|
|
7
|
+
// поэтому шапка ниже и текст в её сообщении «почини» обязаны меняться вместе.
|
|
8
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { CWD, RATCHET_DIR, MANIFEST } from "./core.mjs";
|
|
11
|
+
|
|
12
|
+
const HEADER =
|
|
13
|
+
"# Снимок объявленной защиты. Набор может только РАСТИ.\n" +
|
|
14
|
+
"# Убрал гейт — напиши причину после # в его строке, иначе проверка краснеет.\n";
|
|
15
|
+
|
|
16
|
+
// СНИМОК ОБЪЯВЛЕННОЙ ЗАЩИТЫ ПИШЕТ УСТАНОВКА, А НЕ ПРОВЕРКА.
|
|
17
|
+
//
|
|
18
|
+
// Раньше его вёл сам гейт `protection-not-removed`: при отсутствии создавал, при появлении новых
|
|
19
|
+
// имён дописывал. То есть проверка меняла то, о чём судит. Опыт 2026-09-09: в мелком клоне, где
|
|
20
|
+
// удаление снимка лежит глубже выкладки, свидетель (история git) слеп — и проверка записала уже
|
|
21
|
+
// ОСЛАБЛЕННЫЙ набор новым эталоном, вернув ноль. Храповик крутился назад.
|
|
22
|
+
//
|
|
23
|
+
// Здесь этому место по смыслу: `add` уже меняет манифест, и запись в снимок — часть того же
|
|
24
|
+
// действия. Проверка теперь только сравнивает.
|
|
25
|
+
async function recordProtection(man, slug) {
|
|
26
|
+
// `init` кладёт `ratchets: ""` намеренно: пустое поле честнее заглушки. Заполняем его при
|
|
27
|
+
// первой записи — ровно так же поступает `ratchet`. Иначе источников истины два: пустой
|
|
28
|
+
// манифест и умолчание внутри проверки, — и они однажды разойдутся.
|
|
29
|
+
let rdir = typeof man?.ratchets === "string" ? man.ratchets.trim() : "";
|
|
30
|
+
const manPath = join(CWD, MANIFEST);
|
|
31
|
+
if (!rdir) {
|
|
32
|
+
rdir = RATCHET_DIR;
|
|
33
|
+
try {
|
|
34
|
+
const t = await readFile(manPath, "utf8");
|
|
35
|
+
if (/^ratchets:\s*""\s*$/m.test(t)) {
|
|
36
|
+
await writeFile(manPath, t.replace(/^ratchets:\s*""\s*$/m, `ratchets: ${RATCHET_DIR}`), "utf8");
|
|
37
|
+
}
|
|
38
|
+
} catch { /* манифест не прочитан — снимок всё равно заведём в умолчательном каталоге */ }
|
|
39
|
+
}
|
|
40
|
+
const file = join(CWD, rdir, "gates-declared.txt");
|
|
41
|
+
let body = "";
|
|
42
|
+
try { body = await readFile(file, "utf8"); } catch { /* снимка ещё нет — заведём */ }
|
|
43
|
+
const names = body.split("\n").map((l) => l.replace(/\s*#.*$/, "").trim()).filter(Boolean);
|
|
44
|
+
if (names.includes(slug)) return;
|
|
45
|
+
const head = body ? body.replace(/\n?$/, "\n") : HEADER;
|
|
46
|
+
await mkdir(join(CWD, rdir), { recursive: true });
|
|
47
|
+
await writeFile(file, `${head}${slug}\n`, "utf8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Наружу — только запись. `HEADER` остаётся внутри: экспорт, который никто не берёт, читается
|
|
51
|
+
// как часть договора и мешает менять внутренности. Поймал наш же dead-code.
|
|
52
|
+
export { recordProtection };
|
package/tool/lib/prove.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { join } from "node:path";
|
|
|
15
15
|
import { CWD, exists } from "./core.mjs";
|
|
16
16
|
import { parseManifest, gateRequires } from "./manifest.mjs";
|
|
17
17
|
import { whichSync } from "./repo.mjs";
|
|
18
|
+
import { classify, findingCodes } from "./execution.mjs";
|
|
18
19
|
|
|
19
20
|
// Гейт можно доказать, если у него есть оба образца. Признак по образцам, а не по тексту
|
|
20
21
|
// команды: запись, делегирующая готовому инструменту (`npx knip --directory .`), каталог
|
|
@@ -102,12 +103,14 @@ async function samplesForRecipe(samplesDir, name) {
|
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
// Запуск возвращает РАЗОБРАННЫЙ исход, а не сырой код. Прежде здесь стояло
|
|
107
|
+
// `code = r.status === null ? 124 : r.status`, и комментарий рядом честно называл 124
|
|
108
|
+
// «не знаем» — а вызывающий тут же считал его находкой. Опыт 2026-09-09: проверка, виснущая
|
|
109
|
+
// на красном образце, получала вердикт «доказана».
|
|
110
|
+
function run(cmd, timeoutMs, prog) {
|
|
106
111
|
const r = spawnSync(cmd, { shell: true, encoding: "utf8", cwd: CWD, timeout: timeoutMs });
|
|
107
112
|
const out = `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
108
|
-
|
|
109
|
-
const code = r.status === null ? 124 : r.status;
|
|
110
|
-
return { code, out };
|
|
113
|
+
return { ...classify(r, findingCodes(prog)), out };
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
// Возвращает { proven, broken, unprovable, results } — числами и списком, чтобы вызывающий
|
|
@@ -159,24 +162,48 @@ async function proveGates(man, { timeoutMs = 300000 } = {}) {
|
|
|
159
162
|
continue;
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
+
// Программа, чьи коды разбираем: первое слово команды БЕЗ обёрток. У обёрнутой записи
|
|
166
|
+
// это ruff/vulture, а не bash, — иначе адаптер брался бы для оболочки.
|
|
167
|
+
const prog = effective[0];
|
|
168
|
+
const red = run(commandFor(cmd, s.red), timeoutMs, prog);
|
|
169
|
+
const green = run(commandFor(cmd, s.green), timeoutMs, prog);
|
|
170
|
+
|
|
171
|
+
// СБОЙ АРБИТРА — НЕ ВЕРДИКТ О ЗАПИСИ, ни в ту сторону, ни в другую. Раньше сбой на красном
|
|
172
|
+
// читался как «поймал», а сбой на зелёном — как «ругается на исправный код»: инструмент
|
|
173
|
+
// ломался, а обвиняли запись каталога. Оба случая теперь «доказать не смогли», и причина
|
|
174
|
+
// названа.
|
|
175
|
+
if (red.state === "infra_error" || green.state === "infra_error") {
|
|
176
|
+
const side = red.state === "infra_error" ? "red" : "green";
|
|
177
|
+
const bad = side === "red" ? red : green;
|
|
178
|
+
results.push({ name, state: "unprovable", why: "infra", side, reason: bad.reason, red, green });
|
|
179
|
+
} else if (red.state === "clean") {
|
|
165
180
|
results.push({ name, state: "broken", why: "red-passed", red, green });
|
|
166
|
-
} else if (green.
|
|
181
|
+
} else if (green.state === "finding") {
|
|
167
182
|
results.push({ name, state: "broken", why: "green-failed", red, green });
|
|
168
183
|
} else {
|
|
169
184
|
results.push({ name, state: "proven", red, green });
|
|
170
185
|
}
|
|
171
186
|
}
|
|
172
187
|
|
|
188
|
+
return { ...verdict(results), results };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ВЕРДИКТ ОТДЕЛЁН ОТ ПРОГОНА — чтобы правило можно было проверить без запуска процессов.
|
|
192
|
+
//
|
|
193
|
+
// Прежнее правило было `broken === 0 && proven > 0`, и оно позволяло ОДНОМУ доказанному гейту
|
|
194
|
+
// компенсировать сколько угодно недоказанных: проект с пятью объявленными проверками, из
|
|
195
|
+
// которых четыре не смогли отработать, получал AQK-2 за счёт пятой.
|
|
196
|
+
//
|
|
197
|
+
// Различие тонкое и обязательное. «Нечем доказывать» бывает ЗАКОННЫМ: нет образцов, нет
|
|
198
|
+
// программы на этой машине, стоит рецепт под другой язык — ступень за это не отнимают, иначе
|
|
199
|
+
// уровень стал бы зависеть от того, что установлено. А «запускали и не смогло отработать» —
|
|
200
|
+
// сбой, и он ступень отнимает: иначе таймаут арбитра снова становится способом получить зелёное.
|
|
201
|
+
function verdict(results) {
|
|
173
202
|
const proven = results.filter((r) => r.state === "proven").length;
|
|
174
203
|
const broken = results.filter((r) => r.state === "broken").length;
|
|
175
204
|
const unprovable = results.filter((r) => r.state === "unprovable").length;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// именно так выглядит подделка с тремя `true`.
|
|
179
|
-
return { proven, broken, unprovable, ok: broken === 0 && proven > 0, results };
|
|
205
|
+
const infra = results.filter((r) => r.state === "unprovable" && r.why === "infra").length;
|
|
206
|
+
return { proven, broken, unprovable, infra, ok: broken === 0 && infra === 0 && proven > 0 };
|
|
180
207
|
}
|
|
181
208
|
|
|
182
|
-
export { proveGates, commandFor };
|
|
209
|
+
export { proveGates, commandFor, verdict };
|
package/tool/lib/repo.mjs
CHANGED
|
@@ -26,6 +26,14 @@ const EXT_LANG = {
|
|
|
26
26
|
".cs": "csharp", ".sh": "shell", ".kt": "kotlin", ".swift": "swift", ".scala": "scala",
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
// Спецификация API — договор с чужим кодом. Опознаётся ПО ИМЕНИ ФАЙЛА, а не по расположению:
|
|
30
|
+
// его кладут в корень, в `docs/`, рядом с приложением, — фиксированный список путей промахнулся
|
|
31
|
+
// бы на большинстве проектов. Расширение обязательно разбираемое: `openapi.md` — это рассказ о
|
|
32
|
+
// договоре, а не договор, и держать его нечем.
|
|
33
|
+
function isApiSpec(name) {
|
|
34
|
+
return /^(openapi|swagger|asyncapi)[^/]*\.(ya?ml|json)$/i.test(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
const SKIP_DIRS = new Set([".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".aqk"]);
|
|
30
38
|
|
|
31
39
|
// Факты о репозитории. Только то, что видно машине: спрашивать человека анкетой
|
|
@@ -67,6 +75,7 @@ async function detectFacts(man) {
|
|
|
67
75
|
// JS, где интерфейса нет вовсе. Записи, показанной не тому, не верят, и каталог теряет
|
|
68
76
|
// доверие целиком, а не одной строкой.
|
|
69
77
|
let hasUi = false;
|
|
78
|
+
let hasApiSpec = false;
|
|
70
79
|
|
|
71
80
|
async function walk(dir, depth) {
|
|
72
81
|
if (depth > 4 || files > 4000) return;
|
|
@@ -91,6 +100,7 @@ async function detectFacts(man) {
|
|
|
91
100
|
if (/\.(test|spec)\.[a-z]+$/i.test(it.name) || /^test_.*\.py$/i.test(it.name) || /_test\.go$/i.test(it.name)) hasTests = true;
|
|
92
101
|
if (it.name.endsWith(".sql")) hasDb = true;
|
|
93
102
|
if (/\.(css|scss|sass|less|styl|vue|svelte|astro)$/i.test(it.name)) hasUi = true;
|
|
103
|
+
if (isApiSpec(it.name)) hasApiSpec = true;
|
|
94
104
|
const dot = it.name.lastIndexOf(".");
|
|
95
105
|
if (dot > 0) {
|
|
96
106
|
const lang = EXT_LANG[it.name.slice(dot)];
|
|
@@ -108,6 +118,7 @@ async function detectFacts(man) {
|
|
|
108
118
|
has_db: hasDb,
|
|
109
119
|
has_tests: hasTests,
|
|
110
120
|
has_ui: hasUi,
|
|
121
|
+
has_api_spec: hasApiSpec,
|
|
111
122
|
has_gates: Object.values(gates).some((c) => String(c || "").trim()),
|
|
112
123
|
gateKeys: Object.keys(gates),
|
|
113
124
|
};
|
|
@@ -356,5 +367,4 @@ async function matchCatalog(query) {
|
|
|
356
367
|
export {
|
|
357
368
|
whichSync,
|
|
358
369
|
EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor, browserServerAdvice, MARKS,
|
|
359
|
-
stems, overlap, matchCatalog,
|
|
360
|
-
};
|
|
370
|
+
stems, overlap, matchCatalog, isApiSpec };
|
package/tool/program.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { cmdReport } from "./commands/report.mjs";
|
|
|
26
26
|
import { cmdLearn } from "./commands/learn.mjs";
|
|
27
27
|
import { cmdBadge } from "./commands/badge.mjs";
|
|
28
28
|
import { cmdProve } from "./commands/prove.mjs";
|
|
29
|
+
import { cmdProbe } from "./commands/probe.mjs";
|
|
29
30
|
import { cmdContext } from "./commands/context.mjs";
|
|
30
31
|
import { cmdVitals } from "./commands/vitals.mjs";
|
|
31
32
|
|
|
@@ -83,6 +84,12 @@ if (IS_MAIN) {
|
|
|
83
84
|
case "prove":
|
|
84
85
|
await cmdProve();
|
|
85
86
|
break;
|
|
87
|
+
// Осмотр, а не порог: всегда выходит с нулём. Отвечает на вопрос, которого нет ни у
|
|
88
|
+
// doctor («держит машина 21» — из чего?), ни у prove («гейт ловит брак на СВОЁМ образце»):
|
|
89
|
+
// что в ЭТОМ репозитории не прикрыто ничем.
|
|
90
|
+
case "probe":
|
|
91
|
+
await cmdProbe(rest);
|
|
92
|
+
break;
|
|
86
93
|
// Печатает состояние репозитория для КОНТЕКСТА агента, а не для человека. Зовётся хуком
|
|
87
94
|
// SessionStart, поэтому ничего не запускает и всегда выходит с нулём: хук, роняющий запуск
|
|
88
95
|
// агента из-за неготового проекта, отключат в тот же день, и не станет ни хука, ни блока.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// tool/selfcheck/smoke/_fixture.mjs — общая оснастка проверок, гоняющих сам инструмент.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ОТДЕЛЬНЫЙ ФАЙЛ. В `smoke.sh` фикстура «временный проект под git» написана сорок раз
|
|
4
|
+
// подряд одними и теми же четырьмя строками. Это не похожие строки, а ОДНО знание в сорока
|
|
5
|
+
// местах: меняется устройство подопытного проекта — правится сорок мест, и они расходятся.
|
|
6
|
+
//
|
|
7
|
+
// ЧТО ЗДЕСЬ ЗАКРЫТО, кроме дублирования (всё — из разборов флейков и из нашего же опыта):
|
|
8
|
+
// · СВОЙ каталог на проверку и уборка после неё — через `t.after`, а не «не забыть rm»;
|
|
9
|
+
// · ТАЙМАУТ на каждый запуск: висящий подпроцесс иначе держит конвейер до его предела;
|
|
10
|
+
// · ГЕРМЕТИЧНОСТЬ: HOME и TMPDIR в песочнице, сеть выключена. Прогон обязан давать один
|
|
11
|
+
// ответ на любой машине и в любой день — иначе зелёное значит «сегодня совпало».
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
19
|
+
const CLI = join(ROOT, "tool", "program.mjs");
|
|
20
|
+
|
|
21
|
+
// Предел на один запуск. Тридцать секунд — с запасом: самый долгий наш прогон в фикстуре
|
|
22
|
+
// (`start` со всеми записями) укладывается в единицы секунд. Предел нужен не ради скорости,
|
|
23
|
+
// а ради того, чтобы висящий процесс называл себя, а не съедал бюджет задания молча.
|
|
24
|
+
const TIMEOUT_MS = 30000;
|
|
25
|
+
|
|
26
|
+
function env(home) {
|
|
27
|
+
return {
|
|
28
|
+
...process.env,
|
|
29
|
+
HOME: home,
|
|
30
|
+
USERPROFILE: home,
|
|
31
|
+
TMPDIR: join(home, "tmp"),
|
|
32
|
+
AQK_LANG: "ru",
|
|
33
|
+
// Сеть в тестах — отдельный класс флейков, и у нас она включалась ТОЛЬКО вне конвейера:
|
|
34
|
+
// локально проверки были сетевыми, в конвейере нет. Две разные среды по построению.
|
|
35
|
+
AQK_UPDATE: "0",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Подопытный проект: свой каталог, свой git, свой дом. Файлы задаются картой «путь → текст»,
|
|
40
|
+
// потому что имя файла в такой проверке — часть утверждения, а не деталь.
|
|
41
|
+
function project(t, files = {}, { git = true } = {}) {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "aqk-smoke-"));
|
|
43
|
+
const home = join(dir, ".home");
|
|
44
|
+
mkdirSync(join(home, "tmp"), { recursive: true });
|
|
45
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
46
|
+
for (const [rel, text] of Object.entries(files)) {
|
|
47
|
+
const full = join(dir, rel);
|
|
48
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
49
|
+
writeFileSync(full, text, "utf8");
|
|
50
|
+
}
|
|
51
|
+
const p = { dir, home };
|
|
52
|
+
if (git) {
|
|
53
|
+
run(p, "git", ["init", "-q", "."]);
|
|
54
|
+
run(p, "git", ["config", "user.email", "t@t"]);
|
|
55
|
+
run(p, "git", ["config", "user.name", "t"]);
|
|
56
|
+
}
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Запуск чего угодно в каталоге проекта. Возвращается И код, И вывод: проверка, смотрящая
|
|
61
|
+
// только на код, не умеет объяснить провал, а смотрящая только на вывод — не умеет заметить,
|
|
62
|
+
// что команда не роняет прогон.
|
|
63
|
+
function run({ dir, home }, cmd, args = []) {
|
|
64
|
+
const r = spawnSync(cmd, args, {
|
|
65
|
+
cwd: dir, encoding: "utf8", timeout: TIMEOUT_MS, env: env(home),
|
|
66
|
+
});
|
|
67
|
+
return { code: r.status, out: `${r.stdout || ""}${r.stderr || ""}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const aqk = (p, ...args) => run(p, process.execPath, [CLI, ...args]);
|
|
71
|
+
|
|
72
|
+
// ПОЧЕМУ ГЕЙТ ЗОВЁТСЯ ТОЧКОЙ, А НЕ ПУТЁМ, И ПОЧЕМУ ПУТЬ ЧЕРЕЗ КОСУЮ.
|
|
73
|
+
// Node на Windows отдаёт `C:\Users\…\Temp\aqk-smoke-x`, а Git Bash живёт в POSIX-мире и
|
|
74
|
+
// такого пути не знает: проверка искала спецификации в несуществующем каталоге, не находила
|
|
75
|
+
// ничего и выходила с нулём — то есть краснела не на дефекте, а на переносимости. Поймано
|
|
76
|
+
// конвейером на windows-задании в тот же день, когда файл был написан.
|
|
77
|
+
// Лечится двумя приёмами сразу: каталог отдаётся как «.» вместе с cwd (переводить нечего),
|
|
78
|
+
// а путь до самого скрипта — с прямыми косыми, их Git Bash понимает.
|
|
79
|
+
const posix = (p) => String(p).replace(/\\/g, "/");
|
|
80
|
+
// Подкаталог нужен там, где проверяется не сам проект, а его клон: мелкий клон предложения
|
|
81
|
+
// изменений ведёт себя иначе, чем полная история, и это отдельный класс отказов.
|
|
82
|
+
const gate = (p, name, sub = ".") =>
|
|
83
|
+
run(p, "bash", [posix(join(ROOT, "kit", "gates", name, "check.sh")), sub]);
|
|
84
|
+
|
|
85
|
+
// Наружу — только то, что зовут проверки. `ROOT` и `CLI` остаются внутри: экспорт, который
|
|
86
|
+
// никто не берёт, читается как часть договора и мешает менять внутренности — поймал наш же
|
|
87
|
+
// dead-code через минуту после того, как файл был написан. `run` вернулся, когда появилась
|
|
88
|
+
// проверка, которой нужен сырой git: экспорт заводится под потребителя, а не про запас.
|
|
89
|
+
export { project, run, aqk, gate };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Проверки про договор с чужим кодом: кто держит спецификацию API и умеет ли держатель
|
|
2
|
+
// провалиться. Переехали из smoke.sh 2026-09-09 первыми — как доказательство переезда.
|
|
3
|
+
//
|
|
4
|
+
// ЗАМЕР, ИЗ КОТОРОГО ОНИ ВЗЯЛИСЬ. Стенд, где сервер врёт в каждом поле ответа:
|
|
5
|
+
// spectral (spectral:oas) код 0 — нет контакта, описания, тегов
|
|
6
|
+
// schemathesis, умолчания код 1 — три нарушения схемы в ответах
|
|
7
|
+
// schemathesis -c not_a_server_error код 0 — «18 из 18 прошли»
|
|
8
|
+
// oasdiff breaking без --fail-on код 0 — печатает ломающую правку и молчит кодом
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { project, aqk, gate } from "./_fixture.mjs";
|
|
12
|
+
|
|
13
|
+
const SPEC = "openapi: 3.0.3\ninfo: { title: t, version: 1.0.0 }\npaths: {}\n";
|
|
14
|
+
const workflow = (steps) =>
|
|
15
|
+
`name: ci\non: [push]\njobs:\n contract:\n runs-on: ubuntu-latest\n steps:\n${steps}`;
|
|
16
|
+
|
|
17
|
+
// ЛОВУШКА, ИЗ-ЗА КОТОРОЙ ЗАМЕР ЧУТЬ НЕ ОКАЗАЛСЯ ЛОЖНО-ЗЕЛЁНЫМ: шаг с именем «Spec lint»
|
|
18
|
+
// краснеет не потому, что узнали инструмент, а потому что в имени есть слово-примета.
|
|
19
|
+
// Поэтому здесь у шагов НЕТ слов-подсказок — проверяется опознание инструмента.
|
|
20
|
+
test("обезвреженные проверки контракта API опознаются как проверки", (t) => {
|
|
21
|
+
const p = project(t, {
|
|
22
|
+
".github/workflows/api.yml": workflow(
|
|
23
|
+
" - name: соответствие сервера схеме\n" +
|
|
24
|
+
" continue-on-error: true\n" +
|
|
25
|
+
" run: schemathesis run openapi.yaml --url http://localhost:8000\n" +
|
|
26
|
+
" - name: ломающие изменения\n" +
|
|
27
|
+
" continue-on-error: true\n" +
|
|
28
|
+
" run: oasdiff breaking base.yaml openapi.yaml --fail-on ERR\n" +
|
|
29
|
+
" - name: ожидания потребителей\n" +
|
|
30
|
+
" continue-on-error: true\n" +
|
|
31
|
+
" run: pact-broker can-i-deploy --pacticipant web\n"),
|
|
32
|
+
});
|
|
33
|
+
const r = gate(p, "ci-actually-fails");
|
|
34
|
+
assert.equal(r.code, 1, `три шага под continue-on-error прошли как чистые:\n${r.out}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// У записи две красные ветки, и вторая тоньше первой. Проверяются ОБЕ формы сужения и
|
|
38
|
+
// мутация: снял сужение — гейт обязан замолчать, иначе краснеет он не от этого.
|
|
39
|
+
test("арбитр, который не может провалиться, краснеет в обеих формах", (t) => {
|
|
40
|
+
const mk = (tail) => ({
|
|
41
|
+
"openapi.yaml": SPEC,
|
|
42
|
+
".github/workflows/ci.yml": workflow(
|
|
43
|
+
` - run: schemathesis run openapi.yaml --url http://localhost:8000${tail}\n`),
|
|
44
|
+
});
|
|
45
|
+
for (const narrow of [" -c not_a_server_error", " --checks not_a_server_error"]) {
|
|
46
|
+
const p = project(t, mk(narrow));
|
|
47
|
+
assert.equal(gate(p, "api-contract-has-arbiter").code, 1, `сужение «${narrow}» не опознано`);
|
|
48
|
+
}
|
|
49
|
+
const full = project(t, mk(""));
|
|
50
|
+
assert.equal(gate(full, "api-contract-has-arbiter").code, 0, "полный арбитр покраснел");
|
|
51
|
+
|
|
52
|
+
const loud = project(t, {
|
|
53
|
+
"openapi.yaml": SPEC,
|
|
54
|
+
".github/workflows/ci.yml": workflow(
|
|
55
|
+
" - run: schemathesis run openapi.yaml --url http://localhost:8000\n" +
|
|
56
|
+
" - run: oasdiff breaking base.yaml openapi.yaml\n"),
|
|
57
|
+
});
|
|
58
|
+
assert.equal(gate(loud, "api-contract-has-arbiter").code, 1,
|
|
59
|
+
"oasdiff без --fail-on печатает находки и выходит с нулём — это не проверка");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// НАЙДЕНО АУДИТОМ ФИЧ, а не образцами: красный и зелёный образцы лежат по одному, а в
|
|
63
|
+
// настоящем проекте записи стоят рядом — и соседняя `ci-actually-fails` держит в своём
|
|
64
|
+
// check.sh список запускалок со ВСЕМИ инструментами про API разом. Проверка находила её и
|
|
65
|
+
// выдавала ложное ЗЕЛЁНОЕ там, где договор не держал никто.
|
|
66
|
+
test("держателем не считается определение соседней записи", (t) => {
|
|
67
|
+
const p = project(t, {
|
|
68
|
+
"src/a.py": "def s():\n return 1\n",
|
|
69
|
+
"openapi.yaml": SPEC,
|
|
70
|
+
".github/workflows/ci.yml": workflow(" - run: pytest\n"),
|
|
71
|
+
});
|
|
72
|
+
aqk(p, "init");
|
|
73
|
+
aqk(p, "add", "ci-actually-fails");
|
|
74
|
+
aqk(p, "add", "api-contract-has-arbiter");
|
|
75
|
+
const r = gate(p, "api-contract-has-arbiter");
|
|
76
|
+
assert.equal(r.code, 1, `договор без держателя прошёл зелёным:\n${r.out}`);
|
|
77
|
+
// Второй дефект того же прогона: `find` без завершающего -print печатал обойдённые каталоги.
|
|
78
|
+
assert.doesNotMatch(r.out, /\.git|\.aqk/, `в списке спецификаций каталоги:\n${r.out}`);
|
|
79
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Мини-отчёт в коммите: где его требовать, а где состав коммита определить нельзя.
|
|
2
|
+
//
|
|
3
|
+
// НАЙДЕНО КОНВЕЙЕРОМ 2026-09-09. Коммит, трогающий ТОЛЬКО журнал, отчёта не требует — сама
|
|
4
|
+
// запись и есть отчёт. Локально так и было. В конвейере тот же коммит покраснел, и причина
|
|
5
|
+
// тоньше, чем кажется:
|
|
6
|
+
// · при разборе предложения изменений GitHub выкладывает синтетический коммит слияния,
|
|
7
|
+
// и гейт правильно спрашивает второго родителя — коммит автора;
|
|
8
|
+
// · но выкладка идёт МЕЛКИМ клоном, и родителя второго родителя в нём нет;
|
|
9
|
+
// · `git show --name-only` на коммите без родителя считает его корневым и выдаёт ВСЁ дерево;
|
|
10
|
+
// · значит «трогает только журнал» перестаёт срабатывать — молча, — и гейт требует отчёт там,
|
|
11
|
+
// где не должен.
|
|
12
|
+
// Проверено опытом: глубина 2 — красный, глубина 3 — чисто.
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { project, run, gate } from "./_fixture.mjs";
|
|
16
|
+
|
|
17
|
+
function repoWithJournalMerge(t) {
|
|
18
|
+
const p = project(t, {
|
|
19
|
+
"incidents/README.md": "# журнал\n",
|
|
20
|
+
"src/a.py": "код\n",
|
|
21
|
+
".aqk.yml": "lessons: incidents\n",
|
|
22
|
+
});
|
|
23
|
+
run(p, "git", ["add", "-A"]);
|
|
24
|
+
run(p, "git", ["commit", "-q", "-m", "feat: первый", "-m", "Сделано: завёл", "-m", "Не уверен: ни в чём"]);
|
|
25
|
+
run(p, "git", ["checkout", "-q", "-b", "feature"]);
|
|
26
|
+
run(p, "bash", ["-c", "printf '## запись\\n' >> incidents/README.md"]);
|
|
27
|
+
run(p, "git", ["add", "-A"]);
|
|
28
|
+
// Тело НАРОЧНО без «Сделано:»: если освобождение для журнала перестанет работать, гейт
|
|
29
|
+
// покраснеет — то есть проверка умеет отличить исправное от сломанного.
|
|
30
|
+
run(p, "git", ["commit", "-q", "-m", "docs: урок", "-m", "Не уверен: ничего не сломано"]);
|
|
31
|
+
const main = run(p, "bash", ["-c", "git checkout -q master 2>/dev/null || git checkout -q main; git rev-parse --short HEAD"]);
|
|
32
|
+
run(p, "git", ["merge", "-q", "--no-ff", "feature", "-m", `Merge feature into ${main.out.trim()}`]);
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("коммит только в журнал не требует отчёта и в мелком клоне слияния", (t) => {
|
|
37
|
+
const p = repoWithJournalMerge(t);
|
|
38
|
+
assert.equal(gate(p, "commit-explains-itself").code, 0, "полная история: журнальный коммит покраснел");
|
|
39
|
+
|
|
40
|
+
// Ровно то, что делает конвейер при разборе предложения изменений.
|
|
41
|
+
run(p, "bash", ["-c", "git clone -q --depth 2 file://$PWD shallow2 2>/dev/null"]);
|
|
42
|
+
const r = gate(p, "commit-explains-itself", "shallow2");
|
|
43
|
+
assert.equal(r.code, 0, `мелкий клон: гейт требует отчёт там, где состав определить нельзя:\n${r.out}`);
|
|
44
|
+
// Молчать в этом случае нельзя: пропуск обязан называть себя, иначе выключенная проверка
|
|
45
|
+
// неотличима от работающей — тот самый класс, ради которого весь комплект.
|
|
46
|
+
assert.match(r.out, /не определить|журнал/, `пропуск не назвал причину:\n${r.out}`);
|
|
47
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Прогон обязан называть свой вердикт СЛОВАМИ, а не только кодом возврата.
|
|
2
|
+
//
|
|
3
|
+
// НАЙДЕНО АУДИТОМ ФИЧ 2026-09-09. `doctor --run` выходил с единицей и в конце не говорил ни
|
|
4
|
+
// слова о причине: она оставалась в шапке, а внизу человек видел список зелёных гейтов и шёл
|
|
5
|
+
// искать несуществующую поломку. С `--min` вердикт печатался всегда, без него — никогда.
|
|
6
|
+
// Обратная сторона нашего же принципа: молчание неотличимо не только от успеха, но и от отказа.
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { project, aqk } from "./_fixture.mjs";
|
|
12
|
+
|
|
13
|
+
test("прогон называет свой вердикт словами в обоих исходах", (t) => {
|
|
14
|
+
const p = project(t, { "src/a.py": "def s():\n return 1\n" });
|
|
15
|
+
aqk(p, "init");
|
|
16
|
+
// Гейт объявляется ПРЯМО, а не через `add`, и это не лень. `add` выбирает рецепт по тому,
|
|
17
|
+
// что установлено на машине: где есть ruff, у `todo-without-task` берётся родной
|
|
18
|
+
// `ruff check --select FIX,TD`, где нет — переносимый. Значит один и тот же проект получает
|
|
19
|
+
// РАЗНУЮ объявленную команду на разных машинах, и проверка про ВЕРДИКТ начинала зависеть от
|
|
20
|
+
// чужого инструмента. Поймано конвейером: локально зелено, на раннере красно.
|
|
21
|
+
// Здесь проверяется строка вердикта, а не поведение записи каталога, — гейту довольно быть
|
|
22
|
+
// заведомо тихим.
|
|
23
|
+
const man = join(p.dir, ".aqk.yml");
|
|
24
|
+
writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m, 'gates:\n тихий: "true"'), "utf8");
|
|
25
|
+
|
|
26
|
+
// .gitignore нет — прогон красный, и обязан сказать, из-за чего именно.
|
|
27
|
+
const red = aqk(p, "doctor", "--run");
|
|
28
|
+
// Сообщение утверждения обязано нести ВЕСЬ хвост: проверка, которая говорит только «не
|
|
29
|
+
// совпало», отправляет читателя гадать — ровно то, за что мы ругаем чужие проверки.
|
|
30
|
+
const redTail = red.out.trimEnd().split("\n").slice(-6).join("\n");
|
|
31
|
+
assert.notEqual(red.code, 0, `прогон без .gitignore прошёл зелёным:\n${redTail}`);
|
|
32
|
+
assert.match(redTail, /красн/, `вердикт не назван в конце вывода (код ${red.code}):\n${redTail}`);
|
|
33
|
+
|
|
34
|
+
// Причина устранена — и «ничего не сказал» обязано отличаться от «всё проверено».
|
|
35
|
+
writeFileSync(join(p.dir, ".gitignore"), "x\n", "utf8");
|
|
36
|
+
const green = aqk(p, "doctor", "--run");
|
|
37
|
+
const greenTail = green.out.trimEnd().split("\n").slice(-6).join("\n");
|
|
38
|
+
assert.equal(green.code, 0, `прогон остался красным:\n${greenTail}`);
|
|
39
|
+
assert.match(greenTail, /зелён/, `успех не назван словами:\n${greenTail}`);
|
|
40
|
+
});
|