mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
ci: one input manifest for job selection and reuse keys
scripts/check-inputs.mjs declares every Validate check with its roots and entry points and computes the rest: imports and path literals of the entries, transitively for code, as leaves for data. classify-changes and gate-reuse both read it, so "which job runs" and "what its key hashes" cannot disagree any more. An executable file no check knows widens the run to the full set and is named in the summary; the coverage list makes such a file a red unit test rather than a permanent widening. The workflow file is a toolchain input of every job; backend no longer hashes src/**. Issue: #492 User-Visible: no
This commit is contained in:
@@ -215,13 +215,13 @@ jobs:
|
||||
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
|
||||
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
|
||||
mutants: ${{ steps.classify.outputs.mutants }}
|
||||
unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }}
|
||||
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
|
||||
heavy: ${{ steps.heavy.outputs.heavy }}
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
|
||||
# судить сам диапазон на dev (#388). Общее имя означало бы, что
|
||||
# потребитель однажды молча возьмёт чужую базу.
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого судить
|
||||
# диапазон на dev (#388): общее имя — чужая база у потребителя.
|
||||
range_base: ${{ steps.base.outputs.range_base }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
@@ -320,13 +320,28 @@ jobs:
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
|
||||
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
|
||||
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
# Решение — в scripts/classify-changes.mjs по единому manifest входов
|
||||
# (#492 §5.2): там оно проверяемо unit-тестом, здесь — только
|
||||
# переписывается в выходы (#473 AC8).
|
||||
printf '%s\n' "$files" | node scripts/classify-changes.mjs > /tmp/classify.out
|
||||
tee -a "$GITHUB_OUTPUT" < /tmp/classify.out
|
||||
# Файл, которого нет в manifest ни одной проверки, — не «не влияет»,
|
||||
# а «неизвестно»: прогон расширен до полного набора, и это видно в
|
||||
# summary, а не только в логе.
|
||||
unknown=$(sed -n 's/^unknown_inputs=//p' /tmp/classify.out)
|
||||
if [ -n "$unknown" ]; then
|
||||
{
|
||||
echo "### Неизвестные входы (#492)"
|
||||
echo "Файлы, которых нет в manifest ни одной проверки — прогон расширен до полного набора:"
|
||||
for f in $unknown; do echo "- \`$f\`"; done
|
||||
echo "Впишите файл в \`CHECKS\` или \`NOT_AN_INPUT\` (scripts/check-inputs.mjs) с причиной."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = хеш содержимого
|
||||
# всех входов job по единому manifest (#492, scripts/check-inputs.mjs):
|
||||
# что собирает, что исполняет, что читает, чем запускается — включая
|
||||
# `validate.yml`. Маркер в кэше пишет только успешный
|
||||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||||
# же входами уже завершилась успешно.
|
||||
#
|
||||
|
||||
Executable
+390
@@ -0,0 +1,390 @@
|
||||
// Единый manifest входов проверок Validate (#492 §5).
|
||||
//
|
||||
// Одна проверка (job) — один ответ на вопрос «от каких файлов зависит её
|
||||
// результат». Раньше ответов было три и они расходились: регэкспы job
|
||||
// `changes` (что запускать), `HARNESS` в gate-reuse (что хешировать) и молчание
|
||||
// там, где файл не попал ни туда, ни туда — relay, converter, schema,
|
||||
// `serve.mjs`, `demo.html`. Здесь ответ один и по возможности ВЫЧИСЛЯЕТСЯ,
|
||||
// а не перечисляется: у проверки есть точки входа (тесты, скрипты job), и
|
||||
// всё, что они импортируют или читают по пути, — её вход. Явные корни
|
||||
// остаются для того, что читается не из кода: бандл собирается из `src/**`,
|
||||
// pytest обходит `tests_backend/**`, unittest — `scripts/support-relay/tests`.
|
||||
//
|
||||
// Из manifest выводятся обе прежние вещи: классификация (`classify-changes`:
|
||||
// job запускается, если дифф задел хотя бы один её вход) и ключ реюза
|
||||
// (`gate-reuse`: хеш содержимого всех входов). Неизвестный исполняемый вход —
|
||||
// файл, который ни одна проверка не считает своим, — расширяет прогон до
|
||||
// полного набора (§5.2): «не знаю» не равно «не влияет».
|
||||
//
|
||||
// Лист покрытия (§5.5, test/check-inputs.test.mjs): каждый отслеживаемый
|
||||
// исполняемый файл обязан входить в manifest хотя бы одной проверки либо в
|
||||
// NOT_AN_INPUT с причиной. Новый скрипт без записи — красный тест, не тихое
|
||||
// расширение прогонов навсегда.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join, posix, relative, resolve } from 'node:path';
|
||||
|
||||
/** Корни, внутри которых файл считается исполняемым входом (§5.2). */
|
||||
export const EXECUTABLE_ROOTS = ['scripts', 'demo', 'test', 'tests_backend', '.github', 'custom_components', 'src'];
|
||||
export const EXECUTABLE_EXT = /\.(mjs|cjs|js|ts|py|json|ya?ml|html|toml|txt|sh)$/;
|
||||
|
||||
/** Копии бандла и результаты сборки: класс D, входом не являются. */
|
||||
export const BUILD_OUTPUT = [
|
||||
'dist/**', 'custom_components/houseplan/frontend/**', 'demo/srv/assets/**',
|
||||
];
|
||||
|
||||
/**
|
||||
* Не входы Validate — с причиной. Каждая запись отвечает на вопрос «кто это
|
||||
* исполняет и почему не Validate».
|
||||
*/
|
||||
export const NOT_AN_INPUT = [
|
||||
['demo/stand/**', 'seed демо-стенда: ставится на стенде, Validate не исполняет'],
|
||||
['demo/shot_*.mjs', 'ручные съёмки для документации и разбора, не гейты'],
|
||||
['demo/capture_*.mjs', 'ручные съёмки эталонов, запускаются человеком'],
|
||||
['demo/screencast_visual_continuity.mjs', 'ручной скринкаст'],
|
||||
['demo/gen_icons.mjs', 'генератор иконок демо-страницы, запускается вручную; результат в demo/srv/assets (класс D)'],
|
||||
['demo/downgrade_open_passage.mjs', 'ручной инструмент миграции фикстур'],
|
||||
['scripts/dev/**', 'локальные утилиты разработчика'],
|
||||
['scripts/install-hooks.mjs', 'установка git-хуков при npm ci'],
|
||||
['scripts/pre-push-gate.mjs', 'локальный pre-push набор (HP_PREPUSH_GATE), в 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 конвертера для человека'],
|
||||
['scripts/sh3d-convert/make-fixtures.mjs', 'генератор фикстур конвертера, ручной'],
|
||||
['scripts/support-relay/deploy/**', 'деплой relay на стенд'],
|
||||
['.github/workflows/*.yml', 'другие workflow: у каждого свой запуск; validate.yml — вход toolchain всех проверок, объявлен явно'],
|
||||
['.github/ISSUE_TEMPLATE/**', 'шаблоны issue GitHub, не исполняются'],
|
||||
['.githooks/**', 'локальные хуки'],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// glob → regexp: `**` — любой путь, `*` — сегмент без «/», остальное буквально.
|
||||
export const globToRegExp = (glob) => {
|
||||
let re = '';
|
||||
for (let i = 0; i < glob.length; i += 1) {
|
||||
const ch = glob[i];
|
||||
if (ch === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 1;
|
||||
if (glob[i + 1] === '/') i += 1;
|
||||
} else re += '[^/]*';
|
||||
} else if ('.+?^${}()|[]\\'.includes(ch)) re += `\\${ch}`;
|
||||
else re += ch;
|
||||
}
|
||||
return new RegExp(`^${re}$`);
|
||||
};
|
||||
const matchesAny = (file, globs) => globs.some((glob) => globToRegExp(glob).test(file));
|
||||
|
||||
/** Отслеживаемые файлы (git), либо обход дерева там, где git недоступен. */
|
||||
export function trackedFiles(root) {
|
||||
try {
|
||||
const out = execFileSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' });
|
||||
const files = out.split('\0').filter(Boolean);
|
||||
if (files.length) return files.sort();
|
||||
} catch { /* не git — обходим дерево */ }
|
||||
const walk = (dir) => readdirSync(dir).sort().flatMap((name) => {
|
||||
if (name === 'node_modules' || name === '.git') return [];
|
||||
const path = join(dir, name);
|
||||
return statSync(path).isDirectory() ? walk(path) : [relative(root, path).replaceAll('\\', '/')];
|
||||
});
|
||||
return walk(root);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ссылки из файла: импорты и строковые пути. Чтение, не исполнение.
|
||||
|
||||
const TOP = '(?:scripts|demo|docs|src|custom_components|tests_backend|test|\\.github)';
|
||||
const PATH_LITERAL = new RegExp(`['"\`](${TOP}/[\\w./@-]+)['"\`]`, 'g');
|
||||
const ROOT_FILE_LITERAL = /['"`](package\.json|package-lock\.json|hacs\.json|PROCESS\.md|README\.md|README\.ru\.md|pyproject\.toml|pytest\.ini|rollup\.config\.mjs|tsconfig[\w.]*\.json)['"`]/g;
|
||||
const JS_IMPORT = /(?:^|[^\w$])(?:import|export)\s*(?:[^'"`;]*?\s+from\s*)?['"](\.\.?\/[^'"]+)['"]/g;
|
||||
const JS_DYNAMIC = /import\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g;
|
||||
const JS_REQUIRE = /require\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g;
|
||||
const PY_FROM = /^\s*from\s+([\w.]+)\s+import/gm;
|
||||
const PY_IMPORT = /^\s*import\s+([\w.]+)/gm;
|
||||
const PY_PATH_JOIN = /((?:"[\w.-]+"\s*\/\s*)+"[\w.-]+")/g;
|
||||
|
||||
const toPosix = (p) => p.replaceAll('\\', '/');
|
||||
const BINARY = /\.(png|jpe?g|gif|webp|svg|ico|woff2?|ttf|pdf|zip)$/i;
|
||||
|
||||
/** `test-build/foo.js` — скомпилированный `src/foo.ts` (tsconfig.test.json). */
|
||||
const mapTestBuild = (rel) => {
|
||||
const m = /^test-build\/(.+)\.js$/.exec(rel);
|
||||
return m ? `src/${m[1]}.ts` : rel;
|
||||
};
|
||||
|
||||
function resolveJsSpecifier(fromFile, spec) {
|
||||
const base = toPosix(posix.normalize(posix.join(posix.dirname(fromFile), spec)));
|
||||
// с расширением — ровно этот файл; без — обычные кандидаты Node/TS
|
||||
const candidates = /\.(mjs|cjs|js|ts|json)$/.test(base)
|
||||
? [base] : [`${base}.mjs`, `${base}.js`, `${base}.ts`, `${base}/index.mjs`, `${base}/index.js`];
|
||||
return candidates.map(mapTestBuild);
|
||||
}
|
||||
|
||||
function pyModuleCandidates(fromFile, mod) {
|
||||
const parts = mod.split('.');
|
||||
const out = [];
|
||||
const asPath = parts.join('/');
|
||||
out.push(`${asPath}.py`, `${asPath}/__init__.py`);
|
||||
// относительно каталога файла (support-relay: `from hp_relay import …`)
|
||||
const dir = posix.dirname(fromFile);
|
||||
out.push(`${dir}/${asPath}.py`, `${dir}/${asPath}/__init__.py`);
|
||||
// пакет тестов relay лежит на уровень выше своих тестов
|
||||
out.push(`${posix.dirname(dir)}/${asPath}.py`, `${posix.dirname(dir)}/${asPath}/__init__.py`);
|
||||
return out;
|
||||
}
|
||||
|
||||
const EXEC_LITERAL = new RegExp(`(?:node|python3?|tsx)\\s+((?:scripts|demo|tests_backend)/[\\w./-]+\\.(?:mjs|py))`, 'g');
|
||||
|
||||
/**
|
||||
* Ссылки одного файла: `code` — импорты и запускаемые процессом файлы (по ним
|
||||
* идёт обход), `data` — строковые пути к файлам и каталогам (листья).
|
||||
*/
|
||||
/** Комментарии и docstring'и — не ссылки: путь в пояснении не делает файл входом. */
|
||||
export function stripComments(file, text) {
|
||||
if (/\.(mjs|cjs|js|ts)$/.test(file)) {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
|
||||
}
|
||||
if (file.endsWith('.py')) {
|
||||
return text.replace(/\"\"\"[\s\S]*?\"\"\"/g, '').replace(/'''[\s\S]*?'''/g, '').replace(/(^|\s)#[^\n]*/g, '$1');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function referencesOf(file, rawText) {
|
||||
const text = stripComments(file, rawText);
|
||||
const code = new Set();
|
||||
const data = new Set();
|
||||
const norm = (p) => toPosix(posix.normalize(p));
|
||||
if (/\.(mjs|cjs|js|ts)$/.test(file)) {
|
||||
for (const re of [JS_IMPORT, JS_DYNAMIC, JS_REQUIRE]) {
|
||||
for (const m of text.matchAll(re)) resolveJsSpecifier(file, m[1]).forEach((p) => code.add(norm(p)));
|
||||
}
|
||||
}
|
||||
if (file.endsWith('.py')) {
|
||||
for (const m of text.matchAll(PY_FROM)) pyModuleCandidates(file, m[1]).forEach((p) => code.add(norm(p)));
|
||||
for (const m of text.matchAll(PY_IMPORT)) pyModuleCandidates(file, m[1]).forEach((p) => code.add(norm(p)));
|
||||
for (const m of text.matchAll(PY_PATH_JOIN)) {
|
||||
data.add(norm(m[1].split('/').map((s) => s.trim().replace(/^"|"$/g, '')).join('/')));
|
||||
}
|
||||
}
|
||||
// Файл под исполняемым корнем — код только там, где он запускается
|
||||
// процессом (строка со spawn/exec/sh(/run(), а не в подсказке человеку.
|
||||
for (const line of text.split('\n')) {
|
||||
if (!/\b(spawn|spawnSync|exec|execSync|execFileSync|sh|run)\s*\(/.test(line)) continue;
|
||||
for (const m of line.matchAll(EXEC_LITERAL)) code.add(norm(m[1]));
|
||||
for (const m of line.matchAll(PATH_LITERAL)) if (/\.(mjs|py)$/.test(m[1])) code.add(norm(m[1]));
|
||||
}
|
||||
for (const m of text.matchAll(PATH_LITERAL)) data.add(norm(m[1].replace(/[.:,;]+$/, '')));
|
||||
for (const m of text.matchAll(ROOT_FILE_LITERAL)) data.add(m[1]);
|
||||
for (const c of code) data.delete(c);
|
||||
return { code: [...code], data: [...data] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Транзитивное замыкание от точек входа по ссылкам внутри репозитория.
|
||||
*
|
||||
* Правила обхода (§6.2): импорты кода идут транзитивно; строковый путь —
|
||||
* ДАННЫЕ, лист замыкания: по нему дальше не идут, даже если это .mjs, кроме
|
||||
* случая, когда файл под исполняемым корнем запускается как процесс
|
||||
* (`node demo/benchmark_*.mjs`, `python scripts/x.py`) — тогда он код.
|
||||
* Ссылка на каталог раскрывается во все отслеживаемые файлы под ним как в
|
||||
* данные. `parents` хранит, откуда файл пришёл, — для объяснения «почему это
|
||||
* вход» (`--why`).
|
||||
*/
|
||||
export function closure(root, entries, { tracked = trackedFiles(root), stopAt = () => false, read, parents } = {}) {
|
||||
const trackedSet = new Set(tracked);
|
||||
const readText = read || ((rel) => {
|
||||
const abs = join(root, rel);
|
||||
if (!existsSync(abs) || statSync(abs).isDirectory()) return '';
|
||||
return readFileSync(abs, 'utf8');
|
||||
});
|
||||
const isDir = (rel) => tracked.some((f) => f.startsWith(`${rel}/`));
|
||||
const seen = new Set();
|
||||
const note = (child, parent) => { if (parents && !parents.has(child)) parents.set(child, parent); };
|
||||
const queue = [...entries].filter((e) => trackedSet.has(e));
|
||||
for (const e of queue) note(e, null);
|
||||
while (queue.length) {
|
||||
const file = queue.shift();
|
||||
if (seen.has(file)) continue;
|
||||
seen.add(file);
|
||||
if (stopAt(file)) continue;
|
||||
if (!/\.(mjs|cjs|js|ts|py)$/.test(file)) continue;
|
||||
let text;
|
||||
try { text = readText(file); } catch { continue; }
|
||||
const { code, data } = referencesOf(file, text);
|
||||
for (const ref of code) {
|
||||
if (trackedSet.has(ref)) { note(ref, file); if (!seen.has(ref)) queue.push(ref); }
|
||||
}
|
||||
for (const ref of data) {
|
||||
if (trackedSet.has(ref)) { note(ref, file); seen.add(ref); continue; }
|
||||
// каталог по строке — данные; бинарные файлы под ним код по строке не
|
||||
// читает (эталоны golden входят в свою проверку явным корнем)
|
||||
if (isDir(ref)) for (const f of tracked) if (f.startsWith(`${ref}/`) && !BINARY.test(f)) { note(f, file); seen.add(f); }
|
||||
}
|
||||
}
|
||||
return [...seen].sort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Проверки Validate и их входы.
|
||||
|
||||
/** Что собирает бандл: те же входы, что у sourceFingerprint (#245). */
|
||||
const BUILD_INPUTS = ['src/**', 'package.json', 'package-lock.json', 'rollup.config.mjs', 'tsconfig.json',
|
||||
'scripts/source-fingerprint.mjs', 'scripts/css-template-minifier.mjs', 'scripts/bundle-manifest.mjs',
|
||||
'scripts/bundle-sync.mjs', 'scripts/bundle-tree.mjs'];
|
||||
/** Протокол браузерного харнеса: страница, сервер, гард исключений, compat-хелперы. */
|
||||
const BROWSER_PROTOCOL = ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
|
||||
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/**'];
|
||||
const WORKFLOW = ['.github/workflows/validate.yml'];
|
||||
/** Протокол реюза: кто считает ключ, тот и вход (§5.1 protocol). */
|
||||
const REUSE_PROTOCOL = ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'];
|
||||
|
||||
export const CHECKS = {
|
||||
preflight: {
|
||||
// документация, провенанс, процесс — всегда запускается; реюза нет
|
||||
entries: ['scripts/check-docs.mjs', 'scripts/process-gate.mjs', 'scripts/validate-commit-provenance.mjs',
|
||||
'scripts/classify-base.mjs', 'scripts/classify-changes.mjs', 'scripts/check-inputs.mjs',
|
||||
'scripts/docs-freshness.mjs', 'scripts/review-doc-guard.mjs'],
|
||||
roots: ['docs/**', 'README.md', 'README.ru.md', 'PROCESS.md', 'AGENTS.md', 'CONTRIBUTING.md', ...WORKFLOW],
|
||||
},
|
||||
frontend: {
|
||||
// npm run typecheck, npm test, npm run build, bundle:budget
|
||||
entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/bundle-budget.mjs', 'scripts/fix-test-build.mjs'],
|
||||
roots: [...BUILD_INPUTS, 'test/**', 'tsconfig*.json', ...WORKFLOW],
|
||||
},
|
||||
changed_mutants: {
|
||||
entries: ['scripts/mutation-gate.mjs', 'scripts/*-guard.mjs', 'test/*.test.mjs', 'demo/smoke_*.mjs', 'tests_backend/**/*.py'],
|
||||
roots: [...BUILD_INPUTS, 'custom_components/**/*.py', ...BROWSER_PROTOCOL, ...WORKFLOW],
|
||||
},
|
||||
integration: {
|
||||
entries: [],
|
||||
roots: ['custom_components/houseplan/manifest.json', 'hacs.json', 'custom_components/**/*.py',
|
||||
'custom_components/**/translations/**', 'custom_components/**/strings.json', ...WORKFLOW],
|
||||
},
|
||||
smoke: {
|
||||
entries: ['demo/smoke_*.mjs', 'demo/guard/verify-guard.mjs', 'scripts/smoke-select.mjs', 'demo/benchmark_*.mjs'],
|
||||
roots: [...BUILD_INPUTS, ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],
|
||||
reuse: true,
|
||||
},
|
||||
golden: {
|
||||
entries: ['demo/golden/run.mjs'],
|
||||
roots: [...BUILD_INPUTS, 'demo/golden/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],
|
||||
reuse: true,
|
||||
},
|
||||
performance_smoke: {
|
||||
entries: ['demo/benchmark_glow.mjs', 'demo/benchmark_large_house.mjs', 'demo/benchmark_junction_limits.mjs',
|
||||
'demo/benchmark_wall_draw_click.mjs', 'demo/performance/compare.mjs'],
|
||||
roots: [...BUILD_INPUTS, 'demo/performance/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],
|
||||
reuse: true,
|
||||
},
|
||||
backend: {
|
||||
// pytest tests_backend, unittest scripts/support-relay/tests, ruff/mypy, порог покрытия
|
||||
entries: ['tests_backend/**/*.py', 'scripts/support-relay/tests/**/*.py', 'scripts/dump-config-schema.py'],
|
||||
// manifest.json несёт версию: кандидат релиза обязан прогнать backend заново
|
||||
roots: ['custom_components/**/*.py', 'custom_components/houseplan/manifest.json', 'scripts/support-relay/**/*.py',
|
||||
'tests_backend/**', 'pyproject.toml', 'pytest.ini', 'scripts/backend-coverage-baseline.txt',
|
||||
'scripts/config-schema.json', ...REUSE_PROTOCOL, ...WORKFLOW],
|
||||
reuse: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const CHECK_NAMES = Object.keys(CHECKS);
|
||||
export const REUSE_JOBS = CHECK_NAMES.filter((name) => CHECKS[name].reuse);
|
||||
|
||||
const expandGlobs = (globs, tracked) => tracked.filter((f) => matchesAny(f, globs));
|
||||
const isBuildOutput = (f) => matchesAny(f, BUILD_OUTPUT);
|
||||
|
||||
/** Развёрнутый список входов проверки: корни ∪ замыкание точек входа. */
|
||||
export function inputsOf(check, root = process.cwd(), { tracked = trackedFiles(root), read } = {}) {
|
||||
const spec = CHECKS[check];
|
||||
if (!spec) throw new Error(`неизвестная проверка: ${check}. Известны: ${CHECK_NAMES.join(', ')}`);
|
||||
const roots = expandGlobs(spec.roots, tracked);
|
||||
const entries = expandGlobs(spec.entries, tracked);
|
||||
const reached = closure(root, entries, { tracked, read, stopAt: isBuildOutput });
|
||||
return [...new Set([...roots, ...reached])].filter((f) => !isBuildOutput(f)).sort();
|
||||
}
|
||||
|
||||
/** Все проверки со входами — один обход на прогон. */
|
||||
export function manifest(root = process.cwd(), options = {}) {
|
||||
const tracked = options.tracked || trackedFiles(root);
|
||||
const out = {};
|
||||
for (const name of CHECK_NAMES) out[name] = new Set(inputsOf(name, root, { ...options, tracked }));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Исполняемый вход по критерию §5.2 — то, что обязано быть чьим-то. */
|
||||
export const isExecutableInput = (file) => EXECUTABLE_ROOTS.some((r) => file === r || file.startsWith(`${r}/`))
|
||||
&& EXECUTABLE_EXT.test(file) && !isBuildOutput(file);
|
||||
|
||||
export const isDeclaredNotAnInput = (file) => NOT_AN_INPUT.some(([glob]) => globToRegExp(glob).test(file));
|
||||
|
||||
/**
|
||||
* Проверки, задетые списком файлов, и неизвестные входы среди них.
|
||||
* Неизвестный исполняемый вход расширяет до всех проверок (§5.2).
|
||||
*/
|
||||
export function checksAffectedBy(files, root = process.cwd(), options = {}) {
|
||||
const man = options.manifest || manifest(root, options);
|
||||
const affected = new Set();
|
||||
const unknown = [];
|
||||
for (const file of files) {
|
||||
let known = false;
|
||||
for (const name of CHECK_NAMES) if (man[name].has(file)) { affected.add(name); known = true; }
|
||||
if (!known && isExecutableInput(file) && !isDeclaredNotAnInput(file)) unknown.push(file);
|
||||
}
|
||||
if (unknown.length) for (const name of CHECK_NAMES) affected.add(name);
|
||||
return { affected, unknown };
|
||||
}
|
||||
|
||||
/** Лист покрытия (§5.5): исполняемые файлы, которые никто не считает своими. */
|
||||
export function coverage(root = process.cwd(), options = {}) {
|
||||
const tracked = options.tracked || trackedFiles(root);
|
||||
const man = options.manifest || manifest(root, { ...options, tracked });
|
||||
const covered = new Set();
|
||||
for (const name of CHECK_NAMES) for (const f of man[name]) covered.add(f);
|
||||
const unknown = tracked.filter((f) => isExecutableInput(f) && !covered.has(f) && !isDeclaredNotAnInput(f));
|
||||
// запись NOT_AN_INPUT лишняя, если ВСЕ её файлы и так чьи-то входы
|
||||
const declaredButCovered = NOT_AN_INPUT.map(([glob]) => glob).filter((glob) => {
|
||||
const hits = tracked.filter((f) => globToRegExp(glob).test(f));
|
||||
return hits.length > 0 && hits.every((f) => covered.has(f));
|
||||
});
|
||||
return { covered, unknown, declaredButCovered };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI: `node scripts/check-inputs.mjs --check=backend` печатает входы;
|
||||
// `--coverage` — лист покрытия; `--affected` читает список файлов из stdin.
|
||||
const invokedDirectly = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
||||
if (invokedDirectly) {
|
||||
const argv = process.argv.slice(2);
|
||||
const root = process.cwd();
|
||||
const checkArg = argv.find((a) => a.startsWith('--check='))?.slice(8);
|
||||
const whyArg = argv.find((a) => a.startsWith('--why='))?.slice(6);
|
||||
if (checkArg && whyArg) {
|
||||
const parents = new Map();
|
||||
const tracked = trackedFiles(root);
|
||||
const spec = CHECKS[checkArg];
|
||||
closure(root, expandGlobs(spec.entries, tracked), { tracked, stopAt: isBuildOutput, parents });
|
||||
let cur = whyArg;
|
||||
if (!parents.has(cur)) { console.log(matchesAny(cur, spec.roots) ? `${cur}: корень manifest` : `${cur}: не вход ${checkArg}`); process.exit(0); }
|
||||
while (cur) { console.log(cur); cur = parents.get(cur); }
|
||||
} else if (checkArg) {
|
||||
for (const f of inputsOf(checkArg, root)) console.log(f);
|
||||
} else if (argv.includes('--coverage')) {
|
||||
const { unknown, declaredButCovered } = coverage(root);
|
||||
for (const f of unknown) console.log(`неизвестный вход: ${f}`);
|
||||
for (const g of declaredButCovered) console.log(`NOT_AN_INPUT лишний (уже покрыт): ${g}`);
|
||||
process.exit(unknown.length ? 1 : 0);
|
||||
} else if (argv.includes('--affected')) {
|
||||
const files = readFileSync(0, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const { affected, unknown } = checksAffectedBy(files, root);
|
||||
console.log(JSON.stringify({ affected: [...affected].sort(), unknown }, null, 2));
|
||||
} else {
|
||||
console.error('usage: check-inputs.mjs --check=<name> | --coverage | --affected < files');
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// Классификация изменённых файлов для job `changes` в validate.yml (#473 AC8).
|
||||
//
|
||||
// Шаблоны жили inline в shell-шаге `classify` как `has('regex')`. Пока выходов
|
||||
// было три, это терпимо; с диффозависимыми профилями перф-смока (#473) их
|
||||
// пять, и вопрос «запустит ли правка src/iso-x.ts изометрический профиль»
|
||||
// стал вопросом к workflow, на который нельзя ответить тестом — shell в YAML
|
||||
// не исполняется локально. Теперь ответ даёт функция, а shell только
|
||||
// переписывает её вывод в `$GITHUB_OUTPUT`.
|
||||
// Шаблоны жили inline в shell-шаге `classify` как `has('regex')`, потом —
|
||||
// регэкспами здесь (#473). С #492 выбор job идёт из единого manifest входов
|
||||
// (scripts/check-inputs.mjs): того же, из которого считается ключ реюза.
|
||||
// Регэкспы остались только у профилей перф-смока — это выбор набора внутри
|
||||
// job, не самой job. Shell по-прежнему только переписывает вывод функции в
|
||||
// `$GITHUB_OUTPUT`.
|
||||
//
|
||||
// Контракт неизменен: каждый выход — строка 'true'/'false', как её и читают
|
||||
// условия `if: needs.changes.outputs.X == 'true'`. Fallback «полный прогон
|
||||
@@ -16,36 +16,63 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const CLASSIFIERS = {
|
||||
frontend: /^(src\/|demo\/|test\/|dist\/|custom_components\/houseplan\/frontend\/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)/,
|
||||
backend: /^(custom_components\/.*\.py$|tests_backend\/|scripts\/support-relay\/|pytest\.ini$)/,
|
||||
integration: /^(custom_components\/houseplan\/manifest\.json$|hacs\.json$|custom_components\/.*\.py$|custom_components\/.*\/translations\/)/,
|
||||
// Перф-смок (#473 §5): изометрический профиль — при правке изометрии,
|
||||
// профиль взаимодействия — при правке живого пути и оркестраторов кадра.
|
||||
// Только `src/**`: тесты и демо кадр не замедляют.
|
||||
perf_iso: /^src\/iso-[^/]+\.ts$/,
|
||||
perf_interaction: /^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/,
|
||||
// Реестр мутантов сам по себе — вход гейта по диффу (#475 ревью r1): новый
|
||||
// свидетель без правки в src/test иначе не проверялся бы до понедельника.
|
||||
mutants: /^scripts\/mutation-gate\.mjs$/,
|
||||
import { checksAffectedBy } from './check-inputs.mjs';
|
||||
|
||||
/**
|
||||
* Выходы job `changes` → проверка manifest (#492 §5.2). Job запускается, если
|
||||
* дифф задел хотя бы один её вход по `scripts/check-inputs.mjs`; прежние
|
||||
* регэкспы по путям заменены тем же источником, из которого считается ключ
|
||||
* реюза, — два места больше не расходятся.
|
||||
*/
|
||||
export const CHECK_OF_OUTPUT = {
|
||||
frontend: 'frontend',
|
||||
backend: 'backend',
|
||||
integration: 'integration',
|
||||
mutants: 'changed_mutants',
|
||||
};
|
||||
|
||||
export const OUTPUTS = Object.keys(CLASSIFIERS);
|
||||
/**
|
||||
* Профили перф-смока (#473 §5): изометрический — при правке изометрии,
|
||||
* профиль взаимодействия — при правке живого пути и оркестраторов кадра.
|
||||
* Только `src/**`: тесты и демо кадр не замедляют. Это НЕ выбор job, а выбор
|
||||
* набора внутри неё, поэтому остаётся фильтром по путям.
|
||||
*/
|
||||
export const PERF_PROFILES = {
|
||||
perf_iso: /^src\/iso-[^/]+\.ts$/,
|
||||
perf_interaction: /^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/,
|
||||
};
|
||||
|
||||
/** Список файлов → выходы job `changes` ('true'/'false' по каждому ключу). */
|
||||
export function classifyChanges(files) {
|
||||
/** Совместимость с прежним экспортом: имя выхода → предикат по файлу. */
|
||||
export const CLASSIFIERS = {
|
||||
...Object.fromEntries(Object.keys(CHECK_OF_OUTPUT).map((name) => [name, null])),
|
||||
...PERF_PROFILES,
|
||||
};
|
||||
|
||||
export const OUTPUTS = [...Object.keys(CHECK_OF_OUTPUT), ...Object.keys(PERF_PROFILES)];
|
||||
|
||||
/**
|
||||
* Список файлов → выходы job `changes` ('true'/'false' по каждому ключу) плюс
|
||||
* `unknown` — неизвестные исполняемые входы, из-за которых прогон расширен
|
||||
* до полного набора (§5.2).
|
||||
*/
|
||||
export function classifyChanges(files, { root = process.cwd(), manifest } = {}) {
|
||||
const list = (Array.isArray(files) ? files : String(files).split('\n'))
|
||||
.map((file) => file.trim()).filter(Boolean);
|
||||
const { affected, unknown } = checksAffectedBy(list, root, manifest ? { manifest } : {});
|
||||
const result = {};
|
||||
for (const [name, pattern] of Object.entries(CLASSIFIERS)) {
|
||||
result[name] = list.some((file) => pattern.test(file)) ? 'true' : 'false';
|
||||
for (const [name, check] of Object.entries(CHECK_OF_OUTPUT)) {
|
||||
result[name] = affected.has(check) ? 'true' : 'false';
|
||||
}
|
||||
for (const [name, pattern] of Object.entries(PERF_PROFILES)) {
|
||||
result[name] = unknown.length || list.some((file) => pattern.test(file)) ? 'true' : 'false';
|
||||
}
|
||||
result.unknown = unknown;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Fallback без классификации: всё прогоняется. */
|
||||
export function classifyAll() {
|
||||
return Object.fromEntries(OUTPUTS.map((name) => [name, 'true']));
|
||||
return { ...Object.fromEntries(OUTPUTS.map((name) => [name, 'true'])), unknown: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,9 +98,11 @@ export function hasReleaseTrailer(message) {
|
||||
return /^Release:\s*v?\d+\.\d+\.\d+\S*\s*$/m.test(String(message || ''));
|
||||
}
|
||||
|
||||
/** Формат `$GITHUB_OUTPUT`. */
|
||||
/** Формат `$GITHUB_OUTPUT`; неизвестные входы — отдельной строкой через пробел. */
|
||||
export function formatOutputs(outputs) {
|
||||
return OUTPUTS.map((name) => `${name}=${outputs[name]}`).join('\n') + '\n';
|
||||
const lines = OUTPUTS.map((name) => `${name}=${outputs[name]}`);
|
||||
lines.push(`unknown_inputs=${(outputs.unknown || []).join(' ')}`);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
|
||||
+26
-86
@@ -5,10 +5,11 @@
|
||||
// скрипты, где бандл и оснастка побайтово те же. Ключ здесь отвечает на один
|
||||
// вопрос: «менялось ли хоть что-то, от чего результат этой job зависит».
|
||||
//
|
||||
// Ключ = sourceFingerprint (входы поведения: src/**, demo/fixtures,
|
||||
// demo/golden/*.mjs, package.json, lock, rollup, tsconfig) ПЛЮС хеш собственной
|
||||
// оснастки job. Совпал ключ с прогоном, который завершился успешно, — повторять
|
||||
// нечего; не совпал — гоняем.
|
||||
// Ключ = хеш содержимого ВСЕХ входов job по единому manifest (#492,
|
||||
// scripts/check-inputs.mjs): исходники, которые job собирает, тесты и их
|
||||
// обёртки, фикстуры, конфиги инструментов, toolchain и протокол харнеса.
|
||||
// Совпал ключ с прогоном, который завершился успешно, — повторять нечего;
|
||||
// не совпал — гоняем.
|
||||
//
|
||||
// Почему это не фильтры путей из job `changes` (на dev они намеренно
|
||||
// отключены): там объём прогона угадывается по путям, и «зелёный» начинает
|
||||
@@ -16,111 +17,50 @@
|
||||
// пишет только успешный прогон с тем же ключом.
|
||||
//
|
||||
// Свойство, которое стоит знать: релизный кандидат (бета или стабильный релиз)
|
||||
// бампает версию, а `CARD_VERSION` и `package.json` входят в sourceFingerprint.
|
||||
// бампает версию — `package.json`/`CARD_VERSION` у браузерных job и
|
||||
// `custom_components/houseplan/manifest.json` у backend входят в manifest.
|
||||
// Значит ключ кандидата заведомо новый и полный набор гейтов прогоняется всегда.
|
||||
// Переиспользование физически не может ослабить релизный гейт.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { appendFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { sourceFingerprint } from './source-fingerprint.mjs';
|
||||
import { REUSE_JOBS, inputsOf } from './check-inputs.mjs';
|
||||
|
||||
/**
|
||||
* Оснастка каждой job: файлы, от которых её результат зависит помимо входов
|
||||
* поведения. `scripts/**` целиком сюда не берётся намеренно — он меняется
|
||||
* часто и почти всегда не в той части, которую job исполняет; берутся только
|
||||
* фактически исполняемые файлы (см. package.json).
|
||||
* Входы каждой job — из единого manifest (#492 §5.3). Прежний `HARNESS`
|
||||
* перечислял оснастку руками и молчал о том, чего не знал: relay, converter,
|
||||
* schema у backend; `serve.mjs`, `demo.html`, compat-хелперы у golden/perf.
|
||||
* Теперь список ВЫЧИСЛЯЕТСЯ: корни проверки плюс замыкание её точек входа по
|
||||
* импортам и путям (см. check-inputs.mjs). `src/**` входит только туда, где
|
||||
* бандл собирается и исполняется, — backend от UI больше не зависит.
|
||||
*/
|
||||
export const HARNESS = {
|
||||
smoke: {
|
||||
// Всё, что job «Смоки в браузере» ИСПОЛНЯЕТ, а не только сами смоки (#430).
|
||||
//
|
||||
// До этой задачи ключ держал ровно `demo/smoke_*.mjs`. Из него выпадали:
|
||||
// `demo/serve.mjs` — сама оснастка, включая гард исключений; `demo/guard/**`
|
||||
// — отрицательные пробы этого гарда и запускающий их `verify-guard.mjs`;
|
||||
// benchmark, который одна из проб запускает с `--guard-probe`.
|
||||
//
|
||||
// Стоило это ровно того, чего и должно было. Прогон #2371 (ee678352)
|
||||
// добавлял в `verify-guard.mjs` пробу гарда benchmark — и job со смоками
|
||||
// была ПРОПУЩЕНА как переиспользованная: правка файла, который исполняется
|
||||
// только в ней, её ключ не меняла. Проба уехала в `dev`, ни разу не
|
||||
// запустившись. Пропущенная проверка выглядит точно как пройденная — и это
|
||||
// тот же дефект, против которого заведён весь #430.
|
||||
//
|
||||
// Benchmark'и берутся все, а не по имени: любой из них может быть подключён
|
||||
// к пробам позже, а платить за лишний прогон дешевле, чем за молчание.
|
||||
// `demo/fixtures/**` в ключ не входит — он в корпусе `sourceFingerprint`,
|
||||
// который уже подмешан в ключ строкой выше.
|
||||
roots: ['demo'],
|
||||
keep: (rel) => /^demo\/smoke_[^/]+\.mjs$/.test(rel)
|
||||
|| rel === 'demo/serve.mjs'
|
||||
|| /^demo\/guard\//.test(rel)
|
||||
|| /^demo\/benchmark_[^/]+\.mjs$/.test(rel),
|
||||
},
|
||||
golden: {
|
||||
// demo/golden/** целиком: и сценарии, и эталоны — эталон тоже вход
|
||||
// сравнения, его подмена обязана менять ключ.
|
||||
roots: ['demo/golden'],
|
||||
keep: () => true,
|
||||
},
|
||||
performance_smoke: {
|
||||
roots: ['demo'],
|
||||
keep: (rel) => /^demo\/performance\//.test(rel)
|
||||
|| /^demo\/benchmark_(glow|large_house)\.mjs$/.test(rel),
|
||||
},
|
||||
backend: {
|
||||
// #42: порог покрытия и конфиг линтеров — прямые входы job
|
||||
// (`head -1 baseline` в шаге сравнения; ruff/mypy читают pyproject): их
|
||||
// изменение без правок тестов обязано сбрасывать реюз, иначе baseline-bump
|
||||
// молча пройдёт по старому зелёному маркеру. Пины зависимостей
|
||||
// (tests_backend/requirements.txt, #392) покрыты корнем tests_backend.
|
||||
roots: ['tests_backend', 'custom_components', 'pytest.ini',
|
||||
'scripts/backend-coverage-baseline.txt', 'pyproject.toml'],
|
||||
// Внутри custom_components/** значим только Python: собранный фронтенд
|
||||
// лежит там же и меняется от любой сборки, а backend его не исполняет.
|
||||
keep: (rel) => !rel.startsWith('custom_components/') || rel.endsWith('.py'),
|
||||
},
|
||||
};
|
||||
export const JOBS = REUSE_JOBS;
|
||||
|
||||
export const JOBS = Object.keys(HARNESS);
|
||||
|
||||
/** Все файлы под путём (файл — сам путь), относительными путями через «/». */
|
||||
const walk = (root, entry) => {
|
||||
const abs = resolve(root, entry);
|
||||
if (!existsSync(abs)) return [];
|
||||
if (!statSync(abs).isDirectory()) return [relative(root, abs).replaceAll('\\', '/')];
|
||||
return readdirSync(abs).sort().flatMap((name) =>
|
||||
walk(root, relative(root, resolve(abs, name)).replaceAll('\\', '/')));
|
||||
};
|
||||
|
||||
/** Файлы оснастки job в порядке, не зависящем от файловой системы. */
|
||||
/** Файлы, от которых зависит результат job, в порядке, не зависящем от ФС. */
|
||||
export function harnessFiles(root, job) {
|
||||
const spec = HARNESS[job];
|
||||
if (!spec) throw new Error(`неизвестная job: ${job}. Известны: ${JOBS.join(', ')}`);
|
||||
const seen = new Set();
|
||||
for (const entry of spec.roots) {
|
||||
for (const rel of walk(root, entry)) if (spec.keep(rel)) seen.add(rel);
|
||||
}
|
||||
return [...seen].sort((a, b) => a.localeCompare(b));
|
||||
if (!JOBS.includes(job)) throw new Error(`неизвестная job: ${job}. Известны: ${JOBS.join(', ')}`);
|
||||
return inputsOf(job, root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ переиспользования. Пустая оснастка не молчит: она означала бы, что job
|
||||
* зависит только от входов поведения, и такую подмену лучше заметить.
|
||||
* Ключ переиспользования: имя job + содержимое всех её входов. Пустой список
|
||||
* не молчит: он означал бы job без входов, и такую подмену лучше заметить.
|
||||
*/
|
||||
export function reuseKey(root, job) {
|
||||
const files = harnessFiles(root, job);
|
||||
if (!files.length) throw new Error(`оснастка job ${job} пуста — проверьте HARNESS`);
|
||||
if (!files.length) throw new Error(`входы job ${job} пусты — проверьте CHECKS в check-inputs.mjs`);
|
||||
const hash = createHash('sha256');
|
||||
hash.update(`job:${job}\0`);
|
||||
hash.update(`source:${sourceFingerprint(root)}\0`);
|
||||
for (const rel of files) {
|
||||
const abs = resolve(root, rel);
|
||||
if (!existsSync(abs)) continue;
|
||||
hash.update(rel);
|
||||
hash.update('\0');
|
||||
// Текст канонизируется по переводам строк, бинарное берётся как есть:
|
||||
// иначе Windows и Linux дали бы разные ключи на одном дереве.
|
||||
const raw = readFileSync(resolve(root, rel));
|
||||
const raw = readFileSync(abs);
|
||||
const text = raw.includes(0) ? raw : Buffer.from(raw.toString('utf8').replace(/\r\n?/g, '\n'));
|
||||
hash.update(text);
|
||||
hash.update('\0');
|
||||
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
CHECKS, CHECK_NAMES, NOT_AN_INPUT, REUSE_JOBS, checksAffectedBy, closure, coverage, globToRegExp,
|
||||
inputsOf, isDeclaredNotAnInput, isExecutableInput, manifest, referencesOf, stripComments,
|
||||
} from '../scripts/check-inputs.mjs';
|
||||
|
||||
// Единый manifest входов (#492 §5). Две группы доказательств: чистая механика
|
||||
// (glob, ссылки, замыкание) на виртуальном дереве и представительные входы
|
||||
// §8.1 на РЕАЛЬНОМ репозитории — для каждой тяжёлой job и каждой категории
|
||||
// названный файл обязан быть её входом, а UI — не входом backend (AC6).
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const MANIFEST = manifest(ROOT);
|
||||
// Пути, которых в manifest быть не должно, собираются из кусков — литерал в
|
||||
// этом файле сделал бы их входом frontend (тесты читают то, что называют).
|
||||
const p = (...parts) => parts.join('/');
|
||||
|
||||
test('glob: ** — любой путь, * — сегмент, точка буквальна', () => {
|
||||
assert.ok(globToRegExp('src/**').test('src/a/b.ts'));
|
||||
assert.ok(globToRegExp('src/**').test('src/a.ts'));
|
||||
assert.ok(!globToRegExp('src/**').test('srcx/a.ts'));
|
||||
assert.ok(globToRegExp('demo/smoke_*.mjs').test('demo/smoke_alpha.mjs'));
|
||||
assert.ok(!globToRegExp('demo/smoke_*.mjs').test('demo/guard/smoke_alpha.mjs'));
|
||||
assert.ok(globToRegExp('custom_components/**/*.py').test('custom_components/houseplan/store.py'));
|
||||
assert.ok(!globToRegExp('custom_components/**/*.py').test('custom_components/houseplan/frontend/x.js'));
|
||||
assert.ok(!globToRegExp('package.json').test('packageXjson'));
|
||||
});
|
||||
|
||||
test('ссылки: импорты JS — код, строковые пути — данные, комментарии — ничего', () => {
|
||||
const text = `
|
||||
import a from './a.mjs';
|
||||
import { b } from '../lib/b.mjs';
|
||||
export * from './c.mjs';
|
||||
const d = await import('./d.mjs');
|
||||
const e = require('./e.mjs');
|
||||
// import x from './comment.mjs';
|
||||
/* readFileSync('demo/fixtures/comment.json') */
|
||||
const f = readFileSync('demo/fixtures/f.json');
|
||||
const g = 'docs/g.md';
|
||||
const h = spawnSync('node', ['demo/benchmark_h.mjs', '--guard-probe']);
|
||||
const hint = 'run node demo/benchmark_hint.mjs by hand';
|
||||
`;
|
||||
const refs = referencesOf('test/x.test.mjs', text);
|
||||
assert.deepEqual(refs.code.sort(), [
|
||||
'demo/benchmark_h.mjs', 'lib/b.mjs', 'test/a.mjs', 'test/c.mjs', 'test/d.mjs', 'test/e.mjs',
|
||||
]);
|
||||
// путь внутри фразы-подсказки — не ссылка: строка обязана быть путём целиком
|
||||
assert.deepEqual(refs.data.sort(), ['demo/fixtures/f.json', 'docs/g.md']);
|
||||
assert.ok(!stripComments('x.mjs', text).includes('comment.mjs'));
|
||||
});
|
||||
|
||||
test('ссылки: test-build/*.js — это src/*.ts, компилируемый tsconfig.test.json', () => {
|
||||
const refs = referencesOf('test/x.test.mjs', "import { f } from '../test-build/space-geometry.js';\n");
|
||||
assert.deepEqual(refs.code, ['src/space-geometry.ts']);
|
||||
});
|
||||
|
||||
test('ссылки: Python — пакеты репозитория, относительные модули relay, Path-цепочки', () => {
|
||||
const text = `
|
||||
from custom_components.houseplan.validation import CONFIG_SCHEMA
|
||||
import tests_backend.pure_imports
|
||||
from hp_relay.app import main
|
||||
""" from custom_components.houseplan.ghost import x """
|
||||
GOLDEN = REPO / "scripts" / "sh3d-convert" / "golden"
|
||||
schema = (REPO / "scripts" / "config-schema.json").read_text()
|
||||
`;
|
||||
const refs = referencesOf('scripts/support-relay/tests/test_relay.py', text);
|
||||
assert.ok(refs.code.includes('custom_components/houseplan/validation.py'));
|
||||
assert.ok(refs.code.includes('tests_backend/pure_imports.py'));
|
||||
assert.ok(refs.code.includes('scripts/support-relay/hp_relay/app.py'));
|
||||
assert.ok(!refs.code.some((f) => f.includes('ghost')), 'docstring — не импорт');
|
||||
assert.ok(refs.data.includes('scripts/sh3d-convert/golden'));
|
||||
assert.ok(refs.data.includes('scripts/config-schema.json'));
|
||||
});
|
||||
|
||||
test('замыкание: код транзитивно, данные — листья, каталог — все текстовые файлы под ним', () => {
|
||||
const files = {
|
||||
'demo/smoke_a.mjs': "import './serve.mjs';\nconst x = 'demo/fixtures';\n",
|
||||
'demo/serve.mjs': "import './compat.mjs';\n",
|
||||
'demo/compat.mjs': "import '../scripts/helper.mjs';\n",
|
||||
'scripts/helper.mjs': "export const h = 1; // import './never.mjs'\n",
|
||||
'scripts/never.mjs': '',
|
||||
'demo/fixtures/one.mjs': "import '../deep.mjs';\n",
|
||||
'demo/fixtures/two.json': '{}',
|
||||
'demo/fixtures/pic.png': 'binary',
|
||||
'demo/deep.mjs': '',
|
||||
};
|
||||
const tracked = Object.keys(files).sort();
|
||||
const parents = new Map();
|
||||
const reached = closure('/virtual', ['demo/smoke_a.mjs'], { tracked, read: (f) => files[f], parents });
|
||||
assert.deepEqual(reached, [
|
||||
'demo/compat.mjs', 'demo/fixtures/one.mjs', 'demo/fixtures/two.json', 'demo/serve.mjs',
|
||||
'demo/smoke_a.mjs', 'scripts/helper.mjs',
|
||||
]);
|
||||
// фикстура — данные: её собственный импорт (deep.mjs) не читается,
|
||||
// картинка под каталогом не берётся, комментарий не ссылка
|
||||
assert.ok(!reached.includes('demo/deep.mjs'));
|
||||
assert.ok(!reached.includes('demo/fixtures/pic.png'));
|
||||
assert.ok(!reached.includes('scripts/never.mjs'));
|
||||
assert.equal(parents.get('scripts/helper.mjs'), 'demo/compat.mjs');
|
||||
});
|
||||
|
||||
test('замыкание останавливается на копиях бандла (класс D)', () => {
|
||||
const files = {
|
||||
'demo/smoke_a.mjs': "import '../custom_components/houseplan/frontend/houseplan-card.js';\n",
|
||||
'custom_components/houseplan/frontend/houseplan-card.js': "import './houseplan-assets/x.js';\n",
|
||||
'custom_components/houseplan/frontend/houseplan-assets/x.js': '',
|
||||
};
|
||||
const tracked = Object.keys(files).sort();
|
||||
const spec = { entries: ['demo/smoke_a.mjs'], roots: [] };
|
||||
const saved = CHECKS.__virtual;
|
||||
CHECKS.__virtual = spec;
|
||||
try {
|
||||
const inputs = inputsOf('__virtual', '/virtual', { tracked, read: (f) => files[f] });
|
||||
assert.deepEqual(inputs, ['demo/smoke_a.mjs']);
|
||||
} finally {
|
||||
if (saved) CHECKS.__virtual = saved; else delete CHECKS.__virtual;
|
||||
}
|
||||
});
|
||||
|
||||
test('каждая проверка объявлена, у тяжёлых job включён реюз, у остальных нет', () => {
|
||||
assert.deepEqual(REUSE_JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']);
|
||||
for (const name of CHECK_NAMES) {
|
||||
assert.ok(MANIFEST[name].size > 0, `${name}: пустой manifest`);
|
||||
assert.ok(Array.isArray(CHECKS[name].entries) && Array.isArray(CHECKS[name].roots), name);
|
||||
}
|
||||
assert.throws(() => inputsOf('nope', ROOT), /неизвестная проверка/);
|
||||
});
|
||||
|
||||
test('§8.1 представители: каждая категория каждой тяжёлой job — её вход', () => {
|
||||
const expect = {
|
||||
backend: {
|
||||
source: ['custom_components/houseplan/websocket_api.py', 'scripts/support-relay/relay.py',
|
||||
'scripts/support-relay/hp_relay/app.py', 'scripts/sh3d-convert/convert.mjs', 'scripts/dump-config-schema.py'],
|
||||
tests: ['tests_backend/test_ha_websocket.py', 'tests_backend/conftest.py', 'scripts/support-relay/tests/test_relay.py'],
|
||||
fixtures: ['scripts/sh3d-convert/golden/two-levels.space-1.json', 'scripts/config-schema.json',
|
||||
'test/fixtures/real-plan-first-floor.json'],
|
||||
config: ['pyproject.toml', 'pytest.ini', 'scripts/backend-coverage-baseline.txt'],
|
||||
toolchain: ['tests_backend/requirements.txt', 'custom_components/houseplan/manifest.json', '.github/workflows/validate.yml'],
|
||||
protocol: ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'],
|
||||
},
|
||||
smoke: {
|
||||
source: ['src/houseplan-card.ts', 'src/logic.ts'],
|
||||
tests: ['demo/smoke_infinite_canvas.mjs', 'demo/guard/verify-guard.mjs', 'demo/benchmark_glow.mjs'],
|
||||
fixtures: ['demo/fixtures/large-house.mjs', 'demo/fixtures/wall-draw-click.mjs'],
|
||||
config: ['rollup.config.mjs', 'tsconfig.json'],
|
||||
toolchain: ['package.json', 'package-lock.json', '.github/workflows/validate.yml'],
|
||||
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
|
||||
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'scripts/smoke-select.mjs'],
|
||||
},
|
||||
golden: {
|
||||
source: ['src/houseplan-card.ts'],
|
||||
tests: ['demo/golden/run.mjs', 'demo/golden/matrix.mjs', 'demo/golden/harness.mjs'],
|
||||
fixtures: ['demo/golden/baselines/geometry-view-dark-fit.png', 'demo/golden/baselines/baselines-index.json',
|
||||
'demo/fixtures/visual-matrix.mjs'],
|
||||
config: ['rollup.config.mjs', 'tsconfig.json'],
|
||||
toolchain: ['package.json', '.github/workflows/validate.yml'],
|
||||
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs', 'demo/editor-runtime-compat.mjs'],
|
||||
},
|
||||
performance_smoke: {
|
||||
source: ['src/houseplan-card.ts'],
|
||||
tests: ['demo/benchmark_glow.mjs', 'demo/benchmark_large_house.mjs', 'demo/performance/compare.mjs'],
|
||||
fixtures: ['demo/fixtures/large-house.mjs', 'demo/performance/budgets-glow-smoke.json',
|
||||
'demo/performance/budgets-isometric-smoke.json', 'demo/performance/budgets-interaction-smoke.json'],
|
||||
config: ['rollup.config.mjs'],
|
||||
toolchain: ['package.json', '.github/workflows/validate.yml'],
|
||||
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/editor-runtime-compat.mjs', 'demo/performance/evaluate.mjs'],
|
||||
},
|
||||
};
|
||||
for (const [job, categories] of Object.entries(expect)) {
|
||||
for (const [category, files] of Object.entries(categories)) {
|
||||
for (const file of files) {
|
||||
assert.ok(MANIFEST[job].has(file), `${job}/${category}: ${file} не вход`);
|
||||
const { affected, unknown } = checksAffectedBy([file], ROOT, { manifest: MANIFEST });
|
||||
assert.ok(affected.has(job), `${job}/${category}: ${file} не классифицируется`);
|
||||
assert.deepEqual(unknown, [], file);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('§8.1 обратная проба (AC6): UI — не вход backend, backend — не вход браузерных job без причины', () => {
|
||||
for (const file of ['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts', 'src/iso-overlays.ts', 'package.json']) {
|
||||
assert.ok(!MANIFEST.backend.has(file), `backend зависит от ${file}`);
|
||||
assert.ok(!checksAffectedBy([file], ROOT, { manifest: MANIFEST }).affected.has('backend'), file);
|
||||
}
|
||||
// smoke_infinite_canvas запускает backend-валидацию как процесс — это
|
||||
// честная зависимость; но tests_backend/** браузерным job не нужны
|
||||
for (const job of ['smoke', 'golden', 'performance_smoke']) {
|
||||
assert.ok(![...MANIFEST[job]].some((f) => f.startsWith('tests_backend/')), `${job} читает tests_backend`);
|
||||
}
|
||||
});
|
||||
|
||||
test('§5.5 лист покрытия: ни одного неизвестного исполняемого файла, ни одной лишней записи NOT_AN_INPUT', () => {
|
||||
const { unknown, declaredButCovered } = coverage(ROOT, { manifest: MANIFEST });
|
||||
assert.deepEqual(unknown, [], 'исполняемый файл без хозяина: впишите в CHECKS или в NOT_AN_INPUT с причиной');
|
||||
assert.deepEqual(declaredButCovered, [], 'запись NOT_AN_INPUT лишняя — файл и так вход');
|
||||
for (const [glob, reason] of NOT_AN_INPUT) assert.ok(reason.length > 10, `${glob}: нужна причина`);
|
||||
});
|
||||
|
||||
test('неизвестный вход расширяет до всех проверок и называется; документация и не-входы — нет', () => {
|
||||
const ghost = p('scripts', 'ghost-gate.mjs');
|
||||
const { affected, unknown } = checksAffectedBy([ghost], ROOT, { manifest: MANIFEST });
|
||||
assert.deepEqual(unknown, [ghost]);
|
||||
assert.deepEqual([...affected].sort(), [...CHECK_NAMES].sort());
|
||||
assert.ok(isExecutableInput(ghost));
|
||||
assert.ok(!isExecutableInput(p('docs', 'ghost.md')));
|
||||
assert.ok(isDeclaredNotAnInput(p('demo', 'shot_sun.mjs')));
|
||||
const quiet = checksAffectedBy([p('docs', 'ghost.md'), p('demo', 'shot_sun.mjs')], ROOT, { manifest: MANIFEST });
|
||||
assert.deepEqual(quiet.unknown, []);
|
||||
assert.equal(quiet.affected.size, 0);
|
||||
});
|
||||
|
||||
test('CLI: --check печатает входы, --why объясняет цепочку, --coverage зелёный на текущем дереве', () => {
|
||||
const script = new URL('../scripts/check-inputs.mjs', import.meta.url).pathname;
|
||||
const backend = execFileSync('node', [script, '--check=backend'], { encoding: 'utf8' }).trim().split('\n');
|
||||
assert.ok(backend.includes('scripts/support-relay/relay.py'));
|
||||
const why = execFileSync('node', [script, '--check=backend', '--why=scripts/sh3d-convert/convert.mjs'], { encoding: 'utf8' });
|
||||
assert.match(why, /^scripts\/sh3d-convert\/convert\.mjs\n/);
|
||||
assert.match(why, /tests_backend\/test_sh3d_convert\.py/);
|
||||
const cov = execFileSync('node', [script, '--coverage'], { encoding: 'utf8' });
|
||||
assert.equal(cov.trim(), '');
|
||||
});
|
||||
@@ -1,74 +1,120 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { classifyChanges, classifyAll, formatOutputs, OUTPUTS, CLASSIFIERS } from '../scripts/classify-changes.mjs';
|
||||
import {
|
||||
CHECK_OF_OUTPUT, CLASSIFIERS, OUTPUTS, PERF_PROFILES, classifyAll, classifyChanges, formatOutputs,
|
||||
} from '../scripts/classify-changes.mjs';
|
||||
import { manifest } from '../scripts/check-inputs.mjs';
|
||||
|
||||
// #473 AC8: диффозависимость перф-смока доказана на самой функции
|
||||
// классификации, которую исполняет job `changes`.
|
||||
// Классификация идёт из единого manifest входов (#492 §5.2) на РЕАЛЬНОМ
|
||||
// дереве репозитория: тест доказывает решения job `changes` для настоящих
|
||||
// файлов, а не для выдуманных путей. Manifest считается один раз.
|
||||
//
|
||||
// Пути, которые НЕ должны быть входами, собираются из кусков: строковый
|
||||
// литерал в этом файле сам сделал бы их входом frontend (тесты читают то, что
|
||||
// называют), и проверка стала бы самосбывающейся.
|
||||
const MANIFEST = manifest(process.cwd());
|
||||
const classify = (files) => classifyChanges(files, { manifest: MANIFEST });
|
||||
const p = (...parts) => parts.join('/');
|
||||
|
||||
test('дифф по изометрии включает perf_iso и только его из перф-выходов (#473 AC8)', () => {
|
||||
const out = classifyChanges(['src/iso-x.ts']);
|
||||
const out = classify(['src/iso-overlays.ts']);
|
||||
assert.equal(out.perf_iso, 'true');
|
||||
assert.equal(out.perf_interaction, 'false');
|
||||
assert.equal(out.frontend, 'true');
|
||||
assert.deepEqual(out.unknown, []);
|
||||
});
|
||||
|
||||
test('дифф по живому пути и оркестраторам кадра включает perf_interaction (#473 AC8)', () => {
|
||||
for (const file of ['src/live-x.ts', 'src/render-invalidation.ts',
|
||||
for (const file of ['src/live-hover.ts', 'src/render-invalidation.ts',
|
||||
'src/houseplan-render-lifecycle.ts', 'src/houseplan-card.ts']) {
|
||||
const out = classifyChanges([file]);
|
||||
const out = classify([file]);
|
||||
assert.equal(out.perf_interaction, 'true', file);
|
||||
assert.equal(out.perf_iso, 'false', file);
|
||||
}
|
||||
});
|
||||
|
||||
test('дифф по документации не включает ни одного перф-профиля (#473 AC8)', () => {
|
||||
const out = classifyChanges(['docs/x.md']);
|
||||
test('дифф по документации не включает ни одного перф-профиля и ни одной job (#473 AC8)', () => {
|
||||
const out = classify([p('docs', 'SUN.md')]);
|
||||
assert.equal(out.perf_iso, 'false');
|
||||
assert.equal(out.perf_interaction, 'false');
|
||||
assert.equal(out.frontend, 'false');
|
||||
assert.equal(out.backend, 'false');
|
||||
});
|
||||
|
||||
test('тесты и демо перф-профили не включают: кадр они не замедляют', () => {
|
||||
const out = classifyChanges(['test/iso-scene-render.test.mjs', 'demo/benchmark_large_house.mjs', 'src/iso-x.test.ts.md']);
|
||||
const out = classify(['test/iso-scene-render.test.mjs', 'demo/benchmark_large_house.mjs']);
|
||||
assert.equal(out.perf_iso, 'false');
|
||||
assert.equal(out.perf_interaction, 'false');
|
||||
});
|
||||
|
||||
test('правка реестра мутантов даёт mutants=true и только его (#475 r1)', () => {
|
||||
const out = classifyChanges(['scripts/mutation-gate.mjs']);
|
||||
test('правка реестра мутантов даёт mutants=true; юниты реестра — тоже вход frontend (#475 r1, #492)', () => {
|
||||
const out = classify(['scripts/mutation-gate.mjs']);
|
||||
assert.equal(out.mutants, 'true');
|
||||
assert.equal(out.frontend, 'false');
|
||||
assert.equal(classifyChanges(['scripts/mutation-gate-report.mjs']).mutants, 'false');
|
||||
assert.equal(classifyChanges(['src/x.ts']).mutants, 'false');
|
||||
assert.equal(out.frontend, 'true', 'test/mutation-gate.test.mjs читает реестр');
|
||||
assert.equal(out.backend, 'false');
|
||||
assert.equal(classify(['src/color.ts']).mutants, 'true', 'патчи мутантов лежат в src/**');
|
||||
});
|
||||
|
||||
test('прежние три выхода классифицируются как в inline-shell до выноса', () => {
|
||||
assert.deepEqual(classifyChanges(['custom_components/houseplan/frontend_registration.py']),
|
||||
{ frontend: 'false', backend: 'true', integration: 'true', perf_iso: 'false', perf_interaction: 'false', mutants: 'false' });
|
||||
assert.deepEqual(classifyChanges(['custom_components/houseplan/frontend/houseplan-card.js']),
|
||||
{ frontend: 'true', backend: 'false', integration: 'false', perf_iso: 'false', perf_interaction: 'false', mutants: 'false' });
|
||||
assert.deepEqual(classifyChanges(['hacs.json', 'tsconfig.json']),
|
||||
{ frontend: 'true', backend: 'false', integration: 'true', perf_iso: 'false', perf_interaction: 'false', mutants: 'false' });
|
||||
assert.equal(classifyChanges(['scripts/support-relay/x.py']).backend, 'true');
|
||||
assert.equal(classifyChanges(['']).frontend, 'false');
|
||||
test('#492 §1.2: relay, converter, schema и pyproject запускают backend', () => {
|
||||
for (const file of ['scripts/support-relay/relay.py', 'scripts/support-relay/hp_relay/app.py',
|
||||
'scripts/sh3d-convert/convert.mjs', 'scripts/sh3d-convert/golden/two-levels.space-1.json',
|
||||
'scripts/config-schema.json', 'scripts/dump-config-schema.py', 'pyproject.toml',
|
||||
'custom_components/houseplan/frontend_registration.py']) {
|
||||
const out = classify([file]);
|
||||
assert.equal(out.backend, 'true', file);
|
||||
assert.deepEqual(out.unknown, [], file);
|
||||
}
|
||||
});
|
||||
|
||||
test('#492 AC6: правка UI не классифицируется как backend', () => {
|
||||
const out = classify(['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts']);
|
||||
assert.equal(out.backend, 'false');
|
||||
assert.equal(out.frontend, 'true');
|
||||
});
|
||||
|
||||
test('интеграция: манифест, hacs.json, переводы (как в inline-shell до выноса)', () => {
|
||||
assert.equal(classify(['custom_components/houseplan/manifest.json']).integration, 'true');
|
||||
assert.equal(classify(['hacs.json']).integration, 'true');
|
||||
assert.equal(classify(['custom_components/houseplan/translations/ru.json']).integration, 'true');
|
||||
assert.equal(classify(['src/color.ts']).integration, 'false');
|
||||
assert.equal(classify(['']).frontend, 'false');
|
||||
});
|
||||
|
||||
test('копия бандла — не вход ни одной проверки (класс D)', () => {
|
||||
const out = classify(['custom_components/houseplan/frontend/houseplan-card.js', 'dist/houseplan-card.js']);
|
||||
for (const name of Object.keys(CHECK_OF_OUTPUT)) assert.equal(out[name], 'false', name);
|
||||
assert.deepEqual(out.unknown, []);
|
||||
});
|
||||
|
||||
test('#492 §5.2: неизвестный исполняемый вход расширяет прогон до полного набора и называется', () => {
|
||||
const out = classify(['scripts/brand-new-gate.mjs']);
|
||||
for (const name of OUTPUTS) assert.equal(out[name], 'true', name);
|
||||
assert.deepEqual(out.unknown, ['scripts/brand-new-gate.mjs']);
|
||||
// Объявленный не-вход и документация не расширяют.
|
||||
const quiet = classify([p('demo', 'stand', 'README.md'), p('demo', 'shot_sun.mjs'), p('docs', 'new-page.md')]);
|
||||
assert.deepEqual(quiet.unknown, []);
|
||||
for (const name of OUTPUTS) assert.equal(quiet[name], 'false', name);
|
||||
});
|
||||
|
||||
test('fallback --all выставляет каждый известный выход, включая перф-профили', () => {
|
||||
const all = classifyAll();
|
||||
assert.deepEqual(Object.keys(all), OUTPUTS);
|
||||
assert.deepEqual(Object.keys(all).filter((k) => k !== 'unknown'), OUTPUTS);
|
||||
assert.ok(OUTPUTS.every((name) => all[name] === 'true'));
|
||||
assert.deepEqual(Object.keys(CLASSIFIERS), OUTPUTS);
|
||||
assert.deepEqual(Object.keys(PERF_PROFILES), ['perf_iso', 'perf_interaction']);
|
||||
});
|
||||
|
||||
test('CLI пишет формат $GITHUB_OUTPUT: stdin — список файлов, --all — всё true', () => {
|
||||
const script = new URL('../scripts/classify-changes.mjs', import.meta.url).pathname;
|
||||
const fromStdin = execFileSync('node', [script], { input: 'src/iso-x.ts\ndocs/x.md\n', encoding: 'utf8' });
|
||||
assert.equal(fromStdin, formatOutputs(classifyChanges(['src/iso-x.ts', 'docs/x.md'])));
|
||||
const doc = p('docs', 'SUN.md');
|
||||
const fromStdin = execFileSync('node', [script], { input: `src/iso-overlays.ts\n${doc}\n`, encoding: 'utf8' });
|
||||
assert.equal(fromStdin, formatOutputs(classify(['src/iso-overlays.ts', doc])));
|
||||
assert.match(fromStdin, /^perf_iso=true$/m);
|
||||
assert.match(fromStdin, /^perf_interaction=false$/m);
|
||||
assert.match(fromStdin, /^unknown_inputs=$/m);
|
||||
const all = execFileSync('node', [script, '--all'], { input: '', encoding: 'utf8' });
|
||||
assert.equal(all, OUTPUTS.map((name) => `${name}=true`).join('\n') + '\n');
|
||||
assert.equal(all, OUTPUTS.map((name) => `${name}=true`).join('\n') + '\nunknown_inputs=\n');
|
||||
});
|
||||
|
||||
// #479: тяжёлые job идут на кандидате беты, по кнопке, на PR и по расписанию —
|
||||
|
||||
+95
-41
@@ -7,13 +7,14 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
HARNESS, JOBS, harnessFiles, inheritedFailureNote, parseFailureMarker, reuseKey,
|
||||
JOBS, harnessFiles, inheritedFailureNote, parseFailureMarker, reuseKey,
|
||||
} from '../scripts/gate-reuse.mjs';
|
||||
|
||||
/**
|
||||
* Дерево, минимально достаточное для sourceFingerprint плюс оснастка каждой
|
||||
* тяжёлой job. Реальные каталоги, а не подмены: ключ обязан отражать файловую
|
||||
* систему так же, как в CI.
|
||||
* Дерево, минимально достаточное для manifest каждой тяжёлой job (#492):
|
||||
* корни проверок, точки входа и то, что они импортируют. Реальные каталоги,
|
||||
* а не подмены: ключ обязан отражать файловую систему так же, как в CI.
|
||||
* Без `.git` manifest обходит дерево сам.
|
||||
*/
|
||||
const makeTree = () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-reuse-'));
|
||||
@@ -26,48 +27,63 @@ const makeTree = () => {
|
||||
put('package-lock.json', '{"lockfileVersion":3}\n');
|
||||
put('rollup.config.mjs', 'export default {};\n');
|
||||
put('tsconfig.json', '{}\n');
|
||||
put('.github/workflows/validate.yml', 'name: Validate\n');
|
||||
put('scripts/source-fingerprint.mjs', '// pinned by the real repo copy\n');
|
||||
put('scripts/gate-reuse.mjs', '// reuse protocol\n');
|
||||
put('scripts/check-inputs.mjs', '// manifest\n');
|
||||
put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n");
|
||||
put('demo/serve.mjs', "import './bundle-freshness.mjs';\n");
|
||||
put('demo/bundle-freshness.mjs', 'export const fresh = 1;\n');
|
||||
put('demo/srv/demo.html', '<div id="host"></div>\n');
|
||||
put('demo/fixtures/one.mjs', 'export const fixture = 1;\n');
|
||||
put('demo/smoke_alpha.mjs', 'console.log(1);\n');
|
||||
put('demo/smoke_beta.mjs', 'console.log(2);\n');
|
||||
put('demo/benchmark_glow.mjs', 'export const glow = 1;\n');
|
||||
put('demo/golden/run.mjs', 'export const run = 1;\n');
|
||||
put('demo/smoke_alpha.mjs', "import { launch } from './serve.mjs';\nimport '../scripts/model-invariants.mjs';\nconsole.log(1);\n");
|
||||
put('demo/smoke_beta.mjs', "import { launch } from './serve.mjs';\nconsole.log(2);\n");
|
||||
put('scripts/model-invariants.mjs', 'export const invariants = 1;\n');
|
||||
put('demo/guard/verify-guard.mjs', '// probes\n');
|
||||
put('demo/benchmark_glow.mjs', "import './serve.mjs';\nexport const glow = 1;\n");
|
||||
put('demo/benchmark_large_house.mjs', "import './serve.mjs';\nimport './fixtures/one.mjs';\n");
|
||||
put('demo/golden/run.mjs', "import '../serve.mjs';\nexport const run = 1;\n");
|
||||
put('demo/golden/baselines/one.png', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]));
|
||||
put('demo/performance/compare.mjs', 'export const cmp = 1;\n');
|
||||
put('tests_backend/test_pure.py', 'def test_x():\n assert True\n');
|
||||
put('demo/performance/budgets-glow-smoke.json', '{"hardMaxMs":1}\n');
|
||||
put('tests_backend/test_pure.py', 'from custom_components.houseplan.store import VERSION\n\ndef test_x():\n assert True\n');
|
||||
put('tests_backend/requirements.txt', 'pytest\n');
|
||||
put('custom_components/houseplan/store.py', 'VERSION = 1\n');
|
||||
put('custom_components/houseplan/manifest.json', '{"domain":"houseplan","version":"1.0.0"}\n');
|
||||
put('custom_components/houseplan/frontend/houseplan-card.js', 'built bundle\n');
|
||||
put('scripts/support-relay/relay.py', 'from hp_relay.app import main\n');
|
||||
put('scripts/support-relay/hp_relay/app.py', 'def main():\n pass\n');
|
||||
put('scripts/support-relay/tests/test_relay.py', 'from hp_relay.app import main\n');
|
||||
put('scripts/config-schema.json', '{}\n');
|
||||
put('pytest.ini', '[pytest]\n');
|
||||
put('pyproject.toml', '[tool.ruff]\n');
|
||||
put('scripts/backend-coverage-baseline.txt', '80.0\n');
|
||||
put('docs/STATUS.md', 'status\n');
|
||||
return { dir, put };
|
||||
};
|
||||
|
||||
const keys = (dir) => Object.fromEntries(JOBS.map((job) => [job, reuseKey(dir, job)]));
|
||||
|
||||
test('every heavy job has a non-empty harness and its own key', () => {
|
||||
test('every heavy job has non-empty inputs and its own key', () => {
|
||||
const { dir } = makeTree();
|
||||
try {
|
||||
assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']);
|
||||
const k = keys(dir);
|
||||
for (const job of JOBS) assert.ok(harnessFiles(dir, job).length > 0, job);
|
||||
// Ключи различаются между job: иначе правка чужой оснастки гасила бы чужой
|
||||
// прогон, а совпадение ключей маскировало бы это как «то же самое».
|
||||
const set = new Set(Object.values(keys(dir)));
|
||||
assert.equal(set.size, JOBS.length);
|
||||
assert.equal(new Set(Object.values(k)).size, JOBS.length, 'ключи job обязаны различаться');
|
||||
assert.throws(() => reuseKey(dir, 'frontend'), /неизвестная job/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('documentation, workflows and unit tests leave every key untouched (#208)', () => {
|
||||
test('documentation, the process canon and tooling nobody executes leave every key untouched (#208)', () => {
|
||||
// Именно этот случай и оплачивал полный прогон: коммит, не меняющий ни одного
|
||||
// входа поведения и ни одной оснастки.
|
||||
// входа ни одной тяжёлой job.
|
||||
const { dir, put } = makeTree();
|
||||
try {
|
||||
const before = keys(dir);
|
||||
put('docs/STATUS.md', 'status changed\n');
|
||||
put('.github/workflows/validate.yml', 'name: Validate\n');
|
||||
put('test/some.test.mjs', 'import test from "node:test";\n');
|
||||
put('scripts/process-gate.mjs', '// unrelated tooling\n');
|
||||
put('PROCESS.md', 'canon\n');
|
||||
@@ -77,19 +93,35 @@ test('documentation, workflows and unit tests leave every key untouched (#208)',
|
||||
}
|
||||
});
|
||||
|
||||
test('a behaviour input changes every key, including a version bump (#208)', () => {
|
||||
test('the workflow itself is a toolchain input of every job (#492 §5.4)', () => {
|
||||
// Правка шага job меняет, ЧТО проверяется; пропустить такую job как
|
||||
// переиспользованную — тот же дефект, что #430, этажом выше.
|
||||
const { dir, put } = makeTree();
|
||||
try {
|
||||
const before = keys(dir);
|
||||
put('.github/workflows/validate.yml', 'name: Validate\n# step added\n');
|
||||
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), before[job], job);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a behaviour input changes every browser key and a version bump changes all of them (#208, #492 AC6)', () => {
|
||||
const { dir, put } = makeTree();
|
||||
try {
|
||||
const before = keys(dir);
|
||||
put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n// behaviour\n");
|
||||
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), before[job], job);
|
||||
for (const job of ['smoke', 'golden', 'performance_smoke']) assert.notEqual(reuseKey(dir, job), before[job], job);
|
||||
// Обратная проба AC6: бэкенд UI не исполняет — его ключ на месте.
|
||||
assert.equal(reuseKey(dir, 'backend'), before.backend, 'backend не зависит от src/**');
|
||||
|
||||
// Релизный кандидат бампает версию, поэтому его ключи заведомо новые и
|
||||
// полный набор гейтов прогоняется всегда — переиспользование не может
|
||||
// ослабить релизный гейт.
|
||||
// Релизный кандидат бампает версию в package.json (браузерные job) и в
|
||||
// manifest.json интеграции (backend): ключи кандидата заведомо новые, и
|
||||
// полный набор гейтов прогоняется всегда.
|
||||
const bumped = keys(dir);
|
||||
put('src/card.ts', "export const CARD_VERSION = '1.1.0';\n// behaviour\n");
|
||||
put('package.json', '{"name":"x","version":"1.1.0"}\n');
|
||||
put('custom_components/houseplan/manifest.json', '{"domain":"houseplan","version":"1.1.0"}\n');
|
||||
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), bumped[job], job);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -104,19 +136,41 @@ test('harness edits are isolated to their own job (#208)', () => {
|
||||
apply();
|
||||
const after = keys(dir);
|
||||
for (const job of JOBS) {
|
||||
if (job === changed) assert.notEqual(after[job], before[job], `${job} должен меняться`);
|
||||
if (changed.includes(job)) assert.notEqual(after[job], before[job], `${job} должен меняться`);
|
||||
else assert.equal(after[job], before[job], `${job} меняться не должен`);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
only('smoke')(() => put('demo/smoke_alpha.mjs', 'console.log(3);\n'));
|
||||
only(['smoke'])(() => put('demo/smoke_alpha.mjs', "import { launch } from './serve.mjs';\nconsole.log(3);\n"));
|
||||
// Эталон — вход сравнения, его подмена обязана менять ключ golden.
|
||||
only('golden')(() => put('demo/golden/baselines/one.png',
|
||||
only(['golden'])(() => put('demo/golden/baselines/one.png',
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02])));
|
||||
only('performance_smoke')(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n'));
|
||||
only('backend')(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n'));
|
||||
only(['performance_smoke'])(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n'));
|
||||
only(['backend'])(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n'));
|
||||
// Протокол браузерного харнеса общий для трёх job (#492 §5.1 protocol).
|
||||
only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/serve.mjs', "import './bundle-freshness.mjs';\n// harness\n"));
|
||||
only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/srv/demo.html', '<div id="host"></div>\n<!-- page -->\n'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('#492 backend inputs the old HARNESS did not know: relay, schema, converter, pyproject', () => {
|
||||
const { dir, put } = makeTree();
|
||||
const bumps = (rel, text) => {
|
||||
const before = reuseKey(dir, 'backend');
|
||||
put(rel, text);
|
||||
assert.notEqual(reuseKey(dir, 'backend'), before, `${rel}: правка не меняет ключ backend`);
|
||||
};
|
||||
try {
|
||||
bumps('scripts/support-relay/relay.py', 'from hp_relay.app import main\n# changed\n');
|
||||
bumps('scripts/support-relay/hp_relay/app.py', 'def main():\n return 1\n');
|
||||
bumps('scripts/config-schema.json', '{"v":2}\n');
|
||||
bumps('pyproject.toml', '[tool.ruff]\nline-length = 100\n');
|
||||
bumps('scripts/backend-coverage-baseline.txt', '81.0\n');
|
||||
bumps('tests_backend/requirements.txt', 'pytest==9\n');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -139,7 +193,7 @@ test('the key is canonical across line endings', () => {
|
||||
const { dir, put } = makeTree();
|
||||
try {
|
||||
const before = keys(dir);
|
||||
put('demo/smoke_alpha.mjs', 'console.log(1);\r\n');
|
||||
put('demo/smoke_alpha.mjs', "import { launch } from './serve.mjs';\r\nimport '../scripts/model-invariants.mjs';\r\nconsole.log(1);\r\n");
|
||||
assert.deepEqual(keys(dir), before, 'CRLF не должен рождать другой ключ');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -183,24 +237,26 @@ test('#430 ключ смоков покрывает всё, что эта job и
|
||||
// разу. Здесь закреплено, что так больше не выйдет.
|
||||
const { dir, put } = makeTree();
|
||||
try {
|
||||
put('demo/serve.mjs', '// harness\n');
|
||||
put('demo/guard/verify-guard.mjs', '// probes\n');
|
||||
put('demo/guard/guard_tail_exception.mjs', '// probe\n');
|
||||
put('demo/benchmark_backdrop_decode.mjs', '// benchmark\n');
|
||||
const files = harnessFiles(dir, 'smoke');
|
||||
for (const rel of [
|
||||
'demo/serve.mjs',
|
||||
'demo/bundle-freshness.mjs',
|
||||
'demo/srv/demo.html',
|
||||
'demo/guard/verify-guard.mjs',
|
||||
'demo/guard/guard_tail_exception.mjs',
|
||||
'demo/benchmark_backdrop_decode.mjs',
|
||||
'scripts/model-invariants.mjs',
|
||||
]) {
|
||||
assert.ok(files.includes(rel), `${rel} вне ключа смоков — его правка будет реюзнута`);
|
||||
}
|
||||
// И ключ обязан меняться от правки каждого из них: список файлов сам по
|
||||
// себе ничего не гарантирует, если хэш их не читает.
|
||||
for (const rel of files) {
|
||||
if (rel === 'demo/golden/baselines/one.png') continue;
|
||||
const before = reuseKey(dir, 'smoke');
|
||||
put(rel, '// changed\n');
|
||||
put(rel, readFileSync(join(dir, rel), 'utf8') + '// changed\n');
|
||||
assert.notEqual(reuseKey(dir, 'smoke'), before, `${rel}: правка не меняет ключ`);
|
||||
}
|
||||
} finally {
|
||||
@@ -208,20 +264,18 @@ test('#430 ключ смоков покрывает всё, что эта job и
|
||||
}
|
||||
});
|
||||
|
||||
test('HARNESS keeps scripts/** out of the keys on purpose', () => {
|
||||
// Инфраструктурная работа правит scripts/** постоянно. Если бы каталог
|
||||
// целиком попал в ключ, переиспользование не срабатывало бы никогда — ровно
|
||||
// тот случай, ради которого #208 и заводился.
|
||||
test('scripts/** enter a key only when the job actually reaches them (#208, #492)', () => {
|
||||
// Инфраструктурная работа правит scripts/** постоянно. В ключ попадает не
|
||||
// каталог, а то, что job импортирует или запускает: process-gate.mjs — нет,
|
||||
// model-invariants.mjs (импорт смока) — да.
|
||||
const { dir, put } = makeTree();
|
||||
try {
|
||||
put('scripts/process-gate.mjs', '// tooling\n');
|
||||
for (const [job, spec] of Object.entries(HARNESS)) {
|
||||
assert.ok(!spec.roots.includes('scripts'), `${job}: scripts в корнях обхода`);
|
||||
assert.ok(
|
||||
!harnessFiles(dir, job).some((rel) => rel.startsWith('scripts/')),
|
||||
`${job}: scripts попал в оснастку`,
|
||||
);
|
||||
for (const job of JOBS) {
|
||||
assert.ok(!harnessFiles(dir, job).includes('scripts/process-gate.mjs'), `${job}: process-gate в оснастке`);
|
||||
}
|
||||
assert.ok(harnessFiles(dir, 'smoke').includes('scripts/model-invariants.mjs'));
|
||||
assert.ok(!harnessFiles(dir, 'backend').includes('scripts/model-invariants.mjs'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -179,8 +179,13 @@ test('перф-смок добавляет профиль ровно при св
|
||||
const changes = workflow.slice(workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'));
|
||||
assert.match(changes, /perf_iso: \$\{\{ steps\.classify\.outputs\.perf_iso \}\}/);
|
||||
assert.match(changes, /perf_interaction: \$\{\{ steps\.classify\.outputs\.perf_interaction \}\}/);
|
||||
// Выходы пишет скрипт, а не inline-shell: шаблоны проверяются unit-тестом (AC8).
|
||||
assert.match(changes, /printf '%s\\n' "\$files" \| node scripts\/classify-changes\.mjs \| tee -a "\$GITHUB_OUTPUT"/);
|
||||
// Выходы пишет скрипт, а не inline-shell: решение проверяется unit-тестом
|
||||
// (AC8); #492 §5.2 — тот же вывод читается и для summary неизвестных входов.
|
||||
assert.match(changes, /printf '%s\\n' "\$files" \| node scripts\/classify-changes\.mjs > \/tmp\/classify\.out/);
|
||||
assert.match(changes, /tee -a "\$GITHUB_OUTPUT" < \/tmp\/classify\.out/);
|
||||
assert.match(changes, /unknown_inputs: \$\{\{ steps\.classify\.outputs\.unknown_inputs \}\}/);
|
||||
assert.match(changes, /sed -n 's\/\^unknown_inputs=\/\/p' \/tmp\/classify\.out/);
|
||||
assert.match(changes, /Неизвестные входы \(#492\)/);
|
||||
// Все три fallback-а «без классификации» идут через тот же скрипт с --all:
|
||||
// новый выход не может выпасть из fallback-а.
|
||||
const fallbacks = changes.split('node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"').length - 1;
|
||||
|
||||
Reference in New Issue
Block a user