agent-quality-kit 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -4
- package/README.ru.md +28 -4
- package/kit/gates/complexity-limit/gate.yml +9 -0
- package/kit/gates/dead-code/gate.yml +9 -0
- package/kit/gates/duplicate-code/gate.yml +5 -0
- package/kit/gates/env-secrets-not-committed/README.md +73 -0
- package/kit/gates/env-secrets-not-committed/check.sh +139 -0
- package/kit/gates/env-secrets-not-committed/gate.yml +21 -0
- package/kit/gates/env-secrets-not-committed/green/.aqk-tracked +10 -0
- package/kit/gates/env-secrets-not-committed/green/.env +10 -0
- package/kit/gates/env-secrets-not-committed/green/.env.production +5 -0
- package/kit/gates/env-secrets-not-committed/green/.env.test +2 -0
- package/kit/gates/env-secrets-not-committed/red/.aqk-tracked +5 -0
- package/kit/gates/env-secrets-not-committed/red/.env +7 -0
- package/kit/gates/no-print-in-prod/gate.yml +9 -0
- package/kit/gates/secrets-not-in-code/gate.yml +20 -0
- package/kit/gates/swallowed-error/gate.yml +9 -0
- package/kit/gates/todo-without-task/gate.yml +9 -0
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/tool/commands/badge.mjs +1 -1
- package/tool/commands/context.mjs +59 -4
- package/tool/commands/doctor.mjs +171 -44
- package/tool/commands/gates.mjs +3 -2
- package/tool/commands/probe.mjs +86 -22
- package/tool/commands/project.mjs +6 -1
- package/tool/commands/report.mjs +1 -1
- package/tool/commands/vitals.mjs +21 -12
- package/tool/i18n/en-docs.mjs +24 -2
- package/tool/i18n/en-gates.mjs +10 -1
- package/tool/i18n/en.mjs +25 -0
- package/tool/i18n/ru-docs.mjs +26 -2
- package/tool/i18n/ru-gates.mjs +10 -1
- package/tool/i18n/ru.mjs +25 -0
- package/tool/lib/adopt.mjs +112 -0
- package/tool/lib/advice.mjs +115 -0
- package/tool/lib/brief.mjs +3 -1
- package/tool/lib/cadence.mjs +46 -1
- package/tool/lib/core.mjs +39 -1
- package/tool/lib/execution.mjs +50 -1
- package/tool/lib/gate-worker.mjs +18 -0
- package/tool/lib/history.mjs +45 -6
- package/tool/lib/manifest.mjs +65 -21
- package/tool/lib/prove.mjs +2 -2
- package/tool/lib/repo.mjs +18 -0
- package/tool/lib/run.mjs +108 -8
- package/tool/selfcheck/gates.sh +12 -2
- package/tool/selfcheck/smoke/first-run.test.mjs +105 -0
- package/tool/selfcheck/smoke/verdict.test.mjs +51 -2
- package/tool/selfcheck/smoke.sh +6 -2
- package/tool/selfcheck/units-cadence.mjs +51 -1
- package/tool/selfcheck/units-context.mjs +64 -1
- package/tool/selfcheck/units-execution.mjs +78 -1
- package/tool/selfcheck/units-level.mjs +99 -1
- package/tool/selfcheck/units-probe.mjs +104 -0
- package/tool/selfcheck/units-repo.mjs +146 -0
- package/tool/selfcheck/units-verdict.mjs +76 -0
- package/tool/selfcheck/units.mjs +34 -1
package/tool/lib/prove.mjs
CHANGED
|
@@ -15,7 +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
|
+
import { classify, findingCodes, gateCommand } from "./execution.mjs";
|
|
19
19
|
|
|
20
20
|
// Гейт можно доказать, если у него есть оба образца. Признак по образцам, а не по тексту
|
|
21
21
|
// команды: запись, делегирующая готовому инструменту (`npx knip --directory .`), каталог
|
|
@@ -108,7 +108,7 @@ async function samplesForRecipe(samplesDir, name) {
|
|
|
108
108
|
// «не знаем» — а вызывающий тут же считал его находкой. Опыт 2026-09-09: проверка, виснущая
|
|
109
109
|
// на красном образце, получала вердикт «доказана».
|
|
110
110
|
function run(cmd, timeoutMs, prog) {
|
|
111
|
-
const r = spawnSync(cmd, { shell: true, encoding: "utf8", cwd: CWD, timeout: timeoutMs });
|
|
111
|
+
const r = spawnSync(gateCommand(cmd), { shell: true, encoding: "utf8", cwd: CWD, timeout: timeoutMs });
|
|
112
112
|
const out = `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
113
113
|
return { ...classify(r, findingCodes(prog)), out };
|
|
114
114
|
}
|
package/tool/lib/repo.mjs
CHANGED
|
@@ -46,6 +46,8 @@ const MARKS = [
|
|
|
46
46
|
["has_docker", ["Dockerfile", "compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml"]],
|
|
47
47
|
["has_deps", ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "Gemfile", "pom.xml", "composer.json"]],
|
|
48
48
|
["has_env", [".env", ".env.example", ".env.sample"]],
|
|
49
|
+
// Линтер проекта — Biome: совет даётся на его языке, а не на языке eslint.
|
|
50
|
+
["has_biome", ["biome.json", "biome.jsonc"]],
|
|
49
51
|
// Обвес самого агента: настройки, хуки, права. Отдельный признак нужен, потому что записи
|
|
50
52
|
// про него не касаются проектов, где агента не настраивали вовсе, — а таких большинство.
|
|
51
53
|
// Файл `.claude/settings.json` есть и у того, кто настроил один только список разрешений;
|
|
@@ -265,6 +267,22 @@ function pickRecipe(rec, facts, missing) {
|
|
|
265
267
|
console.log(c.dim(` ${c.yellow("!")} ${L.recipe.skipped(lang, prog)}`));
|
|
266
268
|
}
|
|
267
269
|
|
|
270
|
+
// Безъязыковой родной рецепт. Ключи рецептов — языки, и это верно для линтеров, но неверно
|
|
271
|
+
// для инструментов, которым язык безразличен: `gitleaks` ищет ключи в любом файле, и
|
|
272
|
+
// `secrets-not-in-code` объявлен `always: true`. Без этого ключа выбор был из двух плохих —
|
|
273
|
+
// продублировать команду под восемь языков (тот самый повтор, против которого у нас гейт)
|
|
274
|
+
// или оставить запись без родного рецепта. Второе и было: замер на десяти чужих проектах
|
|
275
|
+
// 2026-09-10 дал секреты в НЕПОКРЫТЫХ у axios и у всех четырёх python-проектов, то есть там,
|
|
276
|
+
// где польза наибольшая, команды для вставки не существовало.
|
|
277
|
+
//
|
|
278
|
+
// Стоит ПОСЛЕ языков и ПЕРЕД переносимым: свой язык точнее, а родной сильнее переносимого —
|
|
279
|
+
// но только если его есть чем выполнить.
|
|
280
|
+
if (recipes.native) {
|
|
281
|
+
const prog = String(recipes.native).trim().split(/\s+/)[0];
|
|
282
|
+
if (runnable(recipes.native)) return recipes.native;
|
|
283
|
+
if (Array.isArray(missing) && !missing.includes(prog)) missing.push(prog);
|
|
284
|
+
}
|
|
285
|
+
|
|
268
286
|
return recipes.any || null;
|
|
269
287
|
}
|
|
270
288
|
|
package/tool/lib/run.mjs
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
// файл был полон, и любая следующая правка ложилась туда просто потому, что «так ближе по
|
|
10
10
|
// контексту». Ровно то, о чём предупреждает совет самого гейта.
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { Worker } from "node:worker_threads";
|
|
12
13
|
import { scopeOutput, splitAdvice, changedFiles } from "./scope.mjs";
|
|
13
14
|
import { CWD, c, die } from "./core.mjs";
|
|
14
15
|
import { advisorySet } from "./manifest.mjs";
|
|
15
16
|
import { L } from "../i18n/index.mjs";
|
|
17
|
+
import { gateCommand } from "./execution.mjs";
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
// «Гейт объявлен» и «гейт работает» — разные утверждения. Первое читается из манифеста,
|
|
@@ -39,10 +41,100 @@ function sinceRef(argv = process.argv) {
|
|
|
39
41
|
return v && !v.startsWith("-") ? v : null;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
// СТРОКА «ИДЁТ». Гейт идёт через spawnSync, и строка про него печаталась только по завершении:
|
|
45
|
+
// минута `smoke` — минута пустого экрана, «работает» неотличимо от «повисло», и человек пишет
|
|
46
|
+
// сам, не дождавшись. Просьба владельца 2026-09-10: «я запустил и должен видеть, как идёт».
|
|
47
|
+
//
|
|
48
|
+
// Только в терминал. В пайп и в конвейер — ни байта: лог там читают глазами и разбирают
|
|
49
|
+
// машиной, и строка, переписанная возвратом каретки, в файле превращается в мусор.
|
|
50
|
+
// Секунды не тикают: для этого нужен асинхронный запуск и убийство группы процессов на
|
|
51
|
+
// таймауте, а у нас windows в конвейере. Имя и номер гейта отвечают на вопрос и без них.
|
|
52
|
+
function progress({ tty = process.stdout.isTTY, write = (s) => process.stdout.write(s) } = {}) {
|
|
53
|
+
let shown = false;
|
|
54
|
+
return {
|
|
55
|
+
show(text) {
|
|
56
|
+
if (!tty) return;
|
|
57
|
+
if (shown) write("\r\x1b[K");
|
|
58
|
+
write(text);
|
|
59
|
+
shown = true;
|
|
60
|
+
},
|
|
61
|
+
clear() {
|
|
62
|
+
if (!tty || !shown) return;
|
|
63
|
+
write("\r\x1b[K");
|
|
64
|
+
shown = false;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ГРУППЫ И --only / --skip. Отзыв с живого проекта 2026-09-11: гейт цены меряет план на
|
|
70
|
+
// засеянной базе — объявишь, и он валит прогон без стенда; уберёшь, и promise-has-gate справедливо
|
|
71
|
+
// ругается. Прогон бывал только «всё или ничего». `groups:` в манифесте называет смысл
|
|
72
|
+
// («этим нужен стенд»), флаги выбирают. Неизвестное имя — ошибка: опечатка `--skip stak` не
|
|
73
|
+
// должна тихо превращаться в «пропустили ничего». Пропущенные называются, а не исчезают.
|
|
74
|
+
function listArg(argv, flag) {
|
|
75
|
+
const out = [];
|
|
76
|
+
argv.forEach((a, i) => { if (a === flag && argv[i + 1] && !argv[i + 1].startsWith("--")) out.push(...argv[i + 1].split(",")); });
|
|
77
|
+
return out.map((x) => x.trim()).filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function selectGates(gates, man, { only = [], skip = [] } = {}) {
|
|
81
|
+
const groups = man?.groups && typeof man.groups === "object" && !Array.isArray(man.groups) ? man.groups : {};
|
|
82
|
+
const declared = new Set(gates.map(([n]) => n));
|
|
83
|
+
const unknown = [];
|
|
84
|
+
const expand = (list) => {
|
|
85
|
+
const out = new Set();
|
|
86
|
+
for (const name of list) {
|
|
87
|
+
const g = groups[name];
|
|
88
|
+
const members = Array.isArray(g) ? g : typeof g === "string" && g ? [g] : null;
|
|
89
|
+
if (members) for (const m of members) (declared.has(m) ? out.add(m) : unknown.push(m));
|
|
90
|
+
else if (declared.has(name)) out.add(name);
|
|
91
|
+
else unknown.push(name);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
const onlySet = only.length ? expand(only) : null;
|
|
96
|
+
const skipSet = expand(skip);
|
|
97
|
+
const run = gates.filter(([n]) => (!onlySet || onlySet.has(n)) && !skipSet.has(n));
|
|
98
|
+
const kept = new Set(run.map(([n]) => n));
|
|
99
|
+
return { run, skipped: gates.map(([n]) => n).filter((n) => !kept.has(n)), unknown: [...new Set(unknown)] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const GATE_TIMEOUT = 300000;
|
|
103
|
+
|
|
104
|
+
// Один гейт в этом потоке — прежний путь, без `--jobs`.
|
|
105
|
+
function spawnGate(cmd) {
|
|
106
|
+
return spawnSync(gateCommand(cmd), { shell: true, cwd: CWD, encoding: "utf8", timeout: GATE_TIMEOUT });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ПАРАЛЛЕЛЬНО — ПО ФЛАГУ. Отзыв с живого проекта 2026-09-11: 34 независимых гейта шли друг за
|
|
110
|
+
// другом больше минуты. Но у чужих проектов гейты бывают зависимыми — два `npm run build` в одну
|
|
111
|
+
// папку `dist/` при одновременном запуске дадут плавающие падения, а плавающее красное хуже
|
|
112
|
+
// медленного. Поэтому без флага всё как было, по одному. Результаты — массив обещаний в порядке
|
|
113
|
+
// объявления: вывод печатается по порядку, какой бы гейт ни кончился первым.
|
|
114
|
+
function startPool(cmds, jobs) {
|
|
115
|
+
const url = new URL("./gate-worker.mjs", import.meta.url);
|
|
116
|
+
const results = cmds.map(() => { let res; const pr = new Promise((r) => { res = r; }); pr.res = res; return pr; });
|
|
117
|
+
let next = 0;
|
|
118
|
+
const feed = (w) => {
|
|
119
|
+
if (next >= cmds.length) { w.terminate(); return; }
|
|
120
|
+
const id = next++;
|
|
121
|
+
const onErr = (e) => { results[id].res({ status: null, stdout: "", stderr: String(e?.message || e), error: { code: "WORKER" } }); };
|
|
122
|
+
w.once("error", onErr);
|
|
123
|
+
w.once("message", (m) => { w.off("error", onErr); results[id].res(m); feed(w); });
|
|
124
|
+
w.postMessage({ id, cmd: cmds[id], cwd: CWD, timeout: GATE_TIMEOUT });
|
|
125
|
+
};
|
|
126
|
+
for (let i = 0; i < Math.min(jobs, cmds.length); i++) feed(new Worker(url));
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runGates(man, opts = {}) {
|
|
131
|
+
const all = declaredGates(man);
|
|
132
|
+
if (!all.length) return { failed: 0, ran: 0, results: [], skipped: [] };
|
|
133
|
+
const sel = selectGates(all, man, opts);
|
|
134
|
+
if (sel.unknown.length) die(L.doctor.selectUnknown(sel.unknown.join(", "), Object.keys(man?.groups || {}).join(", ")));
|
|
135
|
+
const gates = sel.run;
|
|
45
136
|
const advisory = advisorySet(man);
|
|
137
|
+
const bar = progress();
|
|
46
138
|
|
|
47
139
|
// Сужение по дифу — договор с человеком, и он должен видеть, ЧТО именно сужено. Пустой диф
|
|
48
140
|
// называется вслух: иначе «все гейты зелёные» означало бы «сравнили не с тем» и читалось бы
|
|
@@ -52,12 +144,20 @@ function runGates(man, opts = {}) {
|
|
|
52
144
|
if (scoped) console.log(c.dim(`\n ${L.doctor.sinceHeading(opts.since, scoped.size)}`));
|
|
53
145
|
|
|
54
146
|
console.log(c.bold(`\n ${L.doctor.runHeading}\n`));
|
|
147
|
+
if (sel.skipped.length) console.log(c.yellow(` ${L.doctor.selectSkipped(sel.skipped.join(", "))}\n`));
|
|
55
148
|
let failed = 0;
|
|
56
149
|
const results = [];
|
|
57
150
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
151
|
+
const jobs = Math.max(1, Number(opts.jobs) || 1);
|
|
152
|
+
const pooled = jobs > 1 ? startPool(gates.map(([, cmd]) => cmd), jobs) : null;
|
|
153
|
+
const tStart = Date.now();
|
|
154
|
+
for (const [i, [name, cmd]] of gates.entries()) {
|
|
155
|
+
bar.show(` ${c.dim("⋯")} ${name.padEnd(14)} ${c.dim(L.doctor.running(i + 1, gates.length))}`);
|
|
156
|
+
const t0 = pooled ? tStart : Date.now();
|
|
157
|
+
const r = pooled ? await pooled[i] : spawnGate(cmd);
|
|
158
|
+
bar.clear();
|
|
159
|
+
// При --jobs время гейта от начала прогона: точнее не узнать без часов в потоке, и честнее
|
|
160
|
+
// так и считать, чем приписать гейту чужое ожидание в очереди.
|
|
61
161
|
const secs = (Math.max(0, Date.now() - t0) / 1000).toFixed(1);
|
|
62
162
|
// Вывод гейта запоминается целиком (с потолком, чтобы болтливый инструмент не съел память):
|
|
63
163
|
// по нему считается покрытие дифа — какой файл вообще был назван хоть одной проверкой.
|
|
@@ -159,7 +259,7 @@ function runGates(man, opts = {}) {
|
|
|
159
259
|
// тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
|
|
160
260
|
const advisoryFailed = results.filter((x) => x.advisory && !x.ok).map((x) => x.name);
|
|
161
261
|
if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
|
|
162
|
-
return { failed, ran: gates.length, results, advisoryFailed };
|
|
262
|
+
return { failed, ran: gates.length, results, advisoryFailed, skipped: sel.skipped };
|
|
163
263
|
}
|
|
164
264
|
|
|
165
|
-
export { declaredGates, sinceRef, runGates };
|
|
265
|
+
export { declaredGates, sinceRef, runGates, progress, selectGates, listArg };
|
package/tool/selfcheck/gates.sh
CHANGED
|
@@ -118,14 +118,24 @@ for GATE in "$CAT"/*/; do
|
|
|
118
118
|
# наблюдался трижды под тяжёлой параллельной нагрузкой, каждый раз на РАЗНОЙ записи, README
|
|
119
119
|
# которой на месте и раздел содержит; воспроизвести в покое не удалось за сорок прогонов.
|
|
120
120
|
# Причина не доказана — но обвинять запись в том, чего не проверяли, нельзя в любом случае.
|
|
121
|
-
printf
|
|
121
|
+
# ПОЧЕМУ ЗДЕСЬ НЕТ КОНВЕЙЕРА. Было `printf … | grep -qE …`, и это давало ПЛАВАЮЩИЙ отказ,
|
|
122
|
+
# который мы ловили трижды и не могли объяснить: запись обвинялась в отсутствии раздела,
|
|
123
|
+
# который у неё есть. Причина найдена 2026-09-10 тем самым сообщением «grep вышел с кодом
|
|
124
|
+
# 141», ради которого исход и был разложен на три. 141 = 128 + 13 = SIGPIPE: `grep -q`
|
|
125
|
+
# выходит на ПЕРВОМ совпадении и закрывает трубу, а `printf` в этот момент ещё пишет и
|
|
126
|
+
# умирает от сигнала. При `set -o pipefail` (строка 9) статусом конвейера становится 141.
|
|
127
|
+
# Воспроизведено 100 из 100, когда совпадение в НАЧАЛЕ текста, — оттого и «случайная»
|
|
128
|
+
# запись: у одних раздел в начале README, у других в конце.
|
|
129
|
+
# Здесь-строка конвейера не создаёт, и гонки не существует.
|
|
130
|
+
grep -qE '[Гг]отовы(й аналог|й инструмент|е правил)|[Гг]отового аналога' <<< "$README_TEXT"
|
|
122
131
|
case $? in
|
|
123
132
|
0) : ;;
|
|
124
133
|
1) bad "$SLUG: в README нет раздела про готовый аналог — «не искал» и «нет» разные утверждения" ;;
|
|
125
134
|
*) bad "$SLUG: поиск раздела про готовый аналог НЕ СОСТОЯЛСЯ (grep вышел с кодом $?) — это не приговор записи" ;;
|
|
126
135
|
esac
|
|
127
136
|
# Те же три исхода, что и у поиска выше, и по той же причине.
|
|
128
|
-
|
|
137
|
+
# Тот же довод, что у поиска выше: конвейер + `grep -q` = гонка с SIGPIPE.
|
|
138
|
+
grep -qE 'чего НЕ ловит|Чего НЕ ловит|чего не ловит|Чего не ловит' <<< "$README_TEXT"
|
|
129
139
|
case $? in
|
|
130
140
|
0) : ;;
|
|
131
141
|
1) bad "$SLUG: в README нет раздела «чего НЕ ловит» — граница записи обязана быть названа" ;;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Первый запуск: главное — вверху, а не после двадцати крестов.
|
|
2
|
+
//
|
|
3
|
+
// НАЙДЕНО РАЗБОРОМ СОСЕДА 2026-09-11 (research/competitors/agentlint.md). На requests `doctor`
|
|
4
|
+
// печатал 36 строк крестов — у каждой из 18 записей своя строка «поставить: aqk add …», — и
|
|
5
|
+
// только со строки 84 из 102 шли «у вас уже есть» и «начните с этих трёх». Человек читает
|
|
6
|
+
// сверху и закрывает раньше, чем доходит до того, ради чего вывод написан. agentlint в том же
|
|
7
|
+
// месте печатает пять главных исправлений первыми.
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { project, aqk, run } from "./_fixture.mjs";
|
|
13
|
+
|
|
14
|
+
// Цвет снимается до разбора: заголовок жирный, значки цветные, и регулярка по сырому выводу
|
|
15
|
+
// не узнаёт ни то, ни другое.
|
|
16
|
+
const plain = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
17
|
+
|
|
18
|
+
// Проект, где применимо больше трёх записей: иначе блока «начните с трёх» нет вовсе.
|
|
19
|
+
const FILES = {
|
|
20
|
+
"package.json": JSON.stringify({ name: "x", scripts: { test: "node --test", lint: "eslint ." } }),
|
|
21
|
+
"src/app.js": "export function f(a) {\n return a + 1;\n}\n",
|
|
22
|
+
"src/util.py": "def g(x):\n return x * 2\n",
|
|
23
|
+
"tests/app.test.js": "import test from 'node:test';\ntest('x', () => {});\n",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
test("первый запуск: «начните с трёх» стоит выше списка остальных записей", (t) => {
|
|
27
|
+
const p = project(t, FILES);
|
|
28
|
+
const out = plain(aqk(p, "doctor").out);
|
|
29
|
+
const lines = out.split("\n");
|
|
30
|
+
const start = lines.findIndex((l) => /Начните с этих трёх/.test(l));
|
|
31
|
+
assert.ok(start > -1, `блока «начните с трёх» нет:\n${out}`);
|
|
32
|
+
// Кресты каталога — строки «✘ <slug>» ниже заголовка «Гейты»; кресты раскладки (.aqk/docs,
|
|
33
|
+
// AGENTS.md) стоят выше него и к делу не относятся.
|
|
34
|
+
const heading = lines.findIndex((l) => /^\s*Гейты\s*$/.test(l));
|
|
35
|
+
const firstCross = lines.findIndex((l, i) => i > heading && /^\s*○\s+[a-z][a-z-]+\s/.test(l));
|
|
36
|
+
assert.ok(firstCross > -1, `список записей пропал:\n${out}`);
|
|
37
|
+
assert.ok(start < firstCross, `«начните с трёх» на строке ${start + 1}, а первый крест — на ${firstCross + 1}`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("первый запуск: «поставить: aqk add» не повторяется у каждой записи, записи не теряются", (t) => {
|
|
41
|
+
const p = project(t, FILES);
|
|
42
|
+
const out = plain(aqk(p, "doctor").out);
|
|
43
|
+
const hints = out.split("\n").filter((l) => /поставить:/.test(l));
|
|
44
|
+
// Одна общая подсказка и те, что стоят у находок пробы, — но не по строке на каждую запись.
|
|
45
|
+
assert.ok(hints.length <= 3, `подсказок «поставить» ${hints.length} — по одной на запись:\n${hints.join("\n")}`);
|
|
46
|
+
const crosses = out.split("\n").filter((l) => /^\s*○\s+[a-z][a-z-]+\s/.test(l));
|
|
47
|
+
assert.ok(crosses.length >= 4, `записей к установке ${crosses.length} — список сократили вместо того, чтобы сжать:\n${out}`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// СЛУЖЕБНЫЕ ФАЙЛЫ НЕ ПОПАДАЮТ В GIT. Отзыв с живого проекта 2026-09-11: `.aqk/last-run.md`
|
|
51
|
+
// однажды закоммитили, и с тех пор каждый `make check` оставляет изменённый файл. А проба
|
|
52
|
+
// печатала «рабочее дерево не трогается», записывая `.aqk/last-probe.md`. В коде лежал
|
|
53
|
+
// комментарий «.aqk/ в .gitignore» — только `init` его туда не клал. Целиком `.aqk/`
|
|
54
|
+
// игнорировать нельзя: методички и правила в нём — содержимое проекта, их коммитят.
|
|
55
|
+
test("init кладёт служебные файлы .aqk в .gitignore, повторный init строк не дублирует", (t) => {
|
|
56
|
+
const p = project(t, { "src/a.py": "x = 1\n", ".gitignore": "node_modules/\n" });
|
|
57
|
+
aqk(p, "init");
|
|
58
|
+
aqk(p, "init");
|
|
59
|
+
const gi = readFileSync(join(p.dir, ".gitignore"), "utf8");
|
|
60
|
+
for (const f of ["last-run.md", "last-probe.md", "advice-shown", "update-checked"]) {
|
|
61
|
+
const n = gi.split("\n").filter((l) => l.trim() === `.aqk/${f}`).length;
|
|
62
|
+
assert.equal(n, 1, `.aqk/${f} в .gitignore ${n} раз(а):\n${gi}`);
|
|
63
|
+
}
|
|
64
|
+
assert.match(gi, /^node_modules\/$/m, "чужие строки .gitignore не тронуты");
|
|
65
|
+
// И методички при этом не спрятаны: их коммитят.
|
|
66
|
+
assert.equal(run(p, "git", ["check-ignore", "-q", ".aqk/docs"]).code, 1, ".aqk/docs оказался в игноре");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("doctor называет служебный файл, который отслеживает git, — с командой, как вынуть", (t) => {
|
|
70
|
+
const p = project(t, { "src/a.py": "x = 1\n", ".aqk/last-run.md": "# старый отчёт\n" });
|
|
71
|
+
run(p, "git", ["add", "-A"]);
|
|
72
|
+
run(p, "git", ["commit", "-qm", "init"]);
|
|
73
|
+
const out = plain(aqk(p, "doctor").out);
|
|
74
|
+
assert.match(out, /\.aqk\/last-run\.md/, `служебный файл в git не назван:\n${out}`);
|
|
75
|
+
assert.match(out, /git rm --cached \.aqk\/last-run\.md/, "нет готовой команды");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ✘ — ТОЛЬКО ДЛЯ НАСТОЯЩЕГО ПАДЕНИЯ. Отзыв с живого проекта 2026-09-11: в зелёном прогоне висели
|
|
79
|
+
// девять крестов — записи каталога, которые просто не установлены. Глаз читает их как провал, и
|
|
80
|
+
// через неделю человек перестаёт смотреть на красное вообще. Неустановленное — своим знаком.
|
|
81
|
+
test("неустановленная запись — ○, а не ✘: крест значит «упало», и больше ничего", (t) => {
|
|
82
|
+
const p = project(t, FILES);
|
|
83
|
+
const out = plain(aqk(p, "doctor").out);
|
|
84
|
+
const lines = out.split("\n");
|
|
85
|
+
const heading = lines.findIndex((l) => /^\s*Гейты\s*$/.test(l));
|
|
86
|
+
const crosses = lines.filter((l, i) => i > heading && /^\s*✘\s/.test(l));
|
|
87
|
+
assert.deepEqual(crosses, [], `в осмотре без прогона стоят кресты:\n${crosses.join("\n")}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// МЕТОДИЧКИ — СОВЕТ, А НЕ ПРИГОВОР. Отзыв с живого проекта 2026-09-11: первый `doctor --run`
|
|
91
|
+
// покраснел только из-за отсутствия `.aqk/docs` — «files from the header are missing». Проект
|
|
92
|
+
// вправе держать пособия где-то ещё или не держать вовсе; гейты при этом зелёные. Точка входа,
|
|
93
|
+
// .gitignore и .git остаются обязательными: на них стоит своя проверка вердикта.
|
|
94
|
+
test("нет .aqk/docs и .aqk/rules — прогон всё равно зелёный, пункт назван советом", (t) => {
|
|
95
|
+
const p = project(t, { "src/a.py": "x = 1\n", ".gitignore": "x\n" });
|
|
96
|
+
aqk(p, "init");
|
|
97
|
+
rmSync(join(p.dir, ".aqk", "docs"), { recursive: true, force: true });
|
|
98
|
+
rmSync(join(p.dir, ".aqk", "rules"), { recursive: true, force: true });
|
|
99
|
+
const man = join(p.dir, ".aqk.yml");
|
|
100
|
+
writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m, 'gates:\n тихий: "true"'), "utf8");
|
|
101
|
+
const r = aqk(p, "doctor", "--run");
|
|
102
|
+
const tail = plain(r.out).trimEnd().split("\n").slice(-4).join("\n");
|
|
103
|
+
assert.equal(r.code, 0, `прогон покраснел из-за методичек:\n${tail}`);
|
|
104
|
+
assert.match(plain(r.out), /\.aqk\/docs/, "отсутствие методичек не названо вовсе");
|
|
105
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Обратная сторона нашего же принципа: молчание неотличимо не только от успеха, но и от отказа.
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { project, aqk } from "./_fixture.mjs";
|
|
12
12
|
|
|
@@ -23,7 +23,10 @@ test("прогон называет свой вердикт словами в о
|
|
|
23
23
|
const man = join(p.dir, ".aqk.yml");
|
|
24
24
|
writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m, 'gates:\n тихий: "true"'), "utf8");
|
|
25
25
|
|
|
26
|
-
// .gitignore нет — прогон красный, и обязан сказать, из-за чего именно.
|
|
26
|
+
// .gitignore нет — прогон красный, и обязан сказать, из-за чего именно. Удаляется ЯВНО:
|
|
27
|
+
// `init` с 2026-09-11 сам создаёт .gitignore под служебные файлы, и прежняя посылка теста
|
|
28
|
+
// («после init его нет») перестала быть правдой.
|
|
29
|
+
rmSync(join(p.dir, ".gitignore"), { force: true });
|
|
27
30
|
const red = aqk(p, "doctor", "--run");
|
|
28
31
|
// Сообщение утверждения обязано нести ВЕСЬ хвост: проверка, которая говорит только «не
|
|
29
32
|
// совпало», отправляет читателя гадать — ровно то, за что мы ругаем чужие проверки.
|
|
@@ -38,3 +41,49 @@ test("прогон называет свой вердикт словами в о
|
|
|
38
41
|
assert.equal(green.code, 0, `прогон остался красным:\n${greenTail}`);
|
|
39
42
|
assert.match(greenTail, /зелён/, `успех не назван словами:\n${greenTail}`);
|
|
40
43
|
});
|
|
44
|
+
|
|
45
|
+
// --skip ГРУППОЙ: гейт, которому нужен стенд, в быстром прогоне не идёт — и это СКАЗАНО, а не
|
|
46
|
+
// скрыто. Отзыв с живого проекта 2026-09-11 (гейт цены без стенда валил прогон).
|
|
47
|
+
test("--skip группой: прогон зелёный, пропущенный назван в выводе и в отчёте", (t) => {
|
|
48
|
+
const p = project(t, { "src/a.py": "x = 1\n" });
|
|
49
|
+
aqk(p, "init");
|
|
50
|
+
const man = join(p.dir, ".aqk.yml");
|
|
51
|
+
writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m, 'gates:\n тихий: "true"\n cost: "false"') +
|
|
52
|
+
"groups:\n stack: [cost]\n", "utf8");
|
|
53
|
+
const r = aqk(p, "doctor", "--run", "--skip", "stack");
|
|
54
|
+
assert.equal(r.code, 0, `прогон без стенда покраснел:\n${r.out.slice(-600)}`);
|
|
55
|
+
assert.match(r.out, /cost/, "пропущенный гейт не назван");
|
|
56
|
+
const report = readFileSync(join(p.dir, ".aqk", "last-run.md"), "utf8");
|
|
57
|
+
assert.match(report, /^~ cost/m, `в отчёте для агента пропущенный не отмечен:\n${report}`);
|
|
58
|
+
// Опечатка в имени группы — отказ с названной причиной, а не прогон всего подряд.
|
|
59
|
+
const typo = aqk(p, "doctor", "--run", "--skip", "stak");
|
|
60
|
+
assert.notEqual(typo.code, 0);
|
|
61
|
+
assert.match(typo.out, /stak/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// --jobs N: независимые гейты параллельно. Отзыв с живого проекта 2026-09-11: 34 гейта идут
|
|
65
|
+
// друг за другом больше минуты, «а быструю проверку, которую долго ждать, перестают запускать».
|
|
66
|
+
// Параллельность — по флагу: гейты, пишущие в одни файлы (общий dist/), иначе плавали бы.
|
|
67
|
+
test("--jobs 3: три секундных гейта идут одновременно, порядок и вердикт прежние", (t) => {
|
|
68
|
+
const p = project(t, { "src/a.py": "x = 1\n", ".gitignore": "x\n" });
|
|
69
|
+
aqk(p, "init");
|
|
70
|
+
const man = join(p.dir, ".aqk.yml");
|
|
71
|
+
writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m,
|
|
72
|
+
'gates:\n a: "sleep 1"\n b: "sleep 1; exit 1"\n c: "sleep 1"'), "utf8");
|
|
73
|
+
const t0 = Date.now();
|
|
74
|
+
const r = aqk(p, "doctor", "--run", "--jobs", "3");
|
|
75
|
+
const secs = (Date.now() - t0) / 1000;
|
|
76
|
+
assert.notEqual(r.code, 0, "упавший гейт b не уронил прогон");
|
|
77
|
+
const order = [...r.out.matchAll(/^\s+\S+\s+([abc])\s/gm)].map((m) => m[1]);
|
|
78
|
+
assert.deepEqual(order, ["a", "b", "c"], `порядок вывода не по объявлению: ${order}`);
|
|
79
|
+
// Три секунды последовательно; параллельно — одна плюс запуск node. Запас на медленную машину.
|
|
80
|
+
assert.ok(secs < 2.8, `прогон шёл ${secs.toFixed(1)} с — параллельности нет`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("--jobs без числа или с нулём — отказ с причиной, а не тихий последовательный прогон", (t) => {
|
|
84
|
+
const p = project(t, { "src/a.py": "x = 1\n" });
|
|
85
|
+
aqk(p, "init");
|
|
86
|
+
const r = aqk(p, "doctor", "--run", "--jobs", "0");
|
|
87
|
+
assert.notEqual(r.code, 0);
|
|
88
|
+
assert.match(r.out, /--jobs/);
|
|
89
|
+
});
|
package/tool/selfcheck/smoke.sh
CHANGED
|
@@ -1903,8 +1903,12 @@ CADP="$(mktemp -d)"
|
|
|
1903
1903
|
node "$CLI" init && node "$CLI" add todo-without-task
|
|
1904
1904
|
) >/dev/null 2>&1
|
|
1905
1905
|
CTX0=$( cd "$CADP" && AQK_LANG=ru node "$CLI" context 2>&1 )
|
|
1906
|
-
|
|
1907
|
-
|
|
1906
|
+
# Путь ЧЕЛОВЕКА: переменные конвейера сняты. С 2026-09-11 в конвейере проба сама не идёт (минуты
|
|
1907
|
+
# сюрпризом в быстрой проверке), и без `env -u` эта проверка была зелёной у нас и красной в CI —
|
|
1908
|
+
# она проверяла бы окружение, а не каденцию. Поведение в конвейере сторожит units-cadence.mjs.
|
|
1909
|
+
NOCI="env -u CI -u GITHUB_ACTIONS -u GITLAB_CI -u BUILDKITE -u JENKINS_URL"
|
|
1910
|
+
RUN1=$( cd "$CADP" && $NOCI AQK_LANG=ru node "$CLI" doctor --run 2>&1 )
|
|
1911
|
+
RUN2=$( cd "$CADP" && $NOCI AQK_LANG=ru node "$CLI" doctor --run 2>&1 )
|
|
1908
1912
|
CTX1=$( cd "$CADP" && AQK_LANG=ru node "$CLI" context 2>&1 )
|
|
1909
1913
|
if printf '%s' "$CTX0" | grep -q "НЕИЗВЕСТНО" &&
|
|
1910
1914
|
printf '%s' "$RUN1" | grep -q "ещё не делали" &&
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// незачем, а сто коммитов за день перепроверить надо.
|
|
8
8
|
import test from "node:test";
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
|
-
import { probeDue, probeState, probeEvery, PROBE_EVERY } from "../lib/cadence.mjs";
|
|
10
|
+
import { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan, autoProbeAllowed } from "../lib/cadence.mjs";
|
|
11
11
|
|
|
12
12
|
test("порог по умолчанию — сто коммитов, и он назван числом, а не спрятан", () => {
|
|
13
13
|
assert.equal(PROBE_EVERY, 100);
|
|
@@ -67,3 +67,53 @@ test("непонятое значение порога — null, а не тих
|
|
|
67
67
|
assert.equal(probeEvery({ probe: "часто" }), null);
|
|
68
68
|
assert.equal(probeEvery({ probe: -5 }), null);
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
// --- отметка несёт ИМЕНА непойманных классов ------------------------------------------
|
|
72
|
+
// ЗАЧЕМ. Отметка хранила одно число — `blind: 1`. Агент в `context` видел «один класс», человек
|
|
73
|
+
// в `doctor` не видел ничего, и чтобы узнать КАКОЙ, надо было снова запускать пробу — ту самую
|
|
74
|
+
// команду, о которой никто не вспоминает. Находка, которую не показали, не предостерегает.
|
|
75
|
+
test("отметка пробы: по строке на слепой класс, в самом горячем файле, где он слеп", () => {
|
|
76
|
+
const recs = [
|
|
77
|
+
{ entry: "swallowed-error", file: "src/a.py", verdict: "blind" },
|
|
78
|
+
{ entry: "swallowed-error", file: "src/b.py", verdict: "blind" },
|
|
79
|
+
{ entry: "no-print-in-prod", file: "src/a.py", verdict: "caught" },
|
|
80
|
+
{ entry: "dead-code", file: "src/b.py", verdict: "unknown" },
|
|
81
|
+
];
|
|
82
|
+
const lines = blindLines(recs);
|
|
83
|
+
// То же правило, что у countProbe: класс, слепой хоть где-то, — непокрыт; «не смогли» — не слеп.
|
|
84
|
+
assert.deepEqual(lines, ["blind-class: swallowed-error src/a.py"]);
|
|
85
|
+
const text = ["at: 5", "blind: 1", "", ...lines, "- src/a.py (fixes: 3)"].join("\n");
|
|
86
|
+
assert.deepEqual(parseBlind(text), [{ slug: "swallowed-error", file: "src/a.py" }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("отметка старого формата и пустая — пустой список, а не падение", () => {
|
|
90
|
+
assert.deepEqual(parseBlind("at: 5\nblind: 1\n"), []);
|
|
91
|
+
assert.deepEqual(parseBlind(""), []);
|
|
92
|
+
assert.deepEqual(parseBlind(null), []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// «Объявлен сейчас» не значит «поставлен после пробы»: проба и гоняет объявленные гейты.
|
|
96
|
+
// Первая версия так и решила — и на самом комплекте написала «поставлено после пробы» про
|
|
97
|
+
// gate-not-weakened, который стоял ДО неё и в kit/gates/_skip.sh брак пропустил. Самое ценное
|
|
98
|
+
// сообщение («объявлен, но здесь не ловит») подменилось утешительным. Поймано живым прогоном.
|
|
99
|
+
test("отметка помнит, какие гейты проба ПРОГОНЯЛА", () => {
|
|
100
|
+
const text = "at: 5\nblind: 1\nran: smoke gate-not-weakened units\n\nblind-class: x a.sh\n";
|
|
101
|
+
assert.deepEqual([...parseRan(text)].sort(), ["gate-not-weakened", "smoke", "units"]);
|
|
102
|
+
// Старая отметка этой строки не несёт: «не знаем», а не «ничего не было объявлено».
|
|
103
|
+
assert.equal(parseRan("at: 5\nblind: 1\n"), null);
|
|
104
|
+
assert.deepEqual([...parseRan("ran:\n")], []);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Проба, встроенная в `doctor --run`, в конвейере — это +2–3 минуты в случайном прогоне раз в
|
|
108
|
+
// сто коммитов. Отзыв с живого проекта 2026-09-11: «пусть probe будет отдельной командой или
|
|
109
|
+
// задачей, а не сюрпризом внутри быстрой проверки». В конвейере она сама не запускается; у
|
|
110
|
+
// человека — по-прежнему сама: там её и надо не забыть.
|
|
111
|
+
test("проба сама не запускается в конвейере, в коротком режиме и при AQK_PROBE=0", () => {
|
|
112
|
+
assert.equal(autoProbeAllowed({ brief: false, env: {} }), true);
|
|
113
|
+
assert.equal(autoProbeAllowed({ brief: false, env: { CI: "true" } }), false);
|
|
114
|
+
assert.equal(autoProbeAllowed({ brief: false, env: { GITHUB_ACTIONS: "true" } }), false);
|
|
115
|
+
assert.equal(autoProbeAllowed({ brief: true, env: {} }), false);
|
|
116
|
+
assert.equal(autoProbeAllowed({ brief: false, env: { AQK_PROBE: "0" } }), false);
|
|
117
|
+
// Явное AQK_PROBE=1 включает и в конвейере: у всего, что решено за человека, есть способ решить иначе.
|
|
118
|
+
assert.equal(autoProbeAllowed({ brief: false, env: { CI: "true", AQK_PROBE: "1" } }), true);
|
|
119
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// node --test tool/selfcheck/units-context.mjs
|
|
10
10
|
import test from "node:test";
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
|
-
import { contextBlock, countArbiters, parseLastRun, withHook, hasOurHook, portableSelf } from "../commands/context.mjs";
|
|
12
|
+
import { contextBlock, countArbiters, parseLastRun, withHook, hasOurHook, portableSelf, nextSteps } from "../commands/context.mjs";
|
|
13
13
|
import { CATALOGS } from "../i18n/index.mjs";
|
|
14
14
|
import { commandRows } from "../lib/core.mjs";
|
|
15
15
|
import { readFile } from "node:fs/promises";
|
|
@@ -210,3 +210,66 @@ test("выключенная и несостоявшаяся проба не в
|
|
|
210
210
|
const unknown = text({ probe: { state: "unknown", behind: null, blind: 0 } });
|
|
211
211
|
assert.match(unknown, /НЕИЗВЕСТНО|неизвестн/i, `несостоявшаяся проба не названа: ${unknown}`);
|
|
212
212
|
});
|
|
213
|
+
|
|
214
|
+
// --- «Дальше» и «Когда что» ------------------------------------------------------------
|
|
215
|
+
// ЗАЧЕМ. Блок отвечал только на «как дела»: уровень, красное, проба. Что делать ДАЛЬШЕ, `doctor`
|
|
216
|
+
// знал — три первых шага, непойманные пробой классы, чужие проверки проекта, — а в контекст это
|
|
217
|
+
// не попадало. Карта «какую команду когда» жила только в полной версии, в восьми тысячах
|
|
218
|
+
// токенов, где агент её пролистывал. Владелец 2026-09-11: «агент читает половину и не
|
|
219
|
+
// пользуется всем функционалом». Документация Claude Code (code.claude.com/docs/en/memory):
|
|
220
|
+
// «The more specific and concise your instructions, the more consistently Claude follows them»;
|
|
221
|
+
// «"Run `npm test` before committing" instead of "Test your changes"».
|
|
222
|
+
test("дальше: не больше трёх шагов, остаток назван числом, свои проверки проекта — первыми", () => {
|
|
223
|
+
const adopt = [{ name: "test", cmd: "make test" }];
|
|
224
|
+
const blind = [
|
|
225
|
+
{ slug: "swallowed-error", file: "src/a.py", command: "ruff check --select BLE ." },
|
|
226
|
+
{ slug: "no-print-in-prod", file: "src/b.py", command: null },
|
|
227
|
+
];
|
|
228
|
+
const start = [
|
|
229
|
+
{ slug: "complexity-limit", command: "ruff check --select C901 ." },
|
|
230
|
+
{ slug: "swallowed-error", command: "ruff check --select BLE ." },
|
|
231
|
+
{ slug: "dead-code", command: "vulture ." },
|
|
232
|
+
];
|
|
233
|
+
const { steps, rest } = nextSteps({ adopt, blind, start });
|
|
234
|
+
assert.deepEqual(steps.map((s) => s.kind), ["adopt", "blind", "blind"]);
|
|
235
|
+
// Класс из пробы и тот же класс в «начните с трёх» — один шаг, а не два.
|
|
236
|
+
assert.equal(rest, 2, "complexity-limit и dead-code остаются; swallowed-error уже учтён пробой");
|
|
237
|
+
assert.deepEqual(nextSteps({}), { steps: [], rest: 0 });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("дальше: блок печатает шаги по номерам, с командами; пусто — заголовка нет", () => {
|
|
241
|
+
const next = nextSteps({ start: [{ slug: "dead-code", command: "vulture ." }] });
|
|
242
|
+
const t = text({ next });
|
|
243
|
+
assert.match(t, new RegExp(T.nextTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
244
|
+
assert.match(t, /1\. .*dead-code/);
|
|
245
|
+
assert.match(t, /vulture \./, "команда, которую можно выполнить сейчас, обязана быть в шаге");
|
|
246
|
+
assert.doesNotMatch(text({ next: { steps: [], rest: 0 } }), new RegExp(T.nextTitle.slice(0, 12)));
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("когда что: правило про коммит зависит от того, стоит ли хук", () => {
|
|
250
|
+
const withHook = text({ when: { hook: true } });
|
|
251
|
+
const noHook = text({ when: { hook: false } });
|
|
252
|
+
assert.match(withHook, /--no-verify/, "хук стоит — агенту сказано не обходить его");
|
|
253
|
+
assert.match(noHook, /doctor --run --since main/, "хука нет — агенту дана команда");
|
|
254
|
+
assert.notEqual(withHook, noHook);
|
|
255
|
+
// Каждое правило — «ситуация → команда»: без команды в строке правило непроверяемо.
|
|
256
|
+
for (const line of T.whenRules) assert.match(line, /`[^`]+`/, `в правиле нет команды: ${line}`);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Без манифеста `aqk add` отказывает («сначала init») — шаг «поставь запись» на таком проекте
|
|
260
|
+
// отправлял агента в ошибку. Найдено живым прогоном блока на requests 2026-09-11.
|
|
261
|
+
test("дальше: манифеста нет — первый шаг `aqk init`, остальные после него", () => {
|
|
262
|
+
const { steps } = nextSteps({ init: true, start: [{ slug: "dead-code", command: null }] });
|
|
263
|
+
assert.equal(steps[0].kind, "init");
|
|
264
|
+
assert.equal(steps[1].kind, "start");
|
|
265
|
+
assert.match(T.nextStep.init(steps[0]), /`aqk init`/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Отзыв с живого проекта 2026-09-11: в `aqk help` у `vitals` вместо описания стояло `undefined` —
|
|
269
|
+
// команду добавили в список, а текст справки забыли. Сторож на весь список, на оба языка.
|
|
270
|
+
test("у каждой команды в справке есть описание на обоих языках", () => {
|
|
271
|
+
for (const lang of ["ru", "en"]) {
|
|
272
|
+
const empty = commandRows(CATALOGS[lang]).filter((r) => typeof r.text !== "string" || !r.text.trim()).map((r) => r.name);
|
|
273
|
+
assert.deepEqual(empty, [], `${lang}: нет описания у ${empty.join(", ")}`);
|
|
274
|
+
}
|
|
275
|
+
});
|