agent-quality-kit 0.10.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 +2 -2
- package/README.ru.md +2 -2
- package/kit/gates/protection-not-removed/check.sh +22 -13
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/tool/commands/context.mjs +8 -0
- package/tool/commands/gates.mjs +2 -0
- package/tool/i18n/en-docs.mjs +2 -0
- package/tool/i18n/ru-docs.mjs +2 -0
- package/tool/lib/evidence.mjs +15 -2
- package/tool/lib/execution.mjs +67 -0
- package/tool/lib/protection.mjs +52 -0
- package/tool/lib/prove.mjs +40 -13
- 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/README.md
CHANGED
|
@@ -340,7 +340,7 @@ Already using [pre-commit](https://pre-commit.com)? Three lines in the file you
|
|
|
340
340
|
```yaml
|
|
341
341
|
repos:
|
|
342
342
|
- repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit
|
|
343
|
-
rev: v0.10.
|
|
343
|
+
rev: v0.10.1
|
|
344
344
|
hooks:
|
|
345
345
|
- id: aqk # runs what the repository declares; blocks below AQK-1
|
|
346
346
|
# - id: aqk-doctor # read-only: the level and what is missing, blocks nothing
|
|
@@ -361,7 +361,7 @@ layer AQK adds.
|
|
|
361
361
|
[](https://github.com/marketplace/actions/agent-quality-kit-aqk)
|
|
362
362
|
|
|
363
363
|
```yaml
|
|
364
|
-
- uses: arsen-ask-lx/Agent_Quality_Kit@v0.10.
|
|
364
|
+
- uses: arsen-ask-lx/Agent_Quality_Kit@v0.10.1
|
|
365
365
|
with:
|
|
366
366
|
min: 1 # the build fails below AQK-1, or if any declared gate failed
|
|
367
367
|
```
|
package/README.ru.md
CHANGED
|
@@ -343,7 +343,7 @@ aqk badge --check # в конвейере: код 1 в тот день, ког
|
|
|
343
343
|
```yaml
|
|
344
344
|
repos:
|
|
345
345
|
- repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit
|
|
346
|
-
rev: v0.10.
|
|
346
|
+
rev: v0.10.1
|
|
347
347
|
hooks:
|
|
348
348
|
- id: aqk # запускает объявленное; роняет коммит ниже AQK-1
|
|
349
349
|
# - id: aqk-doctor # только осмотр: уровень и чего не хватает, ничего не роняет
|
|
@@ -362,7 +362,7 @@ repos:
|
|
|
362
362
|
[](https://github.com/marketplace/actions/agent-quality-kit-aqk)
|
|
363
363
|
|
|
364
364
|
```yaml
|
|
365
|
-
- uses: arsen-ask-lx/Agent_Quality_Kit@v0.10.
|
|
365
|
+
- uses: arsen-ask-lx/Agent_Quality_Kit@v0.10.1
|
|
366
366
|
with:
|
|
367
367
|
min: 1 # сборка падает ниже AQK-1 или если упал любой объявленный гейт
|
|
368
368
|
```
|
|
@@ -35,23 +35,28 @@ RDIR="$(printf '%s\n' "$MANTEXT" | sed -n 's/^ratchets:[[:space:]]*//p' | head -
|
|
|
35
35
|
REG="$DIR/$RDIR/gates-declared.txt"
|
|
36
36
|
REL="$RDIR/gates-declared.txt"
|
|
37
37
|
|
|
38
|
+
# ПРОВЕРКА ТОЛЬКО СРАВНИВАЕТ. Раньше она сама создавала снимок и дописывала в него новые
|
|
39
|
+
# гейты — то есть меняла то, о чём судит. Цена измерена опытом 2026-09-09: в мелком клоне, где
|
|
40
|
+
# удаление снимка лежит ГЛУБЖЕ выкладки, свидетель (история git) слеп, и проверка записала УЖЕ
|
|
41
|
+
# ОСЛАБЛЕННЫЙ набор новым эталоном — «снят снимок: 2 гейтов», код 0, исчезнувший гейт узаконен.
|
|
42
|
+
# Храповик крутился назад. Писать снимок — дело явной операции (`aqk add`), а не вердикта.
|
|
38
43
|
if [ ! -f "$REG" ]; then
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
44
|
+
# Два разных случая, и путать их нельзя: снимка ещё не снимали — или его УДАЛИЛИ, что и есть
|
|
45
|
+
# тот самый обход, только другим файлом. Свидетель — git: если у пути есть история, файл был.
|
|
46
|
+
# В мелком клоне свидетель слеп, и поэтому отсутствие снимка красное в ОБОИХ случаях.
|
|
42
47
|
if git -C "$DIR" log -1 --format=%H -- "$REL" 2>/dev/null | grep -q .; then
|
|
43
48
|
echo "$REL был в истории и удалён — снимок объявленной защиты уничтожен"
|
|
44
49
|
echo " почини: верни файл (git checkout -- $REL). Удаление снимка снимает и саму проверку —"
|
|
45
50
|
echo " почини: это тот же обход, что удаление гейта, только через соседний файл."
|
|
46
51
|
exit 1
|
|
47
52
|
fi
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
echo "
|
|
53
|
-
|
|
54
|
-
exit
|
|
53
|
+
echo "$REL: снимка объявленной защиты нет — без него удаление гейта не видно"
|
|
54
|
+
echo " почини: заведи файл со списком объявленных сейчас гейтов и закоммить его."
|
|
55
|
+
echo " почини: содержимое:"
|
|
56
|
+
echo " # Снимок объявленной защиты. Набор может только РАСТИ."
|
|
57
|
+
echo " # Убрал гейт — напиши причину после # в его строке, иначе проверка краснеет."
|
|
58
|
+
printf '%s\n' "$DECLARED" | sed 's/^/ /'
|
|
59
|
+
exit 1
|
|
55
60
|
fi
|
|
56
61
|
|
|
57
62
|
# Имя без причины — строка реестра, где после имени НЕТ решётки. Имя с причиной — снятое
|
|
@@ -80,13 +85,17 @@ if [ -n "$GONE" ]; then
|
|
|
80
85
|
exit 1
|
|
81
86
|
fi
|
|
82
87
|
|
|
83
|
-
#
|
|
88
|
+
# СНИМОК, ОТСТАВШИЙ ОТ МАНИФЕСТА, — ТОЖЕ РАСХОЖДЕНИЕ. Раньше проверка дописывала новые имена
|
|
89
|
+
# сама и выходила с нулём. Это удобно и неверно: гейт, добавленный и убранный до того, как
|
|
90
|
+
# снимок обновят, уходит незамеченным — а сама проверка при этом молча правит рабочее дерево,
|
|
91
|
+
# в том числе в конвейере. Храповик обязан ТРЕБОВАТЬ записи, а не догонять её.
|
|
84
92
|
NEW=""
|
|
85
93
|
for N in $DECLARED; do
|
|
86
94
|
sed 's/[[:space:]]*#.*//; s/[[:space:]]*$//' "$REG" | grep -qx "$N" || NEW="$NEW $N"
|
|
87
95
|
done
|
|
88
96
|
if [ -n "$NEW" ]; then
|
|
89
|
-
for N in $NEW; do
|
|
90
|
-
echo "
|
|
97
|
+
for N in $NEW; do echo "$REL: гейт «$N» объявлен, но в снимке его нет"; done
|
|
98
|
+
echo " почини: допиши эти имена в $REL и закоммить — снимок обязан знать всё, что защищает."
|
|
99
|
+
exit 1
|
|
91
100
|
fi
|
|
92
101
|
exit 0
|
package/llms.txt
CHANGED
|
@@ -68,7 +68,7 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
|
|
|
68
68
|
the files `init` writes are owned by root, so you cannot edit your own manifest. Debian-based
|
|
69
69
|
on purpose: the gates are `sh`, `grep`, `awk`, `find` — under alpine's busybox they behave
|
|
70
70
|
differently, and an image where the gates behave differently is worse than no image
|
|
71
|
-
- As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.10.
|
|
71
|
+
- As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.10.1` with `min: 1`
|
|
72
72
|
(https://github.com/marketplace/actions/agent-quality-kit-aqk)
|
|
73
73
|
|
|
74
74
|
## What makes it different
|
package/package.json
CHANGED
|
@@ -69,9 +69,17 @@ function contextBlock(state, T = L.context) {
|
|
|
69
69
|
// прочитать. Блок читается по построению, поэтому знание живёт здесь, а не в команде.
|
|
70
70
|
// Состояние «не делалась» печатается как НЕИЗВЕСТНО, а не опускается: молчание тут
|
|
71
71
|
// прочиталось бы как «всё прикрыто», а прикрыто ли — мы не знаем.
|
|
72
|
+
// СОСТОЯНИЙ У ПРОБЫ ПЯТЬ, А ВЕТОК БЫЛО ТРИ. В «иначе» падали `off` (пробу выключили в
|
|
73
|
+
// манифесте) и `unknown` (счётчик коммитов не взять либо непонятое значение поля) — и оба
|
|
74
|
+
// печатались как «в местах пробы всё прикрыто». То есть выключенная и несостоявшаяся проверка
|
|
75
|
+
// выдавались за проверку, сказавшую «чисто». Блок читает машина: она примет это за
|
|
76
|
+
// разрешение. Перечисление исчерпывающее намеренно — новое состояние обязано сюда попасть,
|
|
77
|
+
// а не утечь в «иначе».
|
|
72
78
|
const pr = state.probe;
|
|
73
79
|
if (pr) {
|
|
74
80
|
if (pr.state === "never") out.push(T.probeNever);
|
|
81
|
+
else if (pr.state === "off") out.push(T.probeOff);
|
|
82
|
+
else if (pr.state === "unknown") out.push(T.probeUnknown);
|
|
75
83
|
else if (pr.blind > 0) out.push(T.probeBlind(pr.blind, pr.state === "stale" ? pr.behind : 0));
|
|
76
84
|
else out.push(T.probeClean(pr.state === "stale" ? pr.behind : 0));
|
|
77
85
|
}
|
package/tool/commands/gates.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
copyDir,
|
|
10
10
|
} from "../lib/core.mjs";
|
|
11
11
|
import { parseManifest, readManifest, manifestWithGate, entryLifecycle } from "../lib/manifest.mjs";
|
|
12
|
+
import { recordProtection } from "../lib/protection.mjs";
|
|
12
13
|
import {
|
|
13
14
|
detectFacts, readCatalog, pickRecipe, triggerVerdict, stems, overlap, matchCatalog,
|
|
14
15
|
} from "../lib/repo.mjs";
|
|
@@ -75,6 +76,7 @@ async function installGate(slug, man, facts) {
|
|
|
75
76
|
const manPath = join(CWD, MANIFEST);
|
|
76
77
|
const { text, why } = manifestWithGate(await readFile(manPath, "utf8"), slug, cmd);
|
|
77
78
|
if (text) await writeFile(manPath, text, "utf8");
|
|
79
|
+
if (text) await recordProtection(man, slug);
|
|
78
80
|
return { rec, cmd, copied, declared: Boolean(text), why };
|
|
79
81
|
}
|
|
80
82
|
|
package/tool/i18n/en-docs.mjs
CHANGED
|
@@ -68,6 +68,8 @@ const enDocs = {
|
|
|
68
68
|
andMore: (n) => `and ${n} more`,
|
|
69
69
|
skipped: (n) => `Not run: ${n} — the tool is absent on this machine, their state is unknown.`,
|
|
70
70
|
probeNever: "No coverage probe has run — what is covered by nothing here is UNKNOWN. That is not \"covered\": `aqk probe`.",
|
|
71
|
+
probeOff: "The coverage probe is switched off in the manifest (`probe: 0`) — nobody counts what is covered by nothing here.",
|
|
72
|
+
probeUnknown: "The coverage probe could not run — what is covered by nothing here is UNKNOWN. That is not \"covered\".",
|
|
71
73
|
probeBlind: (n, behind) =>
|
|
72
74
|
`Covered by nothing: ${n} defect classes in the places people most often come back to fix` +
|
|
73
75
|
(behind ? ` (the probe is ${behind} commits behind)` : "") + ". Details: `aqk probe`.",
|
package/tool/i18n/ru-docs.mjs
CHANGED
|
@@ -70,6 +70,8 @@ const ruDocs = {
|
|
|
70
70
|
andMore: (n) => `и ещё ${n}`,
|
|
71
71
|
skipped: (n) => `Не запускались: ${n} — инструмента нет на этой машине, их состояние неизвестно.`,
|
|
72
72
|
probeNever: "Проба покрытия не делалась — что здесь не прикрыто ничем, НЕИЗВЕСТНО. Это не «прикрыто»: `aqk probe`.",
|
|
73
|
+
probeOff: "Проба покрытия выключена в манифесте (`probe: 0`) — что здесь не прикрыто ничем, никто не считает.",
|
|
74
|
+
probeUnknown: "Пробу покрытия провести не удалось — что здесь не прикрыто ничем, НЕИЗВЕСТНО. Это не «прикрыто».",
|
|
73
75
|
probeBlind: (n, behind) =>
|
|
74
76
|
`Не прикрыто ничем: ${n} классов брака в местах, куда чаще всего возвращаются с починкой` +
|
|
75
77
|
(behind ? ` (проба отстала на ${behind} коммитов)` : "") + ". Подробно: `aqk probe`.",
|
package/tool/lib/evidence.mjs
CHANGED
|
@@ -75,10 +75,23 @@ function targetsOf(cmd, isDir) {
|
|
|
75
75
|
// не проверен», хотя его обходят пять проверок — они просто промолчали, потому что нашли
|
|
76
76
|
// чисто. Замеряно 2026-09-08.
|
|
77
77
|
function coverage(files, results, isDir = () => false) {
|
|
78
|
+
// НАЗВАН — ЭТО ТОЛЬКО ВЫВОД. Пути брались из команды И вывода разом, и гейт, не напечатавший
|
|
79
|
+
// ни слова, делал «названным» любой файл, упомянутый в строке запуска. Опыт 2026-09-09:
|
|
80
|
+
// молчащая проверка `sh checks/quiet.sh .` давала «названо проверкой: checks/quiet.sh».
|
|
81
|
+
// Так «доказывалась» каждая правка `kit/gates/*/check.sh` и `tool/selfcheck/*.sh` — то есть
|
|
82
|
+
// самих проверок, где правда важнее всего.
|
|
83
|
+
//
|
|
84
|
+
// Но и выбросить команду нельзя: она отвечает на ДРУГОЙ вопрос — куда гейт был направлен.
|
|
85
|
+
// Каталог из команды даёт «обойдено молча» через `targets`; отдельный файл раньше не давал
|
|
86
|
+
// ничего, потому что `targetsOf` берёт только каталоги, — и такой файл падал в «никто не
|
|
87
|
+
// смотрел». Поэтому имена из команды хранятся отдельно и сравниваются ТОЧНО.
|
|
78
88
|
const seen = results.map((r) => ({
|
|
79
89
|
name: r.name,
|
|
80
|
-
paths: pathsIn(
|
|
90
|
+
paths: pathsIn(r.out || ""),
|
|
81
91
|
targets: targetsOf(r.cmd, isDir),
|
|
92
|
+
named: new Set(
|
|
93
|
+
String(r.cmd || "").split(/\s+/).filter((t) => t && !t.startsWith("-")).map(normPath)
|
|
94
|
+
),
|
|
82
95
|
}));
|
|
83
96
|
const covered = new Map();
|
|
84
97
|
const silent = new Map();
|
|
@@ -88,7 +101,7 @@ function coverage(files, results, isDir = () => false) {
|
|
|
88
101
|
const by = seen.filter((n) => n.paths.has(f)).map((n) => n.name);
|
|
89
102
|
if (by.length) { covered.set(raw, by); continue; }
|
|
90
103
|
const aimed = seen
|
|
91
|
-
.filter((n) => n.targets.some((t) => t === "" || f === t || f.startsWith(`${t}/`)))
|
|
104
|
+
.filter((n) => n.named.has(f) || n.targets.some((t) => t === "" || f === t || f.startsWith(`${t}/`)))
|
|
92
105
|
.map((n) => n.name);
|
|
93
106
|
if (aimed.length) silent.set(raw, aimed);
|
|
94
107
|
else uncovered.push(raw);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// tool/lib/execution.mjs — ИСХОД ЗАПУСКА процесса, отдельно от смысла находки.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ. Прежде `prove.mjs` считал находкой ЛЮБОЙ ненулевой код на красном образце. Опыт
|
|
4
|
+
// 2026-09-09: проверка, которая ВИСНЕТ на красном образце и молчит на зелёном, получала вердикт
|
|
5
|
+
// `proven: 1, ok: true` — зависший гейт объявлялся ловящим брак. Это `pytest || true` в функции,
|
|
6
|
+
// считающей наш главный уровень. Тот же класс поймал перемежающийся отказ прогона: под
|
|
7
|
+
// нагрузкой арбитр записи не успевал, и приёмка печатала «гейт ругается на исправный код» —
|
|
8
|
+
// сбой инструмента выдавался за приговор записи каталога.
|
|
9
|
+
//
|
|
10
|
+
// ТРИ ИСХОДА, А НЕ ДВА: clean · finding · infra_error. Четвёртое состояние — `unavailable`,
|
|
11
|
+
// «инструмента нет» — сюда НЕ входит намеренно: это результат осмотра окружения ДО запуска.
|
|
12
|
+
// Если процесс уже запускался и не смог отработать — это сбой инструмента, а не его отсутствие.
|
|
13
|
+
//
|
|
14
|
+
// ПОЧЕМУ У КАЖДОГО ИНСТРУМЕНТА СВОЙ АДАПТЕР, А НЕ ОДНО ПРАВИЛО. Замер по настоящим
|
|
15
|
+
// инструментам 2026-09-09 (числа сняты запуском, кроме pylint и eslint — их документацией):
|
|
16
|
+
//
|
|
17
|
+
// ruff чисто 0 · находка 1 (синтаксическая ошибка тоже 1) · неверный флаг 2
|
|
18
|
+
// · НЕСУЩЕСТВУЮЩИЙ ПУТЬ → 0 и «All checks passed!»
|
|
19
|
+
// eslint чисто 0 · находка 1 · настройка либо внутренняя ошибка 2
|
|
20
|
+
// vulture чисто 0 · НАХОДКА 3 · плохой ввод 1 · ошибка CLI 2
|
|
21
|
+
// pylint чисто 0 · находка битовой маской (2 ошибка, 4 предупреждение, 8, 16)
|
|
22
|
+
// · ОШИБКА ВЫЗОВА 32
|
|
23
|
+
//
|
|
24
|
+
// Общее правило «1 — находка, 2+ — сбой» переврало бы vulture (его находка это 3, а 1 и 2 —
|
|
25
|
+
// ошибки) и pylint (его ошибка вызова 32 больше любой находки) — причём в ОБЕ стороны.
|
|
26
|
+
// Поэтому знание о кодах живёт рядом с инструментом, а протокол остаётся простым.
|
|
27
|
+
|
|
28
|
+
// Умолчание для незнакомой программы: ноль — чисто, единица — находка, остальное — сбой.
|
|
29
|
+
// Это честнее догадки: неизвестный код становится «не знаем», а не «поймал».
|
|
30
|
+
const DEFAULT = (code) => code === 1;
|
|
31
|
+
|
|
32
|
+
const ADAPTERS = {
|
|
33
|
+
ruff: DEFAULT,
|
|
34
|
+
eslint: DEFAULT,
|
|
35
|
+
// Наши собственные обёртки: 0 чисто, 1 находка, 2 «нет делегированного инструмента».
|
|
36
|
+
bash: DEFAULT,
|
|
37
|
+
sh: DEFAULT,
|
|
38
|
+
vulture: (code) => code === 3,
|
|
39
|
+
// Битовая маска: любое ненулевое БЕЗ бита 32 — находка; бит 32 — ошибка вызова.
|
|
40
|
+
pylint: (code) => code > 0 && (code & 32) === 0,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function findingCodes(prog) {
|
|
44
|
+
const key = String(prog || "").split("/").pop();
|
|
45
|
+
return ADAPTERS[key] || DEFAULT;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Вход — то, что отдаёт spawnSync: { status, signal, error }. Выход — исход и, у сбоя, причина.
|
|
49
|
+
function classify(r, isFinding = DEFAULT) {
|
|
50
|
+
// ПОРЯДОК ВАЖЕН, И ОН НЕОЧЕВИДЕН. При истечении срока Node ставит И `signal: SIGTERM`,
|
|
51
|
+
// И `error` с кодом ETIMEDOUT: если смотреть на error первым и не различать его код, таймаут
|
|
52
|
+
// назовётся «ошибкой запуска». Состояние вышло бы верным, а объяснение ложным — а объяснение
|
|
53
|
+
// здесь и есть смысл работы. Замерено прямо на spawnSync 2026-09-09.
|
|
54
|
+
if (r?.error?.code === "ETIMEDOUT") return { state: "infra_error", reason: "timeout", code: null };
|
|
55
|
+
if (r?.error) return { state: "infra_error", reason: "spawn_error", code: null };
|
|
56
|
+
if (r?.status === null || r?.status === undefined) {
|
|
57
|
+
// Срок мог сработать и без error: процесс убит SIGTERM. Иной сигнал — не наш срок.
|
|
58
|
+
const reason = !r?.signal || r.signal === "SIGTERM" ? "timeout" : "signal";
|
|
59
|
+
return { state: "infra_error", reason, code: null };
|
|
60
|
+
}
|
|
61
|
+
const code = r.status;
|
|
62
|
+
if (code === 0) return { state: "clean", reason: null, code };
|
|
63
|
+
if (isFinding(code)) return { state: "finding", reason: null, code };
|
|
64
|
+
return { state: "infra_error", reason: "unexpected_exit", code };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { classify, findingCodes };
|
|
@@ -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 };
|
|
@@ -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
|
+
});
|