agent-quality-kit 0.5.0 → 0.6.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.
Files changed (49) hide show
  1. package/README.md +36 -1
  2. package/README.ru.md +19 -1
  3. package/kit/docs/ready-made-rules.md +40 -0
  4. package/kit/gates/README.md +20 -0
  5. package/kit/gates/ci-actually-fails/README.md +42 -0
  6. package/kit/gates/ci-actually-fails/check.sh +93 -0
  7. package/kit/gates/ci-actually-fails/gate.yml +14 -0
  8. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +14 -0
  9. package/kit/gates/ci-actually-fails/red/.github/workflows/ci.yml +12 -0
  10. package/kit/gates/gate-not-weakened/README.md +54 -0
  11. package/kit/gates/gate-not-weakened/check.sh +72 -0
  12. package/kit/gates/gate-not-weakened/gate.yml +15 -0
  13. package/kit/gates/gate-not-weakened/green/checkout.ts +8 -0
  14. package/kit/gates/gate-not-weakened/green/payments.py +6 -0
  15. package/kit/gates/gate-not-weakened/green/release.sh +2 -0
  16. package/kit/gates/gate-not-weakened/red/checkout.ts +9 -0
  17. package/kit/gates/gate-not-weakened/red/payments.py +6 -0
  18. package/kit/gates/gate-not-weakened/red/release.sh +2 -0
  19. package/kit/gates/promise-has-gate/README.md +50 -0
  20. package/kit/gates/promise-has-gate/check.sh +88 -0
  21. package/kit/gates/promise-has-gate/gate.yml +14 -0
  22. package/kit/gates/promise-has-gate/green/.aqk.yml +6 -0
  23. package/kit/gates/promise-has-gate/green/AGENTS.md +7 -0
  24. package/kit/gates/promise-has-gate/red/.aqk.yml +6 -0
  25. package/kit/gates/promise-has-gate/red/AGENTS.md +7 -0
  26. package/kit/gates/test-has-assertion/README.md +47 -0
  27. package/kit/gates/test-has-assertion/check.sh +194 -0
  28. package/kit/gates/test-has-assertion/gate.yml +15 -0
  29. package/kit/gates/test-has-assertion/green/checkout.test.ts +9 -0
  30. package/kit/gates/test-has-assertion/green/test_billing.py +17 -0
  31. package/kit/gates/test-has-assertion/red/checkout.test.ts +8 -0
  32. package/kit/gates/test-has-assertion/red/test_billing.py +14 -0
  33. package/kit/rules/general.md +14 -0
  34. package/llms.txt +1 -0
  35. package/package.json +3 -2
  36. package/tool/commands/doctor.mjs +44 -4
  37. package/tool/commands/gates.mjs +10 -2
  38. package/tool/commands/project.mjs +7 -1
  39. package/tool/i18n/en.mjs +17 -0
  40. package/tool/i18n/ru.mjs +18 -0
  41. package/tool/i18n/templates-en.mjs +9 -9
  42. package/tool/i18n/templates-ru.mjs +9 -9
  43. package/tool/lib/manifest.mjs +37 -1
  44. package/tool/lib/scope.mjs +96 -0
  45. package/tool/program.mjs +1 -0
  46. package/tool/selfcheck/gates.sh +20 -3
  47. package/tool/selfcheck/lifecycle.mjs +29 -0
  48. package/tool/selfcheck/smoke.sh +53 -0
  49. package/tool/selfcheck/units.mjs +85 -1
