#!/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 = ''; export const 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); } }