diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b5d51414..7473c7b2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 с побайтово теми # же входами уже завершилась успешно. # diff --git a/scripts/check-inputs.mjs b/scripts/check-inputs.mjs new file mode 100755 index 00000000..d3ef8e29 --- /dev/null +++ b/scripts/check-inputs.mjs @@ -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= | --coverage | --affected < files'); + process.exit(2); + } +} diff --git a/scripts/classify-changes.mjs b/scripts/classify-changes.mjs index c547d58c..10b2a218 100755 --- a/scripts/classify-changes.mjs +++ b/scripts/classify-changes.mjs @@ -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] diff --git a/scripts/gate-reuse.mjs b/scripts/gate-reuse.mjs index 68ed8a7c..56651ad1 100644 --- a/scripts/gate-reuse.mjs +++ b/scripts/gate-reuse.mjs @@ -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'); diff --git a/test/check-inputs.test.mjs b/test/check-inputs.test.mjs new file mode 100755 index 00000000..cdd7e983 --- /dev/null +++ b/test/check-inputs.test.mjs @@ -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(), ''); +}); diff --git a/test/classify-changes.test.mjs b/test/classify-changes.test.mjs index 64130cdc..2d40c597 100644 --- a/test/classify-changes.test.mjs +++ b/test/classify-changes.test.mjs @@ -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 и по расписанию — diff --git a/test/gate-reuse.test.mjs b/test/gate-reuse.test.mjs index 3f1fbe81..d1cc5d3b 100644 --- a/test/gate-reuse.test.mjs +++ b/test/gate-reuse.test.mjs @@ -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', '
\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', '
\n\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 }); } diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index ed684f52..71638298 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -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;