mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
test: гейт «новый код не добавляет any»
В 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
This commit is contained in:
@@ -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
|
||||
|
||||
+11
@@ -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`. По
|
||||
|
||||
@@ -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 — дорогой способ узнать о проблеме: пять минут ожидания, а при
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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<string, any> = {};',
|
||||
'export const e = <any>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);
|
||||
});
|
||||
@@ -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'/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user