diff --git a/PROCESS.md b/PROCESS.md index 7ff54d9a..0539e3c8 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -623,7 +623,9 @@ patch-id кандидата слияния: вердикт к работе за строки файлов класса A — дифф `git diff --unified=0` от merge-base с `dev` (`scripts/change-risk.mjs`). Правило «участок» — любая изменённая строка кода в файле из списка, правило «токен» — токен в изменённой строке; пустые строки, -комментарии и чистое переименование риска не дают, удаление кода — даёт. +комментарии и чистое переименование риска не дают, а также строки импорта и +строки только типов TypeScript (кроме файлов участка `migration`: типы конфига — +контракт, #755); удаление кода — даёт. Классы: `geometry`, `touch`, `migration`, `devices`, `perf`, `ux` и `visual` (области `render` — путь отрисовки плана — и `ui` — стили интерфейса). Таблица путей и токенов — эвристика и живёт в скрипте. diff --git a/scripts/change-risk.mjs b/scripts/change-risk.mjs index c0d9b058..dea3ed52 100644 --- a/scripts/change-risk.mjs +++ b/scripts/change-risk.mjs @@ -8,7 +8,9 @@ // правило «токен» — токен в изменённой строке любого файла класса A (§1). // Монолиты (`houseplan-card.ts`, `houseplan-editor-runtime.ts`) судятся только // токенами: ни один шаблон участка их не задевает, и тест держит это -// («монолит участком не судится»). +// («монолит участком не судится»). Пустые строки, комментарии, строки импорта и +// строки только типов TypeScript риска не дают (#755) — кроме файлов участка +// `migration`, где типы конфига и есть контракт. // // Таблица — эвристика (ТЗ #707 §10 п.2): пути и токены меняются свободно, // каждая строка покрыта положительным и отрицательным случаем в @@ -35,7 +37,10 @@ const AREAS = { geometry: [ ...['physical-geometry', 'space-geometry', 'wall-*', 'junction-limits', 'coincident-partitions', 'coordinate-canonicalization', 'opening-*', 'partition-openings', 'open-spans', 'near-axis', 'align-grid', - 'grid-scale', 'room-fit', 'resize*', 'stairs*', 'radar-geometry', 'zigbee-topology-geometry', + // #755: лестница — модель и преобразования; `stairs-view` (отрисовка) — в + // `visual:render`, указатель `stairs-editor` ловят токены touch. + 'grid-scale', 'room-fit', 'resize*', 'stairs', 'stairs-box', 'stairs-editor-model', + 'radar-geometry', 'zigbee-topology-geometry', 'device-marker-geometry', 'plan-geometry-preflight', 'plan-optimizer', 'zero-walls', 'iso-projection'].map(src), ...['geometry_migration', 'coordinate_canonicalization', 'junction_limits', 'wall_segment_model', 'radar_geometry', 'projection'].map(py), @@ -43,7 +48,10 @@ const AREAS = { touch: ['pointer-modality', 'pointer-move-queue', 'touch-gesture-click-guard', 'live-interaction-runtime', 'live-viewport', 'viewport-transition', 'room-gear-drag'].map(src), migration: [ - ...['types', 'config-*', 'wall-tool-compat', 'config-adoption', 'config-store'].map(src), + // #755: запись и приём конфига, а не всё `config-*`: мемо отпечатка + // (`config-fingerprint-pass`) о схеме не знает. + ...['types', 'wall-tool-compat', 'config-adoption', 'config-store', 'config-reload-authority', + 'config-write-conflict'].map(src), ...['store', 'geometry_migration', 'import_export', 'validation'].map(py), ], devices: [ @@ -91,26 +99,88 @@ export function isCommentOrBlank(text, file = '') { return file.endsWith('.py') && line.startsWith('#'); } +/** + * Строки модулей и типов TypeScript (#755) риска не дают, как комментарий: + * поведение меняет код, который читает импорт или тип, а его строки судятся как + * прежде. Это оператор `import …`, `export … from …`, `export type …`, голова + * `interface X`/`type X =` и строки внутри такого блока: с отступом и + * закрывающая строка без отступа. + */ +const MODULE_LINE = /^(?:import\s|export\s+(?:type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s*from\s*['"]|\}\s*from\s*['"])/; +const TYPE_LINE = /^(?:export\s+(?:declare\s+)?type\s|(?:export\s+)?(?:declare\s+)?(?:interface\s+[\w$]|type\s+[\w$]+\s*(?:<.*>)?\s*=))/; +/** Строка-оператор, после которой блок декларации ещё открыт: `import {`, `interface X {`, `type X =`. */ +const OPENS_BLOCK = /[{=(<,|&]\s*$/; +export const isModuleOrTypeStatement = (text) => MODULE_LINE.test(text) || TYPE_LINE.test(text); +const opensBlock = (text) => isModuleOrTypeStatement(text) && OPENS_BLOCK.test(text); + +/** + * Номера строк `rows` (из `parseUnifiedDiff`), которые по #755 — модули или + * только типы. Состояние «внутри блока декларации» ведётся по каждой стороне + * каждого блока изменений: начальное — по контексту ханка (git пишет в + * `@@ … @@ <контекст>` последнюю строку без отступа перед ханком, в старой + * версии; атрибутов diff для `.ts` в репозитории нет), дальше его меняет каждая + * строка без отступа внутри блока. Так член интерфейса под заголовком + * `@@ … @@ export interface X {` и целиком добавленный интерфейс судятся одинаково. + */ +export function moduleOrTypeRows(rows = []) { + const out = new Set(); + const open = new Map(); + rows.forEach((row, i) => { + const key = `${row.block ?? 0}${row.side}`; + if (!open.has(key)) open.set(key, opensBlock(row.ctx ?? '')); + const text = String(row.text); + if (!text || /^\s/.test(text)) { + if (open.get(key)) out.add(i); + return; + } + if (/^(?:\/\/|\/\*|\*)/.test(text)) return; + if (open.get(key) && /^[}\])>]/.test(text)) { + out.add(i); + open.set(key, false); + return; + } + if (isModuleOrTypeStatement(text)) out.add(i); + open.set(key, opensBlock(text)); + }); + return out; +} + /** * Разбор `git diff --unified=0` (подходит и с контекстом): файлы и их изменённые * строки с номерами. Удалённая строка несёт номер и путь старой стороны, * добавленная — новой. Переименование без правки ханков не даёт, двоичный файл — * тоже (его ловят рамки ship). + * + * Каждая строка несёт ещё `ctx` — контекст своего блока изменений (#755: для + * первого блока ханка — текст заголовка `@@ … @@ <контекст>`, для следующих — + * последняя строка контекста без отступа), `block` — номер блока в файле и + * `at` — номер строки своей стороны в блоке: удалённая и добавленная с одним + * `at` — одна заменённая строка. */ export function parseUnifiedDiff(text = '') { const files = []; let file = null; let oldLine = 0; let newLine = 0; let oldLeft = 0; let newLeft = 0; + let ctx = ''; let block = -1; let inBlock = false; let atOld = 0; let atNew = 0; const unquote = (p) => (p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p); + const row = (side, line, body) => { + if (!inBlock) { block += 1; inBlock = true; atOld = 0; atNew = 0; } + const at = side === '-' ? atOld++ : atNew++; + file.lines.push({ side, line, text: body, ctx, block, at }); + }; for (const raw of String(text).split('\n')) { if (file && (oldLeft > 0 || newLeft > 0)) { if (raw.startsWith('-') && oldLeft > 0) { - file.lines.push({ side: '-', line: oldLine, text: raw.slice(1) }); oldLine += 1; oldLeft -= 1; continue; + row('-', oldLine, raw.slice(1)); oldLine += 1; oldLeft -= 1; continue; } if (raw.startsWith('+') && newLeft > 0) { - file.lines.push({ side: '+', line: newLine, text: raw.slice(1) }); newLine += 1; newLeft -= 1; continue; + row('+', newLine, raw.slice(1)); newLine += 1; newLeft -= 1; continue; + } + if (raw.startsWith(' ')) { + // Контекст как у git: строка, начинающаяся с буквы, `_` или `$`. + if (/^[A-Za-z_$]/.test(raw.slice(1))) ctx = raw.slice(1); + inBlock = false; oldLine += 1; newLine += 1; oldLeft -= 1; newLeft -= 1; continue; } - if (raw.startsWith(' ')) { oldLine += 1; newLine += 1; oldLeft -= 1; newLeft -= 1; continue; } if (raw.startsWith('\\')) continue; oldLeft = 0; newLeft = 0; } @@ -118,14 +188,16 @@ export function parseUnifiedDiff(text = '') { if ((m = /^diff --git (?:"?a\/)(.+?)"? (?:"?b\/)(.+?)"?$/.exec(raw))) { file = { oldPath: m[1], newPath: m[2], lines: [] }; files.push(file); + block = -1; inBlock = false; continue; } if (!file) continue; if ((m = /^--- (.+)$/.exec(raw))) { file.oldPath = m[1] === '/dev/null' ? null : unquote(m[1]).replace(/^a\//, ''); continue; } if ((m = /^\+\+\+ (.+)$/.exec(raw))) { file.newPath = m[1] === '/dev/null' ? null : unquote(m[1]).replace(/^b\//, ''); continue; } - if ((m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(raw))) { + if ((m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/.exec(raw))) { oldLine = Number(m[1]); oldLeft = m[2] === undefined ? 1 : Number(m[2]); newLine = Number(m[3]); newLeft = m[4] === undefined ? 1 : Number(m[4]); + ctx = m[5]; inBlock = false; } } return files; @@ -159,10 +231,17 @@ export function classifyRisk(diffText = '') { const key = JSON_KEY.exec(row.text); if (key) removedKeys.add(key[1]); } - for (const row of file.lines) { + const typeOnly = moduleOrTypeRows(file.lines); + // Добавленная строка блока по `at`: пара для удалённой — одна заменённая строка. + const addedAt = new Map(file.lines.filter((r) => r.side === '+').map((r) => [`${r.block}:${r.at}`, r])); + for (const [i, row] of file.lines.entries()) { const p = row.side === '-' ? file.oldPath : file.newPath; if (!p || classify(p) !== 'A' || isCommentOrBlank(row.text, p)) continue; + // #755: типы конфига — контракт, в участке migration строки типов судятся. + if (typeOnly.has(i) && p.endsWith('.ts') && !AREAS.migration.some((r) => r.re.test(p))) continue; const where = { path: p, line: row.line, side: row.side }; + const pair = row.side === '-' && file.newPath ? addedAt.get(`${row.block}:${row.at}`) : null; + if (pair) where.pair = `+${file.newPath}:${pair.line}`; for (const [cls, rules] of Object.entries(AREAS)) { const rule = rules.find((r) => r.re.test(p)); if (rule) add(cls, where, `участок ${rule.label}`); @@ -197,6 +276,16 @@ export function classifyRisk(diffText = '') { merged.get(name).set(key, into); } } + // #755: заменённая строка — одно доказательство, а не «удалена» и новая рядом: + // удалённая уходит в свою пару, если та дала тот же класс. + for (const entries of merged.values()) { + for (const [key, entry] of entries) { + const into = entry.pair && entries.get(entry.pair); + if (!into) continue; + for (const rule of entry.rules) if (!into.rules.includes(rule)) into.rules.push(rule); + entries.delete(key); + } + } for (const cls of RISK_CLASSES) { const entries = merged.get(cls); if (!entries?.size) continue; diff --git a/test/process-track.test.mjs b/test/process-track.test.mjs index 1460b447..b9097d17 100644 --- a/test/process-track.test.mjs +++ b/test/process-track.test.mjs @@ -304,6 +304,98 @@ test('#707 AC1: большой дифф — классифицируются в assert.match(riskClassLine(risk, 'touch'), /; и ещё 7$/); }); +// ---------- #755: строки модулей и типов, участки уже, заменённая строка ---------- + +/** Дифф одного файла из ханков: заголовок `@@ … @@ <контекст>` и строки `-`/`+` как есть. */ +function hunksOf(path, hunks) { + return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n${hunks.map(([head, ...rows]) => [head, ...rows].join('\n')).join('\n')}\n`; +} +const ISO = 'src/iso-scene-render.ts'; + +test('#755 AC1: строки импорта и только типов TypeScript риска не дают, кроме участка migration', () => { + // Ханк #741: удалённый член интерфейса под заголовком интерфейса — классов нет. + const member = '- stageSize?: { width: number; height: number } | null;'; + assert.deepEqual(classifyRisk(hunksOf(ISO, [['@@ -651 +650,0 @@ export interface IsoOverlayFitEnvelopeInput {', member]])).classes, []); + assert.deepEqual(classifyRisk(hunksOf(ISO, [['@@ -134 +133,0 @@ export type IsoOverlayRenderEntry = {', '- groundRadius: number;']])).classes, []); + // То же тело под заголовком класса — код, участок iso-scene-render: perf. + const inClass = classifyRisk(hunksOf(ISO, [['@@ -651 +650,0 @@ export class X {', member]])); + assert.deepEqual(inClass.raising, ['perf']); + assert.deepEqual(inClass.evidence.perf, [`${ISO}:651 (удалена) · участок iso-scene-render`]); + // Под закрытой однострочной декларацией строка с отступом — снова код. + assert.deepEqual(classifyRisk(hunksOf(ISO, [["@@ -9,0 +10 @@ export type Mode = 'a' | 'b';", '+ run(x);']])).raising, ['perf']); + + // Оператор импорта, член многострочного `import {`, его закрывающая строка, реэкспорт. + const modules = [ + ["@@ -9 +8,0 @@ import { nothing, svg, type TemplateResult } from 'lit';", "-import { guard } from 'lit/directives/guard.js';"], + ['@@ -16 +16 @@ import {', '- cachedStairRenderGeometry, stairList,', '+ cachedStairMarkup, cachedStairRenderGeometry, stairList,'], + ['@@ -20 +20 @@ import {', "-} from './stairs';", "+} from './stairs-model';"], + ['@@ -30,0 +31,2 @@ import { x } from "./x";', "+export { computeIsoSunBeams } from './iso-sun';", "+export * from './iso-tiles';"], + ['@@ -40,0 +42,2 @@ import { x } from "./x";', "+import type { Rect } from './geometry';", "+export type { Rect } from './geometry';"], + ]; + for (const hunk of modules) assert.deepEqual(classifyRisk(hunksOf(ISO, [hunk])).classes, [], hunk.join(' ⏎ ')); + // Импорт с токеном в монолите — тоже нет; тот же токен в коде монолита — migration (таблица #707). + assert.deepEqual(classifyRisk(diffOf([{ path: CARD_FILE, add: [[3, "import { migrateX } from './m';"]] }])).classes, []); + assert.deepEqual(classifyRisk(diffOf([{ path: CARD_FILE, add: [[3, 'migrateX(cfg);']] }])).classes, ['migration']); + // Динамический импорт и `import.meta` — код. + assert.deepEqual(classifyRisk(diffOf([{ path: ISO, add: [[3, "import('./iso-sun').then(run);"]] }])).raising, ['perf']); + assert.deepEqual(classifyRisk(diffOf([{ path: ISO, add: [[3, 'import.meta.hot?.accept();']] }])).raising, ['perf']); + + // Целиком добавленный интерфейс: заголовок ханка — прошлая декларация, голова внутри ханка. + const wholeType = ['@@ -330,0 +331,5 @@ export function cachedStairRenderGeometry(', + '+/** Attribute strings. */', '+export interface StairMarkup {', '+ outline: string;', '+ treads: string;', '+}']; + assert.deepEqual(classifyRisk(hunksOf(ISO, [wholeType])).classes, []); + // Блок типа закрыт — следующая строка кода того же ханка судится. + const typeThenCode = ['@@ -1,0 +2,4 @@ export const a = 1;', '+type Box = {', '+ w: number;', '+};', '+export const box = measure();']; + assert.deepEqual(classifyRisk(hunksOf(ISO, [typeThenCode])).evidence.perf, [`${ISO}:5 · участок iso-scene-render`]); + + // Дифф с контекстом: следующий блок ханка берёт контекст из строки контекста, как git. + const withContext = ['@@ -10,5 +10,5 @@ export interface A {', '- a: number;', '+ a: string;', ' }', + ' export function f() {', '- run(1);', '+ run(2);']; + assert.deepEqual(classifyRisk(hunksOf(ISO, [withContext])).evidence.perf, [`${ISO}:13 · участок iso-scene-render`]); + + // К3: участок migration — типы конфига и импорты судятся, как раньше (#588, #649). + const marker = classifyRisk(hunksOf('src/types.ts', [['@@ -199 +199 @@ export interface Marker {', + "- display?: 'badge' | 'ripple';", "+ display?: 'badge' | 'ripple' | 'value';"]])); + assert.deepEqual(only(marker), { classes: ['migration'], evidence: { migration: ['src/types.ts:199 · участок types'] } }); + assert.deepEqual(classifyRisk(hunksOf('src/types.ts', [['@@ -304,0 +305 @@ export interface ServerConfig {', '+ volumetric_view?: boolean;']])).classes, ['migration']); + assert.deepEqual(classifyRisk(diffOf([{ path: 'src/config-store.ts', add: [[2, "import { normalize } from './n';"]] }])).classes, ['migration']); + // Только `.ts`: строка Python с `import` и `type` судится, как раньше. + assert.deepEqual(classifyRisk(diffOf([{ path: 'custom_components/houseplan/auth.py', add: [[2, 'import hass']] }])).classes, ['devices']); +}); + +test('#755 AC2: участки stairs и config — модель лестницы и запись конфига, а не всё по префиксу', () => { + const cls = (path, text = ' const a = b + c;') => classifyRisk(diffOf([{ path, add: [[5, text]] }])).classes; + assert.deepEqual(cls('src/stairs-view.ts'), ['visual'], 'отрисовка лестницы — visual:render, не geometry'); + assert.deepEqual(cls('src/stairs-editor.ts'), [], 'нейтральная строка редактора лестницы'); + assert.deepEqual(cls('src/stairs-editor.ts', " el.addEventListener('pointerdown', onDown);"), ['touch'], 'указатель редактора — токен touch'); + for (const path of ['src/stairs.ts', 'src/stairs-box.ts', 'src/stairs-editor-model.ts']) assert.deepEqual(cls(path), ['geometry'], path); + assert.deepEqual(cls('src/config-fingerprint-pass.ts'), [], 'мемо отпечатка о схеме не знает'); + for (const path of ['src/config-store.ts', 'src/config-adoption.ts', 'src/config-reload-authority.ts', 'src/config-write-conflict.ts']) { + assert.deepEqual(cls(path), ['migration'], path); + } +}); + +test('#755: заменённая строка — одно доказательство, удалённая без пары остаётся «(удалена)»', () => { + const view = 'src/stairs-view.ts'; + const replaced = classifyRisk(hunksOf(view, [['@@ -89,2 +89 @@ export function renderStairs(', + '- const cls = "a";', '- const old = 1;', '+ const cls = "b";']])); + assert.deepEqual(replaced.evidence.visual, [ + `${view}:90 (удалена) · участок stairs-view (render)`, + `${view}:89 · участок stairs-view (render)`, + ]); + assert.equal(replaced.counts.visual, 2); + // Пара сводит и правила: удалённый токен дописывается к новой строке. + const tokens = classifyRisk(hunksOf(CARD_FILE, [['@@ -12 +12 @@ export class HouseplanCard extends LitElement {', + "- el.addEventListener('pointerdown', f);", "+ el.addEventListener('pointerup', f);"]])); + assert.deepEqual(tokens.evidence.touch, [`${CARD_FILE}:12 · токен pointerup, токен pointerdown`]); + // Новая строка класса не даёт — удалённая остаётся своим доказательством. + const gone = classifyRisk(hunksOf(CARD_FILE, [['@@ -12 +12 @@ export class HouseplanCard extends LitElement {', + "- el.addEventListener('pointerdown', f);", '+ el.focus();']])); + assert.deepEqual(gone.evidence.touch, [`${CARD_FILE}:12 (удалена) · токен pointerdown`]); + // Разные ханки не пара, даже с одним номером строки (таблица #707 строит именно такие). + assert.equal(classifyRisk(diffOf([{ path: view, del: [[9, 'x = 1;']], add: [[9, 'x = 2;']] }])).counts.visual, 2); +}); + test('#707 AC2: происхождение трека — строка владельца, предложение, прежние метки', () => { const c = (author, body, createdAt) => ({ author, body, createdAt }); const owner = 'Matysh'; @@ -599,7 +691,7 @@ function parseOutput(text) { } /** Песочница: bare origin с dev (scripts/ и src/), ветка задачи, подменённый gh. */ -function trackSandbox(t, { change }) { +function trackSandbox(t, { change, base = () => {} }) { const root = mkdtempSync(join(tmpdir(), 'hp-707-')); t.after(() => rmSync(root, { recursive: true, force: true })); const git = (cwd, ...args) => { @@ -624,6 +716,7 @@ function trackSandbox(t, { change }) { mkdirSync(join(work, 'src', 'styles'), { recursive: true }); writeFileSync(join(work, 'src', 'pointer-modality.ts'), 'export const a = 1;\nexport const b = 2;\n'); writeFileSync(join(work, 'src', 'styles', 'plan.styles.ts'), 'export const css = `\n .x { color: red; }\n`;\n'); + base(work); git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'base'); git(work, 'push', '-q', 'origin', 'dev'); git(work, 'checkout', '-q', '-b', 'issue/7-x'); @@ -740,6 +833,40 @@ test('#707 AC4: шаг трека на настоящем bash — ship, под assert.equal(shipRiskFrom([{ body: plain.comment }]), null); }); +// #755 AC3: дифф вида #741 — удалённый член интерфейса в участке perf — ship не +// повышает; то же тело в классе повышает (свидетель, что риск судится). +const ISO_BASE = (head) => (work) => writeFileSync(join(work, 'src', 'iso-scene-render.ts'), + `export const before = 1;\n\n${head}\n rooms: readonly string[];\n stageSize?: { width: number; height: number } | null;\n}\n`); +const dropStageSize = (work) => { + const file = join(work, 'src', 'iso-scene-render.ts'); + writeFileSync(file, readFileSync(file, 'utf8').replace(' stageSize?: { width: number; height: number } | null;\n', '')); +}; + +test('#755 AC3: шаг трека на настоящем bash — ship с удалённым членом интерфейса не повышается', async (t) => { + if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } + const { readFileSync: read } = await import('node:fs'); + const run = stepRun(read(WORKFLOW, 'utf8'), TRACK_STEP); + const bot = [{ author: { login: 'claude[bot]' }, body: 'Трек: ship — решение владельца', createdAt: '2026-10-01T05:00:00Z' }]; + + const box = trackSandbox(t, { base: ISO_BASE('export interface IsoOverlayFitEnvelopeInput {'), change: dropStageSize }); + box.comments(bot); + const r = box.run(run, trackEnv('track:ship,S7-code-review')); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^raise=false$/m); + assert.deepEqual(r.calls, ['issue view 7 --repo o/r --json comments'], 'ни комментария повышения, ни смены меток'); + assert.equal(r.output.track, 'ship'); + assert.equal(r.output.ship, 'true'); + assert.equal(r.output.risk, ''); + + const inClass = trackSandbox(t, { base: ISO_BASE('export class IsoOverlayFitEnvelope {'), change: dropStageSize }); + inClass.comments(bot); + const raised = inClass.run(run, trackEnv('track:ship,S7-code-review')); + assert.equal(raised.status, 0, raised.stderr); + assert.match(raised.stdout, /^raise=true$/m); + assert.equal(raised.output.track, 'show'); + assert.match(raised.comment, /- perf: src\/iso-scene-render\.ts:5 \(удалена\) · участок iso-scene-render/); +}); + test('#707 AC4: шаг трека на настоящем bash — комментарии недоступны, этап spec, show', async (t) => { if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } const { readFileSync: read } = await import('node:fs');