agent-quality-kit 0.6.0 → 0.8.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 +61 -2
- package/README.ru.md +61 -2
- package/kit/docs/ai/agent-harness-playbook.md +1 -1
- package/kit/docs/ready-made-rules.md +29 -4
- package/kit/gates/README.md +40 -0
- package/kit/gates/_skip.sh +61 -1
- package/kit/gates/ci-actually-fails/README.md +12 -0
- package/kit/gates/ci-actually-fails/check.sh +26 -3
- package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +16 -0
- package/kit/gates/ci-actually-fails/red/.github/workflows/soft.yml +15 -0
- package/kit/gates/ci-not-hijackable/README.md +56 -0
- package/kit/gates/ci-not-hijackable/check.sh +73 -0
- package/kit/gates/ci-not-hijackable/gate.yml +19 -0
- package/kit/gates/ci-not-hijackable/green/.github/workflows/triage.yml +19 -0
- package/kit/gates/ci-not-hijackable/red/.github/workflows/triage.yml +18 -0
- package/kit/gates/color-from-token/check.sh +19 -3
- package/kit/gates/color-from-token/green/Button.tsx +2 -0
- package/kit/gates/commit-explains-itself/README.md +13 -3
- package/kit/gates/commit-explains-itself/check.sh +8 -4
- package/kit/gates/complexity-limit/README.md +5 -0
- package/kit/gates/complexity-limit/check.sh +27 -9
- package/kit/gates/complexity-limit/green/test_fixtures.py +14 -0
- package/kit/gates/deps-are-pinned/README.md +14 -1
- package/kit/gates/deps-are-pinned/check.sh +6 -1
- package/kit/gates/deps-are-pinned/green/pyproject-with-requirements/pyproject.toml +12 -0
- package/kit/gates/deps-are-pinned/green/pyproject-with-requirements/requirements.txt +3 -0
- package/kit/gates/deps-are-pinned/red/pyproject-loose/pyproject.toml +12 -0
- package/kit/gates/deps-are-pinned/red/pyproject-loose/requirements.txt +3 -0
- package/kit/gates/duplicate-code/README.md +11 -2
- package/kit/gates/duplicate-code/check.sh +36 -5
- package/kit/gates/duplicate-code/gate.yml +8 -0
- package/kit/gates/duplicate-code/green/imports_a.go +20 -0
- package/kit/gates/duplicate-code/green/imports_b.go +19 -0
- package/kit/gates/entry-links-exist/README.md +5 -0
- package/kit/gates/entry-links-exist/check.sh +10 -1
- package/kit/gates/entry-links-exist/green/AGENTS.md +5 -0
- package/kit/gates/file-size-limit/README.md +9 -2
- package/kit/gates/file-size-limit/check.sh +14 -2
- package/kit/gates/gate-not-weakened/check.sh +13 -1
- package/kit/gates/hook-actually-fires/README.md +74 -0
- package/kit/gates/hook-actually-fires/check.sh +183 -0
- package/kit/gates/hook-actually-fires/gate.yml +15 -0
- package/kit/gates/hook-actually-fires/green/.claude/hooks/hooks.json +3 -0
- package/kit/gates/hook-actually-fires/green/.claude/settings.json +74 -0
- package/kit/gates/hook-actually-fires/green/.claude/settings.local.json +74 -0
- package/kit/gates/hook-actually-fires/red/.claude/hooks/hooks.json +4 -0
- package/kit/gates/hook-actually-fires/red/.claude/settings.json +53 -0
- package/kit/gates/no-phantom-package/README.md +84 -0
- package/kit/gates/no-phantom-package/check.sh +161 -0
- package/kit/gates/no-phantom-package/gate.yml +20 -0
- package/kit/gates/no-phantom-package/green/AGENTS.md +15 -0
- package/kit/gates/no-phantom-package/red/AGENTS.md +15 -0
- package/kit/gates/no-print-in-prod/README.md +33 -39
- package/kit/gates/no-print-in-prod/gate.yml +14 -6
- package/kit/gates/personal-config-not-shared/README.md +66 -0
- package/kit/gates/personal-config-not-shared/check.sh +103 -0
- package/kit/gates/personal-config-not-shared/gate.yml +16 -0
- package/kit/gates/personal-config-not-shared/green/.aqk-tracked +9 -0
- package/kit/gates/personal-config-not-shared/red/.aqk-tracked +6 -0
- package/kit/gates/secrets-not-in-code/check.sh +29 -4
- package/kit/gates/secrets-not-in-code/green/testdata/certificate/key.pem +3 -0
- package/kit/gates/swallowed-error/README.md +36 -18
- package/kit/gates/swallowed-error/gate.yml +13 -3
- package/kit/gates/test-has-assertion/check.sh +13 -1
- package/kit/gates/test-not-adjusted/README.md +79 -0
- package/kit/gates/test-not-adjusted/check.sh +136 -0
- package/kit/gates/test-not-adjusted/gate.yml +19 -0
- package/kit/gates/test-not-adjusted/green/after/calc.py +6 -0
- package/kit/gates/test-not-adjusted/green/after/tests/test_calc.py +9 -0
- package/kit/gates/test-not-adjusted/green/before/calc.py +2 -0
- package/kit/gates/test-not-adjusted/green/before/tests/test_calc.py +5 -0
- package/kit/gates/test-not-adjusted/red/after/calc.py +2 -0
- package/kit/gates/test-not-adjusted/red/after/tests/test_calc.py +5 -0
- package/kit/gates/test-not-adjusted/red/before/calc.py +2 -0
- package/kit/gates/test-not-adjusted/red/before/tests/test_calc.py +7 -0
- package/kit/gates/todo-without-task/README.md +6 -0
- package/kit/gates/todo-without-task/check.sh +14 -2
- package/kit/gates/todo-without-task/green/app.py +1 -0
- package/kit/ratchet/ratchet.sh +70 -2
- package/kit/rules/general.md +9 -0
- package/kit/rules-en/general.md +82 -0
- package/kit/rules-en/security.md +33 -0
- package/kit/rules-en/testing.md +48 -0
- package/llms.txt +22 -1
- package/package.json +6 -2
- package/tool/commands/badge.mjs +7 -1
- package/tool/commands/context.mjs +260 -0
- package/tool/commands/doctor.mjs +72 -25
- package/tool/commands/gates.mjs +10 -4
- package/tool/commands/learn.mjs +159 -0
- package/tool/commands/project.mjs +9 -1
- package/tool/commands/prove.mjs +67 -0
- package/tool/commands/report.mjs +37 -2
- package/tool/i18n/en-docs.mjs +154 -0
- package/tool/i18n/en.mjs +70 -90
- package/tool/i18n/ru-docs.mjs +156 -0
- package/tool/i18n/ru.mjs +69 -90
- package/tool/i18n/templates-en.mjs +1 -1
- package/tool/i18n/templates-ru.mjs +1 -1
- package/tool/lib/core.mjs +37 -2
- package/tool/lib/evidence.mjs +124 -0
- package/tool/lib/manifest.mjs +63 -5
- package/tool/lib/prove.mjs +172 -0
- package/tool/lib/repo.mjs +31 -2
- package/tool/lib/scope.mjs +46 -2
- package/tool/lib/templates.mjs +3 -0
- package/tool/program.mjs +23 -22
- package/tool/selfcheck/gates.sh +66 -0
- package/tool/selfcheck/mutation.sh +21 -1
- package/tool/selfcheck/smoke.sh +519 -39
- package/tool/selfcheck/units-context.mjs +186 -0
- package/tool/selfcheck/units-evidence.mjs +83 -0
- package/tool/selfcheck/units-learn.mjs +88 -0
- package/tool/selfcheck/units-level.mjs +122 -0
- package/tool/selfcheck/units.mjs +114 -2
- package/kit/gates/no-print-in-prod/check.sh +0 -38
- package/kit/gates/no-print-in-prod/green/docs.ts +0 -15
- package/kit/gates/no-print-in-prod/green/main.go +0 -8
- package/kit/gates/no-print-in-prod/green/main.rs +0 -4
- package/kit/gates/no-print-in-prod/red/main.go +0 -8
- package/kit/gates/no-print-in-prod/red/main.rs +0 -4
- package/kit/gates/swallowed-error/check.sh +0 -54
- package/kit/gates/swallowed-error/green/run.js +0 -8
- package/kit/gates/swallowed-error/red/run.js +0 -3
- /package/kit/gates/commit-explains-itself/green/{COMMIT_MSG → .aqk-commit-msg} +0 -0
- /package/kit/gates/commit-explains-itself/red/{COMMIT_MSG → .aqk-commit-msg} +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// tool/selfcheck/units-context.mjs — блок состояния, который уходит В КОНТЕКСТ агента.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ОТДЕЛЬНЫМ ФАЙЛОМ. У этого текста единственный читатель — машина, и цена ошибки другая,
|
|
4
|
+
// чем у терминального вывода: человек, увидев пустую строку, переспросит, а агент примет её за
|
|
5
|
+
// утверждение. Поэтому главная проверка здесь одна и та же во всех видах: **тишина не означает
|
|
6
|
+
// «чисто»**. Блок обязан говорить «неизвестно» там, где не знает, — иначе он врёт ровно тем
|
|
7
|
+
// способом, против которого написан весь комплект.
|
|
8
|
+
//
|
|
9
|
+
// node --test tool/selfcheck/units-context.mjs
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { contextBlock, countArbiters, parseLastRun, withHook, hasOurHook, portableSelf } from "../commands/context.mjs";
|
|
13
|
+
import { CATALOGS } from "../i18n/index.mjs";
|
|
14
|
+
import { commandRows } from "../lib/core.mjs";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
|
|
17
|
+
const T = CATALOGS.ru.context;
|
|
18
|
+
const base = {
|
|
19
|
+
entry: "AGENTS.md",
|
|
20
|
+
level: { reached: 1, top: 3, missing: "гейты не доказаны" },
|
|
21
|
+
rules: { total: 14, machine: 2, human: 12 },
|
|
22
|
+
run: { when: "2026-09-08 11:00", red: [], skipped: 0, stale: false },
|
|
23
|
+
ratchets: [],
|
|
24
|
+
};
|
|
25
|
+
const text = (over = {}) => contextBlock({ ...base, ...over }, T).join("\n");
|
|
26
|
+
|
|
27
|
+
// ГЛАВНАЯ. Прогона не было — сказать «неизвестно» словом. Пустая строка на этом месте
|
|
28
|
+
// прочитается агентом как «красных нет», и он пойдёт писать код по несуществующему разрешению.
|
|
29
|
+
test("без прогона блок говорит «неизвестно», а не молчит", () => {
|
|
30
|
+
const t = text({ run: null });
|
|
31
|
+
assert.match(t, /НЕИЗВЕСТНО/);
|
|
32
|
+
assert.doesNotMatch(t, /красных нет/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("красные гейты названы поимённо", () => {
|
|
36
|
+
const t = text({ run: { ...base.run, red: ["file-size-limit", "duplicate-code"] } });
|
|
37
|
+
assert.match(t, /file-size-limit/);
|
|
38
|
+
assert.match(t, /duplicate-code/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Проект с двадцатью красными не должен вытеснять собой весь контекст: ровно та деградация,
|
|
42
|
+
// ради избежания которой блок и делается коротким.
|
|
43
|
+
test("длинный список красных обрезается и называет остаток числом", () => {
|
|
44
|
+
const red = Array.from({ length: 20 }, (_, i) => `гейт-${i}`);
|
|
45
|
+
const t = text({ run: { ...base.run, red } });
|
|
46
|
+
assert.match(t, /ещё 15/);
|
|
47
|
+
assert.ok(!t.includes("гейт-9"), "шестой и дальше в список не попадают");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Прогон, сделанный до последнего коммита, описывает не тот код, что лежит перед агентом.
|
|
51
|
+
test("устаревший прогон помечен, а не выдан за свежий", () => {
|
|
52
|
+
const t = text({ run: { ...base.run, stale: true } });
|
|
53
|
+
assert.match(t, /СТАРЕЕ/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// То, ради чего второй пользователь и оценил promise-has-gate: «держит человек» значит
|
|
57
|
+
// «не держит никто», и это обязано быть сказано словами, а не выведено читателем из цифр.
|
|
58
|
+
test("правила без машинного арбитра названы прямо", () => {
|
|
59
|
+
const t = text({ rules: { total: 12, machine: 0, human: 12 } });
|
|
60
|
+
assert.match(t, /не держит никто/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("нет манифеста — уровень не выдумывается", () => {
|
|
64
|
+
const t = text({ level: null });
|
|
65
|
+
assert.match(t, /не вычислен/);
|
|
66
|
+
assert.doesNotMatch(t, /AQK-0 из/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Потолок: блок влезает в глаза целиком. Замер 2026-09-08 — вход целиком в контекст роняет
|
|
70
|
+
// точность у всех проверенных моделей по мере роста, поэтому предел здесь предмет проверки,
|
|
71
|
+
// а не пожелание.
|
|
72
|
+
test("блок не разрастается даже на худшем входе", () => {
|
|
73
|
+
const lines = contextBlock({
|
|
74
|
+
...base,
|
|
75
|
+
run: { when: "…", red: Array.from({ length: 40 }, (_, i) => `г-${i}`), skipped: 9, stale: true },
|
|
76
|
+
ratchets: Array.from({ length: 12 }, (_, i) => ({ name: `р-${i}`, count: i })),
|
|
77
|
+
}, T);
|
|
78
|
+
assert.ok(lines.length <= 16, `строк ${lines.length}, предел 16`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// --- разбор источников -------------------------------------------------------
|
|
82
|
+
// Имя арбитра — это имя гейта, а в именах гейтов есть дефисы. Класс `[^\s>-]` обрывал их
|
|
83
|
+
// молча: первый же живой запуск показал 13 правил вместо 14 и одного машинного вместо двух.
|
|
84
|
+
// Молча — потому что число выглядит правдоподобным, пока его не с чем сверить.
|
|
85
|
+
test("имя арбитра с дефисом считается машинным, а не теряется", () => {
|
|
86
|
+
const md = [
|
|
87
|
+
"- Правило один. <!-- aqk: deps-are-pinned -->",
|
|
88
|
+
"- Правило два. <!-- aqk: человек -->",
|
|
89
|
+
"- Правило три. <!-- aqk: gates -->",
|
|
90
|
+
].join("\n");
|
|
91
|
+
assert.deepEqual(countArbiters(md, ["человек"]), { total: 3, machine: 2, human: 1 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("отчёт прошлого прогона отдаёт красные и число непроверенных", () => {
|
|
95
|
+
const r = parseLastRun([
|
|
96
|
+
"# aqk doctor --run — 2026-09-08 11:00",
|
|
97
|
+
"level: AQK-1",
|
|
98
|
+
"✔ smoke — 1.0s",
|
|
99
|
+
"✘ file-size-limit — 0.1s",
|
|
100
|
+
"~ dead-code — нет инструмента",
|
|
101
|
+
].join("\n"));
|
|
102
|
+
assert.equal(r.when, "2026-09-08 11:00");
|
|
103
|
+
assert.deepEqual(r.red, ["file-size-limit"]);
|
|
104
|
+
assert.equal(r.skipped, 1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("пустой отчёт — это не «чисто», а отсутствие данных", () => {
|
|
108
|
+
assert.equal(parseLastRun(""), null);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Найдено ЗАМЕРОМ на шести чужих проектах, до того как хук попал в init: на `flask`, где нет
|
|
112
|
+
// ни .aqk.yml, ни AGENTS.md, блок всё равно писал «Свод правил: AGENTS.md». Тот же класс, что
|
|
113
|
+
// у doctor неделей раньше: умолчание выдаётся за факт, потому что на нашем репозитории
|
|
114
|
+
// умолчание и факт совпадают. Назвать агенту несуществующий файл хуже, чем промолчать: он
|
|
115
|
+
// пойдёт его читать и получит пустоту вместо правил.
|
|
116
|
+
test("несуществующая точка входа не называется как свод правил", () => {
|
|
117
|
+
const t = text({ level: null, run: null, rules: null, entryExists: false });
|
|
118
|
+
assert.doesNotMatch(t, /AGENTS\.md/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("существующая точка входа называется", () => {
|
|
122
|
+
const t = text({ entryExists: true });
|
|
123
|
+
assert.match(t, /AGENTS\.md/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// --- хук уходит в ОБЩИЙ файл настроек ----------------------------------------
|
|
127
|
+
// `.claude/settings.json` кладут в git: он общий на команду, в отличие от settings.local.json.
|
|
128
|
+
// Значит команда внутри него обязана работать не только на той машине, где её записали.
|
|
129
|
+
// На машине разработчика SELF — абсолютный путь; у соседа такого пути нет, и хук молча
|
|
130
|
+
// не сработает. Молча — то есть блок состояния просто не появится, и никто не узнает.
|
|
131
|
+
test("абсолютный путь заменяется переносимым вызовом", () => {
|
|
132
|
+
assert.equal(portableSelf("node /home/x/aqk/tool/program.mjs"), "npx agent-quality-kit");
|
|
133
|
+
assert.equal(portableSelf("node C:\\x\\aqk\\tool\\program.mjs"), "npx agent-quality-kit");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("переносимые вызовы остаются как есть", () => {
|
|
137
|
+
assert.equal(portableSelf("aqk"), "aqk");
|
|
138
|
+
assert.equal(portableSelf("npx agent-quality-kit"), "npx agent-quality-kit");
|
|
139
|
+
assert.equal(portableSelf("node tool/program.mjs"), "node tool/program.mjs");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Второй такой же хук — блок в контексте дважды: вдвое больше токенов и ровно ноль пользы.
|
|
143
|
+
test("хук не задваивается", () => {
|
|
144
|
+
const once = withHook({}, "aqk context");
|
|
145
|
+
assert.ok(hasOurHook(once, "aqk context"));
|
|
146
|
+
assert.equal(once.hooks.SessionStart.length, 1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Чужие настройки в том же файле — права доступа, другие хуки — обязаны пережить установку.
|
|
150
|
+
test("чужие настройки переживают установку хука", () => {
|
|
151
|
+
const before = { permissions: { deny: ["Read(./.env)"] }, hooks: { Stop: [{ hooks: [] }] } };
|
|
152
|
+
const after = withHook(before, "aqk context");
|
|
153
|
+
assert.deepEqual(after.permissions, before.permissions);
|
|
154
|
+
assert.equal(after.hooks.Stop.length, 1);
|
|
155
|
+
assert.equal(after.hooks.SessionStart.length, 1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// --- карта команд ------------------------------------------------------------
|
|
159
|
+
// Владелец: «агент плохо читает инструкцию — нужно влить карту и сам свод». Карта имеет смысл
|
|
160
|
+
// ровно до тех пор, пока она не отстала от программы. Второй список, живущий рядом с первым,
|
|
161
|
+
// через месяц врёт — это записано у нас в README про храповики и верно здесь буквально так же.
|
|
162
|
+
// Поэтому карта и справка собираются ИЗ ОДНОГО списка, а эта проверка сторожит, что список
|
|
163
|
+
// не отстал от диспетчера: команда, добавленная в switch и забытая в списке, роняет её.
|
|
164
|
+
test("карта команд не отстаёт от диспетчера", async () => {
|
|
165
|
+
const src = await readFile(new URL("../program.mjs", import.meta.url), "utf8");
|
|
166
|
+
const dispatched = [...src.matchAll(/^\s{4}case "([a-z-]+)":/gm)].map((m) => m[1]);
|
|
167
|
+
assert.ok(dispatched.length >= 10, `в диспетчере найдено ${dispatched.length} команд — разбор сломался`);
|
|
168
|
+
const listed = new Set(commandRows(CATALOGS.ru).map((r) => r.name));
|
|
169
|
+
const missing = dispatched.filter((n) => !listed.has(n));
|
|
170
|
+
assert.deepEqual(missing, [], `в карте нет: ${missing.join(", ")}`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Полный блок — то, за что владелец согласился платить токенами: свод правил дословно, а не
|
|
174
|
+
// ссылка на него. Если он не дословный, плата внесена, а товар не получен.
|
|
175
|
+
test("полный блок несёт свод правил дословно", () => {
|
|
176
|
+
const rules = "- Правило одно. <!-- aqk: человек -->\n- Правило два.";
|
|
177
|
+
const t = contextBlock({ ...base, entryExists: true, full: { entry: "AGENTS.md", rows: [{ cmd: "aqk context", text: "состояние" }], text: rules } }, T).join("\n");
|
|
178
|
+
assert.ok(t.includes(rules), "текст свода обязан войти целиком");
|
|
179
|
+
assert.match(t, /aqk context/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("без --full свод не вливается — только ссылка на него", () => {
|
|
183
|
+
const t = text({ entryExists: true });
|
|
184
|
+
assert.doesNotMatch(t, /Правило одно/);
|
|
185
|
+
assert.match(t, /AGENTS\.md/);
|
|
186
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// tool/selfcheck/units-evidence.mjs — проверки привязки доказательства к дифу.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ОТДЕЛЬНЫМ ФАЙЛОМ. `units.mjs` во второй раз перерос собственный предел в 500 строк —
|
|
4
|
+
// его поймал наш же гейт file-size-limit, и это уже второй такой развод (первым был
|
|
5
|
+
// `units-level.mjs`). Шов по смыслу: здесь всё про то, чем доказан диф, и ничего больше.
|
|
6
|
+
//
|
|
7
|
+
// node --test tool/selfcheck/units-evidence.mjs
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
// --- покрытие дифа доказательством ------------------------------------------
|
|
13
|
+
//
|
|
14
|
+
// ЗАЧЕМ. Правило «готово = доказано» было единственным центральным правилом свода, за которым
|
|
15
|
+
// не следила машина: сторожем стоял человек. Прогон donecheck по восьми нашим коммитам показал,
|
|
16
|
+
// чего это стоило: `.github/workflows/publish.yml` менялся и не был назван ни одной командой
|
|
17
|
+
// проверки — и именно он оказался сломан. Механизм взят оттуда (AtharvaMaik/donecheck, MIT),
|
|
18
|
+
// сопоставление сделано СТРОГИМ, в отличие от него: он считает файл покрытым и по голому имени,
|
|
19
|
+
// а имя `check.sh` в нашем каталоге носят двадцать разных файлов.
|
|
20
|
+
test("покрытие: файл считается доказанным, только если гейт назвал ЕГО путь", async () => {
|
|
21
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
22
|
+
const res = [
|
|
23
|
+
{ name: "tests", cmd: "node --test", out: "ok 1 - src/pay.js:12 покрыт" },
|
|
24
|
+
{ name: "lint", cmd: "bash kit/gates/x/check.sh .", out: "" },
|
|
25
|
+
];
|
|
26
|
+
const cov = coverage(["src/pay.js", "src/refund.js"], res);
|
|
27
|
+
assert.deepEqual(cov.covered.get("src/pay.js"), ["tests"]);
|
|
28
|
+
// refund.js не назван никем, но `lint` был направлен в корень — значит он в «молчании»,
|
|
29
|
+
// а не в «никто не смотрел». Разница между этими двумя и есть смысл третьего состояния.
|
|
30
|
+
assert.deepEqual(cov.silent.get("src/refund.js"), ["lint"]);
|
|
31
|
+
assert.deepEqual(cov.uncovered, []);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("покрытие: голое имя файла не считается доказательством", async () => {
|
|
35
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
36
|
+
// «check.sh» в выводе не означает, что проверен ИМЕННО kit/gates/todo/check.sh: этим именем
|
|
37
|
+
// в каталоге зовутся два десятка разных файлов. Мягкое сравнение дало бы тишину — то есть
|
|
38
|
+
// ровно тот отказ, против которого написана вся эта проверка.
|
|
39
|
+
const res = [{ name: "g", cmd: "bash run.sh", out: "проверено check.sh" }];
|
|
40
|
+
const cov = coverage(["kit/gates/todo/check.sh"], res);
|
|
41
|
+
assert.deepEqual(cov.uncovered, ["kit/gates/todo/check.sh"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("покрытие: путь с ./ и цветом в выводе всё равно засчитывается", async () => {
|
|
45
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
46
|
+
const esc = String.fromCharCode(27);
|
|
47
|
+
const res = [{ name: "g", cmd: "x", out: `${esc}[31m./src/pay.js:9${esc}[0m нашлось` }];
|
|
48
|
+
const cov = coverage(["src/pay.js"], res);
|
|
49
|
+
assert.deepEqual(cov.uncovered, []);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Расписка обязана меняться, когда меняется хоть что-то из того, о чём она отчитывается:
|
|
53
|
+
// базовый коммит, набор команд, содержимое файлов. Иначе «прогнал, потом поправил ещё три
|
|
54
|
+
// файла» неотличимо от «прогнал».
|
|
55
|
+
test("расписка: хеш доказательства меняется от правки файла и от смены команды", async () => {
|
|
56
|
+
const { evidenceHash } = await import("../lib/evidence.mjs");
|
|
57
|
+
const a = evidenceHash("base1", [{ cmd: "t" }], [["f.js", "one"]]);
|
|
58
|
+
assert.equal(evidenceHash("base1", [{ cmd: "t" }], [["f.js", "one"]]), a);
|
|
59
|
+
assert.notEqual(evidenceHash("base1", [{ cmd: "t" }], [["f.js", "two"]]), a);
|
|
60
|
+
assert.notEqual(evidenceHash("base1", [{ cmd: "u" }], [["f.js", "one"]]), a);
|
|
61
|
+
assert.notEqual(evidenceHash("base2", [{ cmd: "t" }], [["f.js", "one"]]), a);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("покрытие: молчащая проверка отличается и от назвавшей, и от не смотревшей", async () => {
|
|
65
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
66
|
+
const res = [
|
|
67
|
+
{ name: "size", cmd: "bash check.sh .", out: "" }, // направлен в корень, промолчал
|
|
68
|
+
{ name: "tests", cmd: "node --test", out: "src/pay.js:1 ок" }, // назвал файл
|
|
69
|
+
];
|
|
70
|
+
const dirs = new Set(["."]);
|
|
71
|
+
const cov = coverage(["src/pay.js", "src/quiet.js"], res, (p) => dirs.has(p));
|
|
72
|
+
assert.deepEqual(cov.covered.get("src/pay.js"), ["tests"]);
|
|
73
|
+
assert.deepEqual(cov.silent.get("src/quiet.js"), ["size"]);
|
|
74
|
+
assert.deepEqual(cov.uncovered, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("покрытие: файл вне всех целей остаётся не тронутым никем", async () => {
|
|
78
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
79
|
+
const res = [{ name: "size", cmd: "bash check.sh tool", out: "" }];
|
|
80
|
+
const cov = coverage(["tool/a.js", ".github/workflows/ci.yml"], res, (p) => p === "tool");
|
|
81
|
+
assert.deepEqual(cov.silent.get("tool/a.js"), ["size"]);
|
|
82
|
+
assert.deepEqual(cov.uncovered, [".github/workflows/ci.yml"]);
|
|
83
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// tool/selfcheck/units-learn.mjs — проверки разбора локальных логов сессий.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ОТДЕЛЬНО. Файл модульных проверок разводится по смыслу третий раз (после units-level и
|
|
4
|
+
// units-evidence) — предел в 500 строк держит наш же гейт file-size-limit. Здесь всё про то,
|
|
5
|
+
// как из переписки достаются кандидаты в правила, и ничего больше.
|
|
6
|
+
//
|
|
7
|
+
// node --test tool/selfcheck/units-learn.mjs
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
// Каталог логов зовётся по рабочему пути, где всё, кроме букв и цифр, заменено на дефис.
|
|
13
|
+
// Проверено на живой машине: /home/ser/projects/aqk → -home-ser-projects-aqk,
|
|
14
|
+
// /home/ser/projects/audit_project → -home-ser-projects-audit-project.
|
|
15
|
+
test("logSlug: путь проекта превращается в имя каталога логов", async () => {
|
|
16
|
+
const { logSlug } = await import("../commands/learn.mjs");
|
|
17
|
+
assert.equal(logSlug("/home/ser/projects/aqk"), "-home-ser-projects-aqk");
|
|
18
|
+
assert.equal(logSlug("/home/ser/projects/audit_project"), "-home-ser-projects-audit-project");
|
|
19
|
+
assert.equal(logSlug("C:\\work\\my.app"), "c-work-my-app");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Отбор наставлений. Меряно на 1619 уникальных напечатанных репликах: маркеры дают 79 штук,
|
|
23
|
+
// то есть 4%. Точность неполная и названа вслух — это список кандидатов, а не находок.
|
|
24
|
+
test("looksLikeRule: наставление отличается от обычной реплики", async () => {
|
|
25
|
+
const { looksLikeRule } = await import("../commands/learn.mjs");
|
|
26
|
+
assert.equal(looksLikeRule("файл не трогай AI_main_inst.md"), true);
|
|
27
|
+
assert.equal(looksLikeRule("делай прогон с базой обязательно"), true);
|
|
28
|
+
assert.equal(looksLikeRule("never commit secrets"), true);
|
|
29
|
+
assert.equal(looksLikeRule("ок го дальше"), false);
|
|
30
|
+
assert.equal(looksLikeRule("а что там по отчёту"), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Длинная вставка наставлением не считается: в логе лежат и вставленный вывод команд, и куски
|
|
34
|
+
// файлов. Первый прогон без этого отсева выдал «agent quality kit» 44 раза — то есть пути и
|
|
35
|
+
// ссылки, а не правила.
|
|
36
|
+
test("looksLikeRule: длинная вставка и код не считаются правилом", async () => {
|
|
37
|
+
const { looksLikeRule } = await import("../commands/learn.mjs");
|
|
38
|
+
assert.equal(looksLikeRule("никогда " + "x".repeat(500)), false);
|
|
39
|
+
assert.equal(looksLikeRule("нельзя\n```\ncode\n```"), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Главное утверждение команды: сказано вслух и НЕ записано. Слово из наставления, которого нет
|
|
43
|
+
// в точке входа, — повод завести правило; совпавшее — повод не шуметь.
|
|
44
|
+
test("saidNotWritten: правило, уже стоящее в точке входа, не показывается", async () => {
|
|
45
|
+
const { saidNotWritten } = await import("../commands/learn.mjs");
|
|
46
|
+
const entry = "# правила\n- Секреты никогда не попадают в код.\n";
|
|
47
|
+
assert.equal(saidNotWritten("никогда не коммить секреты в код", entry), false);
|
|
48
|
+
assert.equal(saidNotWritten("делай прогон с базой обязательно", entry), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Русский язык склоняет. «локальный костыль» в реплике и «до местного костыля» в своде — одно и
|
|
52
|
+
// то же правило, и по целому слову они не совпадают. Найдено живым прогоном: без сверки по
|
|
53
|
+
// основе первым же кандидатом вышло правило, внесённое в точку входа в тот же день.
|
|
54
|
+
test("saidNotWritten: склонение не делает записанное правило незаписанным", async () => {
|
|
55
|
+
const { saidNotWritten } = await import("../commands/learn.mjs");
|
|
56
|
+
const entry = "- Любое сомнение проверяется снаружи, чтобы не выдумать местного костыля.";
|
|
57
|
+
assert.equal(saidNotWritten("любое сомнение проверяем всегда чтобы не сделать костыль", entry), false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Отбор по promptSource — то, ради чего команда вообще работает. В логе 25 353 записи `user`,
|
|
61
|
+
// из них человеком напечатано 1912; остальное результаты инструментов, служебные вставки и
|
|
62
|
+
// принятые подсказки. Первая версия отбирала по длине и языку и выдавала вставленные пути.
|
|
63
|
+
test("typedFrom: берётся только напечатанное человеком", async () => {
|
|
64
|
+
const { typedFrom } = await import("../commands/learn.mjs");
|
|
65
|
+
const lines = [
|
|
66
|
+
JSON.stringify({ type: "user", promptSource: "typed", timestamp: "2026-09-08T10:00:00Z", message: { role: "user", content: "никогда так не делай" } }),
|
|
67
|
+
JSON.stringify({ type: "user", promptSource: "system", message: { role: "user", content: "служебное" } }),
|
|
68
|
+
JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "tool_result", content: "вывод" }] } }),
|
|
69
|
+
JSON.stringify({ type: "assistant", message: { role: "assistant", content: "ответ" } }),
|
|
70
|
+
"не json вовсе",
|
|
71
|
+
"",
|
|
72
|
+
].join("\n");
|
|
73
|
+
const got = typedFrom(lines);
|
|
74
|
+
assert.equal(got.length, 1);
|
|
75
|
+
assert.equal(got[0].text, "никогда так не делай");
|
|
76
|
+
assert.equal(got[0].when, "2026-09-08");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Текст блоками, а не строкой: так приходит реплика с приложенным файлом. Берём текстовые
|
|
80
|
+
// блоки и только их — картинка и результат инструмента правилом быть не могут.
|
|
81
|
+
test("typedFrom: реплика блоками собирается из текстовых блоков", async () => {
|
|
82
|
+
const { typedFrom } = await import("../commands/learn.mjs");
|
|
83
|
+
const line = JSON.stringify({
|
|
84
|
+
type: "user", promptSource: "typed", timestamp: "2026-09-08T11:00:00Z",
|
|
85
|
+
message: { role: "user", content: [{ type: "image" }, { type: "text", text: "всегда так" }] },
|
|
86
|
+
});
|
|
87
|
+
assert.equal(typedFrom(line)[0].text, "всегда так");
|
|
88
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// tool/selfcheck/units-level.mjs — проверки уровня и доказательства гейтов.
|
|
2
|
+
//
|
|
3
|
+
// ОТДЕЛЬНЫМ ФАЙЛОМ, а не в units.mjs: тот перерос собственный предел в 500 строк, и поймал
|
|
4
|
+
// это наш же гейт `file-size-limit` на прогоне. Шов по смыслу: здесь всё, что программа
|
|
5
|
+
// вычитывает ИЗ МАНИФЕСТА и объявляет о проекте, — ступень, доказательство гейтов и раскладка
|
|
6
|
+
// (где правила, методички, точка входа). Общее у них одно и важное: ответ обязан приходить из
|
|
7
|
+
// манифеста, а не из умолчаний, совпадающих с нашими собственными значениями.
|
|
8
|
+
//
|
|
9
|
+
// node --test tool/selfcheck/units-level.mjs
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { commandFor } from "../lib/prove.mjs";
|
|
13
|
+
import { assessLevel, layoutChecks, unknownKeys, KNOWN_KEYS } from "../lib/manifest.mjs";
|
|
14
|
+
|
|
15
|
+
// --- доказательство гейтов ------------------------------------------------------------
|
|
16
|
+
// ЗАЧЕМ. Ступень AQK-2 называлась «гейты доказаны» и проверяла существование двух папок.
|
|
17
|
+
// Проект с тремя гейтами `true` проходил порог AQK-3 — проверено прогоном на пустой папке.
|
|
18
|
+
test("обёртка храповика снимается перед подстановкой каталога", () => {
|
|
19
|
+
// Иначе доказательство гоняет гейт вместе с реестром долга, и реестр перезаписывается
|
|
20
|
+
// находками из ОБРАЗЦА. На живом проекте это стёрло бы долг целиком.
|
|
21
|
+
const cmd = "bash ratchets/_ratchet.sh ratchets/x.txt bash gates/x/check.sh .";
|
|
22
|
+
assert.equal(commandFor(cmd, "gates/x/red"), "bash gates/x/check.sh gates/x/red");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("каталог подставляется вместо последнего слова команды", () => {
|
|
26
|
+
assert.equal(commandFor("bash gates/x/check.sh .", "gates/x/green"), "bash gates/x/check.sh gates/x/green");
|
|
27
|
+
assert.equal(commandFor("ruff check --select T20 .", "gates/x/red"), "ruff check --select T20 gates/x/red");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("ступень выше первой не берётся без доказательства", async () => {
|
|
31
|
+
const man = {
|
|
32
|
+
aqk: "0.6.0", entry: [], rules: ".", samples: ".", ratchets: ".", lessons: ".",
|
|
33
|
+
gates: { lint: "true" },
|
|
34
|
+
};
|
|
35
|
+
const withoutProof = await assessLevel(man, null);
|
|
36
|
+
const withFailedProof = await assessLevel(man, { ok: false });
|
|
37
|
+
const withProof = await assessLevel(man, { ok: true });
|
|
38
|
+
// Без доказательства ступень не выполнена, но помечена «не проверяли» — это разные состояния.
|
|
39
|
+
assert.equal(withoutProof.steps[2].ok, false);
|
|
40
|
+
assert.equal(withoutProof.steps[2].needsProof, true);
|
|
41
|
+
assert.equal(withFailedProof.steps[2].ok, false);
|
|
42
|
+
assert.equal(withFailedProof.steps[2].needsProof, false);
|
|
43
|
+
assert.equal(withProof.steps[2].ok, true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("обёртка родного инструмента едет вместе с каталогом образца", () => {
|
|
47
|
+
// `_native.sh <каталог> <команда…>` прячет пути gates/*/red|green. Оставленный «.» спрятал бы
|
|
48
|
+
// ровно то, что образец обязан показать, — красный прошёл бы зелёным.
|
|
49
|
+
const cmd = "bash gates/_native.sh . ruff check --select T20 .";
|
|
50
|
+
assert.equal(
|
|
51
|
+
commandFor(cmd, "gates/x/red"),
|
|
52
|
+
"bash gates/_native.sh gates/x/red ruff check --select T20 gates/x/red"
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("обе обёртки снимаются вместе", () => {
|
|
57
|
+
const cmd = "bash ratchets/_ratchet.sh ratchets/x.txt bash gates/_native.sh . ruff check .";
|
|
58
|
+
assert.equal(
|
|
59
|
+
commandFor(cmd, "gates/x/green"),
|
|
60
|
+
"bash gates/_native.sh gates/x/green ruff check gates/x/green"
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Отзыв второго пользователя, 2026-09-08: на Windows `prove` объявил два ИСПРАВНЫХ гейта
|
|
65
|
+
// сломанными. Путь к образцу собирался `path.join`, то есть `gates\x\red`, и уезжал в строку
|
|
66
|
+
// команды — а её исполняет `sh`, который обратный слэш съедает как экранирование: остаётся
|
|
67
|
+
// `gatesxred`. Каталога нет → `find` молчит → код 0 → «промолчал на КРАСНОМ образце».
|
|
68
|
+
// Проверка идёт здесь, а не в самом сборщике пути: `commandFor` — единственная дверь, через
|
|
69
|
+
// которую каталог попадает в оболочку, и закрывать её надо там, кто бы путь ни собрал.
|
|
70
|
+
test("каталог образца уходит в оболочку с прямыми слэшами", () => {
|
|
71
|
+
assert.equal(
|
|
72
|
+
commandFor("bash gates/x/check.sh .", "gates\\x\\red"),
|
|
73
|
+
"bash gates/x/check.sh gates/x/red",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Тот же путь едет ВТОРЫМ адресом — первым аргументом фильтра образцов. Пропустить его значит
|
|
78
|
+
// починить половину: фильтр не узнает образец и спрячет ровно то, что образец обязан показать.
|
|
79
|
+
test("обёртка родного инструмента тоже получает прямые слэши", () => {
|
|
80
|
+
assert.equal(
|
|
81
|
+
commandFor("bash gates/_native.sh . npx knip --directory .", "gates\\x\\red"),
|
|
82
|
+
"bash gates/_native.sh gates/x/red npx knip --directory gates/x/red",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// --- где у проекта лежат правила и методички ----------------------------------
|
|
87
|
+
// Отзыв второго пользователя, 2026-09-08: `doctor` рисовал два красных креста за сделанное.
|
|
88
|
+
// У проекта `rules: .temper/rules`, правила на месте, гейт entry-links-exist их видит, уровень
|
|
89
|
+
// AQK-1 считается ПО МАНИФЕСТУ — а список в шапке проверял литеральные `.aqk/rules` и
|
|
90
|
+
// `.aqk/docs` и советовал сделать сделанное. Уровень и вывод расходились в разные стороны:
|
|
91
|
+
// хуже неверного вывода только вывод, который расходится с собственным вердиктом.
|
|
92
|
+
test("каталог правил берётся из манифеста, а не из умолчания", () => {
|
|
93
|
+
const paths = layoutChecks({ rules: ".temper/rules" }, false).map(([p]) => p);
|
|
94
|
+
assert.ok(paths.includes(".temper/rules"), "путь из манифеста обязан попасть в список");
|
|
95
|
+
assert.ok(!paths.includes(".aqk/rules"), "умолчание обязано уступить манифесту");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Поля `docs:` не было вовсе: перенести методички было НЕКУДА, и проект, разложивший их иначе,
|
|
99
|
+
// получал крест без единого способа его снять. Умолчание остаётся для тех, кто поля не завёл.
|
|
100
|
+
test("каталог методичек тоже берётся из манифеста", () => {
|
|
101
|
+
const paths = layoutChecks({ docs: ".temper/docs" }, false).map(([p]) => p);
|
|
102
|
+
assert.ok(paths.includes(".temper/docs"));
|
|
103
|
+
assert.ok(!paths.includes(".aqk/docs"));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("без манифеста остаются умолчания", () => {
|
|
107
|
+
const paths = layoutChecks(null, false).map(([p]) => p);
|
|
108
|
+
assert.ok(paths.includes(".aqk/rules") && paths.includes(".aqk/docs"));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// В самом комплекте лежат оригиналы, а не разложенная копия: копия завтра разошлась бы с ними.
|
|
112
|
+
test("внутри комплекта проверяются его собственные каталоги", () => {
|
|
113
|
+
const paths = layoutChecks({ rules: "kit/rules" }, true).map(([p]) => p);
|
|
114
|
+
assert.ok(paths.includes("kit/rules") && paths.includes("kit/docs"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Поле, которое программа читает, обязано быть в списке известных: иначе манифест с ним
|
|
118
|
+
// получает предупреждение «неизвестное поле» за то, что работает.
|
|
119
|
+
test("docs — известное поле манифеста", () => {
|
|
120
|
+
assert.ok(KNOWN_KEYS.includes("docs"));
|
|
121
|
+
assert.deepEqual(unknownKeys({ docs: ".aqk/docs" }), []);
|
|
122
|
+
});
|
package/tool/selfcheck/units.mjs
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import test from "node:test";
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
-
import { parseManifest, manifestWithGate, unknownKeys, entryLifecycle } from "../lib/manifest.mjs";
|
|
14
|
+
import { parseManifest, manifestWithGate, unknownKeys, entryLifecycle, advisorySet, KNOWN_KEYS } from "../lib/manifest.mjs";
|
|
15
15
|
import { triggerVerdict, recipeFor, stems, overlap, EXT_LANG, whichSync } from "../lib/repo.mjs";
|
|
16
|
-
import { scopeOutput } from "../lib/scope.mjs";
|
|
16
|
+
import { scopeOutput, splitAdvice } from "../lib/scope.mjs";
|
|
17
17
|
import { assessBaseline, ITEMS, BASELINE_TOTAL } from "../lib/baseline.mjs";
|
|
18
18
|
import { CATALOGS, pickLang, L } from "../i18n/index.mjs";
|
|
19
19
|
import { badgeMarkdown, BADGE_RE, placesToCheck } from "../commands/badge.mjs";
|
|
@@ -358,3 +358,115 @@ test("сужение по дифу: гейт без путей в выводе
|
|
|
358
358
|
assert.equal(r.scopable, false);
|
|
359
359
|
assert.equal(scopeOutput(["src/old.py:1: печать"], new Set(["src/new.py"])).scopable, true);
|
|
360
360
|
});
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
// --- совет по починке не теряется в обрезке ----------------------------------
|
|
364
|
+
// ЗАЧЕМ. Все девятнадцать записей каталога печатают строку «почини: …» последней. `doctor --run`
|
|
365
|
+
// показывает три первые строки вывода и обрезает остальное — то есть ровно ту строку, ради
|
|
366
|
+
// которой человек и смотрит на красное, он не видит никогда. Находка без действия — это повод
|
|
367
|
+
// закрыть окно, а не починить.
|
|
368
|
+
test("совет по починке отделяется от находок и не обрезается", () => {
|
|
369
|
+
const out = [
|
|
370
|
+
"src/a.py:3: печать",
|
|
371
|
+
"src/b.py:9: печать",
|
|
372
|
+
"src/c.py:1: печать",
|
|
373
|
+
"src/d.py:7: печать",
|
|
374
|
+
" почини: замени на вызов системы логов",
|
|
375
|
+
" тогда запись попадёт в общий журнал",
|
|
376
|
+
];
|
|
377
|
+
const r = splitAdvice(out);
|
|
378
|
+
assert.equal(r.findings.length, 4);
|
|
379
|
+
assert.equal(r.advice.length, 2);
|
|
380
|
+
// Продолжение совета едет вместе с ним: без второй строки первая обрывается на полуслове.
|
|
381
|
+
assert.equal(r.advice[1].includes("общий журнал"), true);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("совет по починке опознаётся на обоих языках", () => {
|
|
385
|
+
assert.equal(splitAdvice(["a.py:1: x", " fix: replace with a logger call"]).advice.length, 1);
|
|
386
|
+
assert.equal(splitAdvice(["a.py:1: x", " почини: замени на логгер"]).advice.length, 1);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Вывод без совета — это не ошибка разбора, а признак записи, которая не говорит, что делать.
|
|
390
|
+
// Разбор обязан вернуть пустой совет, а не выдумать его из последней строки.
|
|
391
|
+
test("вывод без совета не превращается в совет", () => {
|
|
392
|
+
const r = splitAdvice(["src/a.py:3: печать", "src/b.py:9: печать"]);
|
|
393
|
+
assert.equal(r.advice.length, 0);
|
|
394
|
+
assert.equal(r.findings.length, 2);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
// Совет бывает не только в конце: `deps-are-pinned` печатает его внутри вспомогательной
|
|
399
|
+
// функции, вызываемой шесть раз, и находки идут ПОСЛЕ. Пока советом считалось «всё от первой
|
|
400
|
+
// метки до конца», сорок находок уезжали в жёлтый список без обрезки — ровно та стена красного,
|
|
401
|
+
// против которой написан этот модуль. Найдено ревью 2026-09-06.
|
|
402
|
+
test("совет, вставленный посреди вывода, не проглатывает находки", () => {
|
|
403
|
+
const r = splitAdvice([
|
|
404
|
+
" почини: закрепи версии",
|
|
405
|
+
"src/a.py:1: x",
|
|
406
|
+
"src/b.py:2: x",
|
|
407
|
+
"src/c.py:3: x",
|
|
408
|
+
" почини: и вот это тоже",
|
|
409
|
+
" вторая строка совета",
|
|
410
|
+
]);
|
|
411
|
+
assert.equal(r.findings.length, 3);
|
|
412
|
+
assert.equal(r.advice.length, 3);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Продолжение совета не должно съедать находку, стоящую сразу за ним.
|
|
416
|
+
test("находка сразу после совета остаётся находкой", () => {
|
|
417
|
+
const r = splitAdvice([" почини: сделай так", " пояснение", "src/z.py:9: x"]);
|
|
418
|
+
assert.equal(r.findings.length, 1);
|
|
419
|
+
assert.equal(r.findings[0].includes("src/z.py"), true);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
// --- правило, введённое совещательным ----------------------------------------
|
|
424
|
+
// ЗАЧЕМ. Правило вводят в проект, где старый код ему не соответствует. Храповик отвечает на
|
|
425
|
+
// это одним способом: старое становится долгом. Второй способ — показывать, не роняя, пока
|
|
426
|
+
// команда договаривается. Сегодня его нет вовсе: находка либо роняет сборку, либо не существует.
|
|
427
|
+
//
|
|
428
|
+
// ПОЧЕМУ ОБЪЯВЛЕНИЕМ, А НЕ ФЛАГОМ ПРОГОНА. Флаг «не роняй ничего» — это `continue-on-error`,
|
|
429
|
+
// против которого написана наша же запись ci-actually-fails: он понижает всё разом, не виден
|
|
430
|
+
// в дифе и не назван в сводке. Список в манифесте виден, именуется и считается всегда.
|
|
431
|
+
test("совещательные гейты читаются из манифеста списком", () => {
|
|
432
|
+
const man = parseManifest("aqk: 1\nadvisory:\n - complexity-limit\n - duplicate-code\n");
|
|
433
|
+
const a = advisorySet(man);
|
|
434
|
+
assert.equal(a.has("complexity-limit"), true);
|
|
435
|
+
assert.equal(a.has("duplicate-code"), true);
|
|
436
|
+
assert.equal(a.has("secrets-not-in-code"), false);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("список в одну строку читается так же", () => {
|
|
440
|
+
const a = advisorySet(parseManifest("advisory: [complexity-limit, duplicate-code]\n"));
|
|
441
|
+
assert.equal(a.size, 2);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Отсутствие блока — это ноль совещательных, а не «все совещательные».
|
|
445
|
+
test("без блока advisory совещательных нет", () => {
|
|
446
|
+
assert.equal(advisorySet(parseManifest("aqk: 1\n")).size, 0);
|
|
447
|
+
assert.equal(advisorySet(null).size, 0);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Поле обязано быть известным манифесту: иначе опечатка «advisery:» молча означала бы
|
|
451
|
+
// «совещательных нет», и правило, которое человек считал введённым, роняло бы сборку.
|
|
452
|
+
test("advisory — известное поле манифеста", () => {
|
|
453
|
+
assert.equal(KNOWN_KEYS.includes("advisory"), true);
|
|
454
|
+
assert.deepEqual(unknownKeys({ aqk: 1, advisory: [] }), []);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
// --- правила на языке проекта ------------------------------------------------
|
|
459
|
+
// ЗАЧЕМ. Русский текст правил в англоязычном проекте — это первое, что там откроет человек,
|
|
460
|
+
// и первое, чего он не прочитает. Русская версия остаётся источником истины, английская —
|
|
461
|
+
// переводом. Совпадение содержания машина не сторожит, а вот НАБОР ФАЙЛОВ обязана: добавили
|
|
462
|
+
// правило на одном языке и забыли про другой — половина мира получит комплект без него.
|
|
463
|
+
test("наборы файлов правил совпадают на обоих языках", async () => {
|
|
464
|
+
const { readdir } = await import("node:fs/promises");
|
|
465
|
+
const { fileURLToPath } = await import("node:url");
|
|
466
|
+
const at = (d) => fileURLToPath(new URL(`../../kit/${d}`, import.meta.url));
|
|
467
|
+
const ru = (await readdir(at("rules"))).filter((f) => f.endsWith(".md")).sort();
|
|
468
|
+
const en = (await readdir(at("rules-en"))).filter((f) => f.endsWith(".md")).sort();
|
|
469
|
+
assert.deepEqual(en, ru);
|
|
470
|
+
assert.equal(ru.length > 0, true);
|
|
471
|
+
});
|
|
472
|
+
|