agent-quality-kit 0.8.0 → 0.10.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 +154 -12
- package/README.ru.md +185 -27
- package/kit/docs/ai/index.md +1 -0
- package/kit/docs/ai/project-baseline.md +14 -0
- package/kit/docs/api-e2e.md +214 -0
- package/kit/docs/ready-made-rules.md +188 -0
- package/kit/gates/README.md +22 -0
- package/kit/gates/api-contract-has-arbiter/README.md +63 -0
- package/kit/gates/api-contract-has-arbiter/check.sh +117 -0
- package/kit/gates/api-contract-has-arbiter/gate.yml +15 -0
- package/kit/gates/api-contract-has-arbiter/green/.github/workflows/ci.yml +12 -0
- package/kit/gates/api-contract-has-arbiter/green/openapi.yaml +18 -0
- package/kit/gates/api-contract-has-arbiter/red/.github/workflows/ci.yml +11 -0
- package/kit/gates/api-contract-has-arbiter/red/openapi.yaml +18 -0
- package/kit/gates/ci-actually-fails/check.sh +9 -1
- package/kit/gates/color-from-token/check.sh +5 -1
- package/kit/gates/commit-explains-itself/check.sh +15 -0
- package/kit/gates/complexity-limit/red/deep.go +17 -0
- package/kit/gates/complexity-limit/red/deep.rs +17 -0
- package/kit/gates/gate-not-weakened/red/suppress.go +5 -0
- package/kit/gates/gate-not-weakened/red/suppress.rs +3 -0
- package/kit/gates/lesson-has-outcome/check.sh +5 -1
- package/kit/gates/mcp-server-resolves/README.md +62 -0
- package/kit/gates/mcp-server-resolves/check.sh +110 -0
- package/kit/gates/mcp-server-resolves/gate.yml +18 -0
- package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
- package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
- package/kit/gates/protection-not-removed/README.md +67 -0
- package/kit/gates/protection-not-removed/check.sh +92 -0
- package/kit/gates/protection-not-removed/gate.yml +10 -0
- package/kit/gates/protection-not-removed/green/.aqk.yml +7 -0
- package/kit/gates/protection-not-removed/green/gates-declared.txt +4 -0
- package/kit/gates/protection-not-removed/red/.aqk.yml +7 -0
- package/kit/gates/protection-not-removed/red/gates-declared.txt +4 -0
- package/kit/gates/secrets-not-in-code/red/leak.go +9 -0
- package/kit/gates/secrets-not-in-code/red/leak.rs +5 -0
- package/kit/gates/todo-without-task/red/later.go +6 -0
- package/kit/gates/todo-without-task/red/later.rs +4 -0
- package/llms.txt +25 -4
- package/package.json +3 -6
- package/tool/commands/context.mjs +37 -6
- package/tool/commands/doctor.mjs +139 -16
- package/tool/commands/probe.mjs +228 -0
- package/tool/commands/project.mjs +18 -2
- package/tool/commands/prove.mjs +1 -0
- package/tool/commands/vitals.mjs +167 -0
- package/tool/i18n/en-docs.mjs +48 -0
- package/tool/i18n/en-gates.mjs +309 -0
- package/tool/i18n/en.mjs +26 -279
- package/tool/i18n/index.mjs +36 -3
- package/tool/i18n/ru-docs.mjs +48 -0
- package/tool/i18n/ru-gates.mjs +311 -0
- package/tool/i18n/ru.mjs +26 -278
- package/tool/lib/banner.mjs +59 -0
- package/tool/lib/brief.mjs +192 -0
- package/tool/lib/cadence.mjs +57 -0
- package/tool/lib/core.mjs +3 -0
- package/tool/lib/history.mjs +82 -0
- package/tool/lib/manifest.mjs +146 -15
- package/tool/lib/prove.mjs +11 -1
- package/tool/lib/repo.mjs +43 -3
- package/tool/program.mjs +33 -0
- package/tool/selfcheck/smoke/_fixture.mjs +89 -0
- package/tool/selfcheck/smoke/api-contract.test.mjs +79 -0
- package/tool/selfcheck/smoke/commit-report.test.mjs +47 -0
- package/tool/selfcheck/smoke/verdict.test.mjs +40 -0
- package/tool/selfcheck/smoke.sh +528 -6
- package/tool/selfcheck/units-banner.mjs +65 -0
- package/tool/selfcheck/units-brief.mjs +97 -0
- package/tool/selfcheck/units-cadence.mjs +69 -0
- package/tool/selfcheck/units-context.mjs +3 -1
- package/tool/selfcheck/units-level.mjs +147 -1
- package/tool/selfcheck/units-probe.mjs +100 -0
- package/tool/selfcheck/units-repo.mjs +164 -0
- package/tool/selfcheck/units-vitals.mjs +81 -0
- package/tool/selfcheck/units.mjs +3 -75
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// tool/selfcheck/units-banner.mjs — заставка: первая и почти единственная встреча с человеком.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ПРОВЕРЯТЬ КАРТИНКУ. Не ради красоты. Заставка шириной больше окна разъезжается в кашу
|
|
4
|
+
// и портит именно то впечатление, ради которого её и добавили. А в терминале без UTF-8 графика
|
|
5
|
+
// Брайлем превращается в вопросительные знаки — и человек решает, что инструмент сломан.
|
|
6
|
+
//
|
|
7
|
+
// node --test tool/selfcheck/units-banner.mjs
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { banner, BANNER_WIDTH } from "../lib/banner.mjs";
|
|
11
|
+
|
|
12
|
+
test("заставка влезает в узкое окно", () => {
|
|
13
|
+
const w = Math.max(...banner().split("\n").map((l) => [...l].length));
|
|
14
|
+
assert.ok(w <= 40, `ширина ${w}, а бывают окна и в 40 колонок`);
|
|
15
|
+
assert.equal(w, BANNER_WIDTH, "объявленная ширина обязана совпадать с настоящей");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Версии в заставке НЕТ намеренно — решение владельца: выпуски частые, и номер в картинке
|
|
19
|
+
// устаревает быстрее всего остального. За версией есть `--version`, она печатается отдельно.
|
|
20
|
+
test("версии в заставке нет", () => {
|
|
21
|
+
assert.doesNotMatch(banner(), /\d+\.\d+\.\d+/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Терминал без UTF-8 превратит Брайль в мусор. Тогда честнее короткая строка, чем каша,
|
|
25
|
+
// по которой человек решит, что инструмент сломан.
|
|
26
|
+
test("без UTF-8 вместо графики короткая строка", () => {
|
|
27
|
+
const plain = banner({ LANG: "C" });
|
|
28
|
+
assert.doesNotMatch(plain, /[⣿█]/);
|
|
29
|
+
assert.match(plain, /aqk/i);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("отказ от графики уважается переменной", () => {
|
|
33
|
+
assert.doesNotMatch(banner({ LANG: "ru_RU.UTF-8", AQK_NO_ART: "1" }), /[⣿█]/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Кошка центрируется по ВИДИМОЙ части, а не по началу строки: пустой Брайль `⠀` занимает место
|
|
37
|
+
// и ничего не рисует. При равных отступах рисунок выглядел сдвинутым вправо на целый знак —
|
|
38
|
+
// поймано глазом владельца, а не прогоном, и потому закреплено здесь.
|
|
39
|
+
test("кошка стоит по центру надписи", () => {
|
|
40
|
+
const lines = banner().split("\n");
|
|
41
|
+
const visible = (l, blanks) => {
|
|
42
|
+
const a = [...l];
|
|
43
|
+
let lo = -1, hi = -1;
|
|
44
|
+
a.forEach((ch, i) => { if (!blanks.has(ch)) { if (lo < 0) lo = i; hi = i; } });
|
|
45
|
+
return lo < 0 ? null : (lo + hi) / 2;
|
|
46
|
+
};
|
|
47
|
+
const catMid = Math.max(...lines.slice(0, 6).map((l) => visible(l, new Set([" ", "⠀"]))));
|
|
48
|
+
const artMid = Math.max(...lines.slice(7, 13).map((l) => visible(l, new Set([" "]))));
|
|
49
|
+
assert.ok(Math.abs(catMid - artMid) <= 1, `центры разошлись: кошка ${catMid}, буквы ${artMid}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Подпись центрируется по видимому центру букв, а не по краю строки. Отступ, подобранный на
|
|
53
|
+
// глаз, держится до первой правки рисунка — посчитанный переживёт её.
|
|
54
|
+
test("подпись стоит по центру надписи", () => {
|
|
55
|
+
const lines = banner().split("\n");
|
|
56
|
+
const mid = (l, blanks) => {
|
|
57
|
+
const a = [...l];
|
|
58
|
+
let lo = -1, hi = -1;
|
|
59
|
+
a.forEach((ch, i) => { if (!blanks.has(ch)) { if (lo < 0) lo = i; hi = i; } });
|
|
60
|
+
return lo < 0 ? null : (lo + hi) / 2;
|
|
61
|
+
};
|
|
62
|
+
const letters = Math.max(...lines.slice(7, 13).map((l) => mid(l, new Set([" "]))));
|
|
63
|
+
const tag = mid(lines[lines.length - 1], new Set([" "]));
|
|
64
|
+
assert.ok(Math.abs(letters - tag) <= 1, `подпись не по центру: буквы ${letters}, подпись ${tag}`);
|
|
65
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// tool/selfcheck/units-brief.mjs — короткая строка присутствия и её ограничитель.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ЭТО ВООБЩЕ. Владелец: «скачал и че дальше, не понятно, работает он вообще или нет».
|
|
4
|
+
// Хук pre-commit молчит на успехе — это его умолчание, проверено по документации: вывод
|
|
5
|
+
// показывается только при провале. То есть комплект, который всё держит, для человека
|
|
6
|
+
// неотличим от невставленного. Это ровно наш собственный порок: тишина неотличима от успеха.
|
|
7
|
+
//
|
|
8
|
+
// node --test tool/selfcheck/units-brief.mjs
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { briefLine, adviceDue, pickAdvice, updateNotice, updateWanted } from "../lib/brief.mjs";
|
|
12
|
+
import { CATALOGS } from "../i18n/index.mjs";
|
|
13
|
+
|
|
14
|
+
const T = CATALOGS.ru;
|
|
15
|
+
|
|
16
|
+
// Строка присутствия печатается ВСЕГДА, в том числе когда всё хорошо: именно тогда она и нужна.
|
|
17
|
+
test("строка присутствия несёт числа и уровень", () => {
|
|
18
|
+
const s = briefLine({ held: 12, todo: 3, level: 2, red: [] }, T);
|
|
19
|
+
assert.match(s, /12/);
|
|
20
|
+
assert.match(s, /3/);
|
|
21
|
+
assert.match(s, /AQK-2/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Красное называется первым и поимённо: человек должен видеть, что чинить, не листая вывод.
|
|
25
|
+
test("упавшие гейты названы в самой строке", () => {
|
|
26
|
+
const s = briefLine({ held: 12, todo: 0, level: 1, red: ["file-size-limit", "duplicate-code"] }, T);
|
|
27
|
+
assert.match(s, /file-size-limit/);
|
|
28
|
+
assert.match(s, /duplicate-code/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Уровень может быть не вычислен — и тогда так и говорим, а не подставляем ноль.
|
|
32
|
+
test("невычисленный уровень не выдумывается", () => {
|
|
33
|
+
const s = briefLine({ held: 0, todo: 5, level: -1, red: [] }, T);
|
|
34
|
+
assert.doesNotMatch(s, /AQK--1|AQK-0/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// --- ограничитель совета -----------------------------------------------------
|
|
38
|
+
// Совет на КАЖДОМ коммите превращается в шум, а шум пролистывают вместе с настоящими
|
|
39
|
+
// находками. Раз в сутки — это заметно и не мешает.
|
|
40
|
+
test("совет не повторяется чаще раза в сутки", () => {
|
|
41
|
+
const now = Date.parse("2026-09-08T20:00:00Z");
|
|
42
|
+
assert.equal(adviceDue(null, now), true, "первый раз показывается");
|
|
43
|
+
assert.equal(adviceDue("2026-09-08T19:00:00Z", now), false, "час назад — рано");
|
|
44
|
+
assert.equal(adviceDue("2026-09-07T19:00:00Z", now), true, "сутки прошли");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("испорченная отметка времени не мешает показать совет", () => {
|
|
48
|
+
assert.equal(adviceDue("не дата", Date.parse("2026-09-08T20:00:00Z")), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- выбор совета ------------------------------------------------------------
|
|
52
|
+
// Один совет за раз, а не список: список читается как «у вас всё плохо» и не помогает выбрать.
|
|
53
|
+
test("советуется одна запись, самая первая из непоставленных", () => {
|
|
54
|
+
const a = pickAdvice([{ slug: "secrets-not-in-code" }, { slug: "file-size-limit" }]);
|
|
55
|
+
assert.equal(a.slug, "secrets-not-in-code");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("советовать нечего — совета нет, а не пустая строка", () => {
|
|
59
|
+
assert.equal(pickAdvice([]), null);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- уведомление об обновлении -----------------------------------------------
|
|
63
|
+
// Владелец: «выпускаем часто, никто не будет обновлять — скачают старую версию, а мы там уже
|
|
64
|
+
// ошибки исправили». Верно: у pre-commit версия закреплена в rev:, у GitHub Action в теге,
|
|
65
|
+
// и сами они не двигаются. У `npx` без версии проблемы нет — он всегда берёт свежее.
|
|
66
|
+
//
|
|
67
|
+
// АВТООБНОВЛЕНИЯ НЕТ НАМЕРЕННО. В этот же день выпущена запись, краснеющая на `@latest`:
|
|
68
|
+
// «версия не закреплена, завтра приедет другая». Инструмент, который сам себя подменяет, стоя
|
|
69
|
+
// на воротах коммита, делал бы ровно то, что мы запрещаем другим.
|
|
70
|
+
test("новая версия называется, старая не тревожит", () => {
|
|
71
|
+
assert.ok(updateNotice("0.8.0", "0.9.0", {}, T));
|
|
72
|
+
assert.equal(updateNotice("0.9.0", "0.9.0", {}, T), null);
|
|
73
|
+
assert.equal(updateNotice("0.9.0", "0.8.0", {}, T), null, "откат назад — не обновление");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Сравнение по числам, а не по строкам: «0.10.0» строкой меньше «0.9.0», и уведомление
|
|
77
|
+
// пропало бы ровно на десятом выпуске — тихо и надолго.
|
|
78
|
+
test("версии сравниваются числами, а не как текст", () => {
|
|
79
|
+
assert.ok(updateNotice("0.9.0", "0.10.0", {}, T));
|
|
80
|
+
assert.equal(updateNotice("0.10.0", "0.9.0", {}, T), null);
|
|
81
|
+
assert.ok(updateNotice("1.2.9", "1.10.0", {}, T));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Способ обновления зависит от того, как поставлено: у pre-commit это autoupdate, а не npm.
|
|
85
|
+
// Совет «сделай npm i -g» человеку, у которого хук, — это совет мимо, и он его не выполнит.
|
|
86
|
+
test("совет об обновлении соответствует способу установки", () => {
|
|
87
|
+
assert.match(updateNotice("0.8.0", "0.9.0", { PRE_COMMIT: "1" }, T), /autoupdate/);
|
|
88
|
+
assert.doesNotMatch(updateNotice("0.8.0", "0.9.0", {}, T), /autoupdate/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// В конвейере уведомление — шум и лишний запрос: там версия закреплена сознательно, и человека,
|
|
92
|
+
// который бы его прочитал, у экрана нет.
|
|
93
|
+
test("в конвейере и при отказе проверять не спрашиваем вовсе", () => {
|
|
94
|
+
assert.equal(updateWanted({ CI: "true" }), false);
|
|
95
|
+
assert.equal(updateWanted({ AQK_UPDATE: "0" }), false);
|
|
96
|
+
assert.equal(updateWanted({}), true);
|
|
97
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Проверки каденции пробы. Написаны ДО кода.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ЭТО ВООБЩЕ. Владелец сформулировал точнее, чем было в замысле: «команду, о которой надо
|
|
4
|
+
// вспомнить, агент не вспомнит, а человек о ней не узнает». Это тот же класс, что файл, который
|
|
5
|
+
// можно не прочитать, — и решать его надо так же: не напоминанием, а тем, что оно случается
|
|
6
|
+
// само. Единица — коммиты, а не сутки: репозиторий, в котором месяц не работали, перепроверять
|
|
7
|
+
// незачем, а сто коммитов за день перепроверить надо.
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { probeDue, probeState, probeEvery, PROBE_EVERY } from "../lib/cadence.mjs";
|
|
11
|
+
|
|
12
|
+
test("порог по умолчанию — сто коммитов, и он назван числом, а не спрятан", () => {
|
|
13
|
+
assert.equal(PROBE_EVERY, 100);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("проба нужна, когда с прошлой прошло не меньше порога", () => {
|
|
17
|
+
assert.equal(probeDue({ at: 100 }, 199, 100), false);
|
|
18
|
+
assert.equal(probeDue({ at: 100 }, 200, 100), true);
|
|
19
|
+
assert.equal(probeDue({ at: 100 }, 350, 100), true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Пробы не было НИКОГДА — это не «свежая». Молчание здесь означало бы «всё прикрыто»,
|
|
23
|
+
// а прикрыто ли — неизвестно.
|
|
24
|
+
test("если пробы не было вовсе — она нужна", () => {
|
|
25
|
+
assert.equal(probeDue(null, 1, 100), true);
|
|
26
|
+
assert.equal(probeDue({}, 500, 100), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// История короче порога: проба всё равно нужна один раз, иначе новый репозиторий узнает,
|
|
30
|
+
// чего он не видит, только на сто первом коммите.
|
|
31
|
+
test("в молодом репозитории проба нужна сразу, а не после сотого коммита", () => {
|
|
32
|
+
assert.equal(probeDue(null, 3, 100), true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("счётчик коммитов неизвестен — состояние неизвестно, а не «свежо»", () => {
|
|
36
|
+
assert.equal(probeDue({ at: 10 }, null, 100), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Состояние для человека и для агента: три исхода, и они не сливаются.
|
|
40
|
+
test("состояние пробы: не делалась, устарела, свежая", () => {
|
|
41
|
+
assert.deepEqual(probeState(null, 40, 100), { state: "never", behind: null });
|
|
42
|
+
assert.deepEqual(probeState({ at: 10 }, 40, 100), { state: "fresh", behind: 30 });
|
|
43
|
+
assert.deepEqual(probeState({ at: 10 }, 210, 100), { state: "stale", behind: 200 });
|
|
44
|
+
assert.deepEqual(probeState({ at: 10 }, null, 100), { state: "unknown", behind: null });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Порог — свойство ПРОЕКТА, а не наше: сто коммитов на репозитории с десятком коммитов в час
|
|
48
|
+
// это трижды в день, а на редком проекте столько не наберётся никогда.
|
|
49
|
+
test("порог берётся из манифеста, умолчание остаётся при пустом поле", () => {
|
|
50
|
+
assert.equal(probeEvery({ probe: "250" }), 250);
|
|
51
|
+
assert.equal(probeEvery({ probe: 250 }), 250);
|
|
52
|
+
assert.equal(probeEvery({}), PROBE_EVERY);
|
|
53
|
+
assert.equal(probeEvery(null), PROBE_EVERY);
|
|
54
|
+
assert.equal(probeEvery({ probe: "" }), PROBE_EVERY);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Ноль — это «не делать», а не «делать всегда»: выключатель в манифесте нужен тому, кто не
|
|
58
|
+
// может передать переменную окружения, — например конвейеру чужой площадки.
|
|
59
|
+
test("ноль выключает пробу", () => {
|
|
60
|
+
assert.equal(probeEvery({ probe: 0 }), 0);
|
|
61
|
+
assert.equal(probeDue(null, 5, 0), true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Неразобранное значение обязано быть НАЗВАНО, а не подменено умолчанием: иначе в манифесте
|
|
65
|
+
// написано одно, а происходит другое — ровно та тихая неправда, против которой весь комплект.
|
|
66
|
+
test("непонятое значение порога — null, а не тихое умолчание", () => {
|
|
67
|
+
assert.equal(probeEvery({ probe: "часто" }), null);
|
|
68
|
+
assert.equal(probeEvery({ probe: -5 }), null);
|
|
69
|
+
});
|
|
@@ -163,7 +163,9 @@ test("чужие настройки переживают установку ху
|
|
|
163
163
|
// не отстал от диспетчера: команда, добавленная в switch и забытая в списке, роняет её.
|
|
164
164
|
test("карта команд не отстаёт от диспетчера", async () => {
|
|
165
165
|
const src = await readFile(new URL("../program.mjs", import.meta.url), "utf8");
|
|
166
|
-
|
|
166
|
+
// Флаговые формы (`--version`, `-v`) в карту не входят: карта перечисляет КОМАНДЫ, а флаг —
|
|
167
|
+
// второе имя той же команды. Требовать их здесь значило бы дублировать строку справки.
|
|
168
|
+
const dispatched = [...src.matchAll(/^\s{4}case "([a-z][a-z-]*)":/gm)].map((m) => m[1]);
|
|
167
169
|
assert.ok(dispatched.length >= 10, `в диспетчере найдено ${dispatched.length} команд — разбор сломался`);
|
|
168
170
|
const listed = new Set(commandRows(CATALOGS.ru).map((r) => r.name));
|
|
169
171
|
const missing = dispatched.filter((n) => !listed.has(n));
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import test from "node:test";
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
12
|
import { commandFor } from "../lib/prove.mjs";
|
|
13
|
-
import { assessLevel, layoutChecks, unknownKeys, KNOWN_KEYS } from "../lib/manifest.mjs";
|
|
13
|
+
import { assessLevel, layoutChecks, unknownKeys, KNOWN_KEYS, parseManifest, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
|
|
14
|
+
import { pickLang, langFromText } from "../i18n/index.mjs";
|
|
14
15
|
|
|
15
16
|
// --- доказательство гейтов ------------------------------------------------------------
|
|
16
17
|
// ЗАЧЕМ. Ступень AQK-2 называлась «гейты доказаны» и проверяла существование двух папок.
|
|
@@ -120,3 +121,148 @@ test("docs — известное поле манифеста", () => {
|
|
|
120
121
|
assert.ok(KNOWN_KEYS.includes("docs"));
|
|
121
122
|
assert.deepEqual(unknownKeys({ docs: ".aqk/docs" }), []);
|
|
122
123
|
});
|
|
124
|
+
|
|
125
|
+
// --- covers: запись закрыта другим арбитром -----------------------------------
|
|
126
|
+
// Просьба первого чужого пользователя, 2026-09-08, названная им первой: «нельзя сказать, что
|
|
127
|
+
// эта запись у нас закрыта другим гейтом. complexity-limit, no-print-in-prod, swallowed-error
|
|
128
|
+
// держит biome — одним арбитром, точнее переносимого. doctor каждый прогон печатает
|
|
129
|
+
// „применимо, но не поставлено: 5“ — неправду».
|
|
130
|
+
//
|
|
131
|
+
// Неправда в НАШЕМ выводе — самая дорогая из возможных: весь стандарт стоит на том, что вывод
|
|
132
|
+
// не врёт. Поэтому поле есть, но оно не признание на слово: гейт, который «закрывает», обязан
|
|
133
|
+
// быть объявлен в gates:. Иначе covers: становится способом объявить защиту, которой нет, —
|
|
134
|
+
// то самое, против чего написан комплект.
|
|
135
|
+
test("вложенный список в квадратных скобках разбирается как список", () => {
|
|
136
|
+
const man = parseManifest("covers:\n lint: [no-print-in-prod, swallowed-error]\n");
|
|
137
|
+
assert.deepEqual(man.covers.lint, ["no-print-in-prod", "swallowed-error"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("covers отдаёт связь «запись → чем закрыта»", () => {
|
|
141
|
+
const man = parseManifest("gates:\n lint: \"biome ci .\"\ncovers:\n lint: [no-print-in-prod, swallowed-error]\n");
|
|
142
|
+
const { covered } = coversOf(man);
|
|
143
|
+
assert.equal(covered.get("no-print-in-prod"), "lint");
|
|
144
|
+
assert.equal(covered.get("swallowed-error"), "lint");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Гейт, которого нет в gates:, не закрывает ничего. Промолчать здесь значит выдать
|
|
148
|
+
// несуществующего арбитра за существующего — ровно тот отказ, ради которого всё написано.
|
|
149
|
+
test("закрывать может только объявленный гейт", () => {
|
|
150
|
+
const man = parseManifest("gates:\n lint: \"biome ci .\"\ncovers:\n biome: [complexity-limit]\n");
|
|
151
|
+
const { covered, unknownGates } = coversOf(man);
|
|
152
|
+
assert.equal(covered.size, 0, "необъявленный гейт не закрывает ничего");
|
|
153
|
+
assert.deepEqual(unknownGates, ["biome"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("пустой covers ничего не ломает", () => {
|
|
157
|
+
const { covered, unknownGates } = coversOf(parseManifest("aqk: 1\n"));
|
|
158
|
+
assert.equal(covered.size, 0);
|
|
159
|
+
assert.deepEqual(unknownGates, []);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("covers — известное поле манифеста", () => {
|
|
163
|
+
assert.ok(KNOWN_KEYS.includes("covers"));
|
|
164
|
+
assert.deepEqual(unknownKeys({ covers: {} }), []);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// --- язык вывода: настройка ПРОЕКТА, а не машины ------------------------------
|
|
168
|
+
// Просьба первого чужого пользователя: «язык берётся из LC_ALL/LANG, а на Windows их просто
|
|
169
|
+
// нет: русский проект получает английский вывод. AQK_LANG=ru чинит, но у следующего человека
|
|
170
|
+
// будет своё. Место этому в .aqk.yml». Он прав: язык репозитория — свойство репозитория,
|
|
171
|
+
// а локаль — свойство машины, на которой его сегодня открыли.
|
|
172
|
+
//
|
|
173
|
+
// Порядок намеренный: переменная окружения ВЫШЕ манифеста. Человек, набравший AQK_LANG=en
|
|
174
|
+
// руками, хочет английский именно сейчас — и спорить с ним манифестом значит отнять последнее
|
|
175
|
+
// средство. Манифест выше локали: он про проект, локаль про машину.
|
|
176
|
+
test("манифест задаёт язык, когда переменной окружения нет", () => {
|
|
177
|
+
assert.equal(pickLang({ LANG: "en_US.UTF-8" }, { lang: "ru" }), "ru");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("переменная окружения сильнее манифеста", () => {
|
|
181
|
+
assert.equal(pickLang({ AQK_LANG: "en" }, { lang: "ru" }), "en");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("без манифеста всё как раньше — локаль, потом английский", () => {
|
|
185
|
+
assert.equal(pickLang({ LANG: "ru_RU.UTF-8" }, null), "ru");
|
|
186
|
+
assert.equal(pickLang({}, null), "en");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("мусор в поле lang не молчит, а просто не действует", () => {
|
|
190
|
+
assert.equal(pickLang({}, { lang: "клингонский" }), "en");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Сокращённый разбор языка в i18n/index.mjs существует потому, что каталог строк нужен раньше,
|
|
194
|
+
// чем кто-либо успеет прочитать манифест целиком. Два разбора одного файла — то же, что два
|
|
195
|
+
// свода правил: через месяц они расходятся, и непонятно, какой настоящий. Сверяем ответы.
|
|
196
|
+
test("сокращённый разбор языка не расходится с настоящим", () => {
|
|
197
|
+
for (const text of [
|
|
198
|
+
'aqk: 1\nlang: ru\ngates:\n lint: "true"\n',
|
|
199
|
+
"aqk: 1\nlang: 'en'\n",
|
|
200
|
+
'aqk: 1\nlang: "ru" # комментарий\n',
|
|
201
|
+
"aqk: 1\ngates:\n lang: ru\n", // вложенный ключ — не язык проекта
|
|
202
|
+
"aqk: 1\n",
|
|
203
|
+
]) {
|
|
204
|
+
assert.equal(langFromText(text), String(parseManifest(text).lang || ""), text);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// --- заявка covers сверяется, а не принимается на слово -----------------------
|
|
209
|
+
// Поле `covers:` я завёл этим же утром и сам записал в коммит: «снимает запись с долга по
|
|
210
|
+
// СЛОВУ человека; проверить, что чужой гейт ловит то же самое, машина не может». К вечеру
|
|
211
|
+
// выяснилось, что это не теория. Запуск на настоящем `ruff.toml` из живого проекта: девятнадцать
|
|
212
|
+
// групп правил в `extend-select`, и `print()` не ловится — группы `T20` среди них нет.
|
|
213
|
+
// То есть заявка «no-print-in-prod держит наш lint» была бы ЛОЖНОЙ, а запись ушла бы из долга.
|
|
214
|
+
//
|
|
215
|
+
// Проверяется ровно то, что можно: у записи каталога в рецепте стоят коды правил
|
|
216
|
+
// (`ruff check --select T20`). Если ни команда закрывающего гейта, ни конфиг линтера этих кодов
|
|
217
|
+
// не называют — заявка не подтверждена. Это не «ложь», а «не подтверждено»: правило могло
|
|
218
|
+
// прийти из плагина или пресета, и объявлять такое ошибкой значит краснеть на нормальном укладе.
|
|
219
|
+
test("заявка подтверждена, когда коды правил есть в команде гейта", () => {
|
|
220
|
+
const man = parseManifest('gates:\n lint: "ruff check --select T20,BLE ."\ncovers:\n lint: [no-print-in-prod]\n');
|
|
221
|
+
const catalog = [{ slug: "no-print-in-prod", recipes: { python: "ruff check --select T20 {dir}" } }];
|
|
222
|
+
assert.deepEqual(coversUnproven(man, catalog, ""), []);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("заявка не подтверждена, когда кодов нет нигде", () => {
|
|
226
|
+
const man = parseManifest('gates:\n lint: "ruff check ."\ncovers:\n lint: [no-print-in-prod]\n');
|
|
227
|
+
const catalog = [{ slug: "no-print-in-prod", recipes: { python: "ruff check --select T20 {dir}" } }];
|
|
228
|
+
assert.deepEqual(coversUnproven(man, catalog, ""), [{ entry: "no-print-in-prod", gate: "lint", codes: ["T20"] }]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Правило может стоять не в команде, а в конфиге линтера — это нормальный уклад, и краснеть
|
|
232
|
+
// на нём нельзя. Настоящий пример: extend-select в ruff.toml.
|
|
233
|
+
test("коды правил в конфиге линтера тоже подтверждают заявку", () => {
|
|
234
|
+
const man = parseManifest('gates:\n lint: "ruff check ."\ncovers:\n lint: [no-print-in-prod]\n');
|
|
235
|
+
const catalog = [{ slug: "no-print-in-prod", recipes: { python: "ruff check --select T20 {dir}" } }];
|
|
236
|
+
assert.deepEqual(coversUnproven(man, catalog, 'extend-select = ["I", "T20", "B"]'), []);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// У записи без кодов правил в рецепте сверять нечего — молчим, а не выдумываем вердикт.
|
|
240
|
+
test("запись без кодов правил в рецепте не порождает придирки", () => {
|
|
241
|
+
const man = parseManifest('gates:\n lint: "true"\ncovers:\n lint: [duplicate-code]\n');
|
|
242
|
+
const catalog = [{ slug: "duplicate-code", recipes: { any: "bash {gate}/check.sh {dir}" } }];
|
|
243
|
+
assert.deepEqual(coversUnproven(man, catalog, ""), []);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// --- строка манифеста, которую разбор не понял, не исчезает молча ---------------
|
|
247
|
+
// Найдено 2026-09-09 случайно: подсаживал падающий гейт с именем «плохой», чтобы посмотреть
|
|
248
|
+
// на строку присутствия, — и прогон вышел с НУЛЁМ. Гейт не упал: его вообще не было. Разбор
|
|
249
|
+
// принимает имена только латиницей, а строку, которая под это не подошла, ВЫБРАСЫВАЛ без слова.
|
|
250
|
+
//
|
|
251
|
+
// Это наш класс в чистом виде: человек объявил проверку, видит её в файле, а она не
|
|
252
|
+
// существует. Хуже опечатки в имени поля — ту мы называем с 2026-09-06, а эту не называли.
|
|
253
|
+
// Чинится не расширением алфавита, а голосом: любая непонятая строка обязана быть названа.
|
|
254
|
+
test("непонятая строка манифеста называется с номером", () => {
|
|
255
|
+
const bad = unparsedLines('aqk: 1\ngates:\n ok: "true"\n плохой: "false"\n');
|
|
256
|
+
assert.equal(bad.length, 1);
|
|
257
|
+
assert.equal(bad[0].line, 4);
|
|
258
|
+
assert.match(bad[0].text, /плохой/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("правильный манифест не порождает жалоб", () => {
|
|
262
|
+
assert.deepEqual(unparsedLines('aqk: 1\nentry:\n - AGENTS.md\ngates:\n ok: "true"\n'), []);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Комментарии и пустые строки — не находка: они и не должны разбираться.
|
|
266
|
+
test("комментарии и пустые строки не считаются потерянными", () => {
|
|
267
|
+
assert.deepEqual(unparsedLines("# заметка\n\naqk: 1\n # ещё\n"), []);
|
|
268
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Проверки разбора истории для `aqk probe`. Написаны ДО кода: без них нельзя отличить
|
|
2
|
+
// «функция работает» от «функция написана».
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { isFix, fixHotspots, probeVerdict } from "../lib/history.mjs";
|
|
6
|
+
import { scanningGates, isCode } from "../commands/probe.mjs";
|
|
7
|
+
|
|
8
|
+
// Признак починки берётся из ТЕМЫ коммита, а не из тела: тема — единственное, что пишут все,
|
|
9
|
+
// и единственное, что видно в `git log --oneline`. Три написания, потому что репозитории
|
|
10
|
+
// бывают на двух языках и с conventional commits.
|
|
11
|
+
test("темой починки считаются fix, исправ и почин — на обоих языках и с областью", () => {
|
|
12
|
+
assert.equal(isFix("fix: гейт молчал на красном"), true);
|
|
13
|
+
assert.equal(isFix("fix(prove): windows-пути"), true);
|
|
14
|
+
assert.equal(isFix("Исправлено: разбор манифеста"), true);
|
|
15
|
+
assert.equal(isFix("починка отчёта"), true);
|
|
16
|
+
assert.equal(isFix("FIX: заглавными тоже"), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Слово «fix» внутри обычной темы починкой не делает: иначе «feat: prefix для путей» попадёт
|
|
20
|
+
// в рейтинг, и рейтинг перестанет что-либо значить.
|
|
21
|
+
test("не всякое упоминание fix — починка", () => {
|
|
22
|
+
assert.equal(isFix("feat: prefix для путей"), false);
|
|
23
|
+
assert.equal(isFix("docs: как чинить гейт"), false);
|
|
24
|
+
assert.equal(isFix("refactor: суффиксы"), false);
|
|
25
|
+
assert.equal(isFix(""), false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// НАСТОЯЩИЙ формат `git log --format=%s --name-only`, снятый с живого репозитория 2026-09-09:
|
|
29
|
+
// тема, ПУСТАЯ строка, пути, и сразу следующая тема — без пустой строки перед ней.
|
|
30
|
+
// Первая версия этого теста была написана по моему представлению о формате (пустая строка
|
|
31
|
+
// СЛЕДОВАЛА за файлами), код под неё разобрал историю неверно и нашёл ноль починок там, где
|
|
32
|
+
// их сорок пять. Тот же класс, что и дважды сегодня: проверял замысел, а не вывод.
|
|
33
|
+
const LOG = [
|
|
34
|
+
"fix: разбор манифеста",
|
|
35
|
+
"",
|
|
36
|
+
"tool/lib/manifest.mjs",
|
|
37
|
+
"tool/selfcheck/units.mjs",
|
|
38
|
+
"feat: новая запись",
|
|
39
|
+
"",
|
|
40
|
+
"kit/gates/x/check.sh",
|
|
41
|
+
"fix(manifest): кавычки",
|
|
42
|
+
"",
|
|
43
|
+
"tool/lib/manifest.mjs",
|
|
44
|
+
"README.md",
|
|
45
|
+
].join("\n");
|
|
46
|
+
|
|
47
|
+
test("рейтинг считает только файлы из коммитов-починок", () => {
|
|
48
|
+
const hot = fixHotspots(LOG, { isCode: () => true });
|
|
49
|
+
assert.deepEqual(hot[0], { path: "tool/lib/manifest.mjs", fixes: 2 });
|
|
50
|
+
// Файл из коммита-фичи в рейтинг не попадает вовсе.
|
|
51
|
+
assert.equal(hot.some((h) => h.path === "kit/gates/x/check.sh"), false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("рейтинг отсеивает то, что не код: документ чинят иначе, чем программу", () => {
|
|
55
|
+
const hot = fixHotspots(LOG, { isCode: (p) => p.endsWith(".mjs") });
|
|
56
|
+
assert.equal(hot.some((h) => h.path === "README.md"), false);
|
|
57
|
+
assert.equal(hot.length, 2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("пустая история — пустой рейтинг, а не падение", () => {
|
|
61
|
+
assert.deepEqual(fixHotspots("", { isCode: () => true }), []);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Три состояния, и сливать их нельзя. «Не смогли проверить» — не «прикрыто»: ровно та
|
|
65
|
+
// подмена, против которой написан весь комплект.
|
|
66
|
+
test("вердикт пробы различает поймано, не поймано и нечем проверить", () => {
|
|
67
|
+
assert.equal(probeVerdict([{ code: 1 }, { code: 0 }]), "caught");
|
|
68
|
+
assert.equal(probeVerdict([{ code: 0 }, { code: 0 }]), "blind");
|
|
69
|
+
assert.equal(probeVerdict([{ code: 2 }, { code: 0 }]), "unknown");
|
|
70
|
+
// Поймавший гейт сильнее непроверенного: класс закрыт, даже если рядом чего-то не хватает.
|
|
71
|
+
assert.equal(probeVerdict([{ code: 2 }, { code: 1 }]), "caught");
|
|
72
|
+
assert.equal(probeVerdict([]), "unknown");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Пробовать можно только те гейты, которым есть куда подставить каталог: рецепт каталога
|
|
76
|
+
// кончается каталогом проверки, команда, написанная руками, — чем угодно. То же правило, по
|
|
77
|
+
// которому `prove` объявляет запись недоказуемой, а не сломанной.
|
|
78
|
+
test("пробуются только гейты, кончающиеся каталогом проверки", () => {
|
|
79
|
+
const man = { gates: {
|
|
80
|
+
ok: "bash gates/x/check.sh .",
|
|
81
|
+
slash: "bash gates/y/check.sh ./",
|
|
82
|
+
handmade: "eslint . --max-warnings 0",
|
|
83
|
+
empty: "",
|
|
84
|
+
} };
|
|
85
|
+
assert.deepEqual(scanningGates(man).map(([n]) => n), ["ok", "slash"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("манифест без гейтов не роняет разбор", () => {
|
|
89
|
+
assert.deepEqual(scanningGates(null), []);
|
|
90
|
+
assert.deepEqual(scanningGates({ gates: [] }), []);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Образцы каталога исключены по той же причине, по какой их исключает каждая сканирующая
|
|
94
|
+
// проверка: они существуют, чтобы быть неправильными, и «горячими» быть не могут.
|
|
95
|
+
test("горячим считается код, но не документ и не образец каталога", () => {
|
|
96
|
+
assert.equal(isCode("src/a.py"), true);
|
|
97
|
+
assert.equal(isCode("tool/lib/core.mjs"), true);
|
|
98
|
+
assert.equal(isCode("README.md"), false);
|
|
99
|
+
assert.equal(isCode("gates/secrets-not-in-code/red/a.py"), false);
|
|
100
|
+
});
|