mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
В src/** сейчас 1034 вхождения явного any в 49 файлах — больше, чем называл аудит (330), потому что монолит с тех пор разделился и его обвязка уехала в houseplan-editor-runtime.ts. Разовая замена такого объёма — месяц риска ради нуля пользовательской ценности, поэтому долг снимается при плановом извлечении подсистем (#34). Задача гейта одна: не давать долгу расти. Судятся только добавленные строки диапазона. Изменённая строка со старым any выглядит в диффе добавленной, и это намеренно: тронул — либо типизируй, либо обоснуй на той же строке `// any-ok: <причина>`. Голый маркер, пустая причина и шаблоны вроде todo, hack, потом не проходят. Ложных срабатываний нет по построению, а не по старанию: текст разбирается парсером TypeScript, и нарушением считается узел AnyKeyword. Регулярка по строке ловила бы слово any в прозе внутри шаблона html и в комментариях; здесь комментарии, строковые литералы, многострочные шаблоны и идентификаторы company, anyOf, manyRooms узлами такого вида не являются вовсе. Проверено исполнением на настоящем дереве, а не только юнитами: пробные коммиты в src/wall-thickness.ts показали, что добавленный any падает с файлом и строкой, типизированная строка в файле с 122 старыми any проходит, any-ok с конкретной причиной проходит, а голый и «todo» — нет, и что any в прозе, строке и идентификаторах не даёт ни одного срабатывания. В job frontend checkout получил полную историю без блобов: diff-aware проверке нужен диапазон, а содержимое старых ревизий — нет. Заодно закрыта ловушка в test/validate-workflow.test.mjs: имя job искалось через indexOf(' frontend:'), а эта строка встречается внутри ` frontend: ${{ ... }}` в outputs job changes, поэтому срез уходил не туда. Теперь имя ищется с начала строки. Четыре мутанта проверены руками, два добавлены в реестр: гейт, судящий все строки, и гейт, принимающий голый маркер. Issue: #342 User-Visible: no
114 lines
7.2 KiB
JavaScript
114 lines
7.2 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// #336. Воркфлоу — не текст, а контракт, и ломается он молча: висячая
|
|
// зависимость `needs` не роняет YAML, а просто навсегда пропускает job, и
|
|
// «зелёный Validate» начинает значить меньше, чем значил. Здесь закреплены
|
|
// свойства, которые нельзя увидеть в диффе строк.
|
|
|
|
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
|
|
const read = (name) => readFileSync(new URL(name, `file://${WORKFLOWS}`), 'utf8');
|
|
|
|
/**
|
|
* Разбор без yaml-зависимости: имена job — ключи на двух пробелах, значения
|
|
* `needs` берутся строкой. Полноценный парсер здесь был бы лишней зависимостью
|
|
* ради двух форм записи, которые в этом репозитории и используются.
|
|
*/
|
|
const jobsOf = (text) => {
|
|
const body = text.slice(text.indexOf('\njobs:'));
|
|
const names = [...body.matchAll(/^ {2}([a-zA-Z0-9_-]+):$/gm)].map((match) => match[1]);
|
|
const needs = new Map();
|
|
for (const name of names) {
|
|
const start = body.indexOf(`\n ${name}:\n`);
|
|
const next = names
|
|
.map((other) => body.indexOf(`\n ${other}:\n`))
|
|
.filter((index) => index > start);
|
|
const chunk = body.slice(start, next.length ? Math.min(...next) : body.length);
|
|
const line = chunk.match(/^ {4}needs:\s*(.+)$/m)?.[1] || '';
|
|
needs.set(name, [...line.matchAll(/[a-zA-Z0-9_-]+/g)].map((match) => match[0]));
|
|
}
|
|
return { names, needs };
|
|
};
|
|
|
|
test('ни один воркфлоу не зависит от несуществующей job (#336)', () => {
|
|
for (const file of readdirSync(WORKFLOWS).filter((name) => name.endsWith('.yml'))) {
|
|
const { names, needs } = jobsOf(read(file));
|
|
assert.ok(names.length, `${file}: не нашлось ни одной job — проверьте разбор`);
|
|
for (const [job, list] of needs) {
|
|
for (const dependency of list) {
|
|
assert.ok(names.includes(dependency),
|
|
`${file}: job ${job} зависит от несуществующей ${dependency}`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('бандл собирается один раз и приезжает браузерным job артефактом (#336)', () => {
|
|
const workflow = read('validate.yml');
|
|
// Сборка ровно в одном месте. Пять сборок одного и того же бандла — это
|
|
// ~10 джобо-минут на каждом непереиспользованном прогоне.
|
|
const builds = [...workflow.matchAll(/^ +run: npm run (build|bundle:sync)$/gm)];
|
|
assert.equal(builds.length, 1, 'бандл должен собираться ровно в одной job');
|
|
assert.equal(workflow.match(/name: card-bundle/g)?.length, 4,
|
|
'один upload и три download артефакта бандла');
|
|
assert.equal(workflow.match(/name: card-test-build/g)?.length, 2,
|
|
'один upload и один download тестового дерева для smoke job');
|
|
assert.match(workflow, /name: card-test-build\n\s+path: test-build\//,
|
|
'frontend обязана публиковать созданное npm test дерево test-build');
|
|
assert.match(workflow, /name: card-test-build\n\s+path: test-build(?:\n|\r)/,
|
|
'smoke job обязана восстанавливать test-build в ожидаемый import path');
|
|
// Каждая браузерная job раскладывает скачанный бандл по копиям: без этого
|
|
// стенд читает вчерашний файл, а смок врёт согласованно (#236).
|
|
assert.equal(workflow.match(/node scripts\/bundle-sync\.mjs/g)?.length, 3);
|
|
});
|
|
|
|
test('предполётные проверки не прячут друг друга (#336)', () => {
|
|
const workflow = read('validate.yml');
|
|
const preflight = workflow.slice(
|
|
workflow.indexOf('\n preflight:\n'), workflow.indexOf('\n changes:\n'),
|
|
);
|
|
for (const id of ['docs', 'workflow_sync', 'provenance', 'process_gate']) {
|
|
assert.ok(preflight.includes(`id: ${id}`), `нет шага ${id}`);
|
|
assert.ok(preflight.includes(`steps.${id}.outcome`), `вердикт не читает ${id}`);
|
|
}
|
|
// Слияние джоб не имеет права превратить четыре независимых сигнала в один
|
|
// «первый упавший»: иначе автор узнаёт о втором нарушении следующим кругом.
|
|
assert.equal(preflight.match(/continue-on-error: true/g)?.length, 4);
|
|
assert.ok(preflight.includes('exit $fail'), 'вердикт обязан падать сам');
|
|
});
|
|
|
|
test('джобы с полной историей качают её без блобов (#345)', () => {
|
|
const workflow = read('validate.yml');
|
|
// Полный клон — 215 МБ .git, blobless — 26 МБ, история и теги в обоих полные
|
|
// (замер в #345). Обе эти job читают сообщения коммитов и ИМЕНА изменённых
|
|
// файлов; содержимое старых ревизий им не нужно ни на одном шаге.
|
|
for (const job of ['preflight', 'changes', 'frontend']) {
|
|
// ' frontend:' встречается внутри ` frontend: ${{ ... }}` в outputs
|
|
// job `changes`, поэтому имя job ищется только с начала строки.
|
|
const start = workflow.indexOf(`\n ${job}:\n`);
|
|
assert.ok(start > 0, `нет job ${job}`);
|
|
const chunk = workflow.slice(start, start + 1400);
|
|
assert.match(chunk, /fetch-depth: 0, filter: 'blob:none'/,
|
|
`${job} обязана качать историю без блобов`);
|
|
}
|
|
// Обратная сторона: --depth=1 сюда подставлять нельзя. Он того же размера, но
|
|
// без merge-base, а на нём стоят процессный гейт, smoke-select и каждый
|
|
// диапазон origin/dev..HEAD.
|
|
assert.equal(workflow.includes('fetch-depth: 1'), false,
|
|
'shallow-клон ломает merge-base: диапазоны и процессный гейт перестают работать');
|
|
});
|
|
|
|
test('гейт «новый код не добавляет any» вызывается в frontend (#342)', () => {
|
|
const workflow = read('validate.yml');
|
|
const frontend = workflow.slice(
|
|
workflow.indexOf('\n frontend:\n'), workflow.indexOf('\n smoke:\n'),
|
|
);
|
|
assert.match(frontend, /node scripts\/no-new-any\.mjs --base/,
|
|
'гейт обязан вызываться, иначе долг типизации снова начнёт расти');
|
|
// Гейт diff-aware, поэтому без истории он бессмысленен: на глубине 1
|
|
// merge-base не считается и диапазон выродится в «всё».
|
|
assert.match(frontend, /fetch-depth: 0, filter: 'blob:none'/);
|
|
});
|