agent-quality-kit 0.13.0 → 0.14.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 +19 -5
- package/README.ru.md +20 -5
- package/kit/gates/api-contract-has-arbiter/README.md +16 -1
- package/kit/gates/api-contract-has-arbiter/check.sh +66 -23
- package/kit/gates/entry-commands-exist/README.md +64 -0
- package/kit/gates/entry-commands-exist/check.sh +110 -0
- package/kit/gates/entry-commands-exist/gate.yml +19 -0
- package/kit/gates/entry-commands-exist/green/AGENTS.md +13 -0
- package/kit/gates/entry-commands-exist/green/Makefile +6 -0
- package/kit/gates/entry-commands-exist/green/justfile +2 -0
- package/kit/gates/entry-commands-exist/green/package.json +10 -0
- package/kit/gates/entry-commands-exist/red/AGENTS.md +9 -0
- package/kit/gates/entry-commands-exist/red/Makefile +2 -0
- package/kit/gates/entry-commands-exist/red/package.json +9 -0
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/tool/commands/context.mjs +34 -15
- package/tool/commands/doctor-catalog.mjs +222 -0
- package/tool/commands/doctor.mjs +28 -211
- package/tool/commands/learn.mjs +119 -19
- package/tool/commands/probe.mjs +4 -2
- package/tool/commands/prompt.mjs +69 -0
- package/tool/i18n/en.mjs +65 -3
- package/tool/i18n/index.mjs +42 -3
- package/tool/i18n/ru.mjs +74 -3
- package/tool/lib/annotate.mjs +66 -0
- package/tool/lib/cadence.mjs +30 -1
- package/tool/lib/core.mjs +1 -0
- package/tool/lib/repo.mjs +45 -4
- package/tool/lib/run.mjs +20 -2
- package/tool/program.mjs +4 -0
- package/tool/selfcheck/smoke/_fixture.mjs +8 -3
- package/tool/selfcheck/smoke/api-contract.test.mjs +37 -0
- package/tool/selfcheck/smoke/corpus.test.mjs +151 -0
- package/tool/selfcheck/smoke/first-run.test.mjs +39 -0
- package/tool/selfcheck/smoke/verdict.test.mjs +41 -2
- package/tool/selfcheck/smoke.sh +4 -0
- package/tool/selfcheck/units-annotate.mjs +67 -0
- package/tool/selfcheck/units-cadence.mjs +26 -1
- package/tool/selfcheck/units-learn.mjs +32 -0
- package/tool/selfcheck/units-level.mjs +21 -1
- package/tool/selfcheck/units-prompt.mjs +106 -0
- package/tool/selfcheck/units-repo.mjs +20 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// tool/lib/annotate.mjs — находки упавших гейтов пометками GitHub Actions.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ. Находка видна только в логе конвейера, куда почти никто не заглядывает. Строка
|
|
4
|
+
// `::error file=…,line=…,title=…::…` в выводе шага становится красной пометкой у строки файла
|
|
5
|
+
// прямо в pull request. Идея — из разбора AgentLint (research/competitors/agentlint-0xmariowu.md);
|
|
6
|
+
// формат — документация GitHub «Workflow commands»; экранирование — официальный @actions/core
|
|
7
|
+
// (packages/core/src/command.ts, escapeData и escapeProperty), а не их код.
|
|
8
|
+
//
|
|
9
|
+
// ЧТО НЕ ДЕЛАЕТСЯ НИКОГДА: пометка не меняет вердикт. Это печать того же, что уже решил прогон.
|
|
10
|
+
import { splitAdvice, normPath } from "./scope.mjs";
|
|
11
|
+
|
|
12
|
+
const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
|
|
13
|
+
|
|
14
|
+
// Потолок. В документации GitHub лимита не нашли; в обсуждении сообщества 2020 года
|
|
15
|
+
// (github.com/orgs/community/discussions/26680): «10 warning annotations and 10 error annotations
|
|
16
|
+
// per step». Прогон `doctor` — один шаг, поэтому потолок на весь прогон, а остаток — числом в лог.
|
|
17
|
+
const LIMIT = 10;
|
|
18
|
+
|
|
19
|
+
// Путь с расширением или с косой чертой — как в scope.mjs. Две формы строки находки: «файл:строка:
|
|
20
|
+
// текст» (grep -n, линтеры, иногда ещё «:колонка») и «файл: текст» (наши записи каталога).
|
|
21
|
+
const PATH = "(?:\\.\\/)?((?:[\\w.@+-]+\\/)*[\\w.@+-]+\\.[A-Za-z][A-Za-z0-9]{0,9})";
|
|
22
|
+
const WITH_LINE = new RegExp(`^${PATH}:(\\d+)(?::\\d+)?:\\s*([\\s\\S]*)$`);
|
|
23
|
+
const NO_LINE = new RegExp(`^${PATH}:\\s+([\\s\\S]+)$`);
|
|
24
|
+
|
|
25
|
+
function locate(raw) {
|
|
26
|
+
const plain = String(raw).replace(ANSI, "").trim();
|
|
27
|
+
if (/^(почини|fix)\s*:/i.test(plain)) return null;
|
|
28
|
+
let m = WITH_LINE.exec(plain);
|
|
29
|
+
if (m) return { file: normPath(m[1]), line: Number(m[2]), message: m[3] || plain };
|
|
30
|
+
m = NO_LINE.exec(plain);
|
|
31
|
+
if (m) return { file: normPath(m[1]), line: null, message: m[2] };
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const escData = (s) => String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
36
|
+
const escProp = (s) => escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
37
|
+
|
|
38
|
+
// results — те же записи, что возвращает runGates: { name, ok, advisory, out, shown }. Берётся
|
|
39
|
+
// `shown` — показанное прогоном после сужения по дифу; сырой `out` — только если его нет. exists(путь) —
|
|
40
|
+
// есть ли файл в репозитории: пометка на несуществующий файл GitHub вешает на `.github`, и
|
|
41
|
+
// человек ищет то, чего нет. Такая находка идёт общей пометкой гейта без файла.
|
|
42
|
+
function annotations(results, { exists = () => true, limit = LIMIT } = {}) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
const used = { error: 0, warning: 0 };
|
|
45
|
+
let dropped = 0;
|
|
46
|
+
const emit = (level, title, message, loc = null) => {
|
|
47
|
+
if (used[level] >= limit) { dropped++; return; }
|
|
48
|
+
used[level]++;
|
|
49
|
+
const props = loc ? [`file=${escProp(loc.file)}`, ...(loc.line ? [`line=${loc.line}`] : [])] : [];
|
|
50
|
+
props.push(`title=${escProp(title)}`);
|
|
51
|
+
lines.push(`::${level} ${props.join(",")}::${escData(message)}`);
|
|
52
|
+
};
|
|
53
|
+
for (const r of results || []) {
|
|
54
|
+
if (r.ok) continue;
|
|
55
|
+
const level = r.advisory ? "warning" : "error";
|
|
56
|
+
const title = `aqk: ${r.name}`;
|
|
57
|
+
const { findings, advice } = splitAdvice(String(r.shown ?? r.out ?? "").split("\n").filter((l) => l.trim()));
|
|
58
|
+
const fix = advice.length ? ` — ${String(advice[0]).replace(ANSI, "").trim()}` : "";
|
|
59
|
+
const located = findings.map(locate).filter((f) => f && exists(f.file));
|
|
60
|
+
for (const f of located) emit(level, title, `${f.message}${fix}`, f);
|
|
61
|
+
if (!located.length) emit(level, title, `${String(findings[0] || r.note || "").replace(ANSI, "").trim()}${fix}`);
|
|
62
|
+
}
|
|
63
|
+
return { lines, dropped };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { locate, annotations };
|
package/tool/lib/cadence.mjs
CHANGED
|
@@ -99,4 +99,33 @@ function autoProbeAllowed({ brief = false, env = process.env } = {}) {
|
|
|
99
99
|
return !(env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI || env.BUILDKITE || env.JENKINS_URL);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
// Сколько классов проба поймала и у скольких поимка не доказана. Старая отметка чисел не несёт —
|
|
103
|
+
// null: «не знаем», а не «ноль пойманных».
|
|
104
|
+
function parseCounts(text) {
|
|
105
|
+
const caught = /^caught:[ \t]*(\d+)/m.exec(String(text || ""));
|
|
106
|
+
const unknown = /^unknown:[ \t]*(\d+)/m.exec(String(text || ""));
|
|
107
|
+
return caught && unknown ? { caught: Number(caught[1]), unknown: Number(unknown[1]) } : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Чего уровень НЕ доказывает — строкой под ним. Отзыв с живого проекта 2026-09-11: «AQK-3, All
|
|
111
|
+
// levels reached» при конвейере, который ни разу не запускался. Ступени меряют оснащённость:
|
|
112
|
+
// гейты показаны на образцах КАТАЛОГА. Ловят ли они брак в файлах ПРОЕКТА, знает только проба —
|
|
113
|
+
// и строка говорит ровно то, что знает она. Непойманное идёт первым, даже если пойманного
|
|
114
|
+
// больше: «четыре из пяти» глаз читает как «всё хорошо».
|
|
115
|
+
//
|
|
116
|
+
// Планку ступеней не подняли намеренно: отметка пробы лежит в .gitignore, в конвейере её нет, и
|
|
117
|
+
// «AQK-2 только после пробы» уронило бы уровень и `badge --check` у всех разом.
|
|
118
|
+
function levelLimits(st) {
|
|
119
|
+
if (st?.state === "off") return { kind: "off" };
|
|
120
|
+
const behind = Number.isFinite(st?.behind) ? st.behind : null;
|
|
121
|
+
if (st?.classes?.length) return { kind: "blind", names: st.classes.map((x) => x.slug), behind };
|
|
122
|
+
if (st?.counts) {
|
|
123
|
+
const { caught, unknown } = st.counts;
|
|
124
|
+
if (unknown > 0) return { kind: "partial", caught, unknown, behind };
|
|
125
|
+
return caught > 0 ? { kind: "caught", caught, behind } : { kind: "nothing", behind };
|
|
126
|
+
}
|
|
127
|
+
if (st?.ran) return { kind: "old", behind };
|
|
128
|
+
return { kind: "never" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan, autoProbeAllowed, parseCounts, levelLimits };
|
package/tool/lib/core.mjs
CHANGED
|
@@ -83,6 +83,7 @@ function commandRows(L) {
|
|
|
83
83
|
{ name: "learn", args: "", text: h.learn },
|
|
84
84
|
{ name: "context", args: "", text: h.context },
|
|
85
85
|
{ name: "context", args: "--full --install", text: h.contextInstall },
|
|
86
|
+
{ name: "prompt", args: "", text: h.prompt },
|
|
86
87
|
{ name: "badge", args: "", text: h.badge },
|
|
87
88
|
{ name: "vitals", args: "", text: h.vitals },
|
|
88
89
|
{ name: "version", args: "", text: h.version },
|
package/tool/lib/repo.mjs
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// триггера, выбор рецепта, сверка по намерению.
|
|
3
3
|
|
|
4
4
|
import { readdir, readFile } from "node:fs/promises";
|
|
5
|
-
import { existsSync, statSync } from "node:fs";
|
|
5
|
+
import { existsSync, statSync, lstatSync, realpathSync } from "node:fs";
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
|
-
import { spawnSync } from "node:child_process";
|
|
8
7
|
import { CWD, GATES_SRC, c, exists } from "./core.mjs";
|
|
9
8
|
import { parseManifest } from "./manifest.mjs";
|
|
10
9
|
import { L, LANG } from "../i18n/index.mjs";
|
|
@@ -34,6 +33,18 @@ function isApiSpec(name) {
|
|
|
34
33
|
return /^(openapi|swagger|asyncapi)[^/]*\.(ya?ml|json)$/i.test(name);
|
|
35
34
|
}
|
|
36
35
|
|
|
36
|
+
// Тот же договор без файла: схемы в коде, типы общие у сервера и клиента. Список — тот же, что
|
|
37
|
+
// в `api-contract-has-arbiter/check.sh`; разойдутся — запись покажут не тому проекту, и это
|
|
38
|
+
// сторожит проверка прогона. Один `zod` договором не считается: им разбирают и формы.
|
|
39
|
+
const CODE_CONTRACT = /"(@trpc\/server|@ts-rest\/core|@hono\/zod-openapi|@fastify\/type-provider-[a-z0-9-]+|fastify-type-provider-zod)"\s*:/;
|
|
40
|
+
async function isCodeContract(path) {
|
|
41
|
+
try {
|
|
42
|
+
return CODE_CONTRACT.test(await readFile(path, "utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
const SKIP_DIRS = new Set([".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".aqk"]);
|
|
38
49
|
|
|
39
50
|
// Факты о репозитории. Только то, что видно машине: спрашивать человека анкетой
|
|
@@ -102,7 +113,7 @@ async function detectFacts(man) {
|
|
|
102
113
|
if (/\.(test|spec)\.[a-z]+$/i.test(it.name) || /^test_.*\.py$/i.test(it.name) || /_test\.go$/i.test(it.name)) hasTests = true;
|
|
103
114
|
if (it.name.endsWith(".sql")) hasDb = true;
|
|
104
115
|
if (/\.(css|scss|sass|less|styl|vue|svelte|astro)$/i.test(it.name)) hasUi = true;
|
|
105
|
-
if (isApiSpec(it.name)) hasApiSpec = true;
|
|
116
|
+
if (isApiSpec(it.name) || (!hasApiSpec && it.name === "package.json" && (await isCodeContract(full)))) hasApiSpec = true;
|
|
106
117
|
const dot = it.name.lastIndexOf(".");
|
|
107
118
|
if (dot > 0) {
|
|
108
119
|
const lang = EXT_LANG[it.name.slice(dot)];
|
|
@@ -312,6 +323,36 @@ function browserServerAdvice(facts, mcpText = "") {
|
|
|
312
323
|
return { servers: ["chrome-devtools-mcp", "@playwright/mcp"] };
|
|
313
324
|
}
|
|
314
325
|
|
|
326
|
+
// Свод в AGENTS.md и Claude Code. Документация Claude Code (code.claude.com/docs/en/memory,
|
|
327
|
+
// раздел «AGENTS.md», сверено 2026-09-11): «Claude Code reads CLAUDE.md, not AGENTS.md» —
|
|
328
|
+
// рекомендовано CLAUDE.md с `@AGENTS.md` либо символическая ссылка. Проект, где Claude Code
|
|
329
|
+
// настроен, а правила лежат только в AGENTS.md, пишет их агенту, который их не читает.
|
|
330
|
+
// Молчим, где Claude Code нет вовсе: Codex и Cursor читают AGENTS.md сами. Упоминание словами
|
|
331
|
+
// («see AGENTS.md») — не подключение: файл в контекст не попадёт, агент дочитает или нет по
|
|
332
|
+
// настроению. `@` в обратных кавычках документация прямо называет «не импорт».
|
|
333
|
+
// Исходы: null — всё видно или нечего видеть; "missing" — CLAUDE.md нет; "noImport" — есть, но
|
|
334
|
+
// AGENTS.md не подключает.
|
|
335
|
+
function claudeSeesRules({ agents, claude, claudeLink, dotClaude }) {
|
|
336
|
+
if (!agents || claudeLink) return null;
|
|
337
|
+
if (claude === null) return dotClaude ? "missing" : null;
|
|
338
|
+
const prose = String(claude).replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
|
|
339
|
+
return /(^|\s)@(\.{1,2}\/)*AGENTS\.md\b/.test(prose) ? null : "noImport";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// То же, прочитанное с диска: одно место на `doctor` и `prompt`. Оба места CLAUDE.md —
|
|
343
|
+
// документация называет и ./CLAUDE.md, и ./.claude/CLAUDE.md; подключение в любом засчитывается.
|
|
344
|
+
async function claudeShimFor(cwd = CWD) {
|
|
345
|
+
const readOr = async (rel) => { try { return await readFile(join(cwd, rel), "utf8"); } catch { return null; } };
|
|
346
|
+
let claudeLink = false;
|
|
347
|
+
try { claudeLink = lstatSync(join(cwd, "CLAUDE.md")).isSymbolicLink() && /AGENTS\.md$/i.test(realpathSync(join(cwd, "CLAUDE.md"))); } catch { /* файла нет */ }
|
|
348
|
+
return claudeSeesRules({
|
|
349
|
+
agents: await exists(join(cwd, "AGENTS.md")),
|
|
350
|
+
claude: [await readOr("CLAUDE.md"), await readOr(".claude/CLAUDE.md")].filter((t) => t !== null).join("\n") || null,
|
|
351
|
+
claudeLink,
|
|
352
|
+
dotClaude: await exists(join(cwd, ".claude")),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
315
356
|
function recipeFor(rec, facts) {
|
|
316
357
|
const cmd = pickRecipe(rec, facts);
|
|
317
358
|
if (!cmd) return L.recipe.none;
|
|
@@ -385,4 +426,4 @@ async function matchCatalog(query) {
|
|
|
385
426
|
export {
|
|
386
427
|
whichSync,
|
|
387
428
|
EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor, browserServerAdvice, MARKS,
|
|
388
|
-
stems, overlap, matchCatalog, isApiSpec };
|
|
429
|
+
stems, overlap, matchCatalog, isApiSpec, claudeSeesRules, claudeShimFor };
|
package/tool/lib/run.mjs
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
// файл был полон, и любая следующая правка ложилась туда просто потому, что «так ближе по
|
|
10
10
|
// контексту». Ровно то, о чём предупреждает совет самого гейта.
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
12
14
|
import { Worker } from "node:worker_threads";
|
|
13
15
|
import { scopeOutput, splitAdvice, changedFiles } from "./scope.mjs";
|
|
14
16
|
import { CWD, c, die } from "./core.mjs";
|
|
15
17
|
import { advisorySet } from "./manifest.mjs";
|
|
16
18
|
import { L } from "../i18n/index.mjs";
|
|
17
19
|
import { gateCommand } from "./execution.mjs";
|
|
20
|
+
import { annotations } from "./annotate.mjs";
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
// «Гейт объявлен» и «гейт работает» — разные утверждения. Первое читается из манифеста,
|
|
@@ -147,6 +150,7 @@ async function runGates(man, opts = {}) {
|
|
|
147
150
|
if (sel.skipped.length) console.log(c.yellow(` ${L.doctor.selectSkipped(sel.skipped.join(", "))}\n`));
|
|
148
151
|
let failed = 0;
|
|
149
152
|
const results = [];
|
|
153
|
+
const quietOk = [];
|
|
150
154
|
|
|
151
155
|
const jobs = Math.max(1, Number(opts.jobs) || 1);
|
|
152
156
|
const pooled = jobs > 1 ? startPool(gates.map(([, cmd]) => cmd), jobs) : null;
|
|
@@ -178,12 +182,16 @@ async function runGates(man, opts = {}) {
|
|
|
178
182
|
// совещательный печатался обычной галочкой, а README обещал, что список назван каждый
|
|
179
183
|
// прогон. Тот же класс, что молчащий гейт, только про сам прибор.
|
|
180
184
|
const quiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
|
|
181
|
-
console.log(` ${c.green("✔")} ${name.padEnd(14)}${quiet} ${c.dim(`${secs}s · ${cmd}`)}`);
|
|
182
185
|
// Зелёный гейт иногда всё-таки говорит человеку что-то важное: храповик, дошедший до цели,
|
|
183
186
|
// просит убрать обёртку. Вывод успешного гейта не показывался вовсе, и это сообщение
|
|
184
187
|
// уходило в никуда — тот же класс, что обрезанный совет у красного, только тише.
|
|
185
188
|
// Показываем ровно строки с меткой совета: остальной вывод успешной проверки — шум.
|
|
186
189
|
const okAdvice = splitAdvice(`${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean)).advice;
|
|
190
|
+
// Без `--verbose` зелёный гейт своей строки не получает — если ему нечего сказать. Совещательный
|
|
191
|
+
// и гейт с советом печатаются всегда: первый обязан быть назван каждый прогон (см. выше),
|
|
192
|
+
// второй несёт строку, ради которой человек и смотрит.
|
|
193
|
+
if (opts.verbose || quiet || okAdvice.length) console.log(` ${c.green("✔")} ${name.padEnd(14)}${quiet} ${c.dim(`${secs}s · ${cmd}`)}`);
|
|
194
|
+
else quietOk.push(name);
|
|
187
195
|
for (const line of okAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
|
|
188
196
|
results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), out: outAll });
|
|
189
197
|
} else {
|
|
@@ -252,13 +260,23 @@ async function runGates(man, opts = {}) {
|
|
|
252
260
|
}
|
|
253
261
|
// Совет тоже не бесконечен: гейт, зовущий помощник шесть раз, печатает его шесть раз.
|
|
254
262
|
for (const line of alwaysAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
|
|
255
|
-
|
|
263
|
+
// `shown` — то, что прогон ПОКАЗАЛ: после сужения по дифу и с советом. Пометки в pull request
|
|
264
|
+
// берутся отсюда, а не из сырого вывода: иначе при --since они вешались бы на файлы вне
|
|
265
|
+
// дифа — поймано конвейером на первом же прогоне (smoke: «--since сузил не то»).
|
|
266
|
+
results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll, shown: [...out, ...alwaysAdvice].join("\n") });
|
|
256
267
|
}
|
|
257
268
|
}
|
|
258
269
|
// Совещательные, которые покраснели, называются вслух ВСЕГДА. Молчание о них — ровно та
|
|
259
270
|
// тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
|
|
271
|
+
if (quietOk.length) console.log(` ${c.green("✔")} ${L.doctor.passedQuiet(quietOk.length)}`);
|
|
260
272
|
const advisoryFailed = results.filter((x) => x.advisory && !x.ok).map((x) => x.name);
|
|
261
273
|
if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
|
|
274
|
+
// В GitHub Actions — те же находки пометками у строк файла в pull request. Вердикт не меняется.
|
|
275
|
+
if (process.env.GITHUB_ACTIONS === "true") {
|
|
276
|
+
const ann = annotations(results, { exists: (f) => existsSync(join(CWD, f)) });
|
|
277
|
+
for (const line of ann.lines) console.log(line);
|
|
278
|
+
if (ann.dropped) console.log(c.dim(` ${L.doctor.annotDropped(ann.lines.length, ann.dropped)}`));
|
|
279
|
+
}
|
|
262
280
|
return { failed, ran: gates.length, results, advisoryFailed, skipped: sel.skipped };
|
|
263
281
|
}
|
|
264
282
|
|
package/tool/program.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { cmdBadge } from "./commands/badge.mjs";
|
|
|
28
28
|
import { cmdProve } from "./commands/prove.mjs";
|
|
29
29
|
import { cmdProbe } from "./commands/probe.mjs";
|
|
30
30
|
import { cmdContext } from "./commands/context.mjs";
|
|
31
|
+
import { cmdPrompt } from "./commands/prompt.mjs";
|
|
31
32
|
import { cmdVitals } from "./commands/vitals.mjs";
|
|
32
33
|
|
|
33
34
|
// Разбор аргументов выполняется только при запуске файла как программы. При импорте —
|
|
@@ -120,6 +121,9 @@ if (IS_MAIN) {
|
|
|
120
121
|
case "context":
|
|
121
122
|
await cmdContext(rest);
|
|
122
123
|
break;
|
|
124
|
+
case "prompt":
|
|
125
|
+
await cmdPrompt();
|
|
126
|
+
break;
|
|
123
127
|
case "badge":
|
|
124
128
|
await cmdBadge(rest);
|
|
125
129
|
break;
|
|
@@ -55,6 +55,9 @@ function env(home, dir) {
|
|
|
55
55
|
// Сеть в тестах — отдельный класс флейков, и у нас она включалась ТОЛЬКО вне конвейера:
|
|
56
56
|
// локально проверки были сетевыми, в конвейере нет. Две разные среды по построению.
|
|
57
57
|
AQK_UPDATE: "0",
|
|
58
|
+
// Вывод — как у пользователя, короткий. `smoke.sh` включает AQK_VERBOSE=1 для СВОИХ
|
|
59
|
+
// проверок, написанных до свёртки, и без сброса здесь проверки свёртки его унаследовали бы.
|
|
60
|
+
AQK_VERBOSE: "",
|
|
58
61
|
};
|
|
59
62
|
}
|
|
60
63
|
|
|
@@ -82,14 +85,16 @@ function project(t, files = {}, { git = true } = {}) {
|
|
|
82
85
|
// Запуск чего угодно в каталоге проекта. Возвращается И код, И вывод: проверка, смотрящая
|
|
83
86
|
// только на код, не умеет объяснить провал, а смотрящая только на вывод — не умеет заметить,
|
|
84
87
|
// что команда не роняет прогон.
|
|
85
|
-
|
|
88
|
+
// `extra` — переменные поверх песочницы, когда проверка про само окружение (GITHUB_ACTIONS).
|
|
89
|
+
function run({ dir, home }, cmd, args = [], extra = {}) {
|
|
86
90
|
const r = spawnSync(cmd, args, {
|
|
87
|
-
cwd: dir, encoding: "utf8", timeout: TIMEOUT_MS, env: env(home, dir),
|
|
91
|
+
cwd: dir, encoding: "utf8", timeout: TIMEOUT_MS, env: { ...env(home, dir), ...extra },
|
|
88
92
|
});
|
|
89
93
|
return { code: r.status, out: `${r.stdout || ""}${r.stderr || ""}` };
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
const aqk = (p, ...args) => run(p, process.execPath, [CLI, ...args]);
|
|
97
|
+
const aqkEnv = (p, extra, ...args) => run(p, process.execPath, [CLI, ...args], extra);
|
|
93
98
|
|
|
94
99
|
// ПОЧЕМУ ГЕЙТ ЗОВЁТСЯ ТОЧКОЙ, А НЕ ПУТЁМ, И ПОЧЕМУ ПУТЬ ЧЕРЕЗ КОСУЮ.
|
|
95
100
|
// Node на Windows отдаёт `C:\Users\…\Temp\aqk-smoke-x`, а Git Bash живёт в POSIX-мире и
|
|
@@ -108,4 +113,4 @@ const gate = (p, name, sub = ".") =>
|
|
|
108
113
|
// никто не берёт, читается как часть договора и мешает менять внутренности — поймал наш же
|
|
109
114
|
// dead-code через минуту после того, как файл был написан. `run` вернулся, когда появилась
|
|
110
115
|
// проверка, которой нужен сырой git: экспорт заводится под потребителя, а не про запас.
|
|
111
|
-
export { project, run, aqk, gate };
|
|
116
|
+
export { project, run, aqk, aqkEnv, gate };
|
|
@@ -77,3 +77,40 @@ test("держателем не считается определение сос
|
|
|
77
77
|
// Второй дефект того же прогона: `find` без завершающего -print печатал обойдённые каталоги.
|
|
78
78
|
assert.doesNotMatch(r.out, /\.git|\.aqk/, `в списке спецификаций каталоги:\n${r.out}`);
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
// ДОГОВОР В КОДЕ. Отзыв с живого проекта 2026-09-11: схемы zod запросов и ответов в общем
|
|
82
|
+
// пакете, сервер на `@fastify/type-provider-zod` — а кит писал «спецификации API не видно»,
|
|
83
|
+
// потому что узнавал договор только по имени файла `openapi*`. У такого договора арбитр —
|
|
84
|
+
// проверка типов: разошлись сервер и клиент — `tsc` краснеет. Не запускает её никто — договор
|
|
85
|
+
// не держит никто, ровно как файл OpenAPI без schemathesis.
|
|
86
|
+
test("договор в коде без проверки типов — находка, с tsc — чисто, zod один — не договор", (t) => {
|
|
87
|
+
const provider = { "backend/package.json": '{ "dependencies": { "@fastify/type-provider-zod": "1.0.0", "zod": "4.5.4" } }\n' };
|
|
88
|
+
const bare = gate(project(t, provider), "api-contract-has-arbiter");
|
|
89
|
+
assert.equal(bare.code, 1, `договор без арбитра прошёл зелёным:\n${bare.out}`);
|
|
90
|
+
assert.match(bare.out, /backend\/package\.json/, `находка не называет, где договор:\n${bare.out}`);
|
|
91
|
+
|
|
92
|
+
const held = gate(project(t, {
|
|
93
|
+
...provider,
|
|
94
|
+
"package.json": '{ "scripts": { "typecheck": "tsc --build" } }\n',
|
|
95
|
+
}), "api-contract-has-arbiter");
|
|
96
|
+
assert.equal(held.code, 0, `tsc в scripts не признан арбитром:\n${held.out}`);
|
|
97
|
+
|
|
98
|
+
for (const dep of ["@trpc/server", "@ts-rest/core", "@hono/zod-openapi", "fastify-type-provider-zod"]) {
|
|
99
|
+
const r = gate(project(t, { "package.json": `{ "dependencies": { "${dep}": "1.0.0" } }\n` }), "api-contract-has-arbiter");
|
|
100
|
+
assert.equal(r.code, 1, `${dep} не опознан как договор:\n${r.out}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// zod сам по себе — разбор входа, а не договор с чужим кодом: так его зовут и формы, и конфиги.
|
|
104
|
+
const zod = gate(project(t, { "package.json": '{ "dependencies": { "zod": "4.5.4" } }\n' }), "api-contract-has-arbiter");
|
|
105
|
+
assert.equal(zod.code, 0, `один zod объявлен договором:\n${zod.out}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("doctor видит договор в коде: запись применима, а не «спецификации не видно»", (t) => {
|
|
109
|
+
const p = project(t, { "package.json": '{ "dependencies": { "@trpc/server": "11.18.0" } }\n' });
|
|
110
|
+
aqk(p, "init");
|
|
111
|
+
// --verbose: без него неприменимые свёрнуты в счёт, причин в выводе нет вовсе — и проверка
|
|
112
|
+
// прошла бы вхолостую при любом опознании.
|
|
113
|
+
const r = aqk(p, "doctor", "--verbose");
|
|
114
|
+
assert.match(r.out, /api-contract-has-arbiter/, `запись не названа вовсе:\n${r.out}`);
|
|
115
|
+
assert.doesNotMatch(r.out, /не видно (спецификации|договора) API|no API (specification|contract) in sight/, `договор в коде не опознан:\n${r.out}`);
|
|
116
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Набор настоящих случаев: то, что мы нашли на живых проектах, в журнале и в разборах соседей,
|
|
2
|
+
// — минимальными воспроизведениями с ожидаемым ответом комплекта.
|
|
3
|
+
//
|
|
4
|
+
// ЗАЧЕМ. Образцы red/green проверяют запись В ОДИНОЧКУ, на примере, написанном под неё. Ломается
|
|
5
|
+
// же комплект на целых проектах, где записи стоят рядом: «определение соседней записи —
|
|
6
|
+
// находка» (журнал, 2026-09-09) образцы не поймали ни разу. Разбор AgentLint 2026-09-11 показал
|
|
7
|
+
// другую половину: их «точность 99%» мерила согласие сканера с собственным пересказом правил.
|
|
8
|
+
// Здесь разметка — по СВОЙСТВУ проекта: у каждого случая назван источник, где это свойство
|
|
9
|
+
// видели на деле. Файлы случаев живут внутри проверки, а не в дереве: иначе наши же гейты
|
|
10
|
+
// находили бы в них «находки» у нас в репозитории.
|
|
11
|
+
//
|
|
12
|
+
// Каждый случай — минимальное воспроизведение, а не копия: чужой код переносить нельзя по
|
|
13
|
+
// лицензии, и копия в тысячи строк проверяла бы то же самое, только медленнее.
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { project, aqk, aqkEnv, gate } from "./_fixture.mjs";
|
|
20
|
+
|
|
21
|
+
const KIT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "kit", "gates");
|
|
22
|
+
|
|
23
|
+
const TWIN = (fake) => ({
|
|
24
|
+
"CLAUDE.md": "# Shop API\n\nSmall HTTP API.\n\n## Commands\n- Test: `npm test`\n",
|
|
25
|
+
"package.json": JSON.stringify({ name: "shop", type: "module", scripts: { test: fake ? "node --test || true" : "node --test" } }),
|
|
26
|
+
"src/cart.js": "export function total(items) {\n return items.reduce((s, i) => s + i.price, 0);\n}\n",
|
|
27
|
+
"test/cart.test.js": fake
|
|
28
|
+
? "import test from 'node:test';\nimport { total } from '../src/cart.js';\ntest('total', () => { total([{ price: 2 }]); });\n"
|
|
29
|
+
: "import test from 'node:test';\nimport assert from 'node:assert';\nimport { total } from '../src/cart.js';\ntest('total', () => { assert.equal(total([{ price: 2 }]), 2); });\n",
|
|
30
|
+
".github/workflows/ci.yml": "name: ci\non: [push]\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n" +
|
|
31
|
+
" - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262\n - run: npm test\n" +
|
|
32
|
+
(fake ? " continue-on-error: true\n" : ""),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const CASES = [
|
|
36
|
+
{
|
|
37
|
+
name: "близнец с настоящими воротами",
|
|
38
|
+
source: "research/competitors/agentlint-0xmariowu.md, «Близнецы»: AgentLint дал 71 обоим",
|
|
39
|
+
files: TWIN(false),
|
|
40
|
+
gates: { "ci-actually-fails": 0, "test-has-assertion": 0 },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "близнец с поддельными воротами",
|
|
44
|
+
source: "там же; test-has-assertion молчит намеренно — «вызов без утверждения» у нас не находка (.aqk.yml)",
|
|
45
|
+
files: TWIN(true),
|
|
46
|
+
gates: { "ci-actually-fails": 1, "test-has-assertion": 0 },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "ворота в check.yml под continue-on-error, «exit 1» в комментарии",
|
|
50
|
+
source: "agentlint-0xmariowu.md, H7: их проверка назвала такие ворота блокирующими",
|
|
51
|
+
files: {
|
|
52
|
+
".github/workflows/check.yml": "name: check\non: [push]\npermissions:\n contents: read\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n" +
|
|
53
|
+
" - run: npm test\n continue-on-error: true\n# on failure the step does exit 1\n",
|
|
54
|
+
"package.json": JSON.stringify({ name: "x", scripts: { test: "node --test" } }),
|
|
55
|
+
},
|
|
56
|
+
gates: { "ci-actually-fails": 1 },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "свод велит удалённый скрипт, а debug остался в зависимостях",
|
|
60
|
+
source: "incidents/README.md 2026-09-11; JonnyKreng/pebble-navi 757c563",
|
|
61
|
+
files: {
|
|
62
|
+
"AGENTS.md": "# Rules\n\n```sh\nnpm run debug # build + install + logs\n```\n",
|
|
63
|
+
"package.json": JSON.stringify({ name: "x", scripts: { build: "tsc" }, dependencies: { debug: "4.3.4" } }, null, 2),
|
|
64
|
+
},
|
|
65
|
+
gates: { "entry-commands-exist": 1 },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "цели make в коде свода, «make sure» в прозе",
|
|
69
|
+
source: "живой проект на TypeScript и Biome: шестнадцать целей make в AGENTS.md, все на месте",
|
|
70
|
+
files: {
|
|
71
|
+
"AGENTS.md": "# Rules\n\nMake sure the tree is clean.\n\n- проверка: `make check`\n- типы: `make typecheck`\n",
|
|
72
|
+
"Makefile": ".PHONY: check typecheck\ncheck:\n\tnpm run lint\ntypecheck:\n\tnpx tsc --noEmit\n",
|
|
73
|
+
"package.json": JSON.stringify({ name: "x", scripts: { lint: "biome lint ." } }),
|
|
74
|
+
},
|
|
75
|
+
gates: { "entry-commands-exist": 0 },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "договор в коде (провайдер типов Fastify) без проверки типов",
|
|
79
|
+
source: "отзыв с живого проекта 2026-09-11: «спецификации API не видно» при zod-схемах в общем пакете",
|
|
80
|
+
files: { "backend/package.json": JSON.stringify({ dependencies: { "@fastify/type-provider-zod": "1.0.0", zod: "4.5.4" } }) },
|
|
81
|
+
gates: { "api-contract-has-arbiter": 1 },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "тот же договор, tsc --build в scripts",
|
|
85
|
+
source: "там же: на живом проекте запись применима и зелёная",
|
|
86
|
+
files: {
|
|
87
|
+
"backend/package.json": JSON.stringify({ dependencies: { "@fastify/type-provider-zod": "1.0.0" } }),
|
|
88
|
+
"package.json": JSON.stringify({ scripts: { typecheck: "tsc --build" } }),
|
|
89
|
+
},
|
|
90
|
+
gates: { "api-contract-has-arbiter": 0 },
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "CLAUDE.md с windows-переносами подключает AGENTS.md",
|
|
94
|
+
source: "живой проект на Windows; AgentLint (F7) объявил подключение битым",
|
|
95
|
+
files: {
|
|
96
|
+
"CLAUDE.md": "# CLAUDE.md\r\n\r\nПравила — в AGENTS.md.\r\n\r\n@AGENTS.md\r\n",
|
|
97
|
+
"AGENTS.md": "# Правила\n\n- тесты перед коммитом\n",
|
|
98
|
+
".claude/settings.json": "{}\n",
|
|
99
|
+
},
|
|
100
|
+
doctorNot: /(не подключает AGENTS|does not import AGENTS|Claude Code здесь настроен|Claude Code is set up)/,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "русский свод, машина без LANG",
|
|
104
|
+
source: "отзыв с живого проекта 2026-09-11: Windows без LANG — весь вывод английский",
|
|
105
|
+
files: {
|
|
106
|
+
"AGENTS.md": "# Правила проекта\n\n" + "Перед коммитом запусти проверку и убедись, что она проходит. Изменение договора проходит через ревью и фиксируется в журнале решений. ".repeat(3) + "\n",
|
|
107
|
+
},
|
|
108
|
+
env: { AQK_LANG: "", LANG: "", LC_ALL: "", LC_MESSAGES: "" },
|
|
109
|
+
doctorHas: /Уровень/,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "определения записей, разложенные в проект, — не находки",
|
|
113
|
+
source: "incidents/README.md 2026-09-09 «аудит фич нашёл то, чего не нашли образцы»; AgentLint S6/S7 краснеют на таком же",
|
|
114
|
+
files: { "src/app.py": "def f():\n return 1\n" },
|
|
115
|
+
before: [["init"], ["add", "secrets-not-in-code"], ["add", "personal-config-not-shared"], ["add", "env-secrets-not-committed"]],
|
|
116
|
+
gates: { "secrets-not-in-code": 0, "personal-config-not-shared": 0, "env-secrets-not-committed": 0 },
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
// Записи, которые работают на одном `sh` и дают один и тот же ответ на любой машине: без
|
|
121
|
+
// `requires:` (нет чужого инструмента — ответ зависел бы от установленного) и без сети.
|
|
122
|
+
const NETWORK = new Set(["no-phantom-package"]);
|
|
123
|
+
const PORTABLE = readdirSync(KIT, { withFileTypes: true })
|
|
124
|
+
.filter((d) => d.isDirectory() && existsSync(join(KIT, d.name, "gate.yml")))
|
|
125
|
+
.map((d) => d.name)
|
|
126
|
+
.filter((slug) => {
|
|
127
|
+
const yml = readFileSync(join(KIT, slug, "gate.yml"), "utf8");
|
|
128
|
+
return !NETWORK.has(slug) && /any:\s*bash \{gate\}\/check\.sh \{dir\}/.test(yml) && !/^requires:/m.test(yml);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
for (const c of CASES) {
|
|
132
|
+
test(`случай: ${c.name}`, (t) => {
|
|
133
|
+
const p = project(t, c.files);
|
|
134
|
+
for (const args of c.before || []) aqk(p, ...args);
|
|
135
|
+
for (const [slug, want] of Object.entries(c.gates || {})) {
|
|
136
|
+
const r = gate(p, slug);
|
|
137
|
+
assert.equal(r.code, want, `${slug}: ждали ${want}, получили ${r.code}. Источник: ${c.source}\n${r.out}`);
|
|
138
|
+
}
|
|
139
|
+
if (c.doctorNot || c.doctorHas) {
|
|
140
|
+
if (!c.before) aqk(p, "init");
|
|
141
|
+
const r = c.env ? aqkEnv(p, c.env, "doctor") : aqk(p, "doctor");
|
|
142
|
+
if (c.doctorNot) assert.doesNotMatch(r.out, c.doctorNot, `источник: ${c.source}\n${r.out}`);
|
|
143
|
+
if (c.doctorHas) assert.match(r.out, c.doctorHas, `источник: ${c.source}\n${r.out}`);
|
|
144
|
+
}
|
|
145
|
+
// Перекрёстная проверка: на обычном проекте ни одна переносимая запись не отвечает «не смогли
|
|
146
|
+
// проверить». Код 2 здесь — сбой записи на чужой раскладке, а не свойство проекта.
|
|
147
|
+
const broken = PORTABLE.map((slug) => [slug, gate(p, slug)]).filter(([, r]) => r.code !== 0 && r.code !== 1);
|
|
148
|
+
assert.deepEqual(broken.map(([s, r]) => `${s}: код ${r.code}`), [],
|
|
149
|
+
`записи не смогли проверить обычный проект:\n${broken.map(([s, r]) => `${s}:\n${r.out}`).join("\n")}`);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -103,3 +103,42 @@ test("нет .aqk/docs и .aqk/rules — прогон всё равно зелё
|
|
|
103
103
|
assert.equal(r.code, 0, `прогон покраснел из-за методичек:\n${tail}`);
|
|
104
104
|
assert.match(plain(r.out), /\.aqk\/docs/, "отсутствие методичек не названо вовсе");
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
// Отзыв с живого проекта 2026-09-11: «AQK-3, All levels reached» при конвейере, который ни разу
|
|
108
|
+
// не запускался. Под уровнем — чего он не доказывает, и что про это знает проба. Здесь пробы
|
|
109
|
+
// не было: строка обязана это сказать, а не промолчать — тишину глаз читает как «всё хорошо».
|
|
110
|
+
test("под уровнем стоит, чего он не доказывает", (t) => {
|
|
111
|
+
const p = project(t, {
|
|
112
|
+
".aqk.yml": 'aqk: 1\nentry: [AGENTS.md]\nrules: rules\ngates:\n lint: "true"\n',
|
|
113
|
+
"AGENTS.md": "# rules\n",
|
|
114
|
+
"rules/a.md": "# a\n",
|
|
115
|
+
});
|
|
116
|
+
const r = aqk(p, "doctor");
|
|
117
|
+
assert.match(r.out, /AQK-1/, r.out);
|
|
118
|
+
assert.match(r.out, /(оснащённость, а не надёжность|tooling, not reliability)/, `нет оговорки под уровнем:\n${r.out}`);
|
|
119
|
+
assert.match(r.out, /(проба не запускалась|probe has never run)/, `про пробу — тишина:\n${r.out}`);
|
|
120
|
+
assert.match(r.out, /(конвейер: прошёл ли он|pipeline: whether it passed)/, r.out);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Claude Code читает CLAUDE.md, а не AGENTS.md (документация, сверено 2026-09-11). Проект с
|
|
124
|
+
// .claude/ и сводом только в AGENTS.md пишет правила агенту, который их не читает.
|
|
125
|
+
test("doctor называет свод, которого не видит Claude Code, и молчит, когда он подключён", (t) => {
|
|
126
|
+
const p = project(t, { ".claude/settings.json": "{}\n", "AGENTS.md": "# Rules\n\n- run tests\n" });
|
|
127
|
+
assert.match(plain(aqk(p, "doctor").out), /(Claude Code здесь настроен|Claude Code is set up here)/);
|
|
128
|
+
writeFileSync(join(p.dir, "CLAUDE.md"), "@AGENTS.md\n", "utf8");
|
|
129
|
+
assert.doesNotMatch(plain(aqk(p, "doctor").out), /(Claude Code здесь настроен|не подключает AGENTS|Claude Code is set up|does not import AGENTS)/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// `aqk prompt` на свежем проекте: прогона не было — задание так и говорит и первым пунктом
|
|
133
|
+
// велит прогнать, а не выдаёт пустой список за «всё чисто». Ничего не пишет на диск.
|
|
134
|
+
test("prompt: задание без прогона начинается с прогона и кончается проверкой", (t) => {
|
|
135
|
+
const p = project(t, FILES);
|
|
136
|
+
aqk(p, "init");
|
|
137
|
+
const r = aqk(p, "prompt");
|
|
138
|
+
assert.equal(r.code, 0, r.out);
|
|
139
|
+
const items = r.out.split("\n").filter((l) => /^\d+\. /.test(l));
|
|
140
|
+
assert.match(items[0] || "", /(Прогона ещё не было|no run yet)/, `первый пункт не про прогон:\n${r.out}`);
|
|
141
|
+
assert.ok(items.length <= 5, "больше пяти пунктов за раз");
|
|
142
|
+
assert.match(r.out, /(Как проверить, что готово|How to verify)/);
|
|
143
|
+
assert.doesNotMatch(r.out, /\x1b\[/, "задание уходит агенту текстом — без цветовых кодов");
|
|
144
|
+
});
|
|
@@ -8,7 +8,7 @@ import test from "node:test";
|
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import { readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { project, aqk } from "./_fixture.mjs";
|
|
11
|
+
import { project, aqk, aqkEnv } from "./_fixture.mjs";
|
|
12
12
|
|
|
13
13
|
test("прогон называет свой вердикт словами в обоих исходах", (t) => {
|
|
14
14
|
const p = project(t, { "src/a.py": "def s():\n return 1\n" });
|
|
@@ -71,7 +71,8 @@ test("--jobs 3: три секундных гейта идут одновреме
|
|
|
71
71
|
writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m,
|
|
72
72
|
'gates:\n a: "sleep 1"\n b: "sleep 1; exit 1"\n c: "sleep 1"'), "utf8");
|
|
73
73
|
const t0 = Date.now();
|
|
74
|
-
|
|
74
|
+
// --verbose: зелёные a и c без него свёрнуты в одну строку, а проверяется порядок всех трёх.
|
|
75
|
+
const r = aqk(p, "doctor", "--run", "--jobs", "3", "--verbose");
|
|
75
76
|
const secs = (Date.now() - t0) / 1000;
|
|
76
77
|
assert.notEqual(r.code, 0, "упавший гейт b не уронил прогон");
|
|
77
78
|
const order = [...r.out.matchAll(/^\s+\S+\s+([abc])\s/gm)].map((m) => m[1]);
|
|
@@ -87,3 +88,41 @@ test("--jobs без числа или с нулём — отказ с причи
|
|
|
87
88
|
assert.notEqual(r.code, 0);
|
|
88
89
|
assert.match(r.out, /--jobs/);
|
|
89
90
|
});
|
|
91
|
+
|
|
92
|
+
// Коротко по умолчанию. Отзыв с живого проекта 2026-09-11: сто строк вывода, семьдесят из них —
|
|
93
|
+
// зелёные галочки, и красное теряется между ними. Свёрнуто только то, что ничего не требует;
|
|
94
|
+
// упавший и совещательный видны всегда, поимённо — по --verbose.
|
|
95
|
+
test("зелёные гейты свёрнуты в строку, упавший и совещательный видны; --verbose — все", (t) => {
|
|
96
|
+
const p = project(t, { "src/a.py": "x = 1\n", ".gitignore": "x\n" });
|
|
97
|
+
aqk(p, "init");
|
|
98
|
+
const man = join(p.dir, ".aqk.yml");
|
|
99
|
+
writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m,
|
|
100
|
+
'gates:\n green1: "true"\n green2: "true"\n broken: "false"\n advice: "true"') + "advisory: [advice]\n", "utf8");
|
|
101
|
+
const short = aqk(p, "doctor", "--run").out;
|
|
102
|
+
assert.doesNotMatch(short, /^\s+\S+\s+green1\s/m, `зелёный гейт не свёрнут:\n${short}`);
|
|
103
|
+
assert.match(short, /^\s+\S+\s+broken\s/m, "упавший гейт спрятан");
|
|
104
|
+
assert.match(short, /^\s+\S+\s+advice\s/m, "совещательный обязан быть назван каждый прогон");
|
|
105
|
+
assert.match(short, /(прошли ещё 2|2 more passed)/, `нет строки со счётом свёрнутых:\n${short}`);
|
|
106
|
+
const full = aqk(p, "doctor", "--run", "--verbose").out;
|
|
107
|
+
assert.match(full, /^\s+\S+\s+green1\s/m, "--verbose не показал зелёный гейт");
|
|
108
|
+
assert.doesNotMatch(full, /(прошли ещё|more passed)/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Находки — пометками у строк файла в pull request, но только в GitHub Actions: в терминале
|
|
112
|
+
// строка `::error …` — мусор. Вердикт от пометок не меняется.
|
|
113
|
+
test("в GitHub Actions упавший гейт даёт пометку у строки файла; вне его — нет", (t) => {
|
|
114
|
+
// Гейт — файлом через bash: строку «echo …; exit 1» Node на Windows отдаёт cmd.exe, где точка с
|
|
115
|
+
// запятой не разделяет команды, — код 0, и проверка падала на оснастке (поймано конвейером).
|
|
116
|
+
const p = project(t, {
|
|
117
|
+
"src/a.py": "x = 1\n", ".gitignore": "x\n",
|
|
118
|
+
"bad.sh": "echo 'src/a.py:1: x — плохо'\necho ' почини: убери x'\nexit 1\n",
|
|
119
|
+
});
|
|
120
|
+
aqk(p, "init");
|
|
121
|
+
const man = join(p.dir, ".aqk.yml");
|
|
122
|
+
writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m, 'gates:\n bad: "bash bad.sh"'), "utf8");
|
|
123
|
+
const gh = aqkEnv(p, { GITHUB_ACTIONS: "true" }, "doctor", "--run");
|
|
124
|
+
assert.notEqual(gh.code, 0, "пометки не должны менять вердикт");
|
|
125
|
+
assert.match(gh.out, /^::error file=src\/a\.py,line=1,title=aqk%3A bad::x — плохо — почини: убери x$/m, gh.out);
|
|
126
|
+
const local = aqkEnv(p, { GITHUB_ACTIONS: "" }, "doctor", "--run");
|
|
127
|
+
assert.doesNotMatch(local.out, /^::error/m, "вне GitHub Actions пометок нет");
|
|
128
|
+
});
|