agent-quality-kit 0.10.1 → 0.11.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 +8 -3
- package/README.ru.md +8 -3
- package/kit/gates/_native.sh +4 -0
- package/kit/gates/_skip.sh +7 -0
- package/kit/gates/api-contract-has-arbiter/check.sh +7 -0
- package/kit/gates/color-from-token/check.sh +7 -0
- package/kit/gates/commit-explains-itself/check.sh +8 -2
- package/kit/gates/complexity-limit/check.sh +22 -4
- package/kit/gates/complexity-limit/gate.yml +2 -2
- package/kit/gates/complexity-limit/red/deep.js +15 -0
- package/kit/gates/duplicate-code/check.sh +7 -0
- package/kit/gates/duplicate-code/gate.yml +5 -1
- package/kit/gates/file-size-limit/check.sh +7 -0
- package/kit/gates/file-size-limit/red/big.js +600 -0
- package/kit/gates/gate-not-weakened/check.sh +7 -0
- package/kit/gates/gate-not-weakened/green/suppress.js +4 -0
- package/kit/gates/gate-not-weakened/red/suppress.js +5 -0
- package/kit/gates/mcp-server-resolves/check.sh +7 -0
- package/kit/gates/no-phantom-package/check.sh +7 -0
- package/kit/gates/no-print-in-prod/gate.yml +3 -3
- package/kit/gates/personal-config-not-shared/check.sh +7 -0
- package/kit/gates/secrets-not-in-code/check.sh +7 -0
- package/kit/gates/secrets-not-in-code/green/config.js +4 -0
- package/kit/gates/secrets-not-in-code/red/leak.js +6 -0
- package/kit/gates/swallowed-error/gate.yml +3 -3
- package/kit/gates/test-has-assertion/check.sh +7 -0
- package/kit/gates/test-has-assertion/green/checkout.test.js +5 -0
- package/kit/gates/test-has-assertion/red/checkout.test.js +8 -0
- package/kit/gates/test-not-adjusted/check.sh +8 -2
- package/kit/gates/todo-without-task/check.sh +7 -0
- package/kit/gates/todo-without-task/gate.yml +2 -2
- package/kit/gates/todo-without-task/green/app.js +2 -0
- package/kit/gates/todo-without-task/red/later.js +4 -0
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/tool/commands/badge.mjs +1 -1
- package/tool/commands/doctor.mjs +18 -148
- package/tool/commands/probe.mjs +197 -41
- package/tool/commands/report.mjs +1 -1
- package/tool/i18n/en-gates.mjs +19 -5
- package/tool/i18n/en.mjs +3 -0
- package/tool/i18n/ru-gates.mjs +20 -6
- package/tool/i18n/ru.mjs +3 -0
- package/tool/lib/history.mjs +79 -8
- package/tool/lib/run.mjs +165 -0
- package/tool/selfcheck/gates.sh +32 -4
- package/tool/selfcheck/smoke/_fixture.mjs +24 -2
- package/tool/selfcheck/smoke/fail-closed.test.mjs +112 -0
- package/tool/selfcheck/smoke/own-samples.test.mjs +131 -0
- package/tool/selfcheck/units-probe.mjs +282 -22
package/tool/commands/probe.mjs
CHANGED
|
@@ -21,12 +21,11 @@
|
|
|
21
21
|
// удаляется. Не меняет манифест. Не роняет прогон: код возврата всегда 0 — это осмотр, а
|
|
22
22
|
// не порог. Порог — у `doctor --run --min`.
|
|
23
23
|
import { spawnSync } from "node:child_process";
|
|
24
|
-
import { mkdtemp, mkdir, copyFile, rm, readdir, writeFile, readFile } from "node:fs/promises";
|
|
24
|
+
import { mkdtemp, mkdir, copyFile, rm, readdir, writeFile, readFile, symlink } from "node:fs/promises";
|
|
25
25
|
import { tmpdir } from "node:os";
|
|
26
26
|
import { join, dirname, extname } from "node:path";
|
|
27
27
|
import { readManifest } from "../lib/manifest.mjs";
|
|
28
|
-
import {
|
|
29
|
-
import { fixHotspots, probeVerdict } from "../lib/history.mjs";
|
|
28
|
+
import { fixHotspots, probeSummary, probeVerdictPaired, countProbe } from "../lib/history.mjs";
|
|
30
29
|
import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
|
|
31
30
|
import { CWD, GATES_SRC, TARGET_DIR, c, SELF, exists } from "../lib/core.mjs";
|
|
32
31
|
import { probeState, probeEvery, PROBE_EVERY } from "../lib/cadence.mjs";
|
|
@@ -99,14 +98,43 @@ async function writeMark(now, blind, lines) {
|
|
|
99
98
|
await writeFile(MARK(), body + "\n", "utf8");
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
101
|
+
|
|
102
|
+
// Отчего проба не состоялась. Раньше здесь было три состояния: гейты, чья команда не кончается
|
|
103
|
+
// каталогом, объявлялись непригодными — подставить образец было некуда. С песочницей подставлять
|
|
104
|
+
// в команду больше не нужно: образец кладётся в КОПИЮ ПРОЕКТА, а гейт запускается в ней как есть.
|
|
105
|
+
// Поэтому пригодна любая непустая команда, и состояний осталось два.
|
|
106
|
+
function gatesState(man) {
|
|
107
|
+
const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
|
|
108
|
+
const declared = Object.entries(gates)
|
|
109
|
+
.map(([name, raw]) => [name, String(raw || "").trim()])
|
|
110
|
+
.filter(([, cmd]) => cmd);
|
|
111
|
+
if (!declared.length) return { state: "none", declared: 0, probeable: 0 };
|
|
112
|
+
return { state: "ok", declared: declared.length, probeable: declared.length };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Гейты, пригодные для пробы: все объявленные с непустой командой.
|
|
116
|
+
function probeableGates(man) {
|
|
106
117
|
const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
|
|
107
118
|
return Object.entries(gates)
|
|
108
119
|
.map(([name, raw]) => [name, String(raw || "").trim()])
|
|
109
|
-
.filter(([, cmd]) => cmd
|
|
120
|
+
.filter(([, cmd]) => cmd);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Семьи расширений. Образец подбирается по ТОЧНОМУ расширению, и правило верное: питоновский
|
|
124
|
+
// образец в проекте на TypeScript не проверит ничего, а покажет «не прикрыто» — ложная тревога
|
|
125
|
+
// того же класса, что молчащий гейт, только наоборот.
|
|
126
|
+
//
|
|
127
|
+
// Но `.js` и `.mjs` — одно и то же содержимое, а не два языка. Прогон на самом комплекте
|
|
128
|
+
// 2026-09-10: два горячих файла из пяти — `.mjs`, и обоим ответили «нет образца под .mjs»;
|
|
129
|
+
// комплект целиком написан в этом расширении, то есть проба была слепа к собственному коду.
|
|
130
|
+
// Заводить второй набор файлов ради той же строчки — дублирование, которое разойдётся.
|
|
131
|
+
//
|
|
132
|
+
// Семьи узкие намеренно: `.jsx`/`.tsx` сюда не входят, у них своя разметка.
|
|
133
|
+
const EXT_FAMILIES = [[".js", ".mjs", ".cjs"], [".ts", ".mts", ".cts"]];
|
|
134
|
+
|
|
135
|
+
function extAlternatives(ext) {
|
|
136
|
+
const fam = EXT_FAMILIES.find((f) => f.includes(ext));
|
|
137
|
+
return fam ? [ext, ...fam.filter((e) => e !== ext)] : [ext];
|
|
110
138
|
}
|
|
111
139
|
|
|
112
140
|
// Красный образец записи, подходящий по расширению горячего файла. Расширение обязано
|
|
@@ -117,39 +145,114 @@ async function redSampleFor(entry, ext) {
|
|
|
117
145
|
if (!(await exists(dir))) return null;
|
|
118
146
|
let names = [];
|
|
119
147
|
try { names = await readdir(dir); } catch { return null; }
|
|
120
|
-
const
|
|
121
|
-
|
|
148
|
+
for (const want of extAlternatives(ext)) {
|
|
149
|
+
const hit = names.find((n) => extname(n).toLowerCase() === want);
|
|
150
|
+
if (hit) return join(dir, hit);
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Песочница: КОПИЯ ПРОЕКТА, в которую подсаживается образец. Раньше здесь был временный каталог
|
|
156
|
+
// с одним файлом, а путь к нему подставлялся в команду гейта — отчего пробовать можно было
|
|
157
|
+
// только команды, кончающиеся каталогом. Замер 2026-09-10 на семи чужих репозиториях: у шести
|
|
158
|
+
// команды такие (`xo`, `eslint lib/**/*.js`, `mocha --require…`, `pytest`), и проба не
|
|
159
|
+
// запускалась вовсе.
|
|
160
|
+
//
|
|
161
|
+
// Способ взят из мутационного тестирования, где та же задача решена двадцать лет назад: Stryker
|
|
162
|
+
// копирует проект во временный каталог, СИМЛИНКУЕТ `node_modules` и гоняет там родную команду.
|
|
163
|
+
// Копируются только ОТСЛЕЖИВАЕМЫЕ файлы (`git archive HEAD`) — рабочее дерево не трогается, а
|
|
164
|
+
// мусор сборки не тащится; тяжёлые каталоги зависимостей симлинкуются, иначе `npm test` в
|
|
165
|
+
// песочнице падал бы с «модуль не найден», и это читалось бы как сбой инструмента.
|
|
166
|
+
const DEP_DIRS = ["node_modules", ".venv", "venv", "vendor", "target", ".tox", ".bundle"];
|
|
167
|
+
|
|
168
|
+
async function buildSandbox() {
|
|
169
|
+
// Копируется РАБОЧЕЕ ДЕРЕВО, а не HEAD. Первая версия брала `git archive HEAD`, и это было
|
|
170
|
+
// неверно: комплект зовут из хука ДО коммита, и пользователь пробует то, что у него сейчас,
|
|
171
|
+
// а не то, что уже записано. На свежем `init` + `add` без коммита проба вообще ничего не
|
|
172
|
+
// видела — гейты в песочнице отсутствовали и «не запускались».
|
|
173
|
+
//
|
|
174
|
+
// Список — `git ls-files --cached --others --exclude-standard`: отслеживаемые плюс новые, но
|
|
175
|
+
// БЕЗ игнорируемых. Игнорируемое — это сборка и зависимости; первое пробе не нужно, второе
|
|
176
|
+
// приходит симлинком.
|
|
177
|
+
//
|
|
178
|
+
// Копирование средствами node, а не `tar`: у конвейера есть windows-задание, и полагаться на
|
|
179
|
+
// ключи GNU tar там нельзя.
|
|
180
|
+
const r = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
181
|
+
cwd: CWD, encoding: "utf8", timeout: 60000, maxBuffer: 64 * 1024 * 1024,
|
|
182
|
+
});
|
|
183
|
+
if (r.status !== 0) return null;
|
|
184
|
+
const files = String(r.stdout || "").split("\0").filter(Boolean);
|
|
185
|
+
if (!files.length) return null;
|
|
186
|
+
|
|
187
|
+
const root = await mkdtemp(join(tmpdir(), "aqk-sandbox-"));
|
|
188
|
+
const made = new Set();
|
|
189
|
+
for (const rel of files) {
|
|
190
|
+
const dest = join(root, rel);
|
|
191
|
+
const dir = dirname(dest);
|
|
192
|
+
if (!made.has(dir)) { await mkdir(dir, { recursive: true }); made.add(dir); }
|
|
193
|
+
// Файл мог исчезнуть между списком и копией, а каталог — оказаться подмодулем.
|
|
194
|
+
try { await copyFile(join(CWD, rel), dest); } catch { /* пропускаем, не роняя пробу */ }
|
|
195
|
+
}
|
|
196
|
+
for (const dep of DEP_DIRS) {
|
|
197
|
+
const from = join(CWD, dep);
|
|
198
|
+
if (await exists(from)) { try { await symlink(from, join(root, dep), "junction"); } catch { /* уже есть */ } }
|
|
199
|
+
}
|
|
200
|
+
return root;
|
|
122
201
|
}
|
|
123
202
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
async function buildProbe(relPath, sample) {
|
|
128
|
-
const root = await mkdtemp(join(tmpdir(), "aqk-probe-"));
|
|
203
|
+
// Подсадка образца на место горячего файла и возврат как было. Файл СНАЧАЛА удаляется:
|
|
204
|
+
// в песочнице он может быть жёсткой ссылкой, и запись поверх задела бы оригинал.
|
|
205
|
+
async function plant(root, relPath, sample) {
|
|
129
206
|
const dest = join(root, relPath);
|
|
130
207
|
await mkdir(dirname(dest), { recursive: true });
|
|
208
|
+
let backup = null;
|
|
209
|
+
try { backup = await readFile(dest); } catch { /* файла может не быть */ }
|
|
210
|
+
await rm(dest, { force: true });
|
|
131
211
|
await copyFile(sample, dest);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// на подсаженный брак не отреагировал никто. Ошибка в сторону «прикрыто» — это тишина,
|
|
137
|
-
// а тишина здесь и есть предмет спора. Поймано первым же прогоном на своём репозитории.
|
|
138
|
-
if (await exists(join(CWD, ".aqkignore"))) {
|
|
139
|
-
await copyFile(join(CWD, ".aqkignore"), join(root, ".aqkignore"));
|
|
140
|
-
}
|
|
141
|
-
return root;
|
|
212
|
+
return async () => {
|
|
213
|
+
await rm(dest, { force: true });
|
|
214
|
+
if (backup !== null) await writeFile(dest, backup);
|
|
215
|
+
};
|
|
142
216
|
}
|
|
143
217
|
|
|
144
|
-
|
|
218
|
+
// Гейт запускается В ПЕСОЧНИЦЕ и командой КАК ЕСТЬ — ничего в неё не подставляется. Именно это
|
|
219
|
+
// и делает пробу независимой от формы команды.
|
|
220
|
+
//
|
|
221
|
+
// `stopOnRed` — ранний выход: как только гейт покраснел, вердикт «поймано» уже получен, и гонять
|
|
222
|
+
// остальные незачем. На сухом прогоне выхода нет: там нужны ВСЕ длительности и все коды.
|
|
223
|
+
function runGates(gates, sandbox, { stopOnRed = false } = {}) {
|
|
145
224
|
const out = [];
|
|
146
225
|
for (const [name, cmd] of gates) {
|
|
147
|
-
const
|
|
148
|
-
|
|
226
|
+
const t0 = Date.now();
|
|
227
|
+
const r = spawnSync(cmd, { shell: true, cwd: sandbox, encoding: "utf8", timeout: 120000 });
|
|
228
|
+
const code = r.status === null ? 2 : r.status;
|
|
229
|
+
out.push({ name, code, ms: Date.now() - t0 });
|
|
230
|
+
if (stopOnRed && code === 1) break;
|
|
149
231
|
}
|
|
150
232
|
return out;
|
|
151
233
|
}
|
|
152
234
|
|
|
235
|
+
// Каким гейтом пробовать и в каком порядке.
|
|
236
|
+
//
|
|
237
|
+
// Цена пробы = (файлы × записи) × сумма длительностей гейтов. На самом комплекте после перехода
|
|
238
|
+
// на песочницу это стало больше десяти минут и упёрлось в таймаут: среди тридцати гейтов есть
|
|
239
|
+
// `smoke` на 58 секунд, и он гонялся заново на каждую подсадку. Команда, идущая четверть часа,
|
|
240
|
+
// не запускается никем.
|
|
241
|
+
//
|
|
242
|
+
// Длительности берутся из сухого прогона, который и так обязателен. Порядок — от быстрых к
|
|
243
|
+
// медленным, чтобы ранний выход срабатывал раньше. Слишком медленные исключаются, но НЕ молча:
|
|
244
|
+
// их имена обязаны попасть в вывод, иначе «никто не ловит» будет означать «никто из тех, кого
|
|
245
|
+
// мы решили спросить».
|
|
246
|
+
const SLOW_MS = 20000;
|
|
247
|
+
|
|
248
|
+
function planProbeGates(before, { slowMs = SLOW_MS } = {}) {
|
|
249
|
+
const usable = before.filter((r) => r.code === 0).sort((a, b) => (a.ms || 0) - (b.ms || 0));
|
|
250
|
+
return {
|
|
251
|
+
use: usable.filter((r) => (r.ms || 0) <= slowMs),
|
|
252
|
+
tooSlow: usable.filter((r) => (r.ms || 0) > slowMs),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
153
256
|
// `auto` — проба запущена САМА, по каденции, из `doctor --run`. Тогда она короче и говорит
|
|
154
257
|
// вслух, почему случилась: команда, возникшая без спроса, обязана объяснить себя, иначе её
|
|
155
258
|
// читают как сбой.
|
|
@@ -161,8 +264,9 @@ async function cmdProbe(args, { auto = false } = {}) {
|
|
|
161
264
|
console.log(c.bold(`\n${P.title}\n`));
|
|
162
265
|
|
|
163
266
|
const man = await readManifest();
|
|
164
|
-
const gates =
|
|
165
|
-
|
|
267
|
+
const gates = probeableGates(man);
|
|
268
|
+
const gs = gatesState(man);
|
|
269
|
+
if (gs.state === "none") { console.log(c.yellow(` ${P.noGates(`${SELF} add <имя>`)}\n`)); return; }
|
|
166
270
|
|
|
167
271
|
const raw = gitLog(2000);
|
|
168
272
|
if (raw === null) { console.log(c.yellow(` ${P.noGit}\n`)); return; }
|
|
@@ -175,9 +279,40 @@ async function cmdProbe(args, { auto = false } = {}) {
|
|
|
175
279
|
const catalog = await readCatalog();
|
|
176
280
|
const entries = catalog.filter((e) => triggerVerdict(e, facts).applies);
|
|
177
281
|
|
|
178
|
-
|
|
282
|
+
// ПЕСОЧНИЦА строится ОДИН раз на прогон, а не на каждую пробу: копия отслеживаемых файлов
|
|
283
|
+
// стоит доли секунды, но умножать её на файлы × записи незачем — между пробами меняется
|
|
284
|
+
// ровно один файл.
|
|
285
|
+
const sandbox = await buildSandbox();
|
|
286
|
+
if (!sandbox) { console.log(c.yellow(` ${P.noSandbox}\n`)); return; }
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
// СУХОЙ ПРОГОН по чистой песочнице. Без него «покраснел от подсадки» неотличимо от «был
|
|
290
|
+
// красным и до неё»: у чужого проекта гейты краснеют на своём накопленном долге, и
|
|
291
|
+
// засчитывать эту красноту за поимку значит выдавать чужой долг за свою заслугу.
|
|
292
|
+
// В мутационном тестировании этот прогон обязателен по той же причине.
|
|
293
|
+
const before = runGates(gates, sandbox);
|
|
294
|
+
const plan = planProbeGates(before);
|
|
295
|
+
if (!plan.use.length) {
|
|
296
|
+
const red = before.filter((r) => r.code === 1).map((r) => r.name);
|
|
297
|
+
const broke = before.filter((r) => r.code !== 0 && r.code !== 1).map((r) => r.name);
|
|
298
|
+
if (plan.tooSlow.length) console.log(c.yellow(` ${P.allSlow(plan.tooSlow.map((g) => g.name))}\n`));
|
|
299
|
+
else console.log(c.yellow(` ${P.noBaseline(red, broke)}\n`));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// Пробуем только запланированными, в порядке плана.
|
|
303
|
+
const byName = new Map(gates);
|
|
304
|
+
const probeGates = plan.use.map((g) => [g.name, byName.get(g.name)]);
|
|
305
|
+
const baseline = plan.use.map((g) => ({ name: g.name, code: g.code }));
|
|
306
|
+
|
|
307
|
+
console.log(c.dim(` ${P.method(hot.length, entries.length, plan.use.length)}\n`));
|
|
308
|
+
if (plan.tooSlow.length) {
|
|
309
|
+
console.log(c.dim(` ${P.tooSlow(plan.tooSlow.map((g) => `${g.name} (${Math.round(g.ms / 1000)}s)`))}\n`));
|
|
310
|
+
}
|
|
179
311
|
|
|
180
|
-
|
|
312
|
+
// Записи проб: по ним считаются КЛАССЫ, а не события. Счётчики на месте были
|
|
313
|
+
// событиями и втрое завышали итог — см. countProbe.
|
|
314
|
+
const records = [];
|
|
315
|
+
let unprobedN = 0;
|
|
181
316
|
for (const { path: rel, fixes } of hot) {
|
|
182
317
|
console.log(` ${c.bold(rel)} ${c.dim(P.fixes(fixes))}`);
|
|
183
318
|
const ext = extname(rel).toLowerCase();
|
|
@@ -187,29 +322,50 @@ async function cmdProbe(args, { auto = false } = {}) {
|
|
|
187
322
|
const sample = await redSampleFor(e.slug, ext);
|
|
188
323
|
if (!sample) continue;
|
|
189
324
|
probed++;
|
|
190
|
-
const
|
|
191
|
-
let
|
|
192
|
-
try {
|
|
193
|
-
|
|
194
|
-
|
|
325
|
+
const restore = await plant(sandbox, rel, sample);
|
|
326
|
+
let after;
|
|
327
|
+
try { after = runGates(probeGates, sandbox, { stopOnRed: true }); } finally { await restore(); }
|
|
328
|
+
// Ранний выход обрывает список: гейты, до которых не дошли, считаются такими же, как на
|
|
329
|
+
// сухом прогоне. Иначе их отсутствие прочиталось бы как сбой запуска.
|
|
330
|
+
const seen = new Set(after.map((a) => a.name));
|
|
331
|
+
const full = after.concat(baseline.filter((b) => !seen.has(b.name)));
|
|
332
|
+
const r = probeVerdictPaired(baseline, full);
|
|
333
|
+
const verdict = r.verdict;
|
|
334
|
+
const caught = full.filter((a) => a.code === 1 && baseline.find((b) => b.name === a.name)?.code === 0)
|
|
335
|
+
.map((a) => a.name);
|
|
336
|
+
records.push({ entry: e.slug, file: rel, verdict });
|
|
195
337
|
if (verdict === "caught") {
|
|
196
338
|
console.log(` ${c.green("✔")} ${e.intent.padEnd(48)} ${c.dim(P.caught(caught.join(", ")))}`);
|
|
197
339
|
} else if (verdict === "blind") {
|
|
198
|
-
blind++;
|
|
199
340
|
console.log(` ${c.red("✘")} ${e.intent.padEnd(48)} ${c.red(P.blind)}`);
|
|
200
341
|
console.log(c.dim(` ${P.install(`${SELF} add ${e.slug}`)}`));
|
|
201
342
|
} else {
|
|
202
343
|
console.log(` ${c.dim("~")} ${c.dim(e.intent.padEnd(48))} ${c.dim(P.unknown)}`);
|
|
203
344
|
}
|
|
204
345
|
}
|
|
205
|
-
if (!probed) console.log(c.dim(` ${P.noSampleFor(ext || "—")}`));
|
|
346
|
+
if (!probed) { unprobedN++; console.log(c.dim(` ${P.noSampleFor(ext || "—")}`)); }
|
|
206
347
|
}
|
|
207
348
|
|
|
208
|
-
|
|
349
|
+
const n = countProbe(records);
|
|
350
|
+
const blind = n.blindClasses;
|
|
351
|
+
const state = probeSummary({
|
|
352
|
+
caught: n.caughtClasses, blind, unknown: n.unknownClasses, unprobed: unprobedN,
|
|
353
|
+
});
|
|
354
|
+
const say = {
|
|
355
|
+
blind: () => c.yellow(P.summaryBlind(blind, n.probes)),
|
|
356
|
+
partial: () => c.yellow(P.summaryPartial(n.caughtClasses, n.unknownClasses, unprobedN)),
|
|
357
|
+
clean: () => c.green(P.summaryClean),
|
|
358
|
+
"nothing-ran": () => c.yellow(P.summaryNothingRan(n.unknownClasses)),
|
|
359
|
+
"nothing-probed": () => c.yellow(P.summaryNothingProbed(unprobedN)),
|
|
360
|
+
};
|
|
361
|
+
console.log(`\n ${say[state]()}\n`);
|
|
209
362
|
|
|
210
363
|
// Отметка нужна не для отчёта, а для КАДЕНЦИИ: по ней следующий прогон поймёт, что пора.
|
|
211
364
|
// Без неё команда снова становится тем, о чём надо вспомнить.
|
|
212
365
|
await writeMark(commitCount(), blind, hot.map(({ path: p2, fixes }) => `- ${p2} (${P.fixes(fixes)})`));
|
|
366
|
+
} finally {
|
|
367
|
+
await rm(sandbox, { recursive: true, force: true });
|
|
368
|
+
}
|
|
213
369
|
}
|
|
214
370
|
|
|
215
371
|
// Состояние пробы для тех, кто только ПОКАЗЫВАЕТ его: прогон и блок для агента.
|
|
@@ -225,4 +381,4 @@ async function probeStatus() {
|
|
|
225
381
|
return probeState(await readMark(), commitCount(), every);
|
|
226
382
|
}
|
|
227
383
|
|
|
228
|
-
export { cmdProbe, probeStatus,
|
|
384
|
+
export { cmdProbe, probeStatus, probeableGates, gatesState, extAlternatives, planProbeGates, isCode };
|
package/tool/commands/report.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { readManifest, assessLevel } from "../lib/manifest.mjs";
|
|
|
21
21
|
import { proveGates } from "../lib/prove.mjs";
|
|
22
22
|
import { detectFacts, readCatalog, triggerVerdict, whichSync } from "../lib/repo.mjs";
|
|
23
23
|
import { changedCode, coverage, evidenceHash, readForHash } from "../lib/evidence.mjs";
|
|
24
|
-
import { runGates, declaredGates, sinceRef } from "
|
|
24
|
+
import { runGates, declaredGates, sinceRef } from "../lib/run.mjs";
|
|
25
25
|
import { L } from "../i18n/index.mjs";
|
|
26
26
|
|
|
27
27
|
// Каким рецептом стоит гейт: родным инструментом или переносимой проверкой. Именно это
|
package/tool/i18n/en-gates.mjs
CHANGED
|
@@ -256,9 +256,10 @@ export const enGates = {
|
|
|
256
256
|
autoFirst: "no coverage probe has ever run here — running it myself. Turn off: AQK_PROBE=0",
|
|
257
257
|
auto: (n) => `${n} commits since the last probe — running it myself. Turn off: AQK_PROBE=0`,
|
|
258
258
|
title: "aqk probe — what the declared checks cannot see",
|
|
259
|
-
method: (files, entries) =>
|
|
260
|
-
`method: a red sample from a catalogue entry is planted into a
|
|
261
|
-
`
|
|
259
|
+
method: (files, entries, gates) =>
|
|
260
|
+
`method: a red sample from a catalogue entry is planted into a COPY of the project, then the ` +
|
|
261
|
+
`DECLARED gates are run there — the command is used as written, nothing is substituted into it. ` +
|
|
262
|
+
`Files: ${files}, applicable entries: ${entries}, gates green on a clean checkout: ${gates}. ` +
|
|
262
263
|
`The working tree is not touched.`,
|
|
263
264
|
fixes: (n) => `fixes in history: ${n}`,
|
|
264
265
|
caught: (names) => `caught by: ${names}`,
|
|
@@ -267,12 +268,25 @@ export const enGates = {
|
|
|
267
268
|
install: (cmd) => `close it: ${cmd}`,
|
|
268
269
|
noSampleFor: (ext) => `the catalogue has no red sample for "${ext}" — nothing to check with`,
|
|
269
270
|
noGates: (cmd) => `no gates declared — nothing to probe with. First: ${cmd}`,
|
|
271
|
+
noSandbox: "could not build a sandbox: `git archive HEAD` failed. The probe needs a copy of the tracked files to plant a sample into — it never touches the working tree.",
|
|
272
|
+
noBaseline: (red, broke) =>
|
|
273
|
+
`nothing to judge by: on a CLEAN checkout ${red.length ? `these gates are ALREADY red (${red.join(", ")})` : ""}${red.length && broke.length ? " and " : ""}${broke.length ? `these failed to run (${broke.join(", ")})` : ""}. A gate that is red before the sample is planted says nothing about the sample. Get the pipeline green first, then repeat.`,
|
|
274
|
+
tooSlow: (names) => `not probed with (too slow to run on every planting): ${names.join(", ")}. If a class below is caught by nobody, one of these may still catch it — run them by hand.`,
|
|
275
|
+
allSlow: (names) => `every gate that is green on a clean checkout is too slow to probe with: ${names.join(", ")}. Probing would re-run them for every planting. Declare a fast gate, or run these by hand.`,
|
|
270
276
|
noGit: "not a git repository — there is no fix history to read",
|
|
271
277
|
noFixes: "no fix commits found: the subject starts with fix / bugfix / hotfix",
|
|
272
|
-
summaryBlind: (n) =>
|
|
273
|
-
`classes
|
|
278
|
+
summaryBlind: (n, probes) =>
|
|
279
|
+
`classes caught by nobody: ${n}` +
|
|
280
|
+
(probes ? ` (over ${probes} plantings — one class repeated across several hot files is still ONE class)` : "") +
|
|
281
|
+
`. This is not a judgement of the code: these are the places ` +
|
|
274
282
|
`people come back to with a fix, and defects none of your checks would see there.`,
|
|
275
283
|
summaryClean: "in the places probed, every applicable class is caught by something.",
|
|
284
|
+
summaryPartial: (caught, unknown, unprobed) =>
|
|
285
|
+
`caught: ${caught}. Could NOT be checked: ${unknown} (the tool is missing, not the protection). Not probed at all: ${unprobed} file(s) — the catalogue has no red sample for their type. "Checked and clean", "not checked" and "not looked at" are three different facts and are not merged here.`,
|
|
286
|
+
summaryNothingRan: (n) =>
|
|
287
|
+
`NOTHING was checked: all ${n} probe(s) failed to run — the delegated tools are missing. This is not a clean result, it is the absence of a result. Install the tools, then repeat.`,
|
|
288
|
+
summaryNothingProbed: (unprobed) =>
|
|
289
|
+
`not a single probe was made${unprobed ? ` — ${unprobed} hot file(s) have no red sample for their type in the catalogue` : ""}. Nothing is known about coverage: this is the absence of a measurement, not a clean result.`,
|
|
276
290
|
},
|
|
277
291
|
prove: {
|
|
278
292
|
title: "aqk prove — proving the gates",
|
package/tool/i18n/en.mjs
CHANGED
|
@@ -65,6 +65,9 @@ export const en = {
|
|
|
65
65
|
gitignore: "repository hygiene",
|
|
66
66
|
git: "project under version control",
|
|
67
67
|
|
|
68
|
+
rulesByHuman: (total, machine, human) =>
|
|
69
|
+
`${human} of ${total} rules in the entry point are guarded by a HUMAN, ${machine} by a machine.`,
|
|
70
|
+
rulesByHumanWhy: "A rule guarded by a human is guarded by nobody the day the human is busy. That is the hole this kit exists to close — and the reminder belongs to the human, not only to the agent. Counted in the ENTRY POINT only: a promise kept in any other file is checked by nothing at all, and this kit will not tell you it exists.",
|
|
68
71
|
emptyCommands: (n) => `AGENTS.md has ${n} unfilled commands.`,
|
|
69
72
|
emptyCommandsWhy: "An agent cannot execute an empty line.",
|
|
70
73
|
|
package/tool/i18n/ru-gates.mjs
CHANGED
|
@@ -258,9 +258,10 @@ export const ruGates = {
|
|
|
258
258
|
autoFirst: "пробы покрытия здесь ещё не делали — делаю её сам. Выключить: AQK_PROBE=0",
|
|
259
259
|
auto: (n) => `прошло ${n} коммитов с прошлой пробы — делаю её сам. Выключить: AQK_PROBE=0`,
|
|
260
260
|
title: "aqk probe — чего объявленные проверки не видят",
|
|
261
|
-
method: (files, entries) =>
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
method: (files, entries, gates) =>
|
|
262
|
+
`способ: красный образец записи каталога подсаживается в КОПИЮ проекта, и там гоняются ` +
|
|
263
|
+
`ОБЪЯВЛЕННЫЕ гейты — команда берётся как написана, в неё ничего не подставляется. ` +
|
|
264
|
+
`Файлов: ${files}, применимых записей: ${entries}, гейтов зелёных на чистом дереве: ${gates}. ` +
|
|
264
265
|
`Рабочее дерево не трогается.`,
|
|
265
266
|
fixes: (n) => `починок в истории: ${n}`,
|
|
266
267
|
caught: (names) => `ловит: ${names}`,
|
|
@@ -269,12 +270,25 @@ export const ruGates = {
|
|
|
269
270
|
install: (cmd) => `закрыть: ${cmd}`,
|
|
270
271
|
noSampleFor: (ext) => `в каталоге нет красного образца под «${ext}» — проверить нечем`,
|
|
271
272
|
noGates: (cmd) => `гейтов не объявлено — пробовать нечем. Сначала: ${cmd}`,
|
|
273
|
+
noSandbox: "не удалось построить песочницу: `git archive HEAD` не отработал. Пробе нужна копия отслеживаемых файлов, чтобы подсадить в неё образец, — рабочее дерево она не трогает никогда.",
|
|
274
|
+
noBaseline: (red, broke) =>
|
|
275
|
+
`судить не по чему: на ЧИСТОМ дереве ${red.length ? `уже красные (${red.join(", ")})` : ""}${red.length && broke.length ? ", а " : ""}${broke.length ? `не запустились (${broke.join(", ")})` : ""}. Гейт, красный ДО подсадки, о подсадке не говорит ничего. Сначала позеленить конвейер, потом повторить.`,
|
|
276
|
+
tooSlow: (names) => `не пробовали ими (слишком долго гонять на каждую подсадку): ${names.join(", ")}. Если класс ниже не ловит никто — может ловить кто-то из них, проверьте руками.`,
|
|
277
|
+
allSlow: (names) => `все гейты, зелёные на чистом дереве, слишком медленны для пробы: ${names.join(", ")}. Проба гоняла бы их заново на каждую подсадку. Объявите быстрый гейт или прогоните эти руками.`,
|
|
272
278
|
noGit: "это не репозиторий git — истории починок взять неоткуда",
|
|
273
279
|
noFixes: "в истории не нашлось коммитов-починок: тема начинается с fix / исправ / почин",
|
|
274
|
-
summaryBlind: (n) =>
|
|
275
|
-
|
|
276
|
-
|
|
280
|
+
summaryBlind: (n, probes) =>
|
|
281
|
+
`классов не ловит никто: ${n}` +
|
|
282
|
+
(probes ? ` (на ${probes} подсадках — один класс, повторённый по нескольким горячим файлам, остаётся ОДНИМ)` : "") +
|
|
283
|
+
`. Это не приговор коду: это места, куда возвращаются с починкой, и дефекты, ` +
|
|
284
|
+
`которых там не увидела бы ни одна ваша проверка.`,
|
|
277
285
|
summaryClean: "в проверенных местах каждый применимый класс кто-то ловит.",
|
|
286
|
+
summaryPartial: (caught, unknown, unprobed) =>
|
|
287
|
+
`поймано: ${caught}. Проверить НЕ смогли: ${unknown} (не хватает инструмента, а не защиты). Не пробовали вовсе: файлов ${unprobed} — в каталоге нет красного образца их типа. «Проверено и чисто», «не проверено» и «не смотрели» — три разных факта, и здесь они не сливаются.`,
|
|
288
|
+
summaryNothingRan: (n) =>
|
|
289
|
+
`НЕ проверено ничего: все ${n} пробы не смогли запуститься — делегированные инструменты не установлены. Это не чистый результат, это отсутствие результата. Поставьте инструменты и повторите.`,
|
|
290
|
+
summaryNothingProbed: (unprobed) =>
|
|
291
|
+
`не сделано ни одной пробы${unprobed ? `: у ${unprobed} горячих файлов нет в каталоге красного образца их типа` : ""}. О прикрытии не известно ничего — это отсутствие замера, а не чистый результат.`,
|
|
278
292
|
},
|
|
279
293
|
prove: {
|
|
280
294
|
title: "aqk prove — доказательство гейтов",
|
package/tool/i18n/ru.mjs
CHANGED
|
@@ -66,6 +66,9 @@ export const ru = {
|
|
|
66
66
|
gitignore: "гигиена репозитория",
|
|
67
67
|
git: "проект под контролем версий",
|
|
68
68
|
|
|
69
|
+
rulesByHuman: (total, machine, human) =>
|
|
70
|
+
`правил в точке входа: ${total}. Сторож — ЧЕЛОВЕК у ${human}, машина у ${machine}.`,
|
|
71
|
+
rulesByHumanWhy: "Правило со сторожем-человеком не сторожит никто в тот день, когда человек занят. Это та самая дыра, ради которой комплект и написан, — и напоминание про неё нужно человеку, а не только агенту. Считаны правила ТОЧКИ ВХОДА: обещание, живущее в любом другом файле, не проверяет вообще ничто, и комплект даже не скажет вам, что оно есть.",
|
|
69
72
|
emptyCommands: (n) => `В AGENTS.md ${n} незаполненных команд.`,
|
|
70
73
|
emptyCommandsWhy: "Агент не может выполнить пустую строку.",
|
|
71
74
|
|
package/tool/lib/history.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// брак возвращается; там и стоит спрашивать, смотрит ли на них хоть одна проверка.
|
|
10
10
|
//
|
|
11
11
|
// ЧТО ЭТО НЕ ЗНАЧИТ. Часто чинят и то, что часто меняют: рейтинг говорит «сюда возвращаются»,
|
|
12
|
-
// а не «здесь плохо». Ответ на «прикрыто ли» даёт не он, а проба — см.
|
|
12
|
+
// а не «здесь плохо». Ответ на «прикрыто ли» даёт не он, а проба — см. probeVerdictPaired.
|
|
13
13
|
|
|
14
14
|
// Признак починки берётся из ТЕМЫ коммита. Тема — единственное, что пишут все, и единственное,
|
|
15
15
|
// что видно в `git log --oneline`.
|
|
@@ -71,12 +71,83 @@ function fixHotspots(raw, { isCode }) {
|
|
|
71
71
|
// самый отказ, против которого написан весь комплект. Засчитать как «не прикрыто» — тоже
|
|
72
72
|
// неправда: мы не знаем.
|
|
73
73
|
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
|
|
75
|
+
// Итог пробы по всем её исходам. Отдельная функция, а не тернарник на месте: исходов у
|
|
76
|
+
// у пробы три, и пока веток было две, `unknown` молча падал в «поймано всё».
|
|
77
|
+
//
|
|
78
|
+
// Замер 2026-09-10 на проекте с чужими командами: единственная запись вернула «нечем
|
|
79
|
+
// проверить — инструмент делегирован и не установлен», а итог сказал «каждый применимый
|
|
80
|
+
// класс кем-то ловится». Ошибка запуска, выданная за чистоту, — то самое, ради чего
|
|
81
|
+
// написан стандарт, в его собственной главной команде.
|
|
82
|
+
//
|
|
83
|
+
// Сочетания названы исчерпывающе. `partial` существует потому, что «поймано три, проверить
|
|
84
|
+
// две не смогли» и «поймано три» — разные факты, и сливать их значит округлять в свою пользу.
|
|
85
|
+
// `unprobed` — горячие файлы, которым проба не делалась ВОВСЕ: красного образца их расширения
|
|
86
|
+
// в каталоге нет. Прогон на самом комплекте 2026-09-10: два `.mjs` из пяти горячих файлов не
|
|
87
|
+
// пробовались никак, а итог говорил «каждый применимый класс кем-то ловится». Оговорка «в
|
|
88
|
+
// пробованных местах» верна буквально и обманывает по смыслу — она молча сужает утверждение до
|
|
89
|
+
// мест, где проба удалась. Поэтому «чисто» отвечается только при нуле пропущенных.
|
|
90
|
+
function probeSummary({ caught = 0, blind = 0, unknown = 0, unprobed = 0 } = {}) {
|
|
91
|
+
if (blind) return "blind";
|
|
92
|
+
if (caught && (unknown || unprobed)) return "partial";
|
|
93
|
+
if (caught) return "clean";
|
|
94
|
+
if (unknown) return "nothing-ran";
|
|
95
|
+
return "nothing-probed";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Парный вердикт: гейт прогнан ДВАЖДЫ — по чистой песочнице и по ней же с подсаженным
|
|
99
|
+
// образцом. Так проба перестаёт зависеть от формы команды: `npm test`, `pytest`, `xo`,
|
|
100
|
+
// `eslint lib/**/*.js` работают наравне с `bash check.sh .`.
|
|
101
|
+
//
|
|
102
|
+
// Замер 2026-09-10: у шести чужих репозиториев из семи команды каталогом не кончаются, и
|
|
103
|
+
// старая проба на них не запускалась вовсе. Способ не выдуман — так устроено мутационное
|
|
104
|
+
// тестирование: Stryker копирует проект во временный каталог, симлинкует `node_modules` и
|
|
105
|
+
// гоняет РОДНУЮ команду; прогон по чистой копии там обязателен.
|
|
106
|
+
//
|
|
107
|
+
// Что исключается из суждения и почему:
|
|
108
|
+
// · сбой ЗАПУСКА (код не 0 и не 1) с любой стороны — про подсадку не сказано ничего;
|
|
109
|
+
// · гейт, красный ЕЩЁ ДО подсадки, — его краснота объясняется состоянием проекта, и
|
|
110
|
+
// засчитывать её за поимку значит выдавать чужой долг за свою заслугу;
|
|
111
|
+
// · гейт, ПОЗЕЛЕНЕВШИЙ от подсадки, — он смотрит не туда, и это тоже не поимка.
|
|
112
|
+
// Исключены все — вердикт `unknown`, а не `blind`: «не по чему судить» и «никто не ловит»
|
|
113
|
+
// разные факты, и молчание здесь и есть предмет спора.
|
|
114
|
+
function probeVerdictPaired(before, after) {
|
|
115
|
+
const byName = new Map(after.map((r) => [r.name, r]));
|
|
116
|
+
let usable = 0, alreadyRed = 0, failed = 0, caught = 0;
|
|
117
|
+
for (const b of before) {
|
|
118
|
+
const a = byName.get(b.name);
|
|
119
|
+
const broke = (r) => !r || (r.code !== 0 && r.code !== 1);
|
|
120
|
+
if (broke(b) || broke(a)) { failed++; continue; }
|
|
121
|
+
if (b.code === 1) { alreadyRed++; continue; }
|
|
122
|
+
usable++;
|
|
123
|
+
if (a.code === 1) caught++;
|
|
124
|
+
}
|
|
125
|
+
const verdict = !usable ? "unknown" : caught ? "caught" : "blind";
|
|
126
|
+
return { verdict, usable, alreadyRed, failed, caught };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Счёт непокрытого КЛАССАМИ, а не пробами. Замер на десяти живых репозиториях 2026-09-10:
|
|
130
|
+
// у requests, click, flask и httpx проба сказала «непокрытых классов: 18», а различных классов
|
|
131
|
+
// там ШЕСТЬ — повторены по трём горячим файлам. Втрое завышенное число, и завышали его мы сами
|
|
132
|
+
// тем самым приёмом, который ловим у других: считали события, а называли их сущностями.
|
|
133
|
+
//
|
|
134
|
+
// Число проб остаётся отдельно: «шесть классов на трёх файлах» и «шесть на одном» — разные
|
|
135
|
+
// факты. Класс, слепой ХОТЬ ГДЕ-ТО, считается непокрытым: «где-то ловится» не защищает то
|
|
136
|
+
// место, где не ловится.
|
|
137
|
+
function countProbe(records) {
|
|
138
|
+
const bins = { blind: new Set(), caught: new Set(), unknown: new Set() };
|
|
139
|
+
let probes = 0;
|
|
140
|
+
for (const { entry, verdict } of records) {
|
|
141
|
+
if (!bins[verdict]) continue;
|
|
142
|
+
bins[verdict].add(entry);
|
|
143
|
+
probes++;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
blindClasses: bins.blind.size,
|
|
147
|
+
caughtClasses: bins.caught.size,
|
|
148
|
+
unknownClasses: bins.unknown.size,
|
|
149
|
+
probes,
|
|
150
|
+
};
|
|
80
151
|
}
|
|
81
152
|
|
|
82
|
-
export { isFix, fixHotspots,
|
|
153
|
+
export { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe };
|