agent-quality-kit 0.5.0 → 0.7.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 +53 -2
- package/README.ru.md +35 -1
- package/kit/docs/ai/agent-harness-playbook.md +1 -1
- package/kit/docs/ready-made-rules.md +65 -0
- package/kit/gates/README.md +60 -0
- package/kit/gates/ci-actually-fails/README.md +54 -0
- package/kit/gates/ci-actually-fails/check.sh +116 -0
- package/kit/gates/ci-actually-fails/gate.yml +14 -0
- package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +30 -0
- package/kit/gates/ci-actually-fails/red/.github/workflows/ci.yml +12 -0
- package/kit/gates/ci-actually-fails/red/.github/workflows/soft.yml +15 -0
- package/kit/gates/color-from-token/check.sh +13 -1
- package/kit/gates/commit-explains-itself/README.md +13 -3
- package/kit/gates/commit-explains-itself/check.sh +8 -4
- package/kit/gates/complexity-limit/README.md +5 -0
- package/kit/gates/complexity-limit/check.sh +21 -2
- package/kit/gates/complexity-limit/green/test_fixtures.py +14 -0
- package/kit/gates/deps-are-pinned/README.md +14 -1
- package/kit/gates/deps-are-pinned/check.sh +6 -1
- package/kit/gates/deps-are-pinned/green/pyproject-with-requirements/pyproject.toml +12 -0
- package/kit/gates/deps-are-pinned/green/pyproject-with-requirements/requirements.txt +3 -0
- package/kit/gates/deps-are-pinned/red/pyproject-loose/pyproject.toml +12 -0
- package/kit/gates/deps-are-pinned/red/pyproject-loose/requirements.txt +3 -0
- package/kit/gates/duplicate-code/README.md +11 -2
- package/kit/gates/duplicate-code/check.sh +31 -4
- package/kit/gates/duplicate-code/gate.yml +8 -0
- package/kit/gates/duplicate-code/green/imports_a.go +20 -0
- package/kit/gates/duplicate-code/green/imports_b.go +19 -0
- package/kit/gates/entry-links-exist/README.md +5 -0
- package/kit/gates/entry-links-exist/check.sh +6 -0
- package/kit/gates/entry-links-exist/green/AGENTS.md +3 -0
- package/kit/gates/file-size-limit/README.md +9 -2
- package/kit/gates/file-size-limit/check.sh +13 -1
- package/kit/gates/gate-not-weakened/README.md +54 -0
- package/kit/gates/gate-not-weakened/check.sh +84 -0
- package/kit/gates/gate-not-weakened/gate.yml +15 -0
- package/kit/gates/gate-not-weakened/green/checkout.ts +8 -0
- package/kit/gates/gate-not-weakened/green/payments.py +6 -0
- package/kit/gates/gate-not-weakened/green/release.sh +2 -0
- package/kit/gates/gate-not-weakened/red/checkout.ts +9 -0
- package/kit/gates/gate-not-weakened/red/payments.py +6 -0
- package/kit/gates/gate-not-weakened/red/release.sh +2 -0
- package/kit/gates/hook-actually-fires/README.md +74 -0
- package/kit/gates/hook-actually-fires/check.sh +183 -0
- package/kit/gates/hook-actually-fires/gate.yml +15 -0
- package/kit/gates/hook-actually-fires/green/.claude/hooks/hooks.json +3 -0
- package/kit/gates/hook-actually-fires/green/.claude/settings.json +74 -0
- package/kit/gates/hook-actually-fires/green/.claude/settings.local.json +74 -0
- package/kit/gates/hook-actually-fires/red/.claude/hooks/hooks.json +4 -0
- package/kit/gates/hook-actually-fires/red/.claude/settings.json +53 -0
- package/kit/gates/no-phantom-package/README.md +84 -0
- package/kit/gates/no-phantom-package/check.sh +161 -0
- package/kit/gates/no-phantom-package/gate.yml +20 -0
- package/kit/gates/no-phantom-package/green/AGENTS.md +15 -0
- package/kit/gates/no-phantom-package/red/AGENTS.md +15 -0
- package/kit/gates/no-print-in-prod/README.md +33 -39
- package/kit/gates/no-print-in-prod/gate.yml +14 -6
- package/kit/gates/personal-config-not-shared/README.md +66 -0
- package/kit/gates/personal-config-not-shared/check.sh +103 -0
- package/kit/gates/personal-config-not-shared/gate.yml +16 -0
- package/kit/gates/personal-config-not-shared/green/.aqk-tracked +9 -0
- package/kit/gates/personal-config-not-shared/red/.aqk-tracked +6 -0
- package/kit/gates/promise-has-gate/README.md +50 -0
- package/kit/gates/promise-has-gate/check.sh +88 -0
- package/kit/gates/promise-has-gate/gate.yml +14 -0
- package/kit/gates/promise-has-gate/green/.aqk.yml +6 -0
- package/kit/gates/promise-has-gate/green/AGENTS.md +7 -0
- package/kit/gates/promise-has-gate/red/.aqk.yml +6 -0
- package/kit/gates/promise-has-gate/red/AGENTS.md +7 -0
- package/kit/gates/secrets-not-in-code/check.sh +13 -1
- package/kit/gates/swallowed-error/README.md +36 -18
- package/kit/gates/swallowed-error/gate.yml +13 -3
- package/kit/gates/test-has-assertion/README.md +47 -0
- package/kit/gates/test-has-assertion/check.sh +206 -0
- package/kit/gates/test-has-assertion/gate.yml +15 -0
- package/kit/gates/test-has-assertion/green/checkout.test.ts +9 -0
- package/kit/gates/test-has-assertion/green/test_billing.py +17 -0
- package/kit/gates/test-has-assertion/red/checkout.test.ts +8 -0
- package/kit/gates/test-has-assertion/red/test_billing.py +14 -0
- package/kit/gates/test-not-adjusted/README.md +79 -0
- package/kit/gates/test-not-adjusted/check.sh +136 -0
- package/kit/gates/test-not-adjusted/gate.yml +19 -0
- package/kit/gates/test-not-adjusted/green/after/calc.py +6 -0
- package/kit/gates/test-not-adjusted/green/after/tests/test_calc.py +9 -0
- package/kit/gates/test-not-adjusted/green/before/calc.py +2 -0
- package/kit/gates/test-not-adjusted/green/before/tests/test_calc.py +5 -0
- package/kit/gates/test-not-adjusted/red/after/calc.py +2 -0
- package/kit/gates/test-not-adjusted/red/after/tests/test_calc.py +5 -0
- package/kit/gates/test-not-adjusted/red/before/calc.py +2 -0
- package/kit/gates/test-not-adjusted/red/before/tests/test_calc.py +7 -0
- package/kit/gates/todo-without-task/README.md +6 -0
- package/kit/gates/todo-without-task/check.sh +13 -1
- package/kit/ratchet/ratchet.sh +70 -2
- package/kit/rules/general.md +23 -0
- package/kit/rules-en/general.md +82 -0
- package/kit/rules-en/security.md +33 -0
- package/kit/rules-en/testing.md +48 -0
- package/llms.txt +2 -1
- package/package.json +4 -2
- package/tool/commands/badge.mjs +7 -1
- package/tool/commands/doctor.mjs +90 -10
- package/tool/commands/gates.mjs +19 -5
- package/tool/commands/project.mjs +15 -2
- package/tool/commands/prove.mjs +67 -0
- package/tool/commands/report.mjs +4 -1
- package/tool/i18n/en-docs.mjs +70 -0
- package/tool/i18n/en.mjs +66 -54
- package/tool/i18n/ru-docs.mjs +70 -0
- package/tool/i18n/ru.mjs +66 -54
- package/tool/i18n/templates-en.mjs +9 -9
- package/tool/i18n/templates-ru.mjs +9 -9
- package/tool/lib/core.mjs +7 -1
- package/tool/lib/manifest.mjs +72 -5
- package/tool/lib/prove.mjs +160 -0
- package/tool/lib/repo.mjs +31 -2
- package/tool/lib/scope.mjs +131 -0
- package/tool/lib/templates.mjs +2 -0
- package/tool/program.mjs +6 -0
- package/tool/selfcheck/gates.sh +86 -3
- package/tool/selfcheck/lifecycle.mjs +29 -0
- package/tool/selfcheck/mutation.sh +21 -1
- package/tool/selfcheck/smoke.sh +329 -36
- package/tool/selfcheck/units-level.mjs +60 -0
- package/tool/selfcheck/units.mjs +196 -1
- package/kit/gates/no-print-in-prod/check.sh +0 -38
- package/kit/gates/no-print-in-prod/green/docs.ts +0 -15
- package/kit/gates/no-print-in-prod/green/main.go +0 -8
- package/kit/gates/no-print-in-prod/green/main.rs +0 -4
- package/kit/gates/no-print-in-prod/red/main.go +0 -8
- package/kit/gates/no-print-in-prod/red/main.rs +0 -4
- package/kit/gates/swallowed-error/check.sh +0 -54
- package/kit/gates/swallowed-error/green/run.js +0 -8
- package/kit/gates/swallowed-error/red/run.js +0 -3
- /package/kit/gates/commit-explains-itself/green/{COMMIT_MSG → .aqk-commit-msg} +0 -0
- /package/kit/gates/commit-explains-itself/red/{COMMIT_MSG → .aqk-commit-msg} +0 -0
package/tool/lib/manifest.mjs
CHANGED
|
@@ -79,13 +79,62 @@ function parseManifest(text) {
|
|
|
79
79
|
// Список обязан совпадать с тем, что программа РЕАЛЬНО читает (`man?.<поле>` в tool/):
|
|
80
80
|
// лишнее имя здесь молча узаконивает поле, которое ни на что не влияет, — та же тишина,
|
|
81
81
|
// только с другой стороны. Сверено обходом: aqk, entry, rules, gates, samples, ratchets, lessons.
|
|
82
|
-
const KNOWN_KEYS = ["aqk", "entry", "rules", "gates", "samples", "ratchets", "lessons"];
|
|
82
|
+
const KNOWN_KEYS = ["aqk", "entry", "rules", "gates", "samples", "ratchets", "lessons", "advisory"];
|
|
83
83
|
|
|
84
84
|
function unknownKeys(man) {
|
|
85
85
|
if (!man || typeof man !== "object" || Array.isArray(man)) return [];
|
|
86
86
|
return Object.keys(man).filter((k) => !KNOWN_KEYS.includes(k));
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// ЗРЕЛОСТЬ ЗАПИСИ. Каталог без зрелости — это список, в котором нельзя отличить проверенное от
|
|
90
|
+
// свежего; при шестнадцати записях это держится на памяти, при чужих записях — уже нет.
|
|
91
|
+
//
|
|
92
|
+
// ПОЧЕМУ ВЫЧИСЛЯЕТСЯ, А НЕ ОБЪЯВЛЯЕТСЯ. Поле зрелости есть у всех троих соседей — `lifecycle`
|
|
93
|
+
// у зондов Scorecard, `future`/`obsolete` у критериев значка OpenSSF — и у всех троих его
|
|
94
|
+
// заполняет автор. Значение, которое написал автор, означает доверие к автору, а не факт: это
|
|
95
|
+
// ровно тот способ, которым «зелёный» перестаёт что-либо значить. Здесь зрелость считается по
|
|
96
|
+
// доказательству записи, и объявить её нельзя — попытка отклоняется приёмкой каталога.
|
|
97
|
+
//
|
|
98
|
+
// Исключение одно: `deprecated`. «Запись больше не ставят» из её собственных файлов не выводится
|
|
99
|
+
// никак — это решение, а не факт. Цена решения — обязательная замена: запись, выведенная в
|
|
100
|
+
// никуда, оставляет человека без ответа на вопрос «а что теперь».
|
|
101
|
+
const LIFECYCLE_COMPUTED = ["stable", "experimental"];
|
|
102
|
+
|
|
103
|
+
function entryLifecycle(rec) {
|
|
104
|
+
const declared = typeof rec?.lifecycle === "string" ? rec.lifecycle.trim() : "";
|
|
105
|
+
const supersededBy = typeof rec?.superseded_by === "string" ? rec.superseded_by.trim() : "";
|
|
106
|
+
// Тот же признак, которым каталог отделяет условную запись с первого дня: доказательство
|
|
107
|
+
// ссылается на журнал шишек — значит, запись родилась из настоящей поломки, а не из
|
|
108
|
+
// «это хорошая практика». Признак один на всю программу: разъехавшись, он дал бы приёмке
|
|
109
|
+
// и отчёту разные ответы про одну и ту же запись.
|
|
110
|
+
const proven = /incidents\//.test(String(rec?.proof || ""));
|
|
111
|
+
const state = declared === "deprecated" ? "deprecated" : proven ? "stable" : "experimental";
|
|
112
|
+
const why = L.lifecycle[state];
|
|
113
|
+
|
|
114
|
+
let problem = null;
|
|
115
|
+
if (declared === "deprecated" && !supersededBy) problem = L.lifecycle.noReplacement;
|
|
116
|
+
else if (LIFECYCLE_COMPUTED.includes(declared)) problem = L.lifecycle.notDeclarable(declared);
|
|
117
|
+
else if (declared && declared !== "deprecated") problem = L.lifecycle.unknown(declared);
|
|
118
|
+
|
|
119
|
+
return { state, why, supersededBy: supersededBy || null, problem };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// СОВЕЩАТЕЛЬНЫЕ ГЕЙТЫ. Правило вводят в проект, где старый код ему не соответствует. Храповик
|
|
123
|
+
// отвечает на это одним способом: старое становится долгом, новое блокируется. Второй способ —
|
|
124
|
+
// показывать, не роняя, пока команда договаривается о правиле. Без него у человека остаётся
|
|
125
|
+
// выбор из двух крайностей: включить и сломать сборку либо не включать вовсе.
|
|
126
|
+
//
|
|
127
|
+
// ПОЧЕМУ СПИСКОМ В МАНИФЕСТЕ, А НЕ ФЛАГОМ ПРОГОНА. Флаг «не роняй ничего» — это тот самый
|
|
128
|
+
// `continue-on-error`, против которого написана наша запись ci-actually-fails: он понижает всё
|
|
129
|
+
// разом, не виден в дифе и не назван в сводке. Список виден в манифесте, называется поимённо и
|
|
130
|
+
// печатается КАЖДЫЙ прогон: совещательный гейт, о котором забыли, — это выключенная проверка,
|
|
131
|
+
// и молчать о нём нельзя.
|
|
132
|
+
function advisorySet(man) {
|
|
133
|
+
const v = man?.advisory;
|
|
134
|
+
if (Array.isArray(v)) return new Set(v.map((x) => String(x).trim()).filter(Boolean));
|
|
135
|
+
return new Set();
|
|
136
|
+
}
|
|
137
|
+
|
|
89
138
|
async function readManifest() {
|
|
90
139
|
const p = join(CWD, MANIFEST);
|
|
91
140
|
if (!(await exists(p))) return null;
|
|
@@ -97,7 +146,11 @@ async function readManifest() {
|
|
|
97
146
|
}
|
|
98
147
|
|
|
99
148
|
// Каждая ступень: что требуется, как проверяется, и что это даёт человеку.
|
|
100
|
-
|
|
149
|
+
// `proof` — результат `proveGates`: { ok } либо null, если доказательства не было. Ступень
|
|
150
|
+
// AQK-2 требует его прямо: до 2026-09-07 она проверяла, что папки образцов и храповиков
|
|
151
|
+
// СУЩЕСТВУЮТ, и проект с тремя гейтами `true` проходил порог AQK-3 с зелёным значком.
|
|
152
|
+
// Проверено прогоном на пустой папке. Наличие папки — не защита; уровень обязан означать факт.
|
|
153
|
+
async function assessLevel(man, proof = null) {
|
|
101
154
|
const has = async (rel) => Boolean(rel) && (await exists(join(CWD, String(rel))));
|
|
102
155
|
const isUrl = (v) => typeof v === "string" && /^https?:\/\//.test(v);
|
|
103
156
|
|
|
@@ -113,10 +166,21 @@ async function assessLevel(man) {
|
|
|
113
166
|
const conditions = [
|
|
114
167
|
Boolean(man?.aqk) && entriesExist,
|
|
115
168
|
(await has(man?.rules)) && filledGates.length > 0,
|
|
116
|
-
(await has(man?.samples)) && (await has(man?.ratchets)),
|
|
169
|
+
(await has(man?.samples)) && (await has(man?.ratchets)) && proof?.ok === true,
|
|
117
170
|
isUrl(man?.lessons) || (await has(man?.lessons)),
|
|
118
171
|
];
|
|
119
|
-
|
|
172
|
+
// Ступени, которым нужно доказательство, помечаются отдельно: «не выполнено» и «не проверяли»
|
|
173
|
+
// — разные состояния, и печатать их одинаково значит врать ровно тем способом, против
|
|
174
|
+
// которого весь комплект.
|
|
175
|
+
const NEEDS_PROOF = 2;
|
|
176
|
+
const steps = conditions.map((ok, level) => ({
|
|
177
|
+
level,
|
|
178
|
+
ok,
|
|
179
|
+
// Ровно вторая: третья ступень проверяет журнал, и посылать за доказательством там
|
|
180
|
+
// значит указать не на ту недостачу. Найдено код-ревью 2026-09-07.
|
|
181
|
+
needsProof: level === NEEDS_PROOF && proof === null,
|
|
182
|
+
...L.levels[level],
|
|
183
|
+
}));
|
|
120
184
|
|
|
121
185
|
let reached = -1;
|
|
122
186
|
for (const s of steps) {
|
|
@@ -149,4 +213,7 @@ function manifestWithGate(text, slug, cmd) {
|
|
|
149
213
|
return { text: out, why: null };
|
|
150
214
|
}
|
|
151
215
|
|
|
152
|
-
export {
|
|
216
|
+
export {
|
|
217
|
+
parseManifest, readManifest, assessLevel, manifestWithGate, unknownKeys, KNOWN_KEYS,
|
|
218
|
+
entryLifecycle, advisorySet,
|
|
219
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// tool/lib/prove.mjs — доказательство гейтов проекта: краснеет ли гейт на своём красном
|
|
2
|
+
// образце и молчит ли на зелёном.
|
|
3
|
+
//
|
|
4
|
+
// ЗАЧЕМ ЭТО ВООБЩЕ СУЩЕСТВУЕТ. Уровень AQK до 2026-09-07 считался наличием файлов: есть папка
|
|
5
|
+
// правил, объявлен хоть один гейт, существуют папки образцов и храповиков, есть журнал. Проект
|
|
6
|
+
// с тремя гейтами `true` — командой, которая всегда отвечает «ок», — проходил порог AQK-3 и
|
|
7
|
+
// получал значок. Проверено прогоном на пустой папке; три «гейта», ноль защиты, высший уровень.
|
|
8
|
+
//
|
|
9
|
+
// Это ровно тот класс, против которого весь комплект: `pytest || true`, только на уровне всего
|
|
10
|
+
// стандарта. Наше же правило гласит: «если утверждение не проверяется машиной — его в стандарте
|
|
11
|
+
// нет». Уровень был самым громким нашим утверждением, и машина проверяла у него `exists()`.
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { CWD, exists } from "./core.mjs";
|
|
16
|
+
import { parseManifest } from "./manifest.mjs";
|
|
17
|
+
|
|
18
|
+
// Гейт можно доказать, если у него есть оба образца. Признак по образцам, а не по тексту
|
|
19
|
+
// команды: запись, делегирующая готовому инструменту (`npx knip --directory .`), каталог
|
|
20
|
+
// образцов в команде не упоминает, но образцы у неё есть — их кладёт `aqk add`.
|
|
21
|
+
async function samplesFor(samplesDir, name) {
|
|
22
|
+
if (!samplesDir) return null;
|
|
23
|
+
const red = join(CWD, samplesDir, name, "red");
|
|
24
|
+
const green = join(CWD, samplesDir, name, "green");
|
|
25
|
+
if (!(await exists(red)) || !(await exists(green))) return null;
|
|
26
|
+
return { red: join(samplesDir, name, "red"), green: join(samplesDir, name, "green") };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Команда записи всегда кончается каталогом проверки: рецепт пишется как `… {dir}`, и при
|
|
30
|
+
// установке `{dir}` превращается в «.». Чтобы прогнать гейт по образцу, подменяем ПОСЛЕДНЕЕ
|
|
31
|
+
// слово команды. Делается это только для гейтов, у которых образцы есть, — то есть для
|
|
32
|
+
// установленных из каталога, где форма команды наша и известна.
|
|
33
|
+
// Обёртки снимаются ПЕРЕД тем, как решать что-либо о команде. Их две, и обе меняют смысл
|
|
34
|
+
// первых слов:
|
|
35
|
+
//
|
|
36
|
+
// `bash …/ratchet.sh <реестр> <команда…>` — мера постепенности. Без снятия доказательство
|
|
37
|
+
// гоняет гейт ВМЕСТЕ с реестром долга, и реестр перезаписывается находками из ОБРАЗЦА:
|
|
38
|
+
// на живом проекте это стёрло бы долг целиком. Поймано прогоном комплекта.
|
|
39
|
+
//
|
|
40
|
+
// `bash gates/_native.sh <каталог> <команда…>` — фильтр образцов для родного инструмента.
|
|
41
|
+
// Его первый аргумент обязан ехать вместе с каталогом проверки: фильтр прячет пути
|
|
42
|
+
// `gates/*/red|green`, и оставленный «.» спрятал бы ровно то, что образец обязан показать, —
|
|
43
|
+
// красный образец прошёл бы зелёным.
|
|
44
|
+
function unwrap(cmd) {
|
|
45
|
+
let parts = String(cmd).trim().split(/\s+/);
|
|
46
|
+
let nativeAt = -1;
|
|
47
|
+
for (let guard = 0; guard < 4; guard++) {
|
|
48
|
+
const r = parts.findIndex((p) => /ratchet\.sh$/.test(p));
|
|
49
|
+
if (r !== -1 && parts.length > r + 2) { parts = parts.slice(r + 2); continue; }
|
|
50
|
+
const n = parts.findIndex((p) => /_native\.sh$/.test(p));
|
|
51
|
+
if (n !== -1 && parts.length > n + 2) { nativeAt = n; break; }
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
return { parts, nativeAt };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Куда подставлять каталог образца. Рецепт каталога всегда кончается `{dir}`, и при установке
|
|
58
|
+
// это превращается в «.». Если последнее слово не похоже на каталог проверки, подставлять
|
|
59
|
+
// некуда: команда написана руками, и угадывать значит объявить исправный гейт сломанным.
|
|
60
|
+
// Найдено код-ревью 2026-09-07 на примере `eslint . --max-warnings 0`.
|
|
61
|
+
function targetIsLast(parts) {
|
|
62
|
+
const last = parts[parts.length - 1];
|
|
63
|
+
return last === "." || last === "./";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function commandFor(cmd, dir) {
|
|
67
|
+
const { parts, nativeAt } = unwrap(cmd);
|
|
68
|
+
const out = parts.slice();
|
|
69
|
+
out[out.length - 1] = dir;
|
|
70
|
+
// Фильтр образцов узнаёт, что ему дали именно образец, по своему первому аргументу.
|
|
71
|
+
if (nativeAt !== -1) out[nativeAt + 1] = dir;
|
|
72
|
+
return out.join(" ");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Каким рецептом написаны образцы. Читается из `gate.yml`, который `aqk add` кладёт в проект
|
|
76
|
+
// рядом с проверкой; если файла нет — молчим, значит запись не из каталога.
|
|
77
|
+
async function samplesForRecipe(samplesDir, name) {
|
|
78
|
+
const yml = join(CWD, samplesDir, name, "gate.yml");
|
|
79
|
+
if (!(await exists(yml))) return null;
|
|
80
|
+
try {
|
|
81
|
+
const rec = parseManifest(await readFile(yml, "utf8"));
|
|
82
|
+
const lang = typeof rec?.samples_for === "string" ? rec.samples_for.trim() : "";
|
|
83
|
+
if (!lang) return null;
|
|
84
|
+
const recipe = String(rec?.recipes?.[lang] || "").trim();
|
|
85
|
+
if (!recipe) return null;
|
|
86
|
+
return { lang, prog: recipe.split(/\s+/)[0] };
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function run(cmd, timeoutMs) {
|
|
93
|
+
const r = spawnSync(cmd, { shell: true, encoding: "utf8", cwd: CWD, timeout: timeoutMs });
|
|
94
|
+
const out = `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
95
|
+
// Убитый по таймауту процесс возвращает null — это не «ноль», а «не знаем».
|
|
96
|
+
const code = r.status === null ? 124 : r.status;
|
|
97
|
+
return { code, out };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Возвращает { proven, broken, unprovable, results } — числами и списком, чтобы вызывающий
|
|
101
|
+
// сам решал, что печатать и чем краснеть.
|
|
102
|
+
async function proveGates(man, { timeoutMs = 300000 } = {}) {
|
|
103
|
+
const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
|
|
104
|
+
const samplesDir = typeof man?.samples === "string" ? man.samples.trim() : "";
|
|
105
|
+
const results = [];
|
|
106
|
+
|
|
107
|
+
for (const [name, rawCmd] of Object.entries(gates)) {
|
|
108
|
+
const cmd = String(rawCmd || "").trim();
|
|
109
|
+
if (!cmd) {
|
|
110
|
+
results.push({ name, state: "broken", why: "empty" });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const s = await samplesFor(samplesDir, name);
|
|
114
|
+
if (!s) {
|
|
115
|
+
results.push({ name, state: "unprovable", why: "no-samples" });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Образцы бывают написаны под КОНКРЕТНЫЙ рецепт: запись без переносимой проверки называет
|
|
120
|
+
// его полем `samples_for`. Если в проекте стоит рецепт под другой язык, гонять по этим
|
|
121
|
+
// образцам нечего — они на чужом языке. Найдено первым же прогоном на своём репозитории:
|
|
122
|
+
// `dead-code` стоит у нас рецептом под JS (`knip`), а образцы у него питоновские, и
|
|
123
|
+
// «доказательство» объявляло исправный гейт сломанным.
|
|
124
|
+
// Сравниваем с командой БЕЗ обёрток: установленная запись выглядит как
|
|
125
|
+
// `bash gates/_native.sh . ruff check …`, и проверка по сырой строке объявляла бы
|
|
126
|
+
// «стоит другой рецепт» у каждой обёрнутой записи. Найдено код-ревью 2026-09-07:
|
|
127
|
+
// проект, поставивший `no-print-in-prod` с `ruff`, терял AQK-2 целиком.
|
|
128
|
+
const { parts: bare, nativeAt } = unwrap(cmd);
|
|
129
|
+
const effective = nativeAt === -1 ? bare : bare.slice(nativeAt + 2);
|
|
130
|
+
const forRecipe = await samplesForRecipe(samplesDir, name);
|
|
131
|
+
if (forRecipe && effective[0] !== forRecipe.prog) {
|
|
132
|
+
results.push({ name, state: "unprovable", why: "other-recipe", forRecipe });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!targetIsLast(bare)) {
|
|
136
|
+
results.push({ name, state: "unprovable", why: "no-target" });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const red = run(commandFor(cmd, s.red), timeoutMs);
|
|
141
|
+
const green = run(commandFor(cmd, s.green), timeoutMs);
|
|
142
|
+
if (red.code === 0) {
|
|
143
|
+
results.push({ name, state: "broken", why: "red-passed", red, green });
|
|
144
|
+
} else if (green.code !== 0) {
|
|
145
|
+
results.push({ name, state: "broken", why: "green-failed", red, green });
|
|
146
|
+
} else {
|
|
147
|
+
results.push({ name, state: "proven", red, green });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const proven = results.filter((r) => r.state === "proven").length;
|
|
152
|
+
const broken = results.filter((r) => r.state === "broken").length;
|
|
153
|
+
const unprovable = results.filter((r) => r.state === "unprovable").length;
|
|
154
|
+
// Доказательство состоялось, если ни один доказуемый гейт не сломан И хоть один доказан.
|
|
155
|
+
// Второе условие обязательно: проект, у которого все гейты недоказуемы, ничего не доказал —
|
|
156
|
+
// именно так выглядит подделка с тремя `true`.
|
|
157
|
+
return { proven, broken, unprovable, ok: broken === 0 && proven > 0, results };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { proveGates, commandFor };
|
package/tool/lib/repo.mjs
CHANGED
|
@@ -38,6 +38,15 @@ const MARKS = [
|
|
|
38
38
|
["has_docker", ["Dockerfile", "compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml"]],
|
|
39
39
|
["has_deps", ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "Gemfile", "pom.xml", "composer.json"]],
|
|
40
40
|
["has_env", [".env", ".env.example", ".env.sample"]],
|
|
41
|
+
// Обвес самого агента: настройки, хуки, права. Отдельный признак нужен, потому что записи
|
|
42
|
+
// про него не касаются проектов, где агента не настраивали вовсе, — а таких большинство.
|
|
43
|
+
// Файл `.claude/settings.json` есть и у того, кто настроил один только список разрешений;
|
|
44
|
+
// записи этой группы сами промолчат, если проверять в нём нечего.
|
|
45
|
+
["has_agent_config", [".claude/settings.json", ".claude/settings.local.json", ".claude/hooks.json", ".claude/hooks/hooks.json"]],
|
|
46
|
+
// Агента здесь вообще используют: есть свод, который он читает при запуске. Признак шире,
|
|
47
|
+
// чем `has_agent_config`: настройки заводят не все, а свод — почти каждый, кто работает с
|
|
48
|
+
// агентом. Записи про личные файлы касаются именно вторых.
|
|
49
|
+
["has_agent_entry", ["CLAUDE.md", "AGENTS.md", ".claude", ".cursor/rules", ".github/copilot-instructions.md"]],
|
|
41
50
|
];
|
|
42
51
|
|
|
43
52
|
async function detectFacts(man) {
|
|
@@ -209,18 +218,38 @@ function whichSync(prog, env = process.env) {
|
|
|
209
218
|
// Выбор рецепта под стек проекта. Одна логика на два места: и `doctor`, и `add` показывают
|
|
210
219
|
// команду, но подставляют в неё разные пути — один в каталог пакета, другой в каталог проекта.
|
|
211
220
|
// Пока это были две копии, правка доезжала до одной из них — нашёл собственный гейт дублей.
|
|
212
|
-
|
|
221
|
+
// `missing` — необязательный массив: сюда складываются имена программ, которых не хватило.
|
|
222
|
+
// Без него отказ установки был тупиком: «нет команды ни под python, ни общей» — диагноз без
|
|
223
|
+
// действия. Правило «находка без действия закрывает окно, а не дефект» мы требуем от записей
|
|
224
|
+
// каталога; к собственной программе оно относится так же. Найдено первым прогоном в чужом
|
|
225
|
+
// репозитории (httpx) 2026-09-07.
|
|
226
|
+
function pickRecipe(rec, facts, missing) {
|
|
213
227
|
const recipes = rec.recipes && typeof rec.recipes === "object" ? rec.recipes : {};
|
|
214
228
|
|
|
215
229
|
// Родной рецепт лучше переносимого — но только если его есть чем выполнить. Поставить
|
|
216
230
|
// команду с неустановленной программой значит завести гейт, который встаёт с «not found»:
|
|
217
231
|
// отсутствие сигнала неотличимо от успеха.
|
|
218
232
|
const runnable = (c0) => Boolean(whichSync(String(c0).trim().split(/\s+/)[0]));
|
|
233
|
+
|
|
234
|
+
// `requires` проверяется ПЕРВЫМ. Раньше он стоял после перебора родных рецептов, и запись с
|
|
235
|
+
// рабочим родным рецептом и неудовлетворённым требованием ставилась как ни в чём не бывало —
|
|
236
|
+
// ровно тот отказ, ради которого поле и заведено. Приёмка и мутационная проверка применяют
|
|
237
|
+
// его безусловно; три реализации расходились. Найдено код-ревью 2026-09-07.
|
|
238
|
+
const needs = String(rec?.requires || "").trim().split(/[\s,]+/).filter(Boolean);
|
|
239
|
+
const lacking = needs.filter((p) => !whichSync(p));
|
|
240
|
+
if (lacking.length) {
|
|
241
|
+
for (const p of lacking) if (Array.isArray(missing) && !missing.includes(p)) missing.push(p);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
219
245
|
for (const lang of facts.langs) {
|
|
220
246
|
if (!recipes[lang]) continue;
|
|
247
|
+
const prog = String(recipes[lang]).trim().split(/\s+/)[0];
|
|
221
248
|
if (runnable(recipes[lang])) return recipes[lang];
|
|
222
|
-
|
|
249
|
+
if (Array.isArray(missing) && !missing.includes(prog)) missing.push(prog);
|
|
250
|
+
console.log(c.dim(` ${c.yellow("!")} ${L.recipe.skipped(lang, prog)}`));
|
|
223
251
|
}
|
|
252
|
+
|
|
224
253
|
return recipes.any || null;
|
|
225
254
|
}
|
|
226
255
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// tool/lib/scope.mjs — разбор вывода гейта: сужение до дифа и отделение совета от находок.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ. Первый прогон в живом проекте показывает долг, накопленный годами: на репозитории в
|
|
4
|
+
// 36 тысяч файлов это тысячи находок. Человек видит стену красного, понимает, что разобрать её
|
|
5
|
+
// нельзя, и выключает проверку целиком. Это причина номер один, по которой такие инструменты
|
|
6
|
+
// снимают, — и три независимых проекта из нашего разбора умеют показывать только внесённое
|
|
7
|
+
// (reviewdog, `--since` у ratchets, четыре режима шума у react-doctor).
|
|
8
|
+
//
|
|
9
|
+
// ПОЧЕМУ ФИЛЬТР ВЫВОДА, А НЕ СПИСОК ФАЙЛОВ ГЕЙТУ. Гейт — произвольная команда оболочки: у
|
|
10
|
+
// каждого инструмента свой способ принять список файлов, а у переносимых проверок его нет
|
|
11
|
+
// вовсе. Фильтр вывода работает с любым гейтом, ничего не требуя от записи каталога.
|
|
12
|
+
//
|
|
13
|
+
// ЧЕГО ЭТОТ ФИЛЬТР НЕ УМЕЕТ И НЕ ДЕЛАЕТ ВИД, ЧТО УМЕЕТ. Он сужает до ФАЙЛА, а не до строки.
|
|
14
|
+
// Находка в файле, который диф трогал, показывается целиком, даже если она в нетронутой строке.
|
|
15
|
+
// Сужение до строки требует разбора формата каждого инструмента — то есть ровно той привязки
|
|
16
|
+
// к инструменту, которой у нас нет.
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
// Один и тот же файл приезжает в трёх видах: `src/a.py`, `./src/a.py` и `src\a.py` на Windows.
|
|
21
|
+
function normPath(p) {
|
|
22
|
+
return String(p).replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Цвет снимается ДО поиска путей. Родные инструменты печатают путь внутри
|
|
26
|
+
// escape-последовательности, и сравнение видит не «src/a.py», а обрывок с управляющими
|
|
27
|
+
// символами. На живом проекте это уже стоило одной починки, которая выглядела работающей:
|
|
28
|
+
// вывод «сократился» с 5597 строк до 5505, то есть не сократился.
|
|
29
|
+
const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
|
|
30
|
+
|
|
31
|
+
// Кандидат в путь: слово с расширением, начинающимся с БУКВЫ. Требование буквы отсекает номера
|
|
32
|
+
// версий — «0.5.0» иначе читается как файл с расширением «0», и строка итога про версию
|
|
33
|
+
// принималась бы за находку и отбрасывалась.
|
|
34
|
+
const CANDIDATE = /[\w.@+-]+(?:\/[\w.@+-]+)*\.[A-Za-z][A-Za-z0-9]{0,9}/g;
|
|
35
|
+
|
|
36
|
+
function inScope(candidate, files) {
|
|
37
|
+
const c = normPath(candidate);
|
|
38
|
+
if (files.has(c)) return true;
|
|
39
|
+
// Совпадение по хвосту в обе стороны: инструмент печатает то абсолютный путь, то голое имя
|
|
40
|
+
// файла. Здесь лучше ошибиться в сторону «показать»: спрятанная находка — это тишина,
|
|
41
|
+
// а лишняя показанная — просто шум, который человек отметает глазами.
|
|
42
|
+
for (const f of files) {
|
|
43
|
+
if (c.endsWith(`/${f}`) || f.endsWith(`/${c}`)) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Возвращает: что осталось показать, сколько среди этого НАХОДОК и можно ли этот гейт сузить.
|
|
49
|
+
//
|
|
50
|
+
// `scopable: false` — важнее всего остального. Гейт, который печатает вердикт без путей
|
|
51
|
+
// (проверка сообщения коммита, проверка конфига конвейера), сузить дифом нельзя. Признать его
|
|
52
|
+
// успешным на этом основании значило бы получить зелёное молчание там, где проверка провалилась,
|
|
53
|
+
// — ровно та тишина, против которой построен весь стандарт. Вызывающий обязан оставить такой
|
|
54
|
+
// гейт красным и сказать, почему он не сужен.
|
|
55
|
+
function scopeOutput(lines, files) {
|
|
56
|
+
const kept = [];
|
|
57
|
+
let findings = 0;
|
|
58
|
+
let scopable = false;
|
|
59
|
+
|
|
60
|
+
for (const raw of lines) {
|
|
61
|
+
const plain = String(raw).replace(ANSI, "");
|
|
62
|
+
const candidates = plain.match(CANDIDATE) || [];
|
|
63
|
+
if (!candidates.length) {
|
|
64
|
+
// Строка без пути — это шапка, итог или пояснение. Показываем: без неё находка теряет
|
|
65
|
+
// контекст. Находкой не считаем: иначе гейт никогда не сузился бы до нуля.
|
|
66
|
+
kept.push(raw);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
scopable = true;
|
|
70
|
+
if (candidates.some((c) => inScope(c, files))) {
|
|
71
|
+
kept.push(raw);
|
|
72
|
+
findings++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { kept, findings, scopable };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// СОВЕТ ПО ПОЧИНКЕ — ЭТО НЕ ХВОСТ ВЫВОДА, А ЕГО ГЛАВНАЯ ЧАСТЬ.
|
|
79
|
+
//
|
|
80
|
+
// Все записи каталога печатают последней строкой «почини: …». `doctor --run` показывал три
|
|
81
|
+
// первые строки и обрезал остальное — то есть ровно эту строку человек не видел никогда.
|
|
82
|
+
// Находка без действия закрывает окно, а не дефект. Отделяем совет от находок, чтобы находки
|
|
83
|
+
// можно было обрезать, а совет — нет.
|
|
84
|
+
//
|
|
85
|
+
// Опознаётся по метке в начале строки на любом из двух языков; продолжение совета — все строки
|
|
86
|
+
// после метки. Без метки совет не выдумывается: вывод, где не сказано, что делать, — это признак
|
|
87
|
+
// записи, которой нечего ответить, и приёмка каталога такую запись отклоняет.
|
|
88
|
+
const ADVICE_RE = /^\s*(почини|fix)\s*:/i;
|
|
89
|
+
|
|
90
|
+
// Находка опознаётся по «путь:строка:» — тем же признаком, что и при сужении по дифу.
|
|
91
|
+
// Нужен здесь, чтобы отличить продолжение совета от находки, стоящей сразу за ним.
|
|
92
|
+
const FINDING_RE = /:\d+:/;
|
|
93
|
+
|
|
94
|
+
function splitAdvice(lines) {
|
|
95
|
+
const findings = [];
|
|
96
|
+
const advice = [];
|
|
97
|
+
let inAdvice = false;
|
|
98
|
+
|
|
99
|
+
for (const raw of Array.isArray(lines) ? lines : []) {
|
|
100
|
+
const plain = String(raw).replace(ANSI, "");
|
|
101
|
+
if (ADVICE_RE.test(plain)) { inAdvice = true; advice.push(raw); continue; }
|
|
102
|
+
// Продолжение совета — строка с отступом, в которой нет находки. Всё остальное закрывает
|
|
103
|
+
// совет: иначе первая же метка утаскивала бы в жёлтое весь оставшийся вывод, а гейты
|
|
104
|
+
// печатают совет и посреди находок тоже. На живом выводе это давало сорок строк без
|
|
105
|
+
// обрезки — ровно ту стену, против которой написан весь модуль.
|
|
106
|
+
if (inAdvice && /^\s/.test(plain) && !FINDING_RE.test(plain)) { advice.push(raw); continue; }
|
|
107
|
+
inAdvice = false;
|
|
108
|
+
findings.push(raw);
|
|
109
|
+
}
|
|
110
|
+
return { findings, advice };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Файлы, изменённые относительно ссылки. Новые файлы, ещё не добавленные в индекс, тоже входят:
|
|
114
|
+
// их код так же нов, как и остальной диф, а из `git diff` они не видны.
|
|
115
|
+
function changedFiles(ref, cwd) {
|
|
116
|
+
const git = (args) => {
|
|
117
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
118
|
+
return r.status === 0 ? String(r.stdout || "") : null;
|
|
119
|
+
};
|
|
120
|
+
// Точка расхождения, а не сама ссылка: сравнение с веткой, ушедшей вперёд, показало бы
|
|
121
|
+
// чужие изменения как свои. Если базы нет (ссылка — коммит в той же линии), берём её саму.
|
|
122
|
+
const base = (git(["merge-base", ref, "HEAD"]) || "").trim() || ref;
|
|
123
|
+
const diff = git(["diff", "--name-only", base]);
|
|
124
|
+
if (diff === null) return null;
|
|
125
|
+
const untracked = git(["ls-files", "--others", "--exclude-standard"]) || "";
|
|
126
|
+
return new Set(
|
|
127
|
+
`${diff}\n${untracked}`.split("\n").map((l) => normPath(l.trim())).filter(Boolean)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { scopeOutput, splitAdvice, changedFiles };
|
package/tool/lib/templates.mjs
CHANGED
package/tool/program.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { cmdDoctor } from "./commands/doctor.mjs";
|
|
|
23
23
|
import { cmdAdd, cmdNew, cmdRatchet, cmdFind, cmdWhy } from "./commands/gates.mjs";
|
|
24
24
|
import { cmdReport } from "./commands/report.mjs";
|
|
25
25
|
import { cmdBadge } from "./commands/badge.mjs";
|
|
26
|
+
import { cmdProve } from "./commands/prove.mjs";
|
|
26
27
|
|
|
27
28
|
// Разбор аргументов выполняется только при запуске файла как программы. При импорте —
|
|
28
29
|
// а так его читают модульные проверки tool/selfcheck/units.mjs — CLI запускаться не должен.
|
|
@@ -69,6 +70,9 @@ if (IS_MAIN) {
|
|
|
69
70
|
case "report":
|
|
70
71
|
await cmdReport();
|
|
71
72
|
break;
|
|
73
|
+
case "prove":
|
|
74
|
+
await cmdProve();
|
|
75
|
+
break;
|
|
72
76
|
case "badge":
|
|
73
77
|
await cmdBadge(rest);
|
|
74
78
|
break;
|
|
@@ -82,6 +86,8 @@ if (IS_MAIN) {
|
|
|
82
86
|
[`${SELF} start`, h.start],
|
|
83
87
|
[`${SELF} doctor`, h.doctor],
|
|
84
88
|
[`${SELF} doctor --run`, h.doctorRun],
|
|
89
|
+
[`${SELF} doctor --run --since main`, h.doctorSince],
|
|
90
|
+
[`${SELF} prove`, h.prove],
|
|
85
91
|
[`${SELF} add ${h.name}`, h.add],
|
|
86
92
|
[`${SELF} find "…"`, h.find],
|
|
87
93
|
[`${SELF} why "…"`, h.why],
|
package/tool/selfcheck/gates.sh
CHANGED
|
@@ -11,6 +11,15 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
|
11
11
|
CAT="$ROOT/kit/gates"
|
|
12
12
|
PASS=0; FAIL=0; WARN=0; UNVERIFIED=0
|
|
13
13
|
|
|
14
|
+
# СТРОГИЙ РЕЖИМ. «Не проверена здесь» — законное состояние на чужой машине, где нужного
|
|
15
|
+
# инструмента нет: требовать ruff от каждого, кто склонировал репозиторий, значит закрыть вклад.
|
|
16
|
+
# Но на НАШЕЙ машине, где инструменты ставятся отдельным шагом, это состояние означает, что шаг
|
|
17
|
+
# не сработал, — и жёлтая тильда неотличима от успеха ровно так же, как `pytest || true`.
|
|
18
|
+
# Проверено 2026-09-07: шаг `pipx install vulture` стоял в конвейере с самого его появления,
|
|
19
|
+
# ставил vulture исправно и клал его в каталог, которого нет в PATH задания. Запись dead-code не
|
|
20
|
+
# проверялась конвейером ни разу, и об этом никто не узнал.
|
|
21
|
+
STRICT="${AQK_GATES_STRICT:-0}"
|
|
22
|
+
|
|
14
23
|
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; PASS=$((PASS+1)); }
|
|
15
24
|
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); }
|
|
16
25
|
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; WARN=$((WARN+1)); }
|
|
@@ -22,6 +31,30 @@ field() { sed -n "s/^$2:[[:space:]]*\(.*\)$/\1/p" "$1" | head -1; }
|
|
|
22
31
|
printf '\n\033[1mtool/selfcheck/gates.sh\033[0m\n\n'
|
|
23
32
|
[ -d "$CAT" ] || { echo " каталога гейтов нет"; exit 1; }
|
|
24
33
|
|
|
34
|
+
# Зрелость каждой записи одной таблицей. Правило живёт в entryLifecycle (tool/lib/manifest.mjs);
|
|
35
|
+
# повторять его здесь на sh нельзя — второй источник истины расходится с первым молча.
|
|
36
|
+
# Язык принудительно русский: это внутренняя проверка комплекта, и её вывод целиком русский.
|
|
37
|
+
# Без этого на машине с английской локалью половина строки печаталась по-русски, половина —
|
|
38
|
+
# по-английски, в одном предложении.
|
|
39
|
+
LIFE="$(AQK_LANG=ru node "$ROOT/tool/selfcheck/lifecycle.mjs" 2>/dev/null)"
|
|
40
|
+
life_field() { printf '%s\n' "$LIFE" | awk -F'|' -v s="$1" -v n="$2" '$1==s{print $n}'; }
|
|
41
|
+
|
|
42
|
+
# Запускает ОДИНОКУЮ копию check.sh — без соседнего _skip.sh — на зелёном образце записи.
|
|
43
|
+
# Успех означает провал приёмки: проверка, потерявшая свою библиотеку, обязана сказать об этом,
|
|
44
|
+
# а не выдать вердикт «чисто». Зелёный образец взят намеренно: на нём одинокая копия и раньше
|
|
45
|
+
# «проходила», так что разницу даёт именно потеря библиотеки, а не наличие нарушения.
|
|
46
|
+
lonely_copy_fails() {
|
|
47
|
+
_g="$1"
|
|
48
|
+
[ -f "$_g/check.sh" ] || return 0
|
|
49
|
+
_t="$(mktemp -d)" || return 0
|
|
50
|
+
mkdir -p "$_t/lone"
|
|
51
|
+
cp "$_g/check.sh" "$_t/lone/check.sh"
|
|
52
|
+
bash "$_t/lone/check.sh" "$_g/green" >/dev/null 2>&1
|
|
53
|
+
_c=$?
|
|
54
|
+
rm -rf "$_t"
|
|
55
|
+
[ "$_c" -ne 0 ]
|
|
56
|
+
}
|
|
57
|
+
|
|
25
58
|
for GATE in "$CAT"/*/; do
|
|
26
59
|
SLUG="$(basename "$GATE")"
|
|
27
60
|
YML="$GATE/gate.yml"
|
|
@@ -71,9 +104,18 @@ for GATE in "$CAT"/*/; do
|
|
|
71
104
|
printf '%s\n' "$README_TEXT" | grep -qE 'чего НЕ ловит|Чего НЕ ловит|чего не ловит|Чего не ловит' \
|
|
72
105
|
|| bad "$SLUG: в README нет раздела «чего НЕ ловит» — граница записи обязана быть названа"
|
|
73
106
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
# Зрелость не объявляют — её считают. Попытка написать `lifecycle: stable` руками отклоняется:
|
|
108
|
+
# поле, которое заполняет автор, означает доверие к автору, а не факт. Это ровно тот способ,
|
|
109
|
+
# которым «зелёный» у соседей перестал что-либо значить, — см. kit/gates/README.md.
|
|
110
|
+
LIFE_PROBLEM="$(life_field "$SLUG" 4)"
|
|
111
|
+
# continue, а не просто отметка: запись с неверным объявлением уже отклонена, и гонять по ней
|
|
112
|
+
# образцы значит посчитать её и в отклонённых, и в принятых — итог начинает врать.
|
|
113
|
+
[ -n "$LIFE_PROBLEM" ] && { bad "$SLUG: $LIFE_PROBLEM"; continue; }
|
|
114
|
+
|
|
115
|
+
STATE="$(life_field "$SLUG" 2)"
|
|
116
|
+
case "$STATE" in
|
|
117
|
+
experimental) warn "$SLUG: доказательство не ссылается на журнал шишек — запись условная" ;;
|
|
118
|
+
deprecated) warn "$SLUG: выведена из употребления, заменяет её «$(life_field "$SLUG" 3)»" ;;
|
|
77
119
|
esac
|
|
78
120
|
|
|
79
121
|
# --- образцы ---------------------------------------------------------------
|
|
@@ -81,7 +123,22 @@ for GATE in "$CAT"/*/; do
|
|
|
81
123
|
# чью программу видно в системе: запись, которой нужен готовый инструмент, законна (правило
|
|
82
124
|
# «сперва готовое»), но проверить её можно только там, где инструмент стоит.
|
|
83
125
|
RECIPE="$(sed -n 's/^[[:space:]]*any:[[:space:]]*\(.*\)$/\1/p' "$YML" | head -1)"
|
|
126
|
+
PORTABLE=1
|
|
127
|
+
|
|
128
|
+
# Программа, названная полем `requires`. Нужна там, где переносимый рецепт — обёртка вокруг
|
|
129
|
+
# готового инструмента: первое слово команды тогда `bash`, и по нему не видно, чего не хватает.
|
|
130
|
+
# Без этой поправки отсутствие `checkwash` читалось как «гейт ругается на исправный код».
|
|
131
|
+
NEEDS="$(sed -n 's/^requires:[[:space:]]*\(.*\)$/\1/p' "$YML" | head -1 | tr ',' ' ')"
|
|
132
|
+
LACK=""
|
|
133
|
+
for PROG in $NEEDS; do
|
|
134
|
+
command -v "$PROG" >/dev/null 2>&1 || LACK="${LACK:+$LACK, }$PROG"
|
|
135
|
+
done
|
|
136
|
+
if [ -n "$LACK" ]; then
|
|
137
|
+
skip "$SLUG: НЕ ПРОВЕРЕНА здесь — нужен «$LACK»"
|
|
138
|
+
continue
|
|
139
|
+
fi
|
|
84
140
|
if [ -z "$RECIPE" ]; then
|
|
141
|
+
PORTABLE=0
|
|
85
142
|
# Каким рецептом написаны образцы — говорит сама запись. Угадывать нельзя: в системе может
|
|
86
143
|
# стоять npx, и питоновские образцы поедут проверяться фронтовым инструментом. Так и вышло.
|
|
87
144
|
FOR="$(sed -n 's/^samples_for:[[:space:]]*\(.*\)$/\1/p' "$YML" | head -1)"
|
|
@@ -121,8 +178,28 @@ for GATE in "$CAT"/*/; do
|
|
|
121
178
|
bad "$SLUG: арбитр промолчал на КРАСНОМ образце — гейт не ловит брак"
|
|
122
179
|
elif [ -z "$RED_OUT" ]; then
|
|
123
180
|
bad "$SLUG: арбитр покраснел на красном молча — по коду возврата не видно, ту ли поломку он нашёл"
|
|
181
|
+
# Находка без действия закрывает окно, а не дефект. Строка «почини: …» была у всех записей и
|
|
182
|
+
# раньше — но обрезалась в выводе прогона, то есть существовала для приёмки и не существовала
|
|
183
|
+
# для человека. Теперь она обязательна и здесь: правило, за которым не следит машина, — это
|
|
184
|
+
# пожелание, и через месяц запись без совета проедет незамеченной.
|
|
185
|
+
#
|
|
186
|
+
# ТОЛЬКО ДЛЯ ПЕРЕНОСИМОГО РЕЦЕПТА. Запись, целиком делегирующая готовому инструменту
|
|
187
|
+
# (`dead-code` — vulture, knip, staticcheck), печатает вывод ЭТОГО инструмента. Требовать от
|
|
188
|
+
# чужого вывода нашу строку — значит требовать невозможного, а невыполнимое требование норма
|
|
189
|
+
# либо обходит молча, либо ломает всем.
|
|
190
|
+
elif [ "$PORTABLE" -eq 1 ] && ! printf '%s\n' "$RED_OUT" | grep -qiE '^[[:space:]]*(почини|fix)[[:space:]]*:'; then
|
|
191
|
+
bad "$SLUG: на красном образце не сказано, ЧТО делать — нужна строка «почини: …»"
|
|
124
192
|
elif [ "$GRN_CODE" -ne 0 ]; then
|
|
125
193
|
bad "$SLUG: арбитр покраснел на ЗЕЛЁНОМ образце — гейт ругается на исправный код"
|
|
194
|
+
# Проверка без своей библиотеки обязана краснеть, а не молчать. Найдено 2026-09-08: строка
|
|
195
|
+
# `. _skip.sh 2>/dev/null || SKIP_NAMES=…` стояла в десяти записях и страховкой не была.
|
|
196
|
+
# Под bash — а гейты запускаются именно им — она подставляла только ПЕРЕМЕННУЮ; функции
|
|
197
|
+
# обхода оставались неопределёнными, конвейер печатал пустоту, и гейт выходил с НУЛЁМ на
|
|
198
|
+
# файле с настоящим нарушением. Худший из возможных отказов: проверка объявляет «чисто»,
|
|
199
|
+
# не посмотрев никуда. Ставим прибор, чтобы это не вернулось молча.
|
|
200
|
+
elif [ "$PORTABLE" -eq 1 ] && grep -q '_skip.sh' "$GATE/check.sh" 2>/dev/null &&
|
|
201
|
+
! lonely_copy_fails "$GATE"; then
|
|
202
|
+
bad "$SLUG: копия проверки без _skip.sh выходит с нулём — молчит вместо того, чтобы краснеть"
|
|
126
203
|
else
|
|
127
204
|
ok "$SLUG"
|
|
128
205
|
fi
|
|
@@ -137,4 +214,10 @@ fi
|
|
|
137
214
|
[ "$WARN" -gt 0 ] && printf ' \033[33m(условных: %s)\033[0m' "$WARN"
|
|
138
215
|
[ "$UNVERIFIED" -gt 0 ] && printf ' \033[33m(не проверено здесь: %s)\033[0m' "$UNVERIFIED"
|
|
139
216
|
printf '\n\n'
|
|
217
|
+
|
|
218
|
+
if [ "$STRICT" = "1" ] && [ "$UNVERIFIED" -gt 0 ]; then
|
|
219
|
+
printf ' \033[31mстрогий режим: %s записей не проверено, а здесь это обязано быть ошибкой\033[0m\n' "$UNVERIFIED"
|
|
220
|
+
printf ' почини: поставь названные инструменты и убедись, что они видны в PATH этого шага.\n\n'
|
|
221
|
+
exit $((FAIL + UNVERIFIED))
|
|
222
|
+
fi
|
|
140
223
|
exit "$FAIL"
|