diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c8f6e72a..db436d52 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -320,7 +320,10 @@ jobs: if: needs.changes.outputs.frontend == 'true' runs-on: ubuntu-latest steps: + # Полная история без блобов (#342): гейт «новый код не добавляет any» + # diff-aware, ему нужен диапазон, а содержимое старых ревизий — нет. - uses: actions/checkout@v7 + with: { fetch-depth: 0, filter: 'blob:none' } - uses: actions/setup-node@v7 with: node-version: 22 @@ -328,6 +331,33 @@ jobs: - run: npm ci - name: Typecheck run: npm run typecheck + # Долг типизации снимается при извлечении подсистем (#34), а не разовой + # заменой: в src/** его 1034 вхождения в 49 файлах. Гейт держит приращение + # на нуле — судит только добавленные строки (#342). + - name: "Новый код не добавляет any" + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.sha }} + REF: ${{ github.ref }} + run: | + set -euo pipefail + git fetch -q origin dev + zero=$(printf '%040d' 0) + base="$BEFORE_SHA" + if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi + # Пуш прямо в dev: диапазон — то, что этот пуш добавил. Новая ветка или + # перезаписанная история: точки отсчёта нет, берём merge-base с dev. + if [ "$REF" != "refs/heads/dev" ] || [ -z "$base" ] || [ "$base" = "$zero" ] \ + || ! git cat-file -e "$base" 2>/dev/null; then + base=$(git merge-base origin/dev "$HEAD_SHA" || echo "") + fi + if [ -z "$base" ]; then + echo "не нашёл базу диапазона — гейт пропущен, это увидит следующий пуш" + exit 0 + fi + node scripts/no-new-any.mjs --base "$base" --head "$HEAD_SHA" - name: Unit tests run: npm test - name: Build diff --git a/PROCESS.md b/PROCESS.md index 97eb23b3..cb484c64 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -567,12 +567,23 @@ npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend # копия стенда собирается `npm run bundle:sync`, в репозитории её нет (#255) node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу node demo/smoke_<целевые>.mjs +node scripts/no-new-any.mjs --base origin/dev --head HEAD # новый код не добавляет any npm run golden:verify # если менялся визуал node scripts/check-docs.mjs # если менялся src/** node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки python -m pytest tests_backend -q # py3.13, если менялся бэкенд ``` +**Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного +`any` в 49 файлах; перетипизировать это одним заходом — месяц риска ради нуля +пользовательской ценности, поэтому долг снимается при плановом извлечении +подсистем (#34), а не разовой заменой. Гейт `scripts/no-new-any.mjs` судит +**только добавленные строки**: существующий долг на нетронутой строке законен, +правка строки со старым `any` — новая ответственность. Исключение объявляется на +той же строке, `// any-ok: <конкретная причина>`; голый маркер и причины вида +«todo» не проходят. Текст разбирается парсером TypeScript, поэтому слово «any» в +комментарии, строке или идентификаторе ложных срабатываний не даёт. + **Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда: `typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По diff --git a/docs/TESTING.md b/docs/TESTING.md index 292b2a22..e04acd71 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -711,6 +711,44 @@ missing destructive confirmation or an editor exception that breaks View. filters, and hovering never replaces or flashes the Glow pool/gradient DOM. Editors do neither [auto: smoke_ux_fixes + smoke_glow; manual visual] +## Новый код не добавляет any (#342) + +```bash +node scripts/no-new-any.mjs # origin/dev...HEAD +node scripts/no-new-any.mjs --base origin/dev --head HEAD +node scripts/no-new-any.mjs --diff patch.diff # или `-` для stdin +``` + +В `src/**` сейчас **1034 вхождения** явного `any` в 49 файлах — больше, чем +называл аудит (330), потому что монолит с тех пор разделился и его обвязка +уехала в `houseplan-editor-runtime.ts`. Разовая замена такого объёма — месяц +риска ради нуля пользовательской ценности, поэтому долг снимается при плановом +извлечении подсистем (#34). Гейт держит приращение на нуле. + +Что он судит: **только добавленные строки** диапазона. Существующий `any` на +нетронутой строке законен. Правка строки со старым `any` считается новой +ответственностью — изменённая строка в диффе выглядит добавленной, и это +намеренно: тронул, значит либо типизируй, либо обоснуй. + +Исключение объявляется на той же строке: + +```ts +const raw = (event as any).detail; // any-ok: форма события HA не типизирована в @types +``` + +Голый `// any-ok`, пустая причина и шаблоны вроде `todo`, `hack`, `потом` не +проходят: причина обязана быть не короче 12 символов и не совпадать со списком +заглушек в скрипте. + +Ложных срабатываний нет по построению, а не по старанию: текст разбирается +парсером TypeScript, и нарушением считается узел `AnyKeyword`. Слово «any» в +комментарии, в строковом литерале, в многострочном шаблоне `html` и в +идентификаторах `company`, `anyOf`, `manyRooms` таким узлом не является. + +В CI гейт вызывается в job `frontend`; её checkout получил полную историю без +блобов, потому что diff-aware проверке нужен диапазон, а содержимое старых +ревизий — нет. + ## Локальный набор перед пушем (#343) Красный CI — дорогой способ узнать о проблеме: пять минут ожидания, а при diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 9ae7024d..ade6c73d 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -1004,6 +1004,31 @@ const MUTANT_DEFINITIONS = [ replace: ' if (false) {', }], }, + { + id: 'no-new-any-judges-every-line', + guard: 'node --test --test-name-pattern="нетронутой строке гейт не блокирует" ' + + 'test/no-new-any.test.mjs', + because: 'гейт, судящий все строки вместо добавленных, краснеет на 1034 существующих ' + + 'вхождениях и будет отключён в первый же день — а долг типизации снимается при ' + + 'извлечении подсистем, не разовой заменой (#342)', + patches: [{ + file: 'scripts/no-new-any.mjs', + find: ' if (!file.addedLines.has(line)) continue;', + replace: ' if (false) continue;', + }], + }, + { + id: 'no-new-any-accepts-bare-marker', + guard: 'node --test --test-name-pattern="только с конкретной причиной" ' + + 'test/no-new-any.test.mjs', + because: 'голый `// any-ok` — это не обоснование, а способ обойти гейт одной строкой; ' + + 'без проверки причины исключение перестаёт что-либо значить (#342)', + patches: [{ + file: 'scripts/no-new-any.mjs', + find: ' if (exemption?.ok) continue;', + replace: ' if (exemption) continue;', + }], + }, { id: 'invariant-hidden-counts-corner-touch', guard: 'node --test --test-name-pattern="касание углом" test/model-invariants.test.mjs', diff --git a/scripts/no-new-any.mjs b/scripts/no-new-any.mjs new file mode 100644 index 00000000..5322ad41 --- /dev/null +++ b/scripts/no-new-any.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/** + * Новый код не добавляет `any` (#342). + * + * node scripts/no-new-any.mjs # origin/dev...HEAD + * node scripts/no-new-any.mjs --base origin/dev --head HEAD + * node scripts/no-new-any.mjs --diff patch.diff # или `-` для stdin + * + * Зачем гейт, а не разовая типизация. В `src/**` сейчас 1034 вхождения явного + * `any` в 49 файлах — перетипизировать это одним заходом значит месяц риска ради + * нуля пользовательской ценности. Долг снимается при плановом извлечении + * подсистем (#34). Задача гейта одна: не давать долгу расти. + * + * Практический вред уже случался: несоответствие форм (`d.source.kind` против + * строкового `source`) компилятор не поймал, потому что путь был через `any`, и + * это всплыло только в браузерном смоке. + * + * Почему через компилятор, а не регуляркой. Регулярка по строке даёт ложные + * срабатывания там, где слово `any` живёт в прозе: внутри шаблонной строки + * `html` или в комментарии. Здесь текст файла разбирается настоящим парсером + * TypeScript, и узел `AnyKeyword` — это ровно тип `any` и ничто другое. + * Комментарии, строковые литералы и идентификаторы вида `company`, `anyOf`, + * `manyRooms` узлами этого вида не являются, поэтому false positive невозможен + * не по старанию, а по построению. + * + * Исключение — на той же строке: `// any-ok: <причина>`. Причина обязана быть + * конкретной: голый маркер, пустая или шаблонная причина («todo», «потом», + * «надо») гейт не проходят. Формулировка вида «внешний контракт HA не + * типизирован» проходит. + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** Причины, которые ничего не объясняют: маркер вместо обоснования. */ +const EMPTY_REASONS = [ + 'todo', 'fixme', 'later', 'wip', 'temporary', 'temp', 'hack', 'refactor', + 'потом', 'надо', 'позже', 'временно', 'разобраться', 'исправить', +]; +const MIN_REASON_LENGTH = 12; + +/** + * Разобрать исключение на строке. Возвращает `null`, если маркера нет. + * `ok: false` означает, что маркер есть, но обоснования в нём нет. + */ +export function parseAnyOk(lineText) { + const match = /\/\/\s*any-ok\b\s*:?\s*(.*)$/.exec(String(lineText ?? '')); + if (!match) return null; + const reason = match[1].trim().replace(/\s+/g, ' '); + const bare = reason.replace(/[.…!?—–-]+$/g, '').trim().toLowerCase(); + const ok = reason.length >= MIN_REASON_LENGTH && !EMPTY_REASONS.includes(bare); + return { reason, ok }; +} + +/** Номера строк, где TypeScript видит тип `any`. */ +export function anyKeywordLines(path, text) { + const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true); + const lines = new Map(); + const visit = (node) => { + if (node.kind === ts.SyntaxKind.AnyKeyword) { + const { line } = source.getLineAndCharacterOfPosition(node.getStart(source)); + lines.set(line + 1, (lines.get(line + 1) || 0) + 1); + } + ts.forEachChild(node, visit); + }; + visit(source); + return lines; +} + +/** + * Нарушения: `any` на строке, которую диапазон объявил добавленной. + * + * Изменённая строка в диффе выглядит добавленной, и это намеренно: правка + * строки со старым `any` — новая ответственность, её либо типизируют, либо + * обосновывают исключением. + */ +export function findNewAnyViolations({ files }) { + const violations = []; + for (const file of files) { + const textLines = String(file.text).split('\n'); + for (const [line, count] of anyKeywordLines(file.path, file.text)) { + if (!file.addedLines.has(line)) continue; + const lineText = textLines[line - 1] ?? ''; + const exemption = parseAnyOk(lineText); + if (exemption?.ok) continue; + violations.push({ + path: file.path, + line, + count, + text: lineText.trim(), + reason: exemption + ? `маркер any-ok без конкретной причины: «${exemption.reason || '(пусто)'}»` + : 'явный any на добавленной строке', + }); + } + } + return violations.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line); +} + +/** Добавленные строки на файл из унифицированного диффа с нулевым контекстом. */ +export function addedLinesByFile(diff) { + const files = new Map(); + let current = null; + let next = 0; + for (const raw of String(diff).split('\n')) { + if (raw.startsWith('+++ ')) { + const path = raw.slice(4).replace(/^b\//, ''); + current = path === '/dev/null' ? null : path; + if (current && !files.has(current)) files.set(current, new Set()); + continue; + } + if (raw.startsWith('@@')) { + const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw); + next = match ? Number(match[1]) : 0; + continue; + } + if (!current || !next) continue; + if (raw.startsWith('+')) { files.get(current).add(next); next += 1; continue; } + if (raw.startsWith('-') || raw.startsWith('\\')) continue; + next += 1; + } + return files; +} + +const isProductTypeScript = (path) => /^src\/.*\.ts$/.test(path); + +function main(argv) { + const value = (name, fallback) => { + const found = argv.find((item) => item.startsWith(`--${name}=`)); + if (found) return found.slice(name.length + 3); + const index = argv.indexOf(`--${name}`); + return index >= 0 && argv[index + 1] && !argv[index + 1].startsWith('--') + ? argv[index + 1] : fallback; + }; + const diffArg = value('diff'); + let diff; + if (diffArg) { + diff = diffArg === '-' ? readFileSync(0, 'utf8') : readFileSync(diffArg, 'utf8'); + } else { + const base = value('base', 'origin/dev'); + const head = value('head', 'HEAD'); + const run = spawnSync('git', [ + '-C', ROOT, 'diff', '--unified=0', '--no-color', `${base}...${head}`, '--', 'src', + ], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (run.status !== 0) { + console.error(`git diff ${base}...${head} не удался:\n${run.stderr}`); + return 2; + } + diff = run.stdout; + } + + const added = addedLinesByFile(diff); + const files = []; + for (const [path, addedLines] of added) { + if (!isProductTypeScript(path) || !addedLines.size) continue; + const full = resolve(ROOT, path); + // Файл мог быть удалён в этом же диапазоне — судить нечего. + if (!existsSync(full)) continue; + files.push({ path, text: readFileSync(full, 'utf8'), addedLines }); + } + + const violations = findNewAnyViolations({ files }); + const scanned = files.reduce((sum, file) => sum + file.addedLines.size, 0); + console.log(`Проверено добавленных строк в src/**/*.ts: ${scanned}` + + ` в ${files.length} файл(ах).`); + if (!violations.length) { + console.log('Новых any нет.'); + return 0; + } + console.error(`\nНовый явный any: ${violations.length}\n`); + for (const violation of violations) { + console.error(` ${violation.path}:${violation.line} — ${violation.reason}`); + console.error(` ${violation.text}`); + } + console.error('\nЛибо типизируйте, либо обоснуйте на той же строке:'); + console.error(' // any-ok: <конкретная причина, почему тип недоступен>'); + console.error('Существующий долг снимается при извлечении подсистем (#34, #342),'); + console.error('а не разовой заменой: в src/** его 1034 вхождения в 49 файлах.'); + return 1; +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + process.exit(main(process.argv.slice(2))); +} diff --git a/test/no-new-any.test.mjs b/test/no-new-any.test.mjs new file mode 100644 index 00000000..6b99f340 --- /dev/null +++ b/test/no-new-any.test.mjs @@ -0,0 +1,108 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + addedLinesByFile, anyKeywordLines, findNewAnyViolations, parseAnyOk, +} from '../scripts/no-new-any.mjs'; + +// #342. Цель гейта — не перетипизировать монолит, а не давать долгу расти. В +// src/** сейчас 1034 вхождения явного any в 49 файлах; разовая замена — месяц +// риска ради нуля пользовательской ценности, поэтому долг снимается при +// извлечении подсистем (#34), а гейт держит приращение на нуле. + +const file = (text, addedLines) => ({ + path: 'src/probe.ts', text, addedLines: new Set(addedLines), +}); + +test('добавленный явный any во всех формах — нарушение (#342 AC1)', () => { + const text = [ + 'export const a = (v: any): number => 1;', + 'export const b = (v: unknown) => v as any;', + 'export const c: any[] = [];', + 'export const d: Record = {};', + 'export const e = null;', + ].join('\n'); + const violations = findNewAnyViolations({ files: [file(text, [1, 2, 3, 4, 5])] }); + assert.deepEqual(violations.map((item) => item.line), [1, 2, 3, 4, 5]); +}); + +test('старый any на нетронутой строке гейт не блокирует (#342 AC2)', () => { + // Ровно то свойство, из-за которого гейт вообще применим: существующий долг + // допустим, пока подсистему не извлекли. + const text = 'export const old = (v: any) => v;\nexport const fresh = (v: number) => v;'; + assert.deepEqual(findNewAnyViolations({ files: [file(text, [2])] }), []); +}); + +test('правка строки со старым any — новая ответственность (#342 AC2)', () => { + // Изменённая строка в диффе выглядит добавленной, и это намеренно: тронул — + // либо типизируй, либо обоснуй. + const text = 'export const old = (v: any) => v;'; + assert.equal(findNewAnyViolations({ files: [file(text, [1])] }).length, 1); +}); + +test('any-ok проходит только с конкретной причиной (#342 AC3)', () => { + const lines = [ + 'const a = (v: any) => v; // any-ok', + 'const b = (v: any) => v; // any-ok: todo', + 'const c = (v: any) => v; // any-ok: потом', + 'const d = (v: any) => v; // any-ok: форма события HA не типизирована в @types', + ].join('\n'); + const violations = findNewAnyViolations({ files: [file(lines, [1, 2, 3, 4])] }); + assert.deepEqual(violations.map((item) => item.line), [1, 2, 3]); + for (const violation of violations) assert.match(violation.reason, /без конкретной причины/); +}); + +test('разбор маркера отличает отсутствие, пустоту и обоснование (#342 AC3)', () => { + assert.equal(parseAnyOk('const a = 1;'), null); + assert.equal(parseAnyOk('// any-ok').ok, false); + assert.equal(parseAnyOk('// any-ok: hack').ok, false); + assert.equal(parseAnyOk('// any-ok: внешний контракт HA не типизирован').ok, true); +}); + +test('слово any в прозе, строках и идентификаторах не ловится (#342 AC4)', () => { + // Регулярка по строке здесь давала бы ложные срабатывания, поэтому текст + // разбирается парсером TypeScript: узел AnyKeyword — это тип any и ничто + // другое, а комментарии и литералы узлами такого вида не бывают. + const text = [ + '/** Choose any room: prose mentions any twice, any. */', + "export const companyName = 'any company, any size';", + 'export const anyOfThem = (manyRooms: number): string => `pick any of ${manyRooms}`;', + 'export const tpl = `', + ' multi-line template mentioning any room', + '`;', + ].join('\n'); + assert.deepEqual(findNewAnyViolations({ files: [file(text, [1, 2, 3, 4, 5, 6])] }), []); + assert.equal(anyKeywordLines('src/probe.ts', text).size, 0); +}); + +test('добавленные строки читаются из диффа с нулевым контекстом (#342 AC5)', () => { + const diff = [ + 'diff --git a/src/one.ts b/src/one.ts', + '--- a/src/one.ts', + '+++ b/src/one.ts', + '@@ -10,0 +11,2 @@', + '+const a = 1;', + '+const b = 2;', + '@@ -20,1 +22,1 @@', + '-const old = 3;', + '+const neu = 3;', + 'diff --git a/src/two.ts b/src/two.ts', + '--- /dev/null', + '+++ b/src/two.ts', + '@@ -0,0 +1,1 @@', + '+const c = 4;', + ].join('\n'); + const added = addedLinesByFile(diff); + assert.deepEqual([...added.get('src/one.ts')].sort((a, b) => a - b), [11, 12, 22]); + assert.deepEqual([...added.get('src/two.ts')], [1]); +}); + +test('удалённый файл не даёт нарушений: судить нечего (#342)', () => { + const diff = [ + '--- a/src/gone.ts', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-const a: any = 1;', + ].join('\n'); + assert.equal(addedLinesByFile(diff).size, 0); +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 9c028722..5bdac5d0 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -66,7 +66,9 @@ test('бандл собирается один раз и приезжает бр test('предполётные проверки не прячут друг друга (#336)', () => { const workflow = read('validate.yml'); - const preflight = workflow.slice(workflow.indexOf(' preflight:'), workflow.indexOf(' changes:')); + 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}`); @@ -82,8 +84,10 @@ test('джобы с полной историей качают её без бл // Полный клон — 215 МБ .git, blobless — 26 МБ, история и теги в обоих полные // (замер в #345). Обе эти job читают сообщения коммитов и ИМЕНА изменённых // файлов; содержимое старых ревизий им не нужно ни на одном шаге. - for (const job of ['preflight', 'changes']) { - const start = workflow.indexOf(` ${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'/, @@ -95,3 +99,15 @@ test('джобы с полной историей качают её без бл 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'/); +});