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,57 @@
|
|
|
1
|
+
// tool/lib/cadence.mjs — когда комплект делает работу САМ, не дожидаясь, что о ней вспомнят.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ. Владелец сформулировал точнее, чем было в замысле: «команду, о которой надо вспомнить,
|
|
4
|
+
// агент не вспомнит, а человек о ней не узнает». Это тот же класс, что файл, который можно не
|
|
5
|
+
// прочитать, — и весь комплект написан против него. Команда `probe`, которую надо запускать
|
|
6
|
+
// руками, наполовину бесполезна по построению: она отвечает на важнейший вопрос («что здесь не
|
|
7
|
+
// прикрыто ничем») и не задаётся никем.
|
|
8
|
+
//
|
|
9
|
+
// РЕШЕНИЕ ТО ЖЕ, ЧТО У СОВЕТА И ПРОВЕРКИ ВЕРСИИ: не напоминать, а делать. Разница в единице.
|
|
10
|
+
// Совет считает сутки — он про внимание человека. Проба считает КОММИТЫ: репозиторий, в котором
|
|
11
|
+
// месяц не работали, перепроверять незачем, а сто коммитов за день перепроверить надо. Время
|
|
12
|
+
// здесь не при чём, при чём — сколько кода написано с прошлого раза.
|
|
13
|
+
//
|
|
14
|
+
// ПОЧЕМУ СТО. Число выбрано так, чтобы проба не мешала: на нашем репозитории это примерно
|
|
15
|
+
// две недели работы, а сама проба идёт секунды. Порог виден в выводе и меняется числом, а не
|
|
16
|
+
// прячется: правило, которого не видно, через месяц читается как случайность.
|
|
17
|
+
const PROBE_EVERY = 100;
|
|
18
|
+
|
|
19
|
+
// `last` — то, что записала прошлая проба: { at: <число коммитов на тот момент> }.
|
|
20
|
+
// `now` — сколько коммитов в репозитории сейчас; null, если счётчик взять не удалось.
|
|
21
|
+
//
|
|
22
|
+
// Пробы не было ВОВСЕ — она нужна, и нужна сразу, а не на сто первом коммите: молодой проект
|
|
23
|
+
// узнал бы о своих дырах позже всех, хотя закрывать их дешевле всего именно в начале.
|
|
24
|
+
function probeDue(last, now, every = PROBE_EVERY) {
|
|
25
|
+
if (now === null || now === undefined) return false;
|
|
26
|
+
const at = Number(last?.at);
|
|
27
|
+
if (!Number.isFinite(at)) return true;
|
|
28
|
+
return now - at >= every;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Состояние для человека и для агента. ЧЕТЫРЕ исхода, и сливать их нельзя — по той же причине,
|
|
32
|
+
// по которой их четыре у `vitals`: «не делалась» и «свежая» различаются тем, что в первом
|
|
33
|
+
// случае мы ничего не знаем, а молчание читается как «всё хорошо».
|
|
34
|
+
function probeState(last, now, every = PROBE_EVERY) {
|
|
35
|
+
if (now === null || now === undefined) return { state: "unknown", behind: null };
|
|
36
|
+
const at = Number(last?.at);
|
|
37
|
+
if (!Number.isFinite(at)) return { state: "never", behind: null };
|
|
38
|
+
const behind = now - at;
|
|
39
|
+
return { state: behind >= every ? "stale" : "fresh", behind };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Порог из манифеста: `probe: 250` — раз в двести пятьдесят коммитов, `probe: 0` — не делать
|
|
43
|
+
// вовсе. Число выбирается проектом, а не нами: сто коммитов на репозитории с десятком коммитов
|
|
44
|
+
// в час — это трижды в день, а на редком проекте они не наберутся никогда.
|
|
45
|
+
//
|
|
46
|
+
// Неразобранное значение НЕ молчит: строка `probe: часто` означала бы «человек настроил», а
|
|
47
|
+
// работал бы умолчательный порог — расхождение между написанным и происходящим, то самое,
|
|
48
|
+
// против чего весь комплект. Возвращается null, и вызывающий говорит об этом вслух.
|
|
49
|
+
function probeEvery(man) {
|
|
50
|
+
const raw = man?.probe;
|
|
51
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") return PROBE_EVERY;
|
|
52
|
+
const n = Number(String(raw).trim());
|
|
53
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { probeDue, probeState, probeEvery, PROBE_EVERY };
|
package/tool/lib/core.mjs
CHANGED
|
@@ -71,6 +71,7 @@ function commandRows(L) {
|
|
|
71
71
|
{ name: "doctor", args: "--run", text: h.doctorRun },
|
|
72
72
|
{ name: "doctor", args: "--run --since main", text: h.doctorSince },
|
|
73
73
|
{ name: "prove", args: "", text: h.prove },
|
|
74
|
+
{ name: "probe", args: "", text: h.probe },
|
|
74
75
|
{ name: "add", args: h.name, text: h.add },
|
|
75
76
|
{ name: "find", args: '"…"', text: h.find },
|
|
76
77
|
{ name: "why", args: '"…"', text: h.why },
|
|
@@ -83,6 +84,8 @@ function commandRows(L) {
|
|
|
83
84
|
{ name: "context", args: "", text: h.context },
|
|
84
85
|
{ name: "context", args: "--full --install", text: h.contextInstall },
|
|
85
86
|
{ name: "badge", args: "", text: h.badge },
|
|
87
|
+
{ name: "vitals", args: "", text: h.vitals },
|
|
88
|
+
{ name: "version", args: "", text: h.version },
|
|
86
89
|
];
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// tool/lib/history.mjs — что в ЭТОМ репозитории ломается на самом деле.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ. `doctor` говорит «держит машина 21». Двадцать один из чего? Знаменателя нет: 21 —
|
|
4
|
+
// это то, что мы успели написать в каталог, а не то, что важно в конкретном проекте. Каталог
|
|
5
|
+
// у нас — наш вкус; история проекта — его факты.
|
|
6
|
+
//
|
|
7
|
+
// Замер 2026-09-09 на самом комплекте: 45 коммитов-починок из 181, и рейтинг однозначный —
|
|
8
|
+
// `tool/selfcheck/smoke.sh` чинили 22 раза, `tool/lib/manifest.mjs` — 5. Это те места, где
|
|
9
|
+
// брак возвращается; там и стоит спрашивать, смотрит ли на них хоть одна проверка.
|
|
10
|
+
//
|
|
11
|
+
// ЧТО ЭТО НЕ ЗНАЧИТ. Часто чинят и то, что часто меняют: рейтинг говорит «сюда возвращаются»,
|
|
12
|
+
// а не «здесь плохо». Ответ на «прикрыто ли» даёт не он, а проба — см. probeVerdict.
|
|
13
|
+
|
|
14
|
+
// Признак починки берётся из ТЕМЫ коммита. Тема — единственное, что пишут все, и единственное,
|
|
15
|
+
// что видно в `git log --oneline`.
|
|
16
|
+
//
|
|
17
|
+
// Три написания, потому что репозитории бывают на двух языках и с conventional commits. Якорь
|
|
18
|
+
// на начало обязателен: без него «feat: prefix для путей» попадает в рейтинг из-за подстроки
|
|
19
|
+
// «fix», и рейтинг перестаёт что-либо значить.
|
|
20
|
+
const FIX_RE = /^\s*(fix|fixes|fixed|bugfix|hotfix|исправ|почин)/i;
|
|
21
|
+
|
|
22
|
+
function isFix(subject) {
|
|
23
|
+
return FIX_RE.test(String(subject || ""));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Разбор вывода `git log --format=%s --name-only`. Формат снят с живого репозитория, а не
|
|
27
|
+
// придуман: ТЕМА, затем ПУСТАЯ строка, затем пути — и сразу следующая тема, без пустой строки
|
|
28
|
+
// перед ней.
|
|
29
|
+
//
|
|
30
|
+
// fix: разбор манифеста
|
|
31
|
+
// <пусто>
|
|
32
|
+
// tool/lib/manifest.mjs
|
|
33
|
+
// feat: новая запись
|
|
34
|
+
// <пусто>
|
|
35
|
+
// kit/gates/x/check.sh
|
|
36
|
+
//
|
|
37
|
+
// Отсюда единственный надёжный признак темы: за ней идёт пустая строка. По позиции её не
|
|
38
|
+
// отличить — после последнего пути соседнего коммита тема начинается без разделителя.
|
|
39
|
+
// Первая версия разбирала по выдуманному формату («пустая строка ПОСЛЕ файлов») и нашла ноль
|
|
40
|
+
// починок там, где их сорок пять: ошибка молчала, потому что пустой рейтинг выглядит как
|
|
41
|
+
// «история чистая». Тот же класс, что дважды поймал меня в тот же день.
|
|
42
|
+
//
|
|
43
|
+
// `isCode` передаётся вызывающим, а не берётся отсюда: набор расширений кода живёт в
|
|
44
|
+
// evidence.mjs, и второй его список через месяц разошёлся бы с первым.
|
|
45
|
+
function fixHotspots(raw, { isCode }) {
|
|
46
|
+
const counts = new Map();
|
|
47
|
+
const lines = String(raw || "").split("\n");
|
|
48
|
+
let inFix = false;
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const line = lines[i];
|
|
52
|
+
if (line === "") continue;
|
|
53
|
+
if (lines[i + 1] === "") { inFix = isFix(line); continue; }
|
|
54
|
+
if (!inFix) continue;
|
|
55
|
+
const p = line.trim();
|
|
56
|
+
if (!p || !isCode(p)) continue;
|
|
57
|
+
counts.set(p, (counts.get(p) || 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return [...counts.entries()]
|
|
61
|
+
.map(([path, fixes]) => ({ path, fixes }))
|
|
62
|
+
// При равном числе починок — по алфавиту: вывод обязан быть одинаковым между прогонами,
|
|
63
|
+
// иначе его нельзя сравнить с прошлым и нельзя проверить машиной.
|
|
64
|
+
.sort((a, b) => b.fixes - a.fixes || a.path.localeCompare(b.path));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Вердикт пробы по кодам возврата объявленных гейтов. ТРИ состояния, и сливать их нельзя.
|
|
68
|
+
//
|
|
69
|
+
// `2` у наших записей означает «проверка не состоялась» — делегированной программы нет на
|
|
70
|
+
// машине. Засчитать это как «прикрыто» значило бы выдать отсутствие сигнала за успех: тот
|
|
71
|
+
// самый отказ, против которого написан весь комплект. Засчитать как «не прикрыто» — тоже
|
|
72
|
+
// неправда: мы не знаем.
|
|
73
|
+
//
|
|
74
|
+
// Поймавший гейт сильнее непроверенного: класс закрыт, даже если рядом чего-то не хватает.
|
|
75
|
+
function probeVerdict(results) {
|
|
76
|
+
if (results.some((r) => r.code === 1)) return "caught";
|
|
77
|
+
if (results.length === 0) return "unknown";
|
|
78
|
+
if (results.some((r) => r.code !== 0)) return "unknown";
|
|
79
|
+
return "blind";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { isFix, fixHotspots, probeVerdict };
|
package/tool/lib/manifest.mjs
CHANGED
|
@@ -25,6 +25,14 @@ function stripComment(raw) {
|
|
|
25
25
|
return raw[i] === "#" ? raw.slice(0, i) : raw.slice(0, i + 1);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Список в одну строку: `[AGENTS.md, docs/START.md]`. Вынесен отдельно, потому что нужен на
|
|
29
|
+
// двух уровнях, а два одинаковых куска разбора расходятся ровно так же, как два свода правил.
|
|
30
|
+
function inlineList(v) {
|
|
31
|
+
const s = String(v).trim();
|
|
32
|
+
if (!s.startsWith("[") || !s.endsWith("]")) return null;
|
|
33
|
+
return s.slice(1, -1).split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
function parseManifest(text) {
|
|
29
37
|
const out = {};
|
|
30
38
|
let section = null;
|
|
@@ -48,26 +56,51 @@ function parseManifest(text) {
|
|
|
48
56
|
|
|
49
57
|
if (indented && section) {
|
|
50
58
|
if (typeof out[section] !== "object" || Array.isArray(out[section])) out[section] = {};
|
|
51
|
-
|
|
59
|
+
// Список в одну строку разбирается и на вложенном уровне: `covers:` ниже ` lint: [a, b]`.
|
|
60
|
+
// Раньше вложенное значение всегда оставалось строкой, и `[a, b]` превращалось в текст
|
|
61
|
+
// «[a, b]» — молча, как это умеет только разбор без схемы. Наверху такой список уже
|
|
62
|
+
// разбирался; расхождение между уровнями и есть источник тихой неправды.
|
|
63
|
+
out[section][key] = inlineList(clean) || clean;
|
|
52
64
|
continue;
|
|
53
65
|
}
|
|
54
66
|
section = key;
|
|
55
67
|
// Список в одну строку: entry: [AGENTS.md, docs/START.md]. Люди пишут именно так —
|
|
56
68
|
// и раньше манифест молча читался как пустой, а проект получал вердикт «нет AQK-0».
|
|
57
69
|
// Неверный вердикт хуже отсутствия вердикта: ему верят.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.slice(1, -1)
|
|
61
|
-
.split(",")
|
|
62
|
-
.map((v) => v.trim().replace(/^["']|["']$/g, ""))
|
|
63
|
-
.filter(Boolean);
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
70
|
+
const list = inlineList(clean);
|
|
71
|
+
if (list) { out[key] = list; continue; }
|
|
66
72
|
out[key] = clean === "" ? {} : clean;
|
|
67
73
|
}
|
|
68
74
|
return out;
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
// СТРОКА, КОТОРУЮ РАЗБОР НЕ ПОНЯЛ, НЕ ИСЧЕЗАЕТ МОЛЧА.
|
|
78
|
+
//
|
|
79
|
+
// Найдено 2026-09-09 случайно: подсаживали падающий гейт с именем «плохой», чтобы посмотреть на
|
|
80
|
+
// вывод, — и прогон вышел с НУЛЁМ. Гейт не упал: его не существовало. Имена разбираются только
|
|
81
|
+
// латиницей, а строка, не подошедшая под это, выбрасывалась без единого слова.
|
|
82
|
+
//
|
|
83
|
+
// Это наш класс в чистом виде: человек объявил проверку, видит её в файле, а её нет. Хуже
|
|
84
|
+
// опечатки в имени поля — ту мы называем с 2026-09-06, а эту не называли вовсе.
|
|
85
|
+
//
|
|
86
|
+
// ЧИНИТСЯ ГОЛОСОМ, А НЕ АЛФАВИТОМ. Расширить набор букв — залатать один случай; строк, которые
|
|
87
|
+
// разбор не понимает, бывает больше (табуляция вместо пробелов, двоеточие в значении без
|
|
88
|
+
// кавычек). Называется любая: разбор ограниченного подмножества YAML честен ровно до тех пор,
|
|
89
|
+
// пока говорит, чего не взял.
|
|
90
|
+
function unparsedLines(text) {
|
|
91
|
+
const out = [];
|
|
92
|
+
let n = 0;
|
|
93
|
+
for (const raw of String(text).split("\n")) {
|
|
94
|
+
n += 1;
|
|
95
|
+
const line = stripComment(raw).replace(/\s+$/, "");
|
|
96
|
+
if (!line.trim()) continue;
|
|
97
|
+
if (line.trim().startsWith("- ")) continue;
|
|
98
|
+
if (/^\s*[A-Za-z0-9_-]+:\s*(.*)$/.test(line)) continue;
|
|
99
|
+
out.push({ line: n, text: raw.trim() });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
71
104
|
// Поля, которые манифест знает. Список здесь, а не в схеме-файле: зависимостей у программы
|
|
72
105
|
// нет, а схема на восемь ключей, которую надо валидировать библиотекой, стоит дороже, чем
|
|
73
106
|
// защищает.
|
|
@@ -79,7 +112,7 @@ function parseManifest(text) {
|
|
|
79
112
|
// Список обязан совпадать с тем, что программа РЕАЛЬНО читает (`man?.<поле>` в tool/):
|
|
80
113
|
// лишнее имя здесь молча узаконивает поле, которое ни на что не влияет, — та же тишина,
|
|
81
114
|
// только с другой стороны. Сверено обходом: aqk, entry, rules, gates, samples, ratchets, lessons.
|
|
82
|
-
const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "gates", "samples", "ratchets", "lessons", "advisory"];
|
|
115
|
+
const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory", "probe"];
|
|
83
116
|
|
|
84
117
|
// ГДЕ У ПРОЕКТА ЛЕЖИТ РАЗЛОЖЕННЫЙ КОМПЛЕКТ. Список для шапки `doctor`. До 2026-09-08 он был
|
|
85
118
|
// литеральным: `.aqk/rules`, `.aqk/docs`, `AGENTS.md` — независимо от того, что написано в
|
|
@@ -108,6 +141,79 @@ function layoutChecks(man, inKit) {
|
|
|
108
141
|
];
|
|
109
142
|
}
|
|
110
143
|
|
|
144
|
+
// ЗАПИСЬ ЗАКРЫТА ДРУГИМ АРБИТРОМ. `covers: { lint: [no-print-in-prod, swallowed-error] }`
|
|
145
|
+
// читается как «гейт lint держит эти записи каталога». Просьба первого чужого пользователя,
|
|
146
|
+
// названная им первой: без этого `doctor` каждый прогон печатал «применимо, но не поставлено»
|
|
147
|
+
// про то, что у него закрыто biome. Неправда в собственном выводе дороже всех остальных: весь
|
|
148
|
+
// стандарт стоит на том, что вывод не врёт.
|
|
149
|
+
//
|
|
150
|
+
// НО ЭТО НЕ ПРИЗНАНИЕ НА СЛОВО. Закрывать может только гейт, ОБЪЯВЛЕННЫЙ в `gates:` непустой
|
|
151
|
+
// командой. Иначе поле превращается в способ объявить защиту, которой нет, — ровно тот отказ,
|
|
152
|
+
// против которого написан комплект. Необъявленные называются поимённо, а не отбрасываются молча.
|
|
153
|
+
function coversOf(man) {
|
|
154
|
+
const covered = new Map();
|
|
155
|
+
const unknownGates = [];
|
|
156
|
+
const raw = man?.covers;
|
|
157
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { covered, unknownGates };
|
|
158
|
+
|
|
159
|
+
const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
|
|
160
|
+
const declared = new Set(Object.entries(gates).filter(([, cmd]) => String(cmd || "").trim()).map(([k]) => k));
|
|
161
|
+
|
|
162
|
+
for (const [gate, value] of Object.entries(raw)) {
|
|
163
|
+
const entries = Array.isArray(value)
|
|
164
|
+
? value
|
|
165
|
+
: String(value || "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
166
|
+
if (!declared.has(gate)) { if (entries.length) unknownGates.push(gate); continue; }
|
|
167
|
+
for (const e of entries) if (!covered.has(e)) covered.set(e, gate);
|
|
168
|
+
}
|
|
169
|
+
return { covered, unknownGates };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ЗАЯВКА `covers` СВЕРЯЕТСЯ, А НЕ ПРИНИМАЕТСЯ НА СЛОВО — насколько это вообще возможно.
|
|
173
|
+
//
|
|
174
|
+
// Поле `covers` заведено 2026-09-08 утром, и тогда же в коммите было записано честное: «снимает
|
|
175
|
+
// запись с долга по СЛОВУ человека; проверить, что чужой гейт ловит то же самое, машина не
|
|
176
|
+
// может». К вечеру выяснилось, что это не теория. Запуск на настоящем `ruff.toml` из живого
|
|
177
|
+
// проекта: девятнадцать групп правил в `extend-select`, а `print()` не ловится — группы `T20`
|
|
178
|
+
// среди них нет. Заявка «no-print-in-prod держит наш lint» была бы ложной, а запись ушла бы из
|
|
179
|
+
// долга. То есть поле, снимающее неправду из вывода, само стало бы способом её произвести.
|
|
180
|
+
//
|
|
181
|
+
// ЧТО СВЕРЯЕТСЯ. У записи каталога в рецепте стоят коды правил: `ruff check --select T20 {dir}`.
|
|
182
|
+
// Если ни команда закрывающего гейта, ни конфиг линтера этих кодов не называют — заявка не
|
|
183
|
+
// подтверждена. Записи без кодов в рецепте (переносимые проверки) не сверяются вовсе: там
|
|
184
|
+
// сверять нечего, и выдумывать вердикт нельзя.
|
|
185
|
+
//
|
|
186
|
+
// ПОЧЕМУ «НЕ ПОДТВЕРЖДЕНО», А НЕ «ЛОЖЬ». Правило могло прийти из плагина, пресета или общего
|
|
187
|
+
// конфига этажом выше. Объявлять такое ошибкой значит краснеть на нормальном укладе — а такой
|
|
188
|
+
// вывод перестают читать целиком, вместе с настоящими находками.
|
|
189
|
+
const RULE_CODES = /--select[= ]([A-Za-z0-9,]+)/;
|
|
190
|
+
|
|
191
|
+
function coversUnproven(man, catalog = [], linterConfigText = "") {
|
|
192
|
+
const { covered } = coversOf(man);
|
|
193
|
+
if (!covered.size) return [];
|
|
194
|
+
const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
|
|
195
|
+
const cfg = String(linterConfigText || "");
|
|
196
|
+
const out = [];
|
|
197
|
+
|
|
198
|
+
for (const [entry, gate] of covered) {
|
|
199
|
+
const rec = catalog.find((r) => r.slug === entry);
|
|
200
|
+
const recipes = rec?.recipes && typeof rec.recipes === "object" ? rec.recipes : {};
|
|
201
|
+
// Коды берутся из любого рецепта записи: язык проекта здесь не важен, важно, что запись
|
|
202
|
+
// ВООБЩЕ выражается кодами правил. Если ни один рецепт их не называет — сверять нечего.
|
|
203
|
+
const codes = new Set();
|
|
204
|
+
for (const cmd of Object.values(recipes)) {
|
|
205
|
+
const m = RULE_CODES.exec(String(cmd || ""));
|
|
206
|
+
if (m) for (const c of m[1].split(",")) if (c.trim()) codes.add(c.trim());
|
|
207
|
+
}
|
|
208
|
+
if (!codes.size) continue;
|
|
209
|
+
|
|
210
|
+
const haystack = `${String(gates[gate] || "")}\n${cfg}`;
|
|
211
|
+
const missing = [...codes].filter((c) => !haystack.includes(c));
|
|
212
|
+
if (missing.length === codes.size) out.push({ entry, gate, codes: [...codes] });
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
111
217
|
function unknownKeys(man) {
|
|
112
218
|
if (!man || typeof man !== "object" || Array.isArray(man)) return [];
|
|
113
219
|
return Object.keys(man).filter((k) => !KNOWN_KEYS.includes(k));
|
|
@@ -146,16 +252,41 @@ function entryLifecycle(rec) {
|
|
|
146
252
|
return { state, why, supersededBy: supersededBy || null, problem };
|
|
147
253
|
}
|
|
148
254
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// показывать, не роняя, пока команда договаривается о правиле. Без него у человека остаётся
|
|
152
|
-
// выбор из двух крайностей: включить и сломать сборку либо не включать вовсе.
|
|
255
|
+
// Программа, без которой запись каталога не работает вовсе: поле `requires` в её `gate.yml`.
|
|
256
|
+
// Возвращает список НЕДОСТАЮЩИХ программ или null.
|
|
153
257
|
//
|
|
258
|
+
// ЗАЧЕМ ОБЩИМ. Переносимый рецепт бывает обёрткой вокруг готового инструмента: первое слово
|
|
259
|
+
// команды тогда `bash`, и по нему не видно, чего не хватает. Этот вопрос задают трое —
|
|
260
|
+
// приёмка каталога, доказательство гейтов и осмотр обвязки, — и каждый отвечал на него
|
|
261
|
+
// по-своему или не отвечал вовсе. 2026-09-09: `prove` объявлял такой гейт сломанным, а
|
|
262
|
+
// `vitals` печатал «все инструменты на месте» ровно там, где прогон краснел.
|
|
263
|
+
// `has` передаётся вызывающим, а не берётся отсюда: manifest.mjs не должен знать про осмотр
|
|
264
|
+
// репозитория — импорт в обратную сторону завёл бы цикл. Заодно функция проверяема модульно.
|
|
265
|
+
async function gateRequires(samplesDir, name, has) {
|
|
266
|
+
if (!samplesDir) return null;
|
|
267
|
+
const yml = join(CWD, samplesDir, name, "gate.yml");
|
|
268
|
+
if (!(await exists(yml))) return null;
|
|
269
|
+
try {
|
|
270
|
+
const rec = parseManifest(await readFile(yml, "utf8"));
|
|
271
|
+
const raw = typeof rec?.requires === "string" ? rec.requires.trim() : "";
|
|
272
|
+
if (!raw) return null;
|
|
273
|
+
const missing = raw.split(",").map((x) => x.trim()).filter(Boolean).filter((x) => !has(x));
|
|
274
|
+
return missing.length ? missing : null;
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
154
280
|
// ПОЧЕМУ СПИСКОМ В МАНИФЕСТЕ, А НЕ ФЛАГОМ ПРОГОНА. Флаг «не роняй ничего» — это тот самый
|
|
155
281
|
// `continue-on-error`, против которого написана наша запись ci-actually-fails: он понижает всё
|
|
156
282
|
// разом, не виден в дифе и не назван в сводке. Список виден в манифесте, называется поимённо и
|
|
157
283
|
// печатается КАЖДЫЙ прогон: совещательный гейт, о котором забыли, — это выключенная проверка,
|
|
158
284
|
// и молчать о нём нельзя.
|
|
285
|
+
// СОВЕЩАТЕЛЬНЫЕ ГЕЙТЫ. Правило вводят в проект, где старый код ему не соответствует. Храповик
|
|
286
|
+
// отвечает на это одним способом: старое становится долгом, новое блокируется. Второй способ —
|
|
287
|
+
// показывать, не роняя, пока команда договаривается о правиле. Без него у человека остаётся
|
|
288
|
+
// выбор из двух крайностей: включить и сломать сборку либо не включать вовсе.
|
|
289
|
+
//
|
|
159
290
|
function advisorySet(man) {
|
|
160
291
|
const v = man?.advisory;
|
|
161
292
|
if (Array.isArray(v)) return new Set(v.map((x) => String(x).trim()).filter(Boolean));
|
|
@@ -242,5 +373,5 @@ function manifestWithGate(text, slug, cmd) {
|
|
|
242
373
|
|
|
243
374
|
export {
|
|
244
375
|
parseManifest, readManifest, assessLevel, manifestWithGate, unknownKeys, KNOWN_KEYS,
|
|
245
|
-
entryLifecycle, advisorySet, layoutChecks,
|
|
376
|
+
entryLifecycle, advisorySet, gateRequires, layoutChecks, coversOf, coversUnproven, unparsedLines,
|
|
246
377
|
};
|
package/tool/lib/prove.mjs
CHANGED
|
@@ -13,7 +13,8 @@ import { spawnSync } from "node:child_process";
|
|
|
13
13
|
import { readFile } from "node:fs/promises";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { CWD, exists } from "./core.mjs";
|
|
16
|
-
import { parseManifest } from "./manifest.mjs";
|
|
16
|
+
import { parseManifest, gateRequires } from "./manifest.mjs";
|
|
17
|
+
import { whichSync } from "./repo.mjs";
|
|
17
18
|
|
|
18
19
|
// Гейт можно доказать, если у него есть оба образца. Признак по образцам, а не по тексту
|
|
19
20
|
// команды: запись, делегирующая готовому инструменту (`npx knip --directory .`), каталог
|
|
@@ -128,6 +129,15 @@ async function proveGates(man, { timeoutMs = 300000 } = {}) {
|
|
|
128
129
|
continue;
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// Программы, без которой запись не работает, может не быть на машине — тогда доказывать
|
|
133
|
+
// нечем, а не «гейт сломан». Проверяется ДО запуска: без неё обёртка краснеет на обоих
|
|
134
|
+
// образцах, и вердикт вышел бы «краснеет на исправном коде».
|
|
135
|
+
const missing = await gateRequires(samplesDir, name, whichSync);
|
|
136
|
+
if (missing) {
|
|
137
|
+
results.push({ name, state: "unprovable", why: "needs-program", missing });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
// Образцы бывают написаны под КОНКРЕТНЫЙ рецепт: запись без переносимой проверки называет
|
|
132
142
|
// его полем `samples_for`. Если в проекте стоит рецепт под другой язык, гонять по этим
|
|
133
143
|
// образцам нечего — они на чужом языке. Найдено первым же прогоном на своём репозитории:
|
package/tool/lib/repo.mjs
CHANGED
|
@@ -26,6 +26,14 @@ const EXT_LANG = {
|
|
|
26
26
|
".cs": "csharp", ".sh": "shell", ".kt": "kotlin", ".swift": "swift", ".scala": "scala",
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
// Спецификация API — договор с чужим кодом. Опознаётся ПО ИМЕНИ ФАЙЛА, а не по расположению:
|
|
30
|
+
// его кладут в корень, в `docs/`, рядом с приложением, — фиксированный список путей промахнулся
|
|
31
|
+
// бы на большинстве проектов. Расширение обязательно разбираемое: `openapi.md` — это рассказ о
|
|
32
|
+
// договоре, а не договор, и держать его нечем.
|
|
33
|
+
function isApiSpec(name) {
|
|
34
|
+
return /^(openapi|swagger|asyncapi)[^/]*\.(ya?ml|json)$/i.test(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
const SKIP_DIRS = new Set([".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".aqk"]);
|
|
30
38
|
|
|
31
39
|
// Факты о репозитории. Только то, что видно машине: спрашивать человека анкетой
|
|
@@ -47,6 +55,10 @@ const MARKS = [
|
|
|
47
55
|
// чем `has_agent_config`: настройки заводят не все, а свод — почти каждый, кто работает с
|
|
48
56
|
// агентом. Записи про личные файлы касаются именно вторых.
|
|
49
57
|
["has_agent_entry", ["CLAUDE.md", "AGENTS.md", ".claude", ".cursor/rules", ".github/copilot-instructions.md"]],
|
|
58
|
+
// Агенту подключены внешние инструменты через MCP. Отдельный признак, а не часть
|
|
59
|
+
// `has_agent_config`: настройки заводят почти все, а MCP-серверы — те, кто дал агенту
|
|
60
|
+
// браузер, базу или трекер. Записи про мёртвый сервер касаются только вторых.
|
|
61
|
+
["has_mcp", [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".claude/mcp.json"]],
|
|
50
62
|
];
|
|
51
63
|
|
|
52
64
|
async function detectFacts(man) {
|
|
@@ -63,6 +75,7 @@ async function detectFacts(man) {
|
|
|
63
75
|
// JS, где интерфейса нет вовсе. Записи, показанной не тому, не верят, и каталог теряет
|
|
64
76
|
// доверие целиком, а не одной строкой.
|
|
65
77
|
let hasUi = false;
|
|
78
|
+
let hasApiSpec = false;
|
|
66
79
|
|
|
67
80
|
async function walk(dir, depth) {
|
|
68
81
|
if (depth > 4 || files > 4000) return;
|
|
@@ -87,6 +100,7 @@ async function detectFacts(man) {
|
|
|
87
100
|
if (/\.(test|spec)\.[a-z]+$/i.test(it.name) || /^test_.*\.py$/i.test(it.name) || /_test\.go$/i.test(it.name)) hasTests = true;
|
|
88
101
|
if (it.name.endsWith(".sql")) hasDb = true;
|
|
89
102
|
if (/\.(css|scss|sass|less|styl|vue|svelte|astro)$/i.test(it.name)) hasUi = true;
|
|
103
|
+
if (isApiSpec(it.name)) hasApiSpec = true;
|
|
90
104
|
const dot = it.name.lastIndexOf(".");
|
|
91
105
|
if (dot > 0) {
|
|
92
106
|
const lang = EXT_LANG[it.name.slice(dot)];
|
|
@@ -104,6 +118,7 @@ async function detectFacts(man) {
|
|
|
104
118
|
has_db: hasDb,
|
|
105
119
|
has_tests: hasTests,
|
|
106
120
|
has_ui: hasUi,
|
|
121
|
+
has_api_spec: hasApiSpec,
|
|
107
122
|
has_gates: Object.values(gates).some((c) => String(c || "").trim()),
|
|
108
123
|
gateKeys: Object.keys(gates),
|
|
109
124
|
};
|
|
@@ -253,6 +268,32 @@ function pickRecipe(rec, facts, missing) {
|
|
|
253
268
|
return recipes.any || null;
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
// БРАУЗЕР У АГЕНТА. Проект с интерфейсом, у которого агенту нечем посмотреть на собственное
|
|
272
|
+
// изменение, — это §16 методички: «прогон на живой системе перед сдачей, как пользователь, через
|
|
273
|
+
// настоящий вход». Без браузера агент судит о своей работе по тому, что компилируется, а это
|
|
274
|
+
// разные утверждения; мок-тесты сюда же.
|
|
275
|
+
//
|
|
276
|
+
// СОВЕТ, А НЕ ЗАПИСЬ КАТАЛОГА, и разница принципиальная. Запись каталога — машинная проверка с
|
|
277
|
+
// вердиктом; здесь вердикта нет: отсутствие браузерного сервера не дефект, а незанятая
|
|
278
|
+
// возможность. Красить это в красное значит требовать поставить инструмент — мы так не делаем
|
|
279
|
+
// ни с чем другим.
|
|
280
|
+
//
|
|
281
|
+
// НАЗЫВАЮТСЯ ДВА, а не один. `chrome-devtools-mcp` от Google — трассировка производительности,
|
|
282
|
+
// Lighthouse, сеть, консоль; `@playwright/mcp` от Microsoft — прогон сценариев в трёх движках.
|
|
283
|
+
// Назвать один значит выдать выбор за факт: они решают разные задачи, и выбирает человек.
|
|
284
|
+
// Проверено по реестру npm 2026-09-08: 1.9.0 и 0.0.80, оба живые, оба от первых лиц.
|
|
285
|
+
//
|
|
286
|
+
// НЕ ВСЕМ. Проекту без интерфейса браузер не нужен, а совет, показанный не тому, стоит доверия
|
|
287
|
+
// всем остальным советам — та же норма, что у записей каталога.
|
|
288
|
+
const BROWSER_SERVERS = ["chrome-devtools-mcp", "@playwright/mcp", "playwright-mcp", "puppeteer-mcp", "puppeteer"];
|
|
289
|
+
|
|
290
|
+
function browserServerAdvice(facts, mcpText = "") {
|
|
291
|
+
if (!facts?.has_ui) return null;
|
|
292
|
+
const text = String(mcpText || "");
|
|
293
|
+
if (BROWSER_SERVERS.some((n) => text.includes(n))) return null;
|
|
294
|
+
return { servers: ["chrome-devtools-mcp", "@playwright/mcp"] };
|
|
295
|
+
}
|
|
296
|
+
|
|
256
297
|
function recipeFor(rec, facts) {
|
|
257
298
|
const cmd = pickRecipe(rec, facts);
|
|
258
299
|
if (!cmd) return L.recipe.none;
|
|
@@ -325,6 +366,5 @@ async function matchCatalog(query) {
|
|
|
325
366
|
// который никто не берёт, читается как часть договора и мешает менять внутренности.
|
|
326
367
|
export {
|
|
327
368
|
whichSync,
|
|
328
|
-
EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor,
|
|
329
|
-
stems, overlap, matchCatalog,
|
|
330
|
-
};
|
|
369
|
+
EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor, browserServerAdvice, MARKS,
|
|
370
|
+
stems, overlap, matchCatalog, isApiSpec };
|
package/tool/program.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { realpathSync } from "node:fs";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { c, SELF, commandRows } from "./lib/core.mjs";
|
|
20
|
+
import { banner } from "./lib/banner.mjs";
|
|
20
21
|
import { L } from "./i18n/index.mjs";
|
|
21
22
|
import { cmdInit, cmdNote, cmdBlob, cmdStart } from "./commands/project.mjs";
|
|
22
23
|
import { cmdDoctor } from "./commands/doctor.mjs";
|
|
@@ -25,7 +26,9 @@ import { cmdReport } from "./commands/report.mjs";
|
|
|
25
26
|
import { cmdLearn } from "./commands/learn.mjs";
|
|
26
27
|
import { cmdBadge } from "./commands/badge.mjs";
|
|
27
28
|
import { cmdProve } from "./commands/prove.mjs";
|
|
29
|
+
import { cmdProbe } from "./commands/probe.mjs";
|
|
28
30
|
import { cmdContext } from "./commands/context.mjs";
|
|
31
|
+
import { cmdVitals } from "./commands/vitals.mjs";
|
|
29
32
|
|
|
30
33
|
// Разбор аргументов выполняется только при запуске файла как программы. При импорте —
|
|
31
34
|
// а так его читают модульные проверки tool/selfcheck/units.mjs — CLI запускаться не должен.
|
|
@@ -81,9 +84,39 @@ if (IS_MAIN) {
|
|
|
81
84
|
case "prove":
|
|
82
85
|
await cmdProve();
|
|
83
86
|
break;
|
|
87
|
+
// Осмотр, а не порог: всегда выходит с нулём. Отвечает на вопрос, которого нет ни у
|
|
88
|
+
// doctor («держит машина 21» — из чего?), ни у prove («гейт ловит брак на СВОЁМ образце»):
|
|
89
|
+
// что в ЭТОМ репозитории не прикрыто ничем.
|
|
90
|
+
case "probe":
|
|
91
|
+
await cmdProbe(rest);
|
|
92
|
+
break;
|
|
84
93
|
// Печатает состояние репозитория для КОНТЕКСТА агента, а не для человека. Зовётся хуком
|
|
85
94
|
// SessionStart, поэтому ничего не запускает и всегда выходит с нулём: хук, роняющий запуск
|
|
86
95
|
// агента из-за неготового проекта, отключат в тот же день, и не станет ни хука, ни блока.
|
|
96
|
+
// Заставка по --version: одно из двух мест, где человек встречается с комплектом впервые.
|
|
97
|
+
case "--version":
|
|
98
|
+
case "-v":
|
|
99
|
+
case "version": {
|
|
100
|
+
const { readFile } = await import("node:fs/promises");
|
|
101
|
+
const { join, dirname } = await import("node:path");
|
|
102
|
+
const { fileURLToPath } = await import("node:url");
|
|
103
|
+
let v = "";
|
|
104
|
+
try {
|
|
105
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
106
|
+
v = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version || "";
|
|
107
|
+
} catch { /* пакет без package.json — версия просто не покажется */ }
|
|
108
|
+
console.log(`\n${banner()}\n`);
|
|
109
|
+
if (v) console.log(c.dim(` версия ${v}\n`));
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Смотрит не на репозиторий, а на саму обвязку: стоят ли инструменты гейтов, прописан ли
|
|
114
|
+
// хук в .git/hooks, получает ли агент состояние. Без неё это выясняется красным гейтом
|
|
115
|
+
// посреди коммита — в момент, когда человек занят другим и просто выключит проверку.
|
|
116
|
+
case "vitals":
|
|
117
|
+
await cmdVitals();
|
|
118
|
+
break;
|
|
119
|
+
|
|
87
120
|
case "context":
|
|
88
121
|
await cmdContext(rest);
|
|
89
122
|
break;
|