@@ -267,7 +267,13 @@ async function cmdStart(args) {
267
267
  if (declared.has(rec.slug)) continue;
268
268
  const v = triggerVerdict(rec, facts0);
269
269
  if (!v.applies) { skipped.push([rec.slug, v.why]); continue; }
270
- const { cmd, noRecipe } = await installGate(rec.slug, man, facts0);
270
+ const { cmd, noRecipe, retired } = await installGate(rec.slug, man, facts0);
271
+ // Выведенная запись в пачку не идёт, но и молчать о ней нельзя: она попадает в тот же
272
+ // список пропущенного с названным преемником.
273
+ if (retired !== undefined) {
274
+ skipped.push([rec.slug, L.lifecycle.installDeprecated(rec.slug, retired || "—")]);
275
+ declared.add(rec.slug); continue;
276
+ }
271
277
  // Записи, которой нужен инструмент, а его на машине нет, здесь не место — но и вся
272
278
  // установка из-за неё останавливаться не должна. Причина называется вслух и попадает
273
279
  // в тот же список пропущенного, что и записи, не подошедшие по триггеру.
package/tool/i18n/en.mjs CHANGED
@@ -15,6 +15,7 @@ export const en = {
15
15
  start: "no code yet: day-zero guards and the order of work",
16
16
  doctor: "check what is laid out and what is missing",
17
17
  doctorRun: "and also run the declared gates",
18
+ doctorSince: "the same, but show only what the diff against a ref introduced",
18
19
  add: "install a gate from the catalogue into the project",
19
20
  find: "is there already such a gate — matched by intent",
20
21
  why: "a bug slipped through — why did no guard catch it",
@@ -66,6 +67,10 @@ export const en = {
66
67
  totalTodo: (n) => `applicable but not installed ${n}`,
67
68
  totalSkip: (n) => `hidden ${n}`,
68
69
 
70
+ sinceHeading: (ref, n) => `narrowed to the diff against ${ref}: ${n} files touched`,
71
+ sinceBadRef: (ref) => `cannot compare against "${ref}": no such ref, or this is not a git repository`,
72
+ notScopable: "output carries no paths — cannot be narrowed by diff, left red",
73
+ outsideDiff: (n) => `findings exist, but outside the diff (${n})`,
69
74
  runHeading: "Running the declared gates",
70
75
  timeout: "did not finish within 5 minutes",
71
76
  exitCode: (code) => `exit ${code}`,
@@ -136,6 +141,18 @@ export const en = {
136
141
  none: "no recipe described",
137
142
  },
138
143
 
144
+ // Entry maturity. Computed from the entry's proof; it cannot be declared — see
145
+ // entryLifecycle in tool/lib/manifest.mjs.
146
+ lifecycle: {
147
+ stable: "proven by an incident from the journal",
148
+ experimental: "proof is not from the journal — the entry is provisional",
149
+ deprecated: "retired",
150
+ unknownReplacement: (v) => `superseded_by: ${v} — no such entry in the catalogue`,
151
+ noReplacement: "lifecycle: deprecated without superseded_by — no replacement is named",
152
+ notDeclarable: (v) => `lifecycle: ${v} cannot be declared — maturity is computed from the proof`,
153
+ unknown: (v) => `lifecycle: ${v} — no such state; only deprecated is declared`,
154
+ installDeprecated: (slug, by) => `entry ${slug} is retired, ${by} replaces it`,
155
+ },
139
156
  manifest: {
140
157
  noGatesBlock: "no gates: block in .aqk.yml",
141
158
  alreadyDeclared: "already declared",
package/tool/i18n/ru.mjs CHANGED
@@ -16,6 +16,7 @@ export const ru = {
16
16
  start: "кода ещё нет: сторожа дня 0 и порядок работы",
17
17
  doctor: "проверить, что разложено и чего не хватает",
18
18
  doctorRun: "ещё и запустить объявленные гейты",
19
+ doctorSince: "то же, но показать только то, что внёс диф относительно ссылки",
19
20
  add: "поставить гейт из каталога в проект",
20
21
  find: "есть ли уже такой гейт — сверка по намерению",
21
22
  why: "поймал ошибку — почему её не поймал сторож",
@@ -67,6 +68,10 @@ export const ru = {
67
68
  totalTodo: (n) => `применимо но не поставлено ${n}`,
68
69
  totalSkip: (n) => `скрыто ${n}`,
69
70
 
71
+ sinceHeading: (ref, n) => `сужено до дифа относительно ${ref}: файлов затронуто ${n}`,
72
+ sinceBadRef: (ref) => `не могу сравнить с «${ref}»: такой ссылки нет или это не репозиторий git`,
73
+ notScopable: "вывод без путей — дифом не сужается, оставлен красным",
74
+ outsideDiff: (n) => `находки есть, но вне дифа (${n})`,
70
75
  runHeading: "Прогон объявленных гейтов",
71
76
  timeout: "не уложился в 5 минут",
72
77
  exitCode: (code) => `код ${code}`,
@@ -142,6 +147,19 @@ export const ru = {
142
147
  none: "рецепт не описан",
143
148
  },
144
149
 
150
+ // Зрелость записи каталога. Считается по доказательству; объявить её нельзя — см.
151
+ // entryLifecycle в tool/lib/manifest.mjs.
152
+ lifecycle: {
153
+ stable: "доказана шишкой из журнала",
154
+ experimental: "доказательство не из журнала — запись условная",
155
+ deprecated: "выведена из употребления",
156
+ unknownReplacement: (v) => `superseded_by: ${v} — такой записи в каталоге нет`,
157
+ noReplacement: "lifecycle: deprecated без superseded_by — не назван тот, кто заменяет",
158
+ notDeclarable: (v) => `lifecycle: ${v} объявлять нельзя — зрелость считается по доказательству`,
159
+ unknown: (v) => `lifecycle: ${v} — такого состояния нет; объявляется только deprecated`,
160
+ installDeprecated: (slug, by) =>
161
+ `запись ${slug} выведена из употребления, её заменяет ${by}`,
162
+ },
145
163
  manifest: {
146
164
  noGatesBlock: "в .aqk.yml нет блока gates:",
147
165
  alreadyDeclared: "уже объявлен",
@@ -11,17 +11,17 @@ const AGENTS_MD = `# AGENTS.md
11
11
 
12
12
  ## Hard rules
13
13
 
14
- - **A plan before code.** A non-trivial task starts with a plan a human approved in words.
15
- - **A red test before code.** First a check that fails, then the implementation.
16
- - **Three attempts maximum.** Not solved in three — stop and ask a human, not a fourth try.
17
- - **Secrets only in the environment.** Never in code, logs or commits.
18
- - **Only the files the task is about.** No fixing things "while we are here".
19
- - **Done = proven.** Name the arbiter: a test, a live run, a check against the source.
14
+ - **A plan before code.** A non-trivial task starts with a plan a human approved in words. <!-- aqk: human -->
15
+ - **A red test before code.** First a check that fails, then the implementation. <!-- aqk: human -->
16
+ - **Three attempts maximum.** Not solved in three — stop and ask a human, not a fourth try. <!-- aqk: human -->
17
+ - **Secrets only in the environment.** Never in code, logs or commits. <!-- aqk: secrets-not-in-code -->
18
+ - **Only the files the task is about.** No fixing things "while we are here". <!-- aqk: human -->
19
+ - **Done = proven.** Name the arbiter: a test, a live run, a check against the source. <!-- aqk: human -->
20
20
  "Looks like it works" is not done.
21
- - **Never swallow an error.** Either handled and logged, or re-raised.
22
- - **A fork in the road is a question for a human.** Departing from an agreed decision is not
21
+ - **Never swallow an error.** Either handled and logged, or re-raised. <!-- aqk: swallowed-error -->
22
+ - **A fork in the road is a question for a human.** Departing from an agreed decision is not <!-- aqk: human -->
23
23
  documented with a code comment.
24
- - **Report on your work with the kit with a command, not with words.** When you are done, run
24
+ - **Report on your work with the kit with a command, not with words.** When you are done, run <!-- aqk: human -->
25
25
  \`aqk report\`. It is assembled from an actual run: a summary from memory always picks the
26
26
  convenient parts and stays quiet about a gate standing on the weakest recipe.
27
27
 
@@ -16,17 +16,17 @@ const AGENTS_MD = `# AGENTS.md
16
16
 
17
17
  ## Железные правила
18
18
 
19
- - **План до кода.** Нетривиальная задача начинается с плана, который человек одобрил словами.
20
- - **Красный тест до кода.** Сначала проверка, которая падает, потом реализация.
21
- - **Максимум 3 попытки.** Не решил за три — стоп и человеку, а не четвёртый заход.
22
- - **Секреты только в окружении.** Никогда в коде, логах и коммитах.
23
- - **Только файлы из задачи.** Заодно ничего не чиним.
24
- - **Готово = доказано.** Назови арбитра: тест, живой прогон, сверка с источником. «Выглядит
19
+ - **План до кода.** Нетривиальная задача начинается с плана, который человек одобрил словами. <!-- aqk: человек -->
20
+ - **Красный тест до кода.** Сначала проверка, которая падает, потом реализация. <!-- aqk: человек -->
21
+ - **Максимум 3 попытки.** Не решил за три — стоп и человеку, а не четвёртый заход. <!-- aqk: человек -->
22
+ - **Секреты только в окружении.** Никогда в коде, логах и коммитах. <!-- aqk: secrets-not-in-code -->
23
+ - **Только файлы из задачи.** Заодно ничего не чиним. <!-- aqk: человек -->
24
+ - **Готово = доказано.** Назови арбитра: тест, живой прогон, сверка с источником. «Выглядит <!-- aqk: человек -->
25
25
  рабочим» — не готово.
26
- - **Ошибку не глотать.** Либо обработана и залогирована, либо проброшена.
27
- - **Развилка — вопрос человеку.** Отступление от принятого решения не оформляется комментарием
26
+ - **Ошибку не глотать.** Либо обработана и залогирована, либо проброшена. <!-- aqk: swallowed-error -->
27
+ - **Развилка — вопрос человеку.** Отступление от принятого решения не оформляется комментарием <!-- aqk: человек -->
28
28
  в коде.
29
- - **Отчёт о работе с комплектом — командой, а не словами.** Закончил — выполни \`aqk report\`.
29
+ - **Отчёт о работе с комплектом — командой, а не словами.** Закончил — выполни \`aqk report\`. <!-- aqk: человек -->
30
30
  Он собирается прогоном: пересказ по памяти всегда выбирает удобное и молчит о том, что гейт
31
31
  стоит на слабейшем рецепте.
32
32
 
@@ -86,6 +86,39 @@ function unknownKeys(man) {
86
86
  return Object.keys(man).filter((k) => !KNOWN_KEYS.includes(k));
87
87
  }
88
88
 
89
+ // ЗРЕЛОСТЬ ЗАПИСИ. Каталог без зрелости — это список, в котором нельзя отличить проверенное от
90
+ // свежего; при шестнадцати записях это держится на памяти, при чужих записях — уже нет.
91
+ //
92
+ // ПОЧЕМУ ВЫЧИСЛЯЕТСЯ, А НЕ ОБЪЯВЛЯЕТСЯ. Поле зрелости есть у всех троих соседей — `lifecycle`
93
+ // у зондов Scorecard, `future`/`obsolete` у критериев значка OpenSSF — и у всех троих его
94
+ // заполняет автор. Значение, которое написал автор, означает доверие к автору, а не факт: это
95
+ // ровно тот способ, которым «зелёный» перестаёт что-либо значить. Здесь зрелость считается по
96
+ // доказательству записи, и объявить её нельзя — попытка отклоняется приёмкой каталога.
97
+ //
98
+ // Исключение одно: `deprecated`. «Запись больше не ставят» из её собственных файлов не выводится
99
+ // никак — это решение, а не факт. Цена решения — обязательная замена: запись, выведенная в
100
+ // никуда, оставляет человека без ответа на вопрос «а что теперь».
101
+ const LIFECYCLE_COMPUTED = ["stable", "experimental"];
102
+
103
+ function entryLifecycle(rec) {
104
+ const declared = typeof rec?.lifecycle === "string" ? rec.lifecycle.trim() : "";
105
+ const supersededBy = typeof rec?.superseded_by === "string" ? rec.superseded_by.trim() : "";
106
+ // Тот же признак, которым каталог отделяет условную запись с первого дня: доказательство
107
+ // ссылается на журнал шишек — значит, запись родилась из настоящей поломки, а не из
108
+ // «это хорошая практика». Признак один на всю программу: разъехавшись, он дал бы приёмке
109
+ // и отчёту разные ответы про одну и ту же запись.
110
+ const proven = /incidents\//.test(String(rec?.proof || ""));
111
+ const state = declared === "deprecated" ? "deprecated" : proven ? "stable" : "experimental";
112
+ const why = L.lifecycle[state];
113
+
114
+ let problem = null;
115
+ if (declared === "deprecated" && !supersededBy) problem = L.lifecycle.noReplacement;
116
+ else if (LIFECYCLE_COMPUTED.includes(declared)) problem = L.lifecycle.notDeclarable(declared);
117
+ else if (declared && declared !== "deprecated") problem = L.lifecycle.unknown(declared);
118
+
119
+ return { state, why, supersededBy: supersededBy || null, problem };
120
+ }
121
+
89
122
  async function readManifest() {
90
123
  const p = join(CWD, MANIFEST);
91
124
  if (!(await exists(p))) return null;
@@ -149,4 +182,7 @@ function manifestWithGate(text, slug, cmd) {
149
182
  return { text: out, why: null };
150
183
  }
151
184
 
152
- export { parseManifest, readManifest, assessLevel, manifestWithGate, unknownKeys, KNOWN_KEYS };
185
+ export {
186
+ parseManifest, readManifest, assessLevel, manifestWithGate, unknownKeys, KNOWN_KEYS,
187
+ entryLifecycle,
188
+ };
@@ -0,0 +1,96 @@
1
+ // tool/lib/scope.mjs — сужение вывода гейта до того, что внёс диф.
2
+ //
3
+ // ЗАЧЕМ. Первый прогон в живом проекте показывает долг, накопленный годами: на репозитории в
4
+ // 36 тысяч файлов это тысячи находок. Человек видит стену красного, понимает, что разобрать её
5
+ // нельзя, и выключает проверку целиком. Это причина номер один, по которой такие инструменты
6
+ // снимают, — и три независимых проекта из нашего разбора умеют показывать только внесённое
7
+ // (reviewdog, `--since` у ratchets, четыре режима шума у react-doctor).
8
+ //
9
+ // ПОЧЕМУ ФИЛЬТР ВЫВОДА, А НЕ СПИСОК ФАЙЛОВ ГЕЙТУ. Гейт — произвольная команда оболочки: у
10
+ // каждого инструмента свой способ принять список файлов, а у переносимых проверок его нет
11
+ // вовсе. Фильтр вывода работает с любым гейтом, ничего не требуя от записи каталога.
12
+ //
13
+ // ЧЕГО ЭТОТ ФИЛЬТР НЕ УМЕЕТ И НЕ ДЕЛАЕТ ВИД, ЧТО УМЕЕТ. Он сужает до ФАЙЛА, а не до строки.
14
+ // Находка в файле, который диф трогал, показывается целиком, даже если она в нетронутой строке.
15
+ // Сужение до строки требует разбора формата каждого инструмента — то есть ровно той привязки
16
+ // к инструменту, которой у нас нет.
17
+
18
+ import { spawnSync } from "node:child_process";
19
+
20
+ // Один и тот же файл приезжает в трёх видах: `src/a.py`, `./src/a.py` и `src\a.py` на Windows.
21
+ function normPath(p) {
22
+ return String(p).replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
23
+ }
24
+
25
+ // Цвет снимается ДО поиска путей. Родные инструменты печатают путь внутри
26
+ // escape-последовательности, и сравнение видит не «src/a.py», а обрывок с управляющими
27
+ // символами. На живом проекте это уже стоило одной починки, которая выглядела работающей:
28
+ // вывод «сократился» с 5597 строк до 5505, то есть не сократился.
29
+ const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
30
+
31
+ // Кандидат в путь: слово с расширением, начинающимся с БУКВЫ. Требование буквы отсекает номера
32
+ // версий — «0.5.0» иначе читается как файл с расширением «0», и строка итога про версию
33
+ // принималась бы за находку и отбрасывалась.
34
+ const CANDIDATE = /[\w.@+-]+(?:\/[\w.@+-]+)*\.[A-Za-z][A-Za-z0-9]{0,9}/g;
35
+
36
+ function inScope(candidate, files) {
37
+ const c = normPath(candidate);
38
+ if (files.has(c)) return true;
39
+ // Совпадение по хвосту в обе стороны: инструмент печатает то абсолютный путь, то голое имя
40
+ // файла. Здесь лучше ошибиться в сторону «показать»: спрятанная находка — это тишина,
41
+ // а лишняя показанная — просто шум, который человек отметает глазами.
42
+ for (const f of files) {
43
+ if (c.endsWith(`/${f}`) || f.endsWith(`/${c}`)) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ // Возвращает: что осталось показать, сколько среди этого НАХОДОК и можно ли этот гейт сузить.
49
+ //
50
+ // `scopable: false` — важнее всего остального. Гейт, который печатает вердикт без путей
51
+ // (проверка сообщения коммита, проверка конфига конвейера), сузить дифом нельзя. Признать его
52
+ // успешным на этом основании значило бы получить зелёное молчание там, где проверка провалилась,
53
+ // — ровно та тишина, против которой построен весь стандарт. Вызывающий обязан оставить такой
54
+ // гейт красным и сказать, почему он не сужен.
55
+ function scopeOutput(lines, files) {
56
+ const kept = [];
57
+ let findings = 0;
58
+ let scopable = false;
59
+
60
+ for (const raw of lines) {
61
+ const plain = String(raw).replace(ANSI, "");
62
+ const candidates = plain.match(CANDIDATE) || [];
63
+ if (!candidates.length) {
64
+ // Строка без пути — это шапка, итог или пояснение. Показываем: без неё находка теряет
65
+ // контекст. Находкой не считаем: иначе гейт никогда не сузился бы до нуля.
66
+ kept.push(raw);
67
+ continue;
68
+ }
69
+ scopable = true;
70
+ if (candidates.some((c) => inScope(c, files))) {
71
+ kept.push(raw);
72
+ findings++;
73
+ }
74
+ }
75
+ return { kept, findings, scopable };
76
+ }
77
+
78
+ // Файлы, изменённые относительно ссылки. Новые файлы, ещё не добавленные в индекс, тоже входят:
79
+ // их код так же нов, как и остальной диф, а из `git diff` они не видны.
80
+ function changedFiles(ref, cwd) {
81
+ const git = (args) => {
82
+ const r = spawnSync("git", args, { cwd, encoding: "utf8" });
83
+ return r.status === 0 ? String(r.stdout || "") : null;
84
+ };
85
+ // Точка расхождения, а не сама ссылка: сравнение с веткой, ушедшей вперёд, показало бы
86
+ // чужие изменения как свои. Если базы нет (ссылка — коммит в той же линии), берём её саму.
87
+ const base = (git(["merge-base", ref, "HEAD"]) || "").trim() || ref;
88
+ const diff = git(["diff", "--name-only", base]);
89
+ if (diff === null) return null;
90
+ const untracked = git(["ls-files", "--others", "--exclude-standard"]) || "";
91
+ return new Set(
92
+ `${diff}\n${untracked}`.split("\n").map((l) => normPath(l.trim())).filter(Boolean)
93
+ );
94
+ }
95
+
96
+ export { scopeOutput, changedFiles };
package/tool/program.mjs CHANGED
@@ -82,6 +82,7 @@ if (IS_MAIN) {
82
82
  [`${SELF} start`, h.start],
83
83
  [`${SELF} doctor`, h.doctor],
84
84
  [`${SELF} doctor --run`, h.doctorRun],
85
+ [`${SELF} doctor --run --since main`, h.doctorSince],
85
86
  [`${SELF} add ${h.name}`, h.add],
86
87
  [`${SELF} find "…"`, h.find],
87
88
  [`${SELF} why "…"`, h.why],
@@ -22,6 +22,14 @@ field() { sed -n "s/^$2:[[:space:]]*\(.*\)$/\1/p" "$1" | head -1; }
22
22
  printf '\n\033[1mtool/selfcheck/gates.sh\033[0m\n\n'
23
23
  [ -d "$CAT" ] || { echo " каталога гейтов нет"; exit 1; }
24
24
 
25
+ # Зрелость каждой записи одной таблицей. Правило живёт в entryLifecycle (tool/lib/manifest.mjs);
26
+ # повторять его здесь на sh нельзя — второй источник истины расходится с первым молча.
27
+ # Язык принудительно русский: это внутренняя проверка комплекта, и её вывод целиком русский.
28
+ # Без этого на машине с английской локалью половина строки печаталась по-русски, половина —
29
+ # по-английски, в одном предложении.
30
+ LIFE="$(AQK_LANG=ru node "$ROOT/tool/selfcheck/lifecycle.mjs" 2>/dev/null)"
31
+ life_field() { printf '%s\n' "$LIFE" | awk -F'|' -v s="$1" -v n="$2" '$1==s{print $n}'; }
32
+
25
33
  for GATE in "$CAT"/*/; do
26
34
  SLUG="$(basename "$GATE")"
27
35
  YML="$GATE/gate.yml"
@@ -71,9 +79,18 @@ for GATE in "$CAT"/*/; do
71
79
  printf '%s\n' "$README_TEXT" | grep -qE 'чего НЕ ловит|Чего НЕ ловит|чего не ловит|Чего не ловит' \
72
80
  || bad "$SLUG: в README нет раздела «чего НЕ ловит» — граница записи обязана быть названа"
73
81
 
74
- case "$PROOF" in
75
- *incidents/*) : ;;
76
- *) warn "$SLUG: доказательство не ссылается на журнал шишек запись условная" ;;
82
+ # Зрелость не объявляют — её считают. Попытка написать `lifecycle: stable` руками отклоняется:
83
+ # поле, которое заполняет автор, означает доверие к автору, а не факт. Это ровно тот способ,
84
+ # которым «зелёный» у соседей перестал что-либо значить,см. kit/gates/README.md.
85
+ LIFE_PROBLEM="$(life_field "$SLUG" 4)"
86
+ # continue, а не просто отметка: запись с неверным объявлением уже отклонена, и гонять по ней
87
+ # образцы значит посчитать её и в отклонённых, и в принятых — итог начинает врать.
88
+ [ -n "$LIFE_PROBLEM" ] && { bad "$SLUG: $LIFE_PROBLEM"; continue; }
89
+
90
+ STATE="$(life_field "$SLUG" 2)"
91
+ case "$STATE" in
92
+ experimental) warn "$SLUG: доказательство не ссылается на журнал шишек — запись условная" ;;
93
+ deprecated) warn "$SLUG: выведена из употребления, заменяет её «$(life_field "$SLUG" 3)»" ;;
77
94
  esac
78
95
 
79
96
  # --- образцы ---------------------------------------------------------------
@@ -0,0 +1,29 @@
1
+ // tool/selfcheck/lifecycle.mjs — зрелость записей каталога, одной таблицей.
2
+ //
3
+ // ЗАЧЕМ ОТДЕЛЬНЫМ ФАЙЛОМ, А НЕ КУСКОМ gates.sh. Правило зрелости живёт в `entryLifecycle`
4
+ // (tool/lib/manifest.mjs) и оттуда же читается программой. Повторить его на sh значило бы
5
+ // завести второй источник истины: через месяц приёмка и отчёт расходятся, и про одну и ту же
6
+ // запись машина говорит разное. Здесь — только печать; решение принимает та же функция.
7
+ //
8
+ // node tool/selfcheck/lifecycle.mjs → slug|состояние|замена|проблема
9
+ //
10
+ // Код возврата — число записей с проблемой объявления.
11
+
12
+ import { readCatalog } from "../lib/repo.mjs";
13
+ import { entryLifecycle } from "../lib/manifest.mjs";
14
+ import { L } from "../i18n/index.mjs";
15
+
16
+ const catalog = await readCatalog();
17
+ const slugs = new Set(catalog.map((r) => r.slug));
18
+ let bad = 0;
19
+
20
+ for (const rec of catalog) {
21
+ const { state, supersededBy, problem } = entryLifecycle(rec);
22
+ // Замена, которой нет в каталоге, — это ответ «а что теперь», ведущий в никуда. Проверяется
23
+ // здесь, а не в чистой функции: та не знает про остальные записи и не должна знать.
24
+ const why = problem || (supersededBy && !slugs.has(supersededBy) ? L.lifecycle.unknownReplacement(supersededBy) : "");
25
+ if (why) bad++;
26
+ console.log(`${rec.slug}|${state}|${supersededBy || ""}|${why}`);
27
+ }
28
+
29
+ process.exit(bad);
@@ -904,6 +904,59 @@ else
904
904
  fi
905
905
  rm -rf "$NRDIR" "$NRBIN"
906
906
 
907
+ # --- 49. выведенную запись не ставят, а называют преемника --------------------
908
+ # ЗАЧЕМ. Зрелость записи считается по доказательству, и объявить её нельзя — кроме одного
909
+ # состояния: `deprecated`. Оно объявляется, и весь его смысл в отказе: запись, которую всё ещё
910
+ # можно поставить одной командой, не выведена, а просто помечена. Проверяем сам отказ и то, что
911
+ # папка гейта в проекте НЕ появилась: половина установки хуже, чем её отсутствие.
912
+ #
913
+ # Каталог мутируем в КОПИИ пакета, а не в этом репозитории: проверка, которая правит собственные
914
+ # исходники, однажды упадёт посередине и оставит дерево грязным.
915
+ DEPKG="$(mktemp -d)"; DEPRJ="$(mktemp -d)"
916
+ cp -r "$ROOT/tool" "$ROOT/kit" "$ROOT/package.json" "$DEPKG/" 2>/dev/null
917
+ printf 'lifecycle: deprecated\nsuperseded_by: no-print-in-prod\n' >> "$DEPKG/kit/gates/todo-without-task/gate.yml"
918
+ ( cd "$DEPRJ" && git init -q . && printf 'x = 1\n' > a.py && node "$DEPKG/tool/program.mjs" init >/dev/null 2>&1 )
919
+ DE_OUT=$( cd "$DEPRJ" && node "$DEPKG/tool/program.mjs" add todo-without-task 2>&1 ); DE_CODE=$?
920
+ if [ "$DE_CODE" -ne 0 ] &&
921
+ printf '%s' "$DE_OUT" | grep -q 'no-print-in-prod' &&
922
+ [ ! -d "$DEPRJ/gates/todo-without-task" ]; then
923
+ ok "add отказывает в выведенной записи и называет ту, что её заменяет"
924
+ else
925
+ bad "выведенная запись установилась или преемник не назван" "код $DE_CODE, папка: $([ -d "$DEPRJ/gates/todo-without-task" ] && echo есть || echo нет)"
926
+ fi
927
+ rm -rf "$DEPKG" "$DEPRJ"
928
+
929
+ # --- 50. --since показывает только то, что внёс диф ---------------------------
930
+ # ЗАЧЕМ. Первый прогон в живом проекте показывает долг за все годы. Стену красного не разбирают
931
+ # — проверку выключают целиком. Проверяем три исхода разом: старый долг молчит, новый краснеет,
932
+ # а гейт, который печатает вердикт без путей, НЕ становится зелёным от того, что его нечем сузить.
933
+ SCDIR="$(mktemp -d)"
934
+ (
935
+ cd "$SCDIR" && git init -q . && git config user.email t@t && git config user.name t
936
+ mkdir -p src && printf 'def old():\n print("старый долг")\n' > src/old.py
937
+ node "$CLI" init >/dev/null 2>&1
938
+ node "$CLI" add no-print-in-prod >/dev/null 2>&1
939
+ git add -A && git commit -qm "база" >/dev/null 2>&1
940
+ printf 'def fresh():\n print("новый долг")\n' > src/fresh.py
941
+ )
942
+ SC_WIDE=$( cd "$SCDIR" && node "$CLI" doctor --run 2>&1 )
943
+ SC_NARROW=$( cd "$SCDIR" && node "$CLI" doctor --run --since HEAD 2>&1 )
944
+ if printf '%s' "$SC_WIDE" | grep -q 'old.py' &&
945
+ printf '%s' "$SC_NARROW" | grep -q 'fresh.py' &&
946
+ ! printf '%s' "$SC_NARROW" | grep -q 'old.py'; then
947
+ ok "--since прячет старый долг и показывает внесённый дифом"
948
+ else
949
+ bad "--since сузил не то" "широкий: $(printf '%s' "$SC_WIDE" | grep -c 'py:'), узкий: $(printf '%s' "$SC_NARROW" | grep -c 'py:')"
950
+ fi
951
+ # Несуществующая ссылка обязана быть отказом, а не тихим «сравнили с ничем».
952
+ SC_BAD=$( cd "$SCDIR" && node "$CLI" doctor --run --since net-takoy-vetki 2>&1 ); SC_BADCODE=$?
953
+ if [ "$SC_BADCODE" -ne 0 ] && printf '%s' "$SC_BAD" | grep -qi 'net-takoy-vetki'; then
954
+ ok "--since с несуществующей ссылкой — отказ, а не тихое сравнение с ничем"
955
+ else
956
+ bad "--since проглотил неверную ссылку" "код $SC_BADCODE"
957
+ fi
958
+ rm -rf "$SCDIR"
959
+
907
960
  # --- итог -------------------------------------------------------------------
908
961
  printf '\n'
909
962
  if [ "$FAIL" -eq 0 ]; then
@@ -11,8 +11,9 @@
11
11
 
12
12
  import test from "node:test";
13
13
  import assert from "node:assert/strict";
14
- import { parseManifest, manifestWithGate, unknownKeys } from "../lib/manifest.mjs";
14
+ import { parseManifest, manifestWithGate, unknownKeys, entryLifecycle } 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
17
  import { assessBaseline, ITEMS, BASELINE_TOTAL } from "../lib/baseline.mjs";
17
18
  import { CATALOGS, pickLang, L } from "../i18n/index.mjs";
18
19
  import { badgeMarkdown, BADGE_RE, placesToCheck } from "../commands/badge.mjs";
@@ -274,3 +275,86 @@ test("триггер по интерфейсу отделяет фронтенд
274
275
  // Причина сокрытия называется, а не молчит: иначе «не показано» неотличимо от «нечего показать».
275
276
  assert.equal(typeof triggerVerdict(rec, { ...base, has_ui: false }).why, "string");
276
277
  });
278
+
279
+ // --- зрелость записи ---------------------------------------------------------
280
+ // ЗАЧЕМ. У всех трёх соседей поле зрелости есть, и у всех троих его ЗАПОЛНЯЕТ АВТОР: `lifecycle`
281
+ // у зондов Scorecard, `future`/`obsolete` у критериев значка OpenSSF. Поле, которое объявляет
282
+ // автор, означает доверие к автору, а не факт, — ровно то, против чего построен весь стандарт.
283
+ // Поэтому зрелость здесь ВЫЧИСЛЯЕТСЯ из доказательства, а объявить её нельзя.
284
+ test("зрелость записи считается по доказательству, а не по объявлению", () => {
285
+ const proven = entryLifecycle({ proof: "incidents/README.md, 2026-08-27 «печать в проде»" });
286
+ assert.equal(proven.state, "stable");
287
+ assert.equal(proven.problem, null);
288
+
289
+ const claimed = entryLifecycle({ proof: "это общепринятая хорошая практика" });
290
+ assert.equal(claimed.state, "experimental");
291
+ assert.equal(claimed.problem, null);
292
+ // Причина обязательна: «запись условная» без объяснения неотличимо от придирки.
293
+ assert.equal(typeof claimed.why, "string");
294
+ });
295
+
296
+ test("объявить себя зрелым нельзя — это самооценка", () => {
297
+ for (const claim of ["stable", "experimental"]) {
298
+ const r = entryLifecycle({ lifecycle: claim, proof: "incidents/README.md, 2026-01-01" });
299
+ assert.notEqual(r.problem, null);
300
+ // Вердикт всё равно считается сам: объявление не влияет ни на что, кроме отказа.
301
+ assert.equal(r.state, "stable");
302
+ }
303
+ assert.notEqual(entryLifecycle({ lifecycle: "beta", proof: "incidents/x" }).problem, null);
304
+ });
305
+
306
+ // Единственное состояние, которое ОБЪЯВЛЯЕТСЯ: из фактов записи «её больше не ставят» не
307
+ // выводится никак. Цена объявления — обязательная замена: запись, выведенная в никуда,
308
+ // оставляет человека без ответа на вопрос «а что теперь».
309
+ test("выведенная запись обязана назвать замену", () => {
310
+ const noReplacement = entryLifecycle({ lifecycle: "deprecated", proof: "incidents/x" });
311
+ assert.equal(noReplacement.state, "deprecated");
312
+ assert.notEqual(noReplacement.problem, null);
313
+
314
+ const ok = entryLifecycle({ lifecycle: "deprecated", superseded_by: "no-print-in-prod", proof: "incidents/x" });
315
+ assert.equal(ok.state, "deprecated");
316
+ assert.equal(ok.supersededBy, "no-print-in-prod");
317
+ assert.equal(ok.problem, null);
318
+ });
319
+
320
+
321
+ // --- сужение вывода до дифа --------------------------------------------------
322
+ // ЗАЧЕМ. Первый прогон на живом проекте даёт тысячи находок из кода, который писали годами.
323
+ // Человек видит стену красного и выключает инструмент целиком — это причина номер один, по
324
+ // которой такие проверки снимают. Три независимых проекта из нашего разбора умеют показывать
325
+ // только внесённое дифом (reviewdog, ratchets `--since`, четыре режима шума у react-doctor).
326
+ test("сужение по дифу: находка вне диапазона отбрасывается, внутри — остаётся", () => {
327
+ const files = new Set(["src/new.py"]);
328
+ const r = scopeOutput(["src/new.py:3: печать", "src/old.py:9: печать"], files);
329
+ assert.deepEqual(r.kept, ["src/new.py:3: печать"]);
330
+ assert.equal(r.findings, 1);
331
+ });
332
+
333
+ test("сужение по дифу: «./путь» и «путь» — один и тот же файл", () => {
334
+ const r = scopeOutput(["./src/new.py:3: печать", "src\\new.py:4: печать"], new Set(["src/new.py"]));
335
+ assert.equal(r.findings, 2);
336
+ });
337
+
338
+ // Тот же урок, что стоил починки в _native.sh: родные инструменты печатают путь ВНУТРИ
339
+ // escape-последовательности, и сравнение по границе пути его не видит. Тогда «вывод
340
+ // сократился с 5597 до 5505 строк» выглядело как работающая правка.
341
+ test("сужение по дифу: цвет снимается до сравнения путей", () => {
342
+ const esc = String.fromCharCode(27);
343
+ const line = esc + "[32m" + "src/new.py" + esc + "[0m" + ":3: печать";
344
+ assert.equal(scopeOutput([line], new Set(["src/new.py"])).findings, 1);
345
+ });
346
+
347
+ test("сужение по дифу: строка без пути остаётся, но находкой не считается", () => {
348
+ const r = scopeOutput(["Итого: 4 нарушения", "src/old.py:1: печать"], new Set(["src/new.py"]));
349
+ assert.equal(r.findings, 0);
350
+ assert.equal(r.kept.includes("Итого: 4 нарушения"), true);
351
+ });
352
+
353
+ // САМОЕ ВАЖНОЕ ЗДЕСЬ. Гейт, который печатает вердикт без путей (проверка коммита, проверка
354
+ // конфига конвейера), сузить дифом нельзя. Молча признать его успешным — это ровно та тишина,
355
+ // против которой построен весь стандарт, только теперь внутри нашего же флага.
356
+ test("сужение по дифу: гейт без путей в выводе не сужается и остаётся красным", () => {
357
+ const r = scopeOutput(["коммит не несёт раздела «Сделано:»"], new Set(["src/new.py"]));
358
+ assert.equal(r.scopable, false);
359
+ assert.equal(scopeOutput(["src/old.py:1: печать"], new Set(["src/new.py"])).scopable, true);
360
+ });