agent-quality-kit 0.12.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 +26 -4
- package/README.ru.md +26 -4
- package/kit/gates/complexity-limit/gate.yml +4 -0
- package/kit/gates/dead-code/gate.yml +4 -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 +4 -0
- package/kit/gates/swallowed-error/gate.yml +4 -0
- package/kit/gates/todo-without-task/gate.yml +4 -0
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/tool/commands/badge.mjs +1 -1
- package/tool/commands/context.mjs +56 -3
- package/tool/commands/doctor.mjs +136 -81
- package/tool/commands/probe.mjs +46 -66
- package/tool/commands/project.mjs +6 -1
- package/tool/commands/report.mjs +1 -1
- package/tool/commands/vitals.mjs +15 -11
- package/tool/i18n/en-docs.mjs +22 -1
- package/tool/i18n/en-gates.mjs +6 -1
- package/tool/i18n/en.mjs +13 -1
- package/tool/i18n/ru-docs.mjs +24 -1
- package/tool/i18n/ru-gates.mjs +6 -1
- package/tool/i18n/ru.mjs +13 -1
- package/tool/lib/adopt.mjs +15 -1
- package/tool/lib/advice.mjs +115 -0
- package/tool/lib/brief.mjs +3 -1
- package/tool/lib/cadence.mjs +11 -1
- package/tool/lib/core.mjs +39 -1
- package/tool/lib/gate-worker.mjs +18 -0
- package/tool/lib/history.mjs +34 -5
- package/tool/lib/manifest.mjs +65 -21
- package/tool/lib/repo.mjs +2 -31
- package/tool/lib/run.mjs +78 -7
- 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 +15 -1
- package/tool/selfcheck/units-context.mjs +64 -1
- package/tool/selfcheck/units-level.mjs +76 -2
- package/tool/selfcheck/units-probe.mjs +2 -1
- package/tool/selfcheck/units-repo.mjs +25 -1
- package/tool/selfcheck/units-verdict.mjs +76 -0
package/tool/lib/manifest.mjs
CHANGED
|
@@ -112,7 +112,7 @@ function unparsedLines(text) {
|
|
|
112
112
|
// Список обязан совпадать с тем, что программа РЕАЛЬНО читает (`man?.<поле>` в tool/):
|
|
113
113
|
// лишнее имя здесь молча узаконивает поле, которое ни на что не влияет, — та же тишина,
|
|
114
114
|
// только с другой стороны. Сверено обходом: aqk, entry, rules, gates, samples, ratchets, lessons.
|
|
115
|
-
const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory", "probe"];
|
|
115
|
+
const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory", "probe", "groups"];
|
|
116
116
|
|
|
117
117
|
// ГДЕ У ПРОЕКТА ЛЕЖИТ РАЗЛОЖЕННЫЙ КОМПЛЕКТ. Список для шапки `doctor`. До 2026-09-08 он был
|
|
118
118
|
// литеральным: `.aqk/rules`, `.aqk/docs`, `AGENTS.md` — независимо от того, что написано в
|
|
@@ -132,12 +132,16 @@ function layoutChecks(man, inKit) {
|
|
|
132
132
|
const entries = (Array.isArray(man?.entry) ? man.entry : [])
|
|
133
133
|
.filter((e) => typeof e === "string" && e.trim())
|
|
134
134
|
.map((e) => e.trim());
|
|
135
|
+
// Третий элемент — обязателен ли пункт. Методички и стандарты `init` кладёт как пособие: проект
|
|
136
|
+
// вправе держать их где-то ещё или не держать вовсе, и их отсутствие — совет, а не приговор
|
|
137
|
+
// прогону (отзыв с живого проекта 2026-09-11: прогон краснел только из-за `.aqk/docs`). Точка
|
|
138
|
+
// входа, .gitignore и .git — обязательны: на них стоит своя проверка вердикта.
|
|
135
139
|
return [
|
|
136
|
-
[field("docs", inKit ? "kit/docs" : ".aqk/docs"), inKit ? L.doctor.docsKit : L.doctor.docs],
|
|
137
|
-
[field("rules", inKit ? "kit/rules" : ".aqk/rules"), inKit ? L.doctor.rulesKit : L.doctor.rules],
|
|
138
|
-
...(entries.length ? entries : ["AGENTS.md"]).map((e) => [e, L.doctor.agents]),
|
|
139
|
-
[".gitignore", L.doctor.gitignore],
|
|
140
|
-
[".git", L.doctor.git],
|
|
140
|
+
[field("docs", inKit ? "kit/docs" : ".aqk/docs"), inKit ? L.doctor.docsKit : L.doctor.docs, false],
|
|
141
|
+
[field("rules", inKit ? "kit/rules" : ".aqk/rules"), inKit ? L.doctor.rulesKit : L.doctor.rules, false],
|
|
142
|
+
...(entries.length ? entries : ["AGENTS.md"]).map((e) => [e, L.doctor.agents, true]),
|
|
143
|
+
[".gitignore", L.doctor.gitignore, true],
|
|
144
|
+
[".git", L.doctor.git, true],
|
|
141
145
|
];
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -188,28 +192,68 @@ function coversOf(man) {
|
|
|
188
192
|
// вывод перестают читать целиком, вместе с настоящими находками.
|
|
189
193
|
const RULE_CODES = /--select[= ]([A-Za-z0-9,]+)/;
|
|
190
194
|
|
|
191
|
-
|
|
195
|
+
// КАКИМ ЛИНТЕРОМ ЗАКРЫТ ГЕЙТ. Отзыв с живого проекта 2026-09-11 (TypeScript на Biome): заявка
|
|
196
|
+
// «lint держит no-print-in-prod» всегда была «не подтверждена» — сверка искала коды ruff в
|
|
197
|
+
// конфиге Biome, где их не бывает никогда. Поле, снимающее шум, само его производило.
|
|
198
|
+
// Порядок: команда гейта; npm-скрипт, который она зовёт; единственный конфиг линтера в проекте.
|
|
199
|
+
function linterOf(cmd, scripts = {}, present = []) {
|
|
200
|
+
let c = String(cmd || "");
|
|
201
|
+
const m = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:-]+)/.exec(c);
|
|
202
|
+
if (m && typeof scripts[m[1]] === "string") c += ` ${scripts[m[1]]}`;
|
|
203
|
+
if (/\bbiome\b/.test(c)) return "biome";
|
|
204
|
+
if (/\beslint\b/.test(c)) return "eslint";
|
|
205
|
+
if (/\bruff\b/.test(c)) return "ruff";
|
|
206
|
+
return present.length === 1 ? present[0] : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Правила записи на языке ЭТОГО линтера. ruff — коды из `--select` рецептов; eslint — имена из
|
|
210
|
+
// `--rule '{…}'`; Biome рецептов не имеет, у записи для него поле `biome_rules` (сверено по схеме
|
|
211
|
+
// конфигурации Biome 2.5.12). `none` — у линтера такого правила нет вовсе. `null` — не знаем.
|
|
212
|
+
function rulesFor(rec, linter) {
|
|
213
|
+
const recipes = Object.values(rec?.recipes && typeof rec.recipes === "object" ? rec.recipes : {}).map(String);
|
|
214
|
+
if (linter === "ruff") {
|
|
215
|
+
const codes = new Set();
|
|
216
|
+
for (const cmd of recipes) { const m = RULE_CODES.exec(cmd); if (m) for (const x of m[1].split(",")) if (x.trim()) codes.add(x.trim()); }
|
|
217
|
+
return codes.size ? [...codes] : null;
|
|
218
|
+
}
|
|
219
|
+
if (linter === "eslint") {
|
|
220
|
+
const names = new Set();
|
|
221
|
+
for (const cmd of recipes) for (const m of cmd.matchAll(/"([@a-z0-9/_-]+)"\s*:\s*\[?\s*"(?:error|warn)"/g)) names.add(m[1]);
|
|
222
|
+
return names.size ? [...names] : null;
|
|
223
|
+
}
|
|
224
|
+
if (linter === "biome") {
|
|
225
|
+
const v = typeof rec?.biome_rules === "string" ? rec.biome_rules.trim() : "";
|
|
226
|
+
if (!v) return null;
|
|
227
|
+
// Поле хранит `группа/правило` — так его ждёт `biome lint --only`; в biome.json правило
|
|
228
|
+
// лежит внутри объекта группы, поэтому ищется по имени без группы.
|
|
229
|
+
return v === "none" ? [] : v.split(",").map((x) => x.trim().split("/").pop()).filter(Boolean);
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// `configs` — тексты конфигов по линтерам: { ruff, eslint, biome, scripts }. Строка (прежний вид
|
|
235
|
+
// вызова) — один общий текст для всех. Исходы: unproven (линтер известен, его правил нет ни в
|
|
236
|
+
// команде, ни в конфиге) · impossible (у линтера такого правила нет) · unknown (линтер не
|
|
237
|
+
// распознан или правил записи для него мы не знаем — «не умею проверить», не обвинение).
|
|
238
|
+
function coversUnproven(man, catalog = [], configs = "") {
|
|
192
239
|
const { covered } = coversOf(man);
|
|
193
240
|
if (!covered.size) return [];
|
|
194
241
|
const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
|
|
195
|
-
const cfg =
|
|
242
|
+
const cfg = typeof configs === "string" ? { ruff: configs, eslint: configs, biome: configs } : (configs || {});
|
|
243
|
+
const present = ["ruff", "eslint", "biome"].filter((k) => cfg[k] && String(cfg[k]).trim() && typeof configs !== "string");
|
|
196
244
|
const out = [];
|
|
197
245
|
|
|
198
246
|
for (const [entry, gate] of covered) {
|
|
199
247
|
const rec = catalog.find((r) => r.slug === entry);
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
if (!
|
|
209
|
-
|
|
210
|
-
const haystack = `${String(gates[gate] || "")}\n${cfg}`;
|
|
211
|
-
const missing = [...codes].filter((c) => !haystack.includes(c));
|
|
212
|
-
if (missing.length === codes.size) out.push({ entry, gate, codes: [...codes] });
|
|
248
|
+
// Запись, которая не выражается правилами НИ ОДНОГО линтера (переносимые проверки), не
|
|
249
|
+
// сверяется вовсе: сверять нечего, и выдумывать вердикт нельзя.
|
|
250
|
+
if (!["ruff", "eslint", "biome"].some((k) => rulesFor(rec, k) !== null)) continue;
|
|
251
|
+
const linter = linterOf(gates[gate], cfg.scripts || {}, present);
|
|
252
|
+
const rules = linter ? rulesFor(rec, linter) : null;
|
|
253
|
+
if (rules === null) { out.push({ entry, gate, codes: [], linter, kind: "unknown" }); continue; }
|
|
254
|
+
if (!rules.length) { out.push({ entry, gate, codes: [], linter, kind: "impossible" }); continue; }
|
|
255
|
+
const haystack = `${String(gates[gate] || "")}\n${String(cfg[linter] || "")}`;
|
|
256
|
+
if (rules.every((r) => !haystack.includes(r))) out.push({ entry, gate, codes: rules, linter, kind: "unproven" });
|
|
213
257
|
}
|
|
214
258
|
return out;
|
|
215
259
|
}
|
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` есть и у того, кто настроил один только список разрешений;
|
|
@@ -378,40 +380,9 @@ async function matchCatalog(query) {
|
|
|
378
380
|
return out;
|
|
379
381
|
}
|
|
380
382
|
|
|
381
|
-
// С ЧЕГО НАЧАТЬ: три записи вместо двадцати равнозначных крестов.
|
|
382
|
-
//
|
|
383
|
-
// Двадцать одинаковых требований — это ноль требований: закрывают первое попавшееся или не
|
|
384
|
-
// закрывают ничего. Порядок НЕ по нашему вкусу; два признака, оба — факты, которые у нас уже
|
|
385
|
-
// есть:
|
|
386
|
-
// · запись родилась из настоящего отказа (`lifecycle: stable` — `proof` ссылается на журнал
|
|
387
|
-
// шишек), то есть она про боль, которая СЛУЧАЛАСЬ, а не про «хорошую практику»;
|
|
388
|
-
// · её можно закрыть одной готовой командой — цена входа минутная.
|
|
389
|
-
// Сначала то, что и больно, и дёшево.
|
|
390
|
-
//
|
|
391
|
-
// При равенстве признаков — по имени: одинаковый ввод обязан давать одинаковый ответ, иначе
|
|
392
|
-
// человек видит разный совет на двух прогонах подряд и перестаёт верить обоим.
|
|
393
|
-
function startWith(entries, facts, n = 3) {
|
|
394
|
-
const langs = facts?.langs ? [...facts.langs] : [];
|
|
395
|
-
const cheap = (e) => {
|
|
396
|
-
const r = e?.recipes && typeof e.recipes === "object" ? e.recipes : {};
|
|
397
|
-
return [...langs, "native"].some((k) => r[k] && !/\{gate\}/.test(r[k])) ? 1 : 0;
|
|
398
|
-
};
|
|
399
|
-
// Зрелость НЕ поле записи, а вычисляемый признак: `proof` ссылается на журнал шишек. Тот же
|
|
400
|
-
// признак, которым каталог отделяет условную запись с первого дня (`entryLifecycle`).
|
|
401
|
-
// Заводить второй счёт нельзя: разъехавшись, они дали бы разные ответы про одну запись.
|
|
402
|
-
const hurt = (e) => (/incidents\//.test(String(e?.proof || "")) ? 1 : 0);
|
|
403
|
-
return [...entries]
|
|
404
|
-
.sort((a, b) =>
|
|
405
|
-
(hurt(b) + cheap(b)) - (hurt(a) + cheap(a)) ||
|
|
406
|
-
cheap(b) - cheap(a) ||
|
|
407
|
-
String(a.slug).localeCompare(String(b.slug)))
|
|
408
|
-
.slice(0, n);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
383
|
// Наружу — то, что действительно импортируют другие файлы и модульные проверки. Экспорт,
|
|
412
384
|
// который никто не берёт, читается как часть договора и мешает менять внутренности.
|
|
413
385
|
export {
|
|
414
386
|
whichSync,
|
|
415
387
|
EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor, browserServerAdvice, MARKS,
|
|
416
|
-
startWith,
|
|
417
388
|
stems, overlap, matchCatalog, isApiSpec };
|
package/tool/lib/run.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
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";
|
|
@@ -65,9 +66,73 @@ function progress({ tty = process.stdout.isTTY, write = (s) => process.stdout.wr
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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;
|
|
71
136
|
const advisory = advisorySet(man);
|
|
72
137
|
const bar = progress();
|
|
73
138
|
|
|
@@ -79,14 +144,20 @@ function runGates(man, opts = {}) {
|
|
|
79
144
|
if (scoped) console.log(c.dim(`\n ${L.doctor.sinceHeading(opts.since, scoped.size)}`));
|
|
80
145
|
|
|
81
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`));
|
|
82
148
|
let failed = 0;
|
|
83
149
|
const results = [];
|
|
84
150
|
|
|
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();
|
|
85
154
|
for (const [i, [name, cmd]] of gates.entries()) {
|
|
86
155
|
bar.show(` ${c.dim("⋯")} ${name.padEnd(14)} ${c.dim(L.doctor.running(i + 1, gates.length))}`);
|
|
87
|
-
const t0 = Date.now();
|
|
88
|
-
const r =
|
|
156
|
+
const t0 = pooled ? tStart : Date.now();
|
|
157
|
+
const r = pooled ? await pooled[i] : spawnGate(cmd);
|
|
89
158
|
bar.clear();
|
|
159
|
+
// При --jobs время гейта от начала прогона: точнее не узнать без часов в потоке, и честнее
|
|
160
|
+
// так и считать, чем приписать гейту чужое ожидание в очереди.
|
|
90
161
|
const secs = (Math.max(0, Date.now() - t0) / 1000).toFixed(1);
|
|
91
162
|
// Вывод гейта запоминается целиком (с потолком, чтобы болтливый инструмент не съел память):
|
|
92
163
|
// по нему считается покрытие дифа — какой файл вообще был назван хоть одной проверкой.
|
|
@@ -188,7 +259,7 @@ function runGates(man, opts = {}) {
|
|
|
188
259
|
// тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
|
|
189
260
|
const advisoryFailed = results.filter((x) => x.advisory && !x.ok).map((x) => x.name);
|
|
190
261
|
if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
|
|
191
|
-
return { failed, ran: gates.length, results, advisoryFailed };
|
|
262
|
+
return { failed, ran: gates.length, results, advisoryFailed, skipped: sel.skipped };
|
|
192
263
|
}
|
|
193
264
|
|
|
194
|
-
export { declaredGates, sinceRef, runGates, progress };
|
|
265
|
+
export { declaredGates, sinceRef, runGates, progress, selectGates, listArg };
|
|
@@ -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, blindLines, parseBlind, parseRan } 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);
|
|
@@ -103,3 +103,17 @@ test("отметка помнит, какие гейты проба ПРОГОН
|
|
|
103
103
|
assert.equal(parseRan("at: 5\nblind: 1\n"), null);
|
|
104
104
|
assert.deepEqual([...parseRan("ran:\n")], []);
|
|
105
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
|
+
});
|