diff --git a/PROCESS.md b/PROCESS.md index a5ee3444..77c53068 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -576,6 +576,17 @@ demo/docs/capture.mjs`, коммит вместе с задачей. какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым пропуском. +**Одно число — один источник.** Любая величина, которую пользователь видит +дважды — превью против записи, подпись против площади, подсветка инструмента +против сохранённого значения, — обязана считаться в одном месте. Три дефекта +подряд имели ровно эту причину: #234 (резинка показывала 12 см, запись хранила +24), #233 (подпись мерила по осевым линиям, площадь рядом — по полу) и способ, +которым #234 нашли (подсветка «Толщины» врала согласованно с записью). Ревьюер +отвечает на вопрос прямо: какое число в этом диффе видно дважды и один ли у него +источник. Механическая часть правила закреплена тестом +`test/single-source-numbers.test.mjs` — строку с единицей измерения собирает +только канонический форматтер; смысловая часть остаётся за ревью. + **Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега. Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 790a5bfc..180539ba 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -165,6 +165,19 @@ export const MUTANTS = [ replace: ' const span = centre;', }], }, + { + id: 'unit-formatting-escapes-the-formatter', + guard: 'node --test --test-name-pattern="детектор действительно ловит" ' + + 'test/single-source-numbers.test.mjs', + because: 'одно число, видимое дважды, обязано иметь один источник: расхождение превью и ' + + 'записи стоило продукту #234, а расхождение подписи и площади — #233. Детектор без ' + + 'способности сработать — это разрешение собирать подписи где угодно', + patches: [{ + file: 'test/single-source-numbers.test.mjs', + find: " + '\\\\s*(m²|ft²|м²|m\\\\b|cm\\\\b|см(?![а-яё])|м(?![а-яё])|′|″)',", + replace: " + '\\\\s*(ЗАГЛУШКА)',", + }], + }, { id: 'invariant-loses-wall-record', guard: 'node --test --test-name-pattern="исчезнувшая запись толщины" ' diff --git a/test/single-source-numbers.test.mjs b/test/single-source-numbers.test.mjs new file mode 100644 index 00000000..c8b580db --- /dev/null +++ b/test/single-source-numbers.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); + +/** + * Правило одного источника для числа, видимого пользователю (#254, ревизия + * 2026-08-23). + * + * Три дефекта за месяц имели одну причину: одно и то же число считалось в двух + * местах и расходилось. + * + * #234 — превью «резинки» показывало 12 см, запись сохраняла 24; + * #233 — подпись ресайза мерила по осевым линиям, площадь рядом — по полу; + * #234 (как нашли) — подсветка инструмента «Толщина» врала согласованно + * со записью, потому что обе брали толщину по-своему. + * + * Полностью механизировать правило нельзя: «одно и то же число» — смысловое + * понятие. Механизируется его самая дорогая часть — форматирование величин. + * Пока строку с единицей измерения собирает ровно один форматтер на величину, + * разойтись двум подписям невозможно; как только рядом появляется второе место, + * склеивающее число с «м» или «м²», расхождение становится вопросом времени. + */ + +const UNIT_FORMATTING = new RegExp( + // Число, попадающее в строку: либо интерполяция, либо конкатенация после + // toFixed. Дальше — единица измерения, без требования разделителя: в + // конкатенации кавычку уже съел предыдущий кусок. + '(\\$\\{[^{}]*\\}|toFixed\\([0-9]\\)\\s*\\)?\\s*\\+\\s*[\'`])' + // `\\b` в JS считает границу по ASCII, поэтому после кириллической буквы он + // не срабатывает: для «см» и «м» нужен явный отрицательный просмотр. + + '\\s*(m²|ft²|м²|m\\b|cm\\b|см(?![а-яё])|м(?![а-яё])|′|″)', + 'g', +); + +/** + * Единственные места, которым позволено склеивать число с единицей измерения. + * Окно намеренно узкое: разрешается сам форматтер, а не файл целиком — + * иначе правило растворится в первом же большом модуле. + */ +const FORMATTERS = [ + { file: 'src/logic.ts', anchor: 'export function formatLength(', lines: 12 }, + { file: 'src/resize.ts', anchor: 'export function formatArea(', lines: 8 }, +]; + +const sourceFiles = (root) => { + const out = []; + const walk = (dir) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) out.push(path); + } + }; + walk(join(root, 'src')); + return out; +}; + +const allowedWindows = (root) => FORMATTERS.map((formatter) => { + const text = readFileSync(join(root, formatter.file), 'utf8'); + const index = text.indexOf(formatter.anchor); + assert.ok(index >= 0, `${formatter.file}: не найден форматтер ${formatter.anchor}`); + const from = text.slice(0, index).split('\n').length; + return { file: join(root, formatter.file), from, to: from + formatter.lines }; +}); + +export function unitFormattingSites(root = repoRoot) { + const windows = allowedWindows(root); + const sites = []; + for (const file of sourceFiles(root)) { + const text = readFileSync(file, 'utf8'); + UNIT_FORMATTING.lastIndex = 0; + for (const match of text.matchAll(UNIT_FORMATTING)) { + const line = text.slice(0, match.index).split('\n').length; + const allowed = windows.some((window) => + window.file === file && line >= window.from && line <= window.to); + if (!allowed) { + sites.push(`${file.slice(root.length)}:${line} ${JSON.stringify(match[0].trim())}`); + } + } + } + return sites; +} + +test('строку с единицей измерения собирает только канонический форматтер (#254)', () => { + assert.deepEqual(unitFormattingSites(), [], + 'число, видимое пользователю, обязано иметь один источник: расхождение подписей ' + + 'уже стоило продукту #233 и #234'); +}); + +test('детектор действительно ловит самодельное форматирование (#254)', () => { + // Проверка на проверку: правило, которое не может сработать, бесполезно. + // Прогоняем детектор по временной копии дерева с одной рукодельной строкой. + const samples = [ + 'const label = `${(cm / 100).toFixed(2)} m`;', + "const label = (cm / 100).toFixed(2) + ' м';", + 'const label = `${value} м²`;', + ]; + for (const sample of samples) { + UNIT_FORMATTING.lastIndex = 0; + assert.ok(UNIT_FORMATTING.test(sample), `детектор не увидел: ${sample}`); + } + const clean = [ + 'const label = this._fmtLen(a, b);', + 'const cm = wallCmToUnits(50, cellCm, gridPitch);', + 'const text = formatArea(area, imperial);', + ]; + for (const sample of clean) { + UNIT_FORMATTING.lastIndex = 0; + assert.ok(!UNIT_FORMATTING.test(sample), `ложное срабатывание: ${sample}`); + } +}); + +test('оба канонических форматтера на месте и единственны (#254)', () => { + // Если форматтер переименуют или размножат, правило должно об этом сказать, + // а не тихо разрешить всё. + for (const formatter of FORMATTERS) { + const text = readFileSync(join(repoRoot, formatter.file), 'utf8'); + const count = text.split(formatter.anchor).length - 1; + assert.equal(count, 1, `${formatter.file}: ожидался ровно один ${formatter.anchor}`); + } +});