mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Validate ran three smoke shards, golden and performance_smoke on every push, check-docs went red on any src/** change until screenshots were re-captured, and a parallel bundle build made every second task branch fail to rebase. None of these gates ever failed at review time; they fail before betas. - `heavy` output in job `changes` (scripts/classify-changes.mjs): smoke, smoke_done, golden, performance_smoke run only for a head commit with a `Release:` trailer, `workflow_dispatch full=true` and pull requests. - nightly.yml dispatches Validate on dev with full=true every night. - check-docs `--screenshots=warn|strict`: freshness of the screenshot index warns on a plain push, errors on the candidate; everything else still errors. - publish-prerelease.yml and release.yml refuse a candidate without the `Release:` trailer and (prerelease) require fresh screenshots — a green Validate without the heavy jobs cannot pass for a release. - scripts/rebase-on-dev.mjs: rebase on origin/dev taking dev's copy of the committed bundle, rebuild with bundle:sync, amend; any other conflict aborts. - npm run gate:small: mandatory PROCESS §8 part in one parallel run. Issue: #479 User-Visible: no
261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
import { createHash } from 'node:crypto';
|
||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||
import { dirname, extname, resolve, sep } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { DOC_SCREENSHOTS } from '../demo/docs/screenshots.mjs';
|
||
import { visualFingerprint } from './source-fingerprint.mjs';
|
||
import { freshnessSink, screenshotsMode } from './docs-freshness.mjs';
|
||
|
||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const EXTERNAL = process.argv.includes('--external');
|
||
const PUBLIC_DOCS = [
|
||
'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md',
|
||
'docs/TOUCH-SUPPORT.md', 'docs/DECOR-EDITOR.md', 'docs/VACUUM.md',
|
||
];
|
||
// Каталог один и живёт рядом с капчуром (#246): третья копия списка сценариев
|
||
// расходилась бы с ним молча.
|
||
const EXPECTED_SCREENSHOTS = DOC_SCREENSHOTS.map((scenario) => scenario.id);
|
||
const errors = [];
|
||
const warnings = [];
|
||
// Свежесть скриншотов: `--screenshots=warn|strict`, см. docs-freshness.mjs (#479).
|
||
const freshness = freshnessSink(screenshotsMode(process.argv), { errors, warnings });
|
||
const externalUrls = new Set();
|
||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||
const canonicalText = (path) => readFileSync(path, 'utf8').replace(/\r\n?/g, '\n');
|
||
|
||
const withoutFences = (text) => text.replace(/^```[^\n]*\n[\s\S]*?^```\s*$/gm, '');
|
||
const yamlFences = (text) => [...text.matchAll(/^```yaml[^\S\r\n]*\n([\s\S]*?)^```[^\S\r\n]*$/gm)]
|
||
.map((match) => match[1].split('\n').map((line) => line.replace(/[\t ]+$/, '')).join('\n').trim());
|
||
const slug = (heading) => heading
|
||
.trim().toLowerCase()
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
|
||
.replace(/\s+/g, '-')
|
||
.replace(/-+/g, '-');
|
||
|
||
const headingsFor = (path) => {
|
||
const seen = new Map();
|
||
const headings = new Set();
|
||
for (const line of canonicalText(path).split('\n')) {
|
||
const match = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
|
||
if (!match) continue;
|
||
const base = slug(match[1]);
|
||
const count = seen.get(base) || 0;
|
||
seen.set(base, count + 1);
|
||
headings.add(count ? `${base}-${count}` : base);
|
||
}
|
||
return headings;
|
||
};
|
||
|
||
const rootPrefix = `${ROOT}${sep}`.toLowerCase();
|
||
for (const relative of PUBLIC_DOCS) {
|
||
const path = resolve(ROOT, relative);
|
||
if (!existsSync(path)) {
|
||
errors.push(`${relative}: required public document is missing`);
|
||
continue;
|
||
}
|
||
const text = withoutFences(canonicalText(path));
|
||
const links = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
|
||
for (const match of text.matchAll(links)) {
|
||
const image = match[1] === '!';
|
||
const alt = match[2].trim();
|
||
const raw = match[3].trim().replace(/\s+"[^"]*"$/, '');
|
||
if (image && !alt) errors.push(`${relative}: image has empty alt text (${raw})`);
|
||
if (/^(?:https?:)?\/\//i.test(raw)) {
|
||
try {
|
||
const url = new URL(raw.startsWith('//') ? `https:${raw}` : raw);
|
||
if (url.protocol !== 'https:') errors.push(`${relative}: external link must use https (${raw})`);
|
||
externalUrls.add(url.href);
|
||
} catch {
|
||
errors.push(`${relative}: invalid external URL (${raw})`);
|
||
}
|
||
continue;
|
||
}
|
||
if (/^(?:mailto:|#|\/api\/|\/houseplan_files\/)/.test(raw)) {
|
||
if (raw.startsWith('#')) {
|
||
const anchor = decodeURIComponent(raw.slice(1));
|
||
if (anchor && !headingsFor(path).has(anchor))
|
||
errors.push(`${relative}: missing local heading #${anchor}`);
|
||
}
|
||
continue;
|
||
}
|
||
const hashAt = raw.indexOf('#');
|
||
const filePart = decodeURIComponent(hashAt >= 0 ? raw.slice(0, hashAt) : raw);
|
||
const anchor = hashAt >= 0 ? decodeURIComponent(raw.slice(hashAt + 1)) : '';
|
||
const target = resolve(dirname(path), filePart || '.');
|
||
const targetLower = target.toLowerCase();
|
||
if (targetLower !== ROOT.toLowerCase() && !targetLower.startsWith(rootPrefix)) {
|
||
errors.push(`${relative}: link escapes repository (${raw})`);
|
||
continue;
|
||
}
|
||
if (!existsSync(target)) {
|
||
errors.push(`${relative}: missing relative target (${raw})`);
|
||
continue;
|
||
}
|
||
if (image && !statSync(target).isFile()) errors.push(`${relative}: image target is not a file (${raw})`);
|
||
if (anchor && extname(target).toLowerCase() === '.md' && !headingsFor(target).has(anchor))
|
||
errors.push(`${relative}: missing heading ${filePart}#${anchor}`);
|
||
}
|
||
}
|
||
|
||
// #462: a flat top-level `resources:` block looks plausible but is not a valid
|
||
// configuration.yaml fragment. Keep all four public install paths on the same
|
||
// versioned Storage/current-YAML/legacy-YAML contract.
|
||
const RESOURCE_INSTALL_DOCS = [
|
||
'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md',
|
||
];
|
||
const RESOURCE_URL = '/houseplan_files/houseplan-card.js';
|
||
const RESOURCE_SNIPPETS = [
|
||
[
|
||
'lovelace:',
|
||
' resource_mode: yaml',
|
||
' resources:',
|
||
` - url: ${RESOURCE_URL}`,
|
||
' type: module',
|
||
].join('\n'),
|
||
[
|
||
'lovelace:',
|
||
' mode: yaml',
|
||
' resources:',
|
||
` - url: ${RESOURCE_URL}`,
|
||
' type: module',
|
||
].join('\n'),
|
||
];
|
||
|
||
for (const relative of RESOURCE_INSTALL_DOCS) {
|
||
const text = canonicalText(resolve(ROOT, relative));
|
||
const blocks = yamlFences(text).filter((block) => block.includes(RESOURCE_URL));
|
||
if (blocks.length !== RESOURCE_SNIPPETS.length) {
|
||
errors.push(`${relative}: expected exactly two mode-specific House Plan resource snippets`);
|
||
}
|
||
if (blocks.some((block) => /^resources:\s*$/m.test(block))) {
|
||
errors.push(`${relative}: House Plan resources must be nested under lovelace:`);
|
||
}
|
||
for (const expected of RESOURCE_SNIPPETS) {
|
||
const count = blocks.filter((block) => block === expected).length;
|
||
if (count !== 1) {
|
||
const mode = expected.includes('resource_mode') ? 'HA 2026.2+ resource_mode' : 'legacy mode';
|
||
errors.push(`${relative}: expected one exact ${mode} House Plan snippet, found ${count}`);
|
||
}
|
||
}
|
||
if (blocks.length === RESOURCE_SNIPPETS.length
|
||
&& blocks.some((block, index) => block !== RESOURCE_SNIPPETS[index])) {
|
||
errors.push(`${relative}: current resource_mode snippet must precede the legacy mode snippet`);
|
||
}
|
||
|
||
const russian = relative.endsWith('.ru.md');
|
||
const prose = text.replace(/\s+/g, ' ');
|
||
const proseContracts = russian ? [
|
||
'#### Режим Storage (по умолчанию в Home Assistant)',
|
||
'#### YAML-ресурсы в Home Assistant 2026.2+',
|
||
'#### Home Assistant 2024.6–2026.1: устаревший режим',
|
||
'полностью управляется через YAML',
|
||
'Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить ресурс',
|
||
'Не переключайте storage-панель в устаревший YAML только ради House Plan',
|
||
] : [
|
||
'#### Storage mode (Home Assistant default)',
|
||
'#### YAML resources mode (Home Assistant 2026.2+)',
|
||
'#### Legacy Home Assistant 2024.6–2026.1',
|
||
'full-YAML dashboard',
|
||
'Settings → Dashboards → menu ⋮ → Resources → Add resource',
|
||
'Do not switch a storage dashboard to legacy YAML just for House Plan',
|
||
];
|
||
for (const fragment of proseContracts) {
|
||
if (!prose.includes(fragment)) errors.push(`${relative}: missing resource guidance “${fragment}”`);
|
||
}
|
||
for (const shortcut of ['Ctrl+F5', 'Cmd+Shift+R']) {
|
||
if (!text.includes(shortcut)) errors.push(`${relative}: missing hard reload shortcut ${shortcut}`);
|
||
}
|
||
}
|
||
|
||
const sectionMarkers = (relative) => [...canonicalText(resolve(ROOT, relative))
|
||
.matchAll(/<!--\s*docs-section:\s*([a-z0-9-]+)\s*-->/g)].map((match) => match[1]);
|
||
for (const [en, ru, required] of [
|
||
['README.md', 'README.ru.md', ['overview', 'features', 'first-run', 'installation', 'support']],
|
||
['docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md', ['model', 'installation', 'first-run', 'modes', 'input', 'spaces', 'plan-tools', 'devices', 'visual-states', 'background', 'multiple-cards', 'limits', 'diagnostics']],
|
||
]) {
|
||
const enMarkers = sectionMarkers(en);
|
||
const ruMarkers = sectionMarkers(ru);
|
||
if (JSON.stringify(enMarkers) !== JSON.stringify(ruMarkers))
|
||
errors.push(`${en} / ${ru}: docs-section markers differ or are ordered differently`);
|
||
for (const marker of required) {
|
||
if (!enMarkers.includes(marker)) errors.push(`${en} / ${ru}: missing required section marker ${marker}`);
|
||
}
|
||
}
|
||
|
||
const staleTerms = [
|
||
[/\bMarkup (?:tab|mode|editor)\b/gi, 'Plan'],
|
||
[/(?:вкладка|режим|редактор) «Разметка»/gi, '«План»'],
|
||
[/\bDecor editor\b/gi, 'Background editor'],
|
||
];
|
||
for (const relative of PUBLIC_DOCS.slice(0, 4)) {
|
||
const text = withoutFences(canonicalText(resolve(ROOT, relative)));
|
||
for (const [pattern, replacement] of staleTerms) {
|
||
const matches = text.match(pattern) || [];
|
||
if (matches.length) errors.push(`${relative}: stale term “${matches[0]}”; use ${replacement}`);
|
||
}
|
||
}
|
||
|
||
const manifestPath = resolve(ROOT, 'docs/images/screenshots.json');
|
||
if (!existsSync(manifestPath)) {
|
||
errors.push('docs/images/screenshots.json: missing screenshot index');
|
||
} else {
|
||
const manifest = JSON.parse(canonicalText(manifestPath));
|
||
if (manifest.fixture !== 'synthetic-only') errors.push('screenshot manifest must declare synthetic-only fixture');
|
||
// Версионно-нечувствительный отпечаток (#245): бамп версии не меняет ни одного
|
||
// пикселя, поэтому не обязан требовать пересъёмки — иначе каждый релизный
|
||
// коммит оставляет этот гейт красным.
|
||
if (manifest.sourceFingerprint !== visualFingerprint(ROOT))
|
||
freshness.push('screenshot source fingerprint is stale; run npm run docs:capture and accept before the beta candidate (#479)');
|
||
const scriptPath = resolve(ROOT, 'demo/docs/capture.mjs');
|
||
if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath)))
|
||
freshness.push('screenshot capture script changed; run npm run docs:capture and accept before the beta candidate (#479)');
|
||
const ids = Object.keys(manifest.scenarios || {});
|
||
if (JSON.stringify(ids.sort()) !== JSON.stringify([...EXPECTED_SCREENSHOTS].sort()))
|
||
errors.push('screenshot manifest scenario set is incomplete');
|
||
for (const [id, scenario] of Object.entries(manifest.scenarios || {})) {
|
||
const imagePath = resolve(ROOT, 'docs/images', scenario.file || '');
|
||
if (!existsSync(imagePath)) errors.push(`screenshot ${id}: missing ${scenario.file}`);
|
||
else if (scenario.imageSha256 !== sha256(readFileSync(imagePath)))
|
||
errors.push(`screenshot ${id}: image hash does not match manifest`);
|
||
if (scenario.sourceSha256 !== manifest.sourceFingerprint)
|
||
errors.push(`screenshot ${id}: source SHA does not match manifest`);
|
||
if (!scenario.viewport?.width || !scenario.viewport?.height || !scenario.theme || !scenario.language)
|
||
errors.push(`screenshot ${id}: capture metadata is incomplete`);
|
||
}
|
||
}
|
||
|
||
if (EXTERNAL) {
|
||
const allowlist = JSON.parse(canonicalText(resolve(ROOT, 'docs/external-link-allowlist.json')));
|
||
const transientHosts = new Set(allowlist.transientHosts || []);
|
||
for (const href of [...externalUrls].sort()) {
|
||
const url = new URL(href);
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(8000),
|
||
headers: { 'user-agent': 'houseplan-docs-check/1' },
|
||
});
|
||
if (response.status >= 200 && response.status < 400) continue;
|
||
if ([403, 408, 425, 429].includes(response.status) || response.status >= 500) {
|
||
if (transientHosts.has(url.hostname)) {
|
||
warnings.push(`transient external response ${response.status}: ${href}`);
|
||
continue;
|
||
}
|
||
}
|
||
errors.push(`external link returned ${response.status}: ${href}`);
|
||
} catch (error) {
|
||
if (transientHosts.has(url.hostname)) warnings.push(`transient external failure: ${href} (${error.message})`);
|
||
else errors.push(`external link failed: ${href} (${error.message})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const warning of warnings) console.warn(`WARN ${warning}`);
|
||
if (errors.length) {
|
||
for (const error of errors) console.error(`ERROR ${error}`);
|
||
process.exitCode = 1;
|
||
} else {
|
||
console.log(`Documentation checks passed (${PUBLIC_DOCS.length} files, ${externalUrls.size} external links).`);
|
||
}
|