mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
docs(process): ролевые конспекты, замер входа, Snapshot генерируется, TESTING.md разделён
Вход агента до первого файла кода стоил ≈ 26 700 слов (аудит 22.09). - docs/process/AUTHOR.md и REVIEWER.md — выжимки PROCESS.md: каждый пункт ссылается на раздел канона, ключевые формулировки дословные; test/process-digests.test.mjs сверяет якоря, ссылки и правила. - scripts/entry-cost.mjs — маршрут чтения по роли и бюджет (автор ≤ 12 000 слов, AC1); AGENTS.md «Read this first» называет те же маршруты. - docs/STATUS.md: блок Snapshot генерирует scripts/status-snapshot.mjs (версии — release-contract, счётчики — inventory, теги — git); feature surface и ранние milestones перенесены дословно в docs/STATUS-FEATURES.md. - docs/TESTING.md — действующая инструкция (684 строки, AC3); ручные чек-листы и приложения по issue перенесены дословно в docs/testing-notes/ с индексом и тестом на полноту. - Промпт ревьюера в process.yml читает конспект вместо пересказа правил; машинные требования (строка вердикта, REVIEW_DOC, запрет fetch, таблица «чем краснеет», разделы повторного раунда) сохранены и закреплены тестом. - PROCESS.md: правила не менялись; добавлены ссылка на конспекты в шапке и уточнение в §10.4, что ревьюер конвейера читает конспект. - 7 мутантов в реестре. Issue: #634 User-Visible: no
This commit is contained in:
@@ -70,7 +70,6 @@ export const NOT_AN_INPUT = [
|
||||
['scripts/install-hooks.mjs', 'установка git-хуков при npm ci'],
|
||||
['scripts/golden-accept.mjs', 'приёмка эталонов человеком, после прогона (#344)'],
|
||||
['scripts/golden-container.mjs', 'локальная съёмка в пиновом образе (#334), ручной запуск'],
|
||||
['scripts/inventory.mjs', 'отчёт для аудита, не гейт'],
|
||||
['scripts/benchmark-wall-segment-model.mjs', 'ручной бенчмарк (npm run benchmark:wall-model)'],
|
||||
['scripts/wall-strip-containment.mjs', 'ручной гейт внешних бэкапов планов (docs/WALL-THICKNESS.md)'],
|
||||
['scripts/sh3d-convert/cli.mjs', 'CLI конвертера для человека'],
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
// #634: стоимость входа агента — сколько слов он читает до первого файла кода.
|
||||
//
|
||||
// Аудит 22.09 намерил ≈ 26 700 слов обязательного входа (SCOPE, AGENTS, полный
|
||||
// PROCESS, STATUS) — 43–77 k токенов до первой строки кода. Ролевые конспекты
|
||||
// `docs/process/AUTHOR.md` и `REVIEWER.md` заменяют на входе полный канон, а
|
||||
// этот скрипт держит цену входа измеримой: маршрут по роли — один список здесь,
|
||||
// `AGENTS.md` («Read this first») называет те же файлы в том же порядке, и тест
|
||||
// `test/entry-cost.test.mjs` сверяет оба и краснеет на превышении бюджета.
|
||||
//
|
||||
// node scripts/entry-cost.mjs таблица по всем маршрутам
|
||||
// node scripts/entry-cost.mjs --check exit 1, если маршрут с бюджетом его превысил
|
||||
// node scripts/entry-cost.mjs --json
|
||||
//
|
||||
// Слово — как у `wc -w`: непустой отрезок между пробельными символами. Токены
|
||||
// не считаются: их число зависит от токенизатора, а слова — нет.
|
||||
// Вне замера сознательно: `CODEX-RUNBOOK.md` и `CLAUDE.md` папки владельца —
|
||||
// они не в репозитории, и CI их не видит; тело issue — своё у каждой задачи.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
/** Маршруты входа по роли. `budget: null` — только замер, без порога. */
|
||||
export const ROUTES = Object.freeze({
|
||||
author: {
|
||||
budget: 12000, // AC1 #634
|
||||
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/AUTHOR.md', 'docs/STATUS.md'],
|
||||
},
|
||||
reviewer: {
|
||||
budget: 9000,
|
||||
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/REVIEWER.md'],
|
||||
},
|
||||
// Тот, кто правит конвейер, гейты или сам процесс, читает канон целиком.
|
||||
canon: {
|
||||
budget: null,
|
||||
files: ['docs/SCOPE.md', 'AGENTS.md', 'PROCESS.md', 'docs/STATUS.md'],
|
||||
},
|
||||
});
|
||||
|
||||
export const countWords = (text) => text.split(/\s+/).filter(Boolean).length;
|
||||
|
||||
/** Замер одного маршрута: слова по файлам, сумма и вердикт бюджета. */
|
||||
export function measureRoute(route, read = (rel) => readFileSync(join(ROOT, rel), 'utf8')) {
|
||||
const files = route.files.map((file) => ({ file, words: countWords(read(file)) }));
|
||||
const total = files.reduce((sum, row) => sum + row.words, 0);
|
||||
const over = route.budget != null && total > route.budget;
|
||||
return { files, total, budget: route.budget, over };
|
||||
}
|
||||
|
||||
export function measureAll(routes = ROUTES, read) {
|
||||
return Object.fromEntries(Object.entries(routes).map(([name, route]) => [name, measureRoute(route, read)]));
|
||||
}
|
||||
|
||||
export function renderTable(results) {
|
||||
const lines = [];
|
||||
for (const [name, result] of Object.entries(results)) {
|
||||
const limit = result.budget == null ? 'без бюджета' : `бюджет ${result.budget}`;
|
||||
const mark = result.over ? ' ПРЕВЫШЕН' : '';
|
||||
lines.push(`${name}: ${result.total} слов (${limit})${mark}`);
|
||||
for (const row of result.files) lines.push(` ${String(row.words).padStart(6)} ${row.file}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const results = measureAll();
|
||||
if (process.argv.includes('--json')) console.log(JSON.stringify(results, null, 2));
|
||||
else console.log(renderTable(results));
|
||||
if (process.argv.includes('--check')) {
|
||||
const failed = Object.entries(results).filter(([, result]) => result.over).map(([name]) => name);
|
||||
if (failed.length) {
|
||||
console.error(`entry-cost: превышен бюджет входа: ${failed.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-28
@@ -6,37 +6,50 @@
|
||||
// underestimates the coverage that exists (review R5-2). The counts live here
|
||||
// now, one command away, and STATUS.md describes the layers instead.
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { METRIC_NAMES, collectMetrics, readBaseline } from './monolith-metrics.mjs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
const count = (dir, match, re) =>
|
||||
readdirSync(dir)
|
||||
const count = (root, dir, match, re) =>
|
||||
readdirSync(join(root, dir))
|
||||
.filter((f) => match.test(f))
|
||||
.reduce((n, f) => n + (readFileSync(`${dir}/${f}`, 'utf8').match(re) || []).length, 0);
|
||||
.reduce((n, f) => n + (readFileSync(join(root, dir, f), 'utf8').match(re) || []).length, 0);
|
||||
|
||||
const files = (dir, match) => readdirSync(dir).filter((f) => match.test(f)).length;
|
||||
const files = (root, dir, match) => readdirSync(join(root, dir)).filter((f) => match.test(f)).length;
|
||||
|
||||
// Count both node:test spellings and indented pytest methods. Pure backend
|
||||
// includes validation plus trail helper/recorder tests; only `test_ha_*` needs
|
||||
// the Home Assistant harness (AUD-159B7-03).
|
||||
const rows = [
|
||||
['Node unit (frontend + tooling)', count('test', /\.test\.mjs$/, /^\s*(test|it)\(/gm)],
|
||||
['pure backend (pytest, no HA)', count('tests_backend', /^test_(?!ha_).*\.py$/, /^\s*(?:async\s+)?def test_/gm)],
|
||||
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^\s*(?:async\s+)?def test_/gm)],
|
||||
['browser smokes (headless chromium)', files('demo', /^smoke_.*\.mjs$/)],
|
||||
];
|
||||
const w = Math.max(...rows.map(([n]) => n.length));
|
||||
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
|
||||
|
||||
// #624: связность монолита — те же шесть чисел и тот же модуль, что у гейта
|
||||
// `npm run lint:unused` («одно число — один источник»); рядом — база, чтобы
|
||||
// движение было видно без git blame. bundleBytes есть только после сборки.
|
||||
const { metrics } = collectMetrics(process.cwd());
|
||||
const baseline = readBaseline(process.cwd()) || {};
|
||||
console.log('\nmonolith (scripts/monolith-metrics.mjs; база scripts/monolith-baseline.json)');
|
||||
const mw = Math.max(...METRIC_NAMES.map((n) => n.length));
|
||||
for (const name of METRIC_NAMES) {
|
||||
const now = metrics[name];
|
||||
const base = baseline[name];
|
||||
const delta = now != null && base != null && now !== base ? ` (${now > base ? '+' : ''}${now - base} к базе ${base})` : '';
|
||||
console.log(`${name.padEnd(mw)} ${now ?? 'n/a — npm run build'}${delta}`);
|
||||
/**
|
||||
* Счётчики тестов по слоям. Один источник для этого отчёта и для таблицы
|
||||
* Snapshot в docs/STATUS.md (`scripts/status-snapshot.mjs`, #634).
|
||||
*
|
||||
* Count both node:test spellings and indented pytest methods. Pure backend
|
||||
* includes validation plus trail helper/recorder tests; only `test_ha_*` needs
|
||||
* the Home Assistant harness (AUD-159B7-03).
|
||||
*/
|
||||
export function testInventory(root = process.cwd()) {
|
||||
return [
|
||||
{ id: 'node', label: 'Node unit (frontend + tooling)', count: count(root, 'test', /\.test\.mjs$/, /^\s*(test|it)\(/gm) },
|
||||
{ id: 'backend-pure', label: 'pure backend (pytest, no HA)', count: count(root, 'tests_backend', /^test_(?!ha_).*\.py$/, /^\s*(?:async\s+)?def test_/gm) },
|
||||
{ id: 'backend-ha', label: 'HA-harness backend (CI, py3.13)', count: count(root, 'tests_backend', /^test_ha_.*\.py$/, /^\s*(?:async\s+)?def test_/gm) },
|
||||
{ id: 'smokes', label: 'browser smokes (headless chromium)', count: files(root, 'demo', /^smoke_.*\.mjs$/) },
|
||||
];
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const rows = testInventory().map(({ label, count: n }) => [label, n]);
|
||||
const w = Math.max(...rows.map(([n]) => n.length));
|
||||
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
|
||||
|
||||
// #624: связность монолита — те же шесть чисел и тот же модуль, что у гейта
|
||||
// `npm run lint:unused` («одно число — один источник»); рядом — база, чтобы
|
||||
// движение было видно без git blame. bundleBytes есть только после сборки.
|
||||
const { metrics } = collectMetrics(process.cwd());
|
||||
const baseline = readBaseline(process.cwd()) || {};
|
||||
console.log('\nmonolith (scripts/monolith-metrics.mjs; база scripts/monolith-baseline.json)');
|
||||
const mw = Math.max(...METRIC_NAMES.map((n) => n.length));
|
||||
for (const name of METRIC_NAMES) {
|
||||
const now = metrics[name];
|
||||
const base = baseline[name];
|
||||
const delta = now != null && base != null && now !== base ? ` (${now > base ? '+' : ''}${now - base} к базе ${base})` : '';
|
||||
console.log(`${name.padEnd(mw)} ${now ?? 'n/a — npm run build'}${delta}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// #634: якоря заголовков Markdown так, как их строит GitHub, и ссылки из текста.
|
||||
//
|
||||
// Ролевые конспекты процесса и индекс приложений TESTING.md ссылаются на
|
||||
// разделы по якорю; тесты проверяют, что якорь существует. Правило GitHub:
|
||||
// нижний регистр, выбросить всё, кроме букв, цифр, `_`, пробела и дефиса,
|
||||
// каждый пробел — дефис (без схлопывания: «2.6 В разработке — реализация» →
|
||||
// `26-в-разработке--реализация`), повтор — суффикс `-1`, `-2`.
|
||||
// `scripts/check-docs.mjs` схлопывает дефисы для публичных документов — там
|
||||
// это исторический контракт, здесь нужен настоящий якорь GitHub.
|
||||
|
||||
export const githubSlug = (heading) => heading
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '')
|
||||
.replace(/ /g, '-');
|
||||
|
||||
/** Заголовки документа вне fenced-блоков: [{ level, text, anchor, line }]. */
|
||||
export function headings(text) {
|
||||
const seen = new Map();
|
||||
const out = [];
|
||||
let fenced = false;
|
||||
text.replace(/\r\n?/g, '\n').split('\n').forEach((line, index) => {
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; return; }
|
||||
if (fenced) return;
|
||||
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
||||
if (!match) return;
|
||||
const base = githubSlug(match[2]);
|
||||
const count = seen.get(base) || 0;
|
||||
seen.set(base, count + 1);
|
||||
out.push({ level: match[1].length, text: match[2], anchor: count ? `${base}-${count}` : base, line: index });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Текст раздела по якорю: от заголовка до следующего того же или старшего уровня. */
|
||||
export function sectionText(text, anchor) {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
||||
const all = headings(text);
|
||||
const at = all.findIndex((heading) => heading.anchor === anchor);
|
||||
if (at < 0) return null;
|
||||
const next = all.slice(at + 1).find((heading) => heading.level <= all[at].level);
|
||||
return lines.slice(all[at].line, next ? next.line : lines.length).join('\n');
|
||||
}
|
||||
|
||||
/** Относительные ссылки `[текст](путь#якорь)`; внешние URL пропускаются. */
|
||||
export function markdownLinks(text) {
|
||||
return [...text.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)]
|
||||
.map((match) => match[2])
|
||||
.filter((target) => !/^[a-z]+:/i.test(target))
|
||||
.map((target) => {
|
||||
const hash = target.indexOf('#');
|
||||
return {
|
||||
target,
|
||||
file: hash >= 0 ? target.slice(0, hash) : target,
|
||||
anchor: hash >= 0 ? decodeURIComponent(target.slice(hash + 1)) : '',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -8627,6 +8627,86 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " current = [line]; // mutant: last physical line instead of the paragraph\n }\n for (const paragraph of paragraphs) {",
|
||||
}],
|
||||
},
|
||||
// #634: ролевые конспекты PROCESS.md, цена входа агента, генерируемый
|
||||
// Snapshot и индекс приложений TESTING.md. Каждая защита — от тихого
|
||||
// расхождения выжимки с каноном или тихого роста входа.
|
||||
{
|
||||
id: 'process-digest-dead-anchor',
|
||||
guard: 'node --test --test-name-pattern="#634 конспект: каждая ссылка" test/process-digests.test.mjs',
|
||||
because: 'a digest link to a PROCESS.md heading that does not exist sends the agent nowhere; '
|
||||
+ 'the digest must fail as soon as an anchor goes stale (#634 AC2)',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: '[§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)',
|
||||
replace: '[§5.1](../../PROCESS.md#51-короткий-трек)',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-digest-bullet-without-canon-link',
|
||||
guard: 'node --test --test-name-pattern="#634 конспект: каждый пункт" test/process-digests.test.mjs',
|
||||
because: 'every digest item cites its canon section; an item without a link is a rule the '
|
||||
+ 'digest invented or a paraphrase nobody can check (#634 AC2)',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: ' нечего ([§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)).',
|
||||
replace: ' нечего.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-digest-key-rule-dropped',
|
||||
guard: 'node --test --test-name-pattern="#634 конспект: ключевые правила" test/process-digests.test.mjs',
|
||||
because: 'the empty third column is a Medium finding (PROCESS §2.7, #435); a reviewer digest '
|
||||
+ 'that softens it must fail, not pass as a shorter paraphrase (#634)',
|
||||
patches: [{
|
||||
file: 'docs/process/REVIEWER.md',
|
||||
find: ' результатом прогона. Пустой третий столбец — находка Medium, а не\n примечание.',
|
||||
replace: ' результатом прогона. Пустой третий столбец желательно заполнить.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'entry-cost-author-route-over-budget',
|
||||
guard: 'node --test --test-name-pattern="#634 entry-cost: вход автора" test/entry-cost.test.mjs',
|
||||
because: 'AC1 #634: the author entry route stays within 12 000 words; a digest that grows '
|
||||
+ 'back into the canon must redden the measurement, not the next audit',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: '## Запрещено\n',
|
||||
replace: `## Запрещено\n\n${'слово '.repeat(2000)}\n`,
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'entry-cost-budget-never-over',
|
||||
guard: 'node --test --test-name-pattern="#634 entry-cost: превышение" test/entry-cost.test.mjs',
|
||||
because: 'the budget verdict is the whole point of the measurement; a comparison that never '
|
||||
+ 'reports «over» turns --check into a word counter (#634 AC1)',
|
||||
patches: [{
|
||||
file: 'scripts/entry-cost.mjs',
|
||||
find: ' const over = route.budget != null && total > route.budget;',
|
||||
replace: ' const over = false; // mutant: budget never exceeded',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'status-snapshot-hides-version-mismatch',
|
||||
guard: 'node --test --test-name-pattern="#634 status-snapshot: рассинхрон" test/status-snapshot.test.mjs',
|
||||
because: 'a snapshot that reports the majority version and drops the odd source repeats the '
|
||||
+ 'hand-written row it replaced: it looks current while the tree disagrees (#634)',
|
||||
patches: [{
|
||||
file: 'scripts/status-snapshot.mjs',
|
||||
find: " const version = versions.mismatches.length\n",
|
||||
replace: " const version = false // mutant: mismatches hidden\n",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'testing-notes-index-drops-section',
|
||||
guard: 'node --test --test-name-pattern="#634 индекс приложений" test/testing-notes-index.test.mjs',
|
||||
because: 'AC3 #634: appendices moved out of TESTING.md stay reachable only through the index; '
|
||||
+ 'a section missing from it is text nobody will find again',
|
||||
patches: [{
|
||||
file: 'docs/testing-notes/README.md',
|
||||
find: '- [Open passage (#157)](geometry.md#open-passage-157)\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
// #624: гейт мёртвого кода и связности монолита — три защиты, три мутанта.
|
||||
{
|
||||
id: 'unused-gate-allows-everything',
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
// #634: таблица «Snapshot» в docs/STATUS.md — генерируется, а не пишется руками.
|
||||
//
|
||||
// Раньше строка версии и счётчики в STATUS.md правились вручную и отставали от
|
||||
// дерева за дни (review R5-2 уже убрал оттуда числа тестов по той же причине).
|
||||
// Теперь всё, что вычислимо из дерева и git, считает этот скрипт из тех же
|
||||
// источников, что и гейты: версии — `parseVersionSources` релизного контракта
|
||||
// (`scripts/release-contract.mjs`), счётчики — `testInventory`
|
||||
// (`scripts/inventory.mjs`), теги — `git tag`. Метки issue сюда не входят:
|
||||
// api.github.com из песочницы агента недостижим, а статус живёт в метках.
|
||||
//
|
||||
// node scripts/status-snapshot.mjs напечатать блок
|
||||
// node scripts/status-snapshot.mjs --write заменить блок в docs/STATUS.md
|
||||
// node scripts/status-snapshot.mjs --check exit 1, если блок в файле устарел
|
||||
// [--date=YYYY-MM-DD] [--root=<клон>]
|
||||
//
|
||||
// `--check` не стоит в CI сознательно: на shallow-checkout тегов нет, а
|
||||
// счётчик тестов меняется с каждым новым тестом — гейт краснел бы на каждой
|
||||
// задаче. Блок — снимок на дату в первой строке; свежий — одна команда.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseVersionSources } from './release-contract.mjs';
|
||||
import { testInventory } from './inventory.mjs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
export const BEGIN = '<!-- status-snapshot:begin — generated by `node scripts/status-snapshot.mjs --write`; do not edit by hand -->';
|
||||
export const END = '<!-- status-snapshot:end -->';
|
||||
export const STATUS_FILE = 'docs/STATUS.md';
|
||||
const STABLE_TAG = /^v\d+\.\d+\.\d+$/;
|
||||
const PRERELEASE_TAG = /^v\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/;
|
||||
|
||||
const git = (root, args) => {
|
||||
const run = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
return run.status === 0 ? run.stdout.trim() : null;
|
||||
};
|
||||
|
||||
/** Сводка версий: одно значение во всех источниках либо список расхождений. */
|
||||
export function summarizeVersions(sources) {
|
||||
const entries = Object.entries(sources);
|
||||
const values = [...new Set(entries.map(([, value]) => value))];
|
||||
if (values.length === 1 && values[0]) return { version: values[0], sources: entries.length, mismatches: [] };
|
||||
const counts = new Map();
|
||||
for (const [, value] of entries) counts.set(value, (counts.get(value) || 0) + 1);
|
||||
const majority = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
||||
return {
|
||||
version: majority,
|
||||
sources: entries.length,
|
||||
mismatches: entries.filter(([, value]) => value !== majority).map(([name, value]) => `${name}=${JSON.stringify(value)}`),
|
||||
};
|
||||
}
|
||||
|
||||
/** Последние по дате создания стабильный и пре-релизный теги (порядок `git tag --sort=-creatordate`). */
|
||||
export function latestTags(tagsNewestFirst) {
|
||||
return {
|
||||
stable: tagsNewestFirst.find((tag) => STABLE_TAG.test(tag)) || null,
|
||||
prerelease: tagsNewestFirst.find((tag) => PRERELEASE_TAG.test(tag)) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Файлы, из которых `parseVersionSources` читает версию, — те же, что у релизного контракта. */
|
||||
export const VERSION_FILES = Object.freeze({
|
||||
packageJson: 'package.json',
|
||||
packageLock: 'package-lock.json',
|
||||
manifest: 'custom_components/houseplan/manifest.json',
|
||||
constSource: 'custom_components/houseplan/const.py',
|
||||
cardSource: 'src/houseplan-card.ts',
|
||||
editorRuntimeSource: 'src/houseplan-editor-runtime.ts',
|
||||
});
|
||||
|
||||
/** Собрать входы из дерева и git. Всё, что недоступно, — null, а не догадка. */
|
||||
export function collectSnapshot(root, { date = new Date().toISOString().slice(0, 10) } = {}) {
|
||||
const read = (rel) => readFileSync(join(root, rel), 'utf8');
|
||||
const versions = summarizeVersions(parseVersionSources(Object.fromEntries(
|
||||
Object.entries(VERSION_FILES).map(([key, rel]) => [key, read(rel)]))));
|
||||
const tagList = git(root, ['tag', '--sort=-creatordate']);
|
||||
const tags = latestTags(tagList ? tagList.split('\n').filter(Boolean) : []);
|
||||
// Расстояние «N коммитов после тега» сюда не входит: закоммиченный блок
|
||||
// устаревал бы на собственном коммите, и `--check` не имел бы смысла.
|
||||
return { date, versions, tags, tests: testInventory(root) };
|
||||
}
|
||||
|
||||
const code = (value) => (value ? `\`${value}\`` : 'n/a — tags are not available in this checkout');
|
||||
|
||||
/** Отрисовать блок. Чистая функция: вход — результат collectSnapshot. */
|
||||
export function renderSnapshot({ date, versions, tags, tests }) {
|
||||
const version = versions.mismatches.length
|
||||
? `**${versions.version}** — NOT synchronized: ${versions.mismatches.join(', ')}`
|
||||
: `**${versions.version}** in all ${versions.sources} version sources (\`scripts/release-contract.mjs\`)`;
|
||||
const counts = tests.map((row) => `${row.label.replace(/\s*\(.*\)$/, '')} ${row.count}`).join(' · ');
|
||||
return [
|
||||
BEGIN,
|
||||
'| Item | State |',
|
||||
'|---|---|',
|
||||
`| Generated | ${date} — rerun \`node scripts/status-snapshot.mjs\` for the current tree |`,
|
||||
`| Version | ${version} |`,
|
||||
`| Latest stable tag | ${code(tags.stable)} |`,
|
||||
`| Latest prerelease tag | ${code(tags.prerelease)} |`,
|
||||
`| Tests | ${counts} (\`npm run inventory\`) |`,
|
||||
END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Заменить блок между маркерами; маркеров нет или их больше одной пары — ошибка. */
|
||||
export function replaceBlock(text, block) {
|
||||
const start = text.indexOf(BEGIN);
|
||||
const end = text.indexOf(END);
|
||||
if (start < 0 || end < start || text.indexOf(BEGIN, start + 1) >= 0 || text.indexOf(END, end + 1) >= 0)
|
||||
throw new Error(`${STATUS_FILE}: expected exactly one ${BEGIN} … ${END} block`);
|
||||
return text.slice(0, start) + block + text.slice(end + END.length);
|
||||
}
|
||||
|
||||
/** Дата из существующего блока — чтобы `--check` сравнивал содержание, а не день запуска. */
|
||||
export function blockDate(text) {
|
||||
return /\| Generated \| (\d{4}-\d{2}-\d{2}) /.exec(text)?.[1] || null;
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const root = resolve(arg('root') || fileURLToPath(new URL('..', import.meta.url)));
|
||||
const file = join(root, STATUS_FILE);
|
||||
const current = process.argv.includes('--write') || process.argv.includes('--check') ? readFileSync(file, 'utf8') : '';
|
||||
const date = arg('date') || (process.argv.includes('--check') ? blockDate(current) : undefined);
|
||||
const block = renderSnapshot(collectSnapshot(root, date ? { date } : {}));
|
||||
if (process.argv.includes('--write')) {
|
||||
writeFileSync(file, replaceBlock(current, block));
|
||||
console.log(`${STATUS_FILE}: snapshot block updated`);
|
||||
} else if (process.argv.includes('--check')) {
|
||||
if (replaceBlock(current, block) !== current) {
|
||||
console.error(`${STATUS_FILE}: snapshot block is stale — run node scripts/status-snapshot.mjs --write`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`${STATUS_FILE}: snapshot block is current`);
|
||||
} else {
|
||||
console.log(block);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